Files
tasteby/docs/design/272-backend-review-memo/README.md
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical).
- 17개 설계서를 Draft → Approved로 갱신
- #267(backend-user)은 critical 결함으로 06-Reviewer 유지
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영
  (critical 3 / major 46 / minor 75)
- docs/README.md에 18개 설계서 인덱스 추가
- CHANGELOG.md 2026-06-15 섹션 추가

Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
2026-06-15 11:08:18 +09:00

11 KiB
Raw Blame History

설계서: 백엔드 - 리뷰/메모 (#272)

상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #272 · 관련 ADR: 없음 · 구현 파일: backend-java/src/main/java/com/tasteby/service/ReviewService.java, backend-java/src/main/java/com/tasteby/service/MemoService.java, backend-java/src/main/java/com/tasteby/controller/ReviewController.java, backend-java/src/main/java/com/tasteby/controller/MemoController.java · 테스트: TBD (현재 없음)

1. 목적 (Why)

사용자가 식당에 대한 공개 리뷰(평점/방문일/텍스트)와 개인 메모(비공개 평점/기록)를 남기고, 즐겨찾기로 관심 식당을 관리하도록 한다. 식당 상세 페이지의 사회적 신뢰도(평균 평점, 리뷰 수) 및 마이페이지의 개인화 콘텐츠 핵심을 제공한다.

2. 범위 (Scope)

  • 포함:
    • 식당별 리뷰 목록/평균 평점/리뷰 수 조회
    • 리뷰 생성/수정/삭제 (본인 글만)
    • 식당별 개인 메모 단건 조회/upsert/삭제 (사용자×식당 유니크)
    • 즐겨찾기 토글/상태 조회/내 즐겨찾기 목록
    • 내 리뷰/내 메모 목록
  • 제외 (out of scope):
    • 리뷰 이미지 첨부, 좋아요/신고 등 사회적 상호작용
    • 리뷰 기반 추천/랭킹 로직
    • 댓글, 대댓글
    • 메모 공개 전환 (private only)

3. 인수조건 (Acceptance Criteria)

  • GET /api/restaurants/{id}/reviews?limit&offset 호출 시 reviews[] + avg_rating + review_count 동시 반환
  • 인증된 사용자만 POST /api/restaurants/{id}/reviews로 리뷰 작성 가능, 응답은 HTTP 201 + 생성된 Review
  • 작성자 본인이 아닌 PUT/DELETE /api/reviews/{id} 시도 시 HTTP 404 ("Review not found or not yours")
  • POST /api/restaurants/{id}/memo 동일 (user_id, restaurant_id) 재호출 시 INSERT가 아닌 UPDATE (upsert), 단건 보장
  • POST /api/restaurants/{id}/favorite 호출 시 기존 레코드 존재 → 삭제(false), 미존재 → 삽입(true) 토글 동작

4. 컨텍스트 & 제약

  • DB: Oracle 23ai. 테이블 reviews, memos, favorites. ID는 32자 UUID(IdGenerator.newId()).
  • MyBatis: ReviewMapper, MemoMapper XML (src/main/resources/mybatis/mapper/). resultMap으로 UPPERCASE 컬럼 → camelCase 매핑.
  • 권한: 모든 쓰기 엔드포인트는 AuthUtil.getUserId()로 인증된 사용자 필요 (Spring Security 필터). 관리자 권한은 불필요.
  • 트랜잭션: create, upsert, toggleFavorite@Transactional 명시.
  • 유니크 제약: memos(user_id, restaurant_id) 유니크. favorites도 동일하게 1쌍 1행.
  • 반환 포맷: Jackson SNAKE_CASE (review_text, visited_at, avg_rating, review_count).
  • 가정: restaurants.id는 사전에 존재 (FK 참조). visited_at은 ISO-8601 (YYYY-MM-DD) 문자열.

5. 아키텍처 개요

  • 모듈/파일 구조:
    • controller/ReviewController.java (REST 엔드포인트, 8개)
    • controller/MemoController.java (REST 엔드포인트, 4개)
    • service/ReviewService.java (리뷰 + 즐겨찾기 비즈니스 로직)
    • service/MemoService.java (메모 upsert 로직)
    • mapper/ReviewMapper.java + XML, mapper/MemoMapper.java + XML
    • domain/Review.java, domain/Memo.java
    • security/AuthUtil.java (사용자 ID 추출)
  • I/O ↔ 순수 로직 경계: Controller는 입력 파싱 + 인증, Service는 트랜잭션·도메인 규칙, Mapper는 SQL I/O.
[Client]
   │ HTTP (JSON)
   ▼
[ReviewController | MemoController]   ← AuthUtil.getUserId()
   │ DTO/Map 파싱, LocalDate 변환
   ▼
[ReviewService | MemoService]         ← @Transactional, upsert/토글 분기
   │ IdGenerator.newId(), JsonUtil.lowerKeys()
   ▼
[ReviewMapper | MemoMapper]  (MyBatis XML)
   │ SQL
   ▼
[Oracle 23ai: reviews / memos / favorites]

6. 데이터 모델

  • Review (domain/Review.java): id, userId, restaurantId, rating(double), reviewText, visitedAt(String), createdAt, updatedAt, userNickname, userAvatarUrl, restaurantName.
  • Memo (domain/Memo.java): id, userId, restaurantId, rating(Double, nullable), memoText, visitedAt(String), createdAt, updatedAt, restaurantName.
  • avg_rating 응답 (Map): { avg_rating: double, review_count: int } — null 시 기본값 {0.0, 0}.
  • favorite 응답: { favorited: boolean }.
  • 경계 검증:
    • rating: 0.0 ~ 5.0 권장 (DB CHECK 권장, 현 구현은 검증 없음 — 향후 ADR 검토).
    • reviewText / memoText: 길이 제한은 DB 컬럼 길이에 위임 (현재 명시적 검증 없음).
    • visitedAt: LocalDate.parse 실패 시 DateTimeParseException 전파 → 400.

7. 함수 명세 (Function Specs)

함수 책임(1줄) 시그니처(잠정) 입력 출력 에러/실패 복잡?
ReviewService.findByRestaurant 식당별 리뷰 페이지 조회 List<Review>(restaurantId, limit, offset) 식당ID, 페이지 List DB 오류 → 전파 단순
ReviewService.getAvgRating 평균 평점/리뷰 수 집계 Map<String,Object>(restaurantId) 식당ID {avg_rating, review_count} null 시 기본값 단순
ReviewService.create 신규 리뷰 작성 Review(userId, restaurantId, rating, text, visitedAt) 사용자/식당/평점 생성된 Review DB 제약 위반 → 전파 단순
ReviewService.update 본인 리뷰 수정 boolean(reviewId, userId, rating?, text?, visitedAt?) ID + 부분 필드 성공 여부 0행 → false 단순
ReviewService.delete 본인 리뷰 삭제 boolean(reviewId, userId) ID, 사용자 성공 여부 0행 → false 단순
ReviewService.findByUser 내 리뷰 목록 List<Review>(userId, limit, offset) 사용자, 페이지 List DB 오류 → 전파 단순
ReviewService.isFavorited 즐겨찾기 여부 boolean(userId, restaurantId) 사용자/식당 true/false DB 오류 → 전파 단순
ReviewService.toggleFavorite 즐겨찾기 토글 boolean(userId, restaurantId) 사용자/식당 토글 후 상태 동시성 시 유니크 충돌 가능 복잡
ReviewService.getUserFavorites 내 즐겨찾기 식당 목록 List<Restaurant>(userId) 사용자 List DB 오류 → 전파 단순
MemoService.findByUserAndRestaurant 메모 단건 조회 Memo(userId, restaurantId) 사용자/식당 Memo or null 없음 단순
MemoService.upsert 메모 신규/갱신 Memo(userId, restaurantId, rating?, text, visitedAt?) 사용자/식당/내용 저장된 Memo 동시성 시 유니크 충돌 가능 복잡
MemoService.delete 메모 삭제 boolean(userId, restaurantId) 사용자/식당 성공 여부 0행 → false 단순
MemoService.findByUser 내 메모 목록 List<Memo>(userId) 사용자 List DB 오류 → 전파 단순
ReviewController.* REST 어댑팅 @RestController HTTP JSON 401/404/400 단순
MemoController.* REST 어댑팅 @RestController HTTP JSON 401/404/400 단순

복잡 표시된 toggleFavorite, upsert는 분기 + 동시성 + 트랜잭션 경계 존재. 별도 fn 설계서 권장.

8. 흐름 / 알고리즘

  1. 리뷰 작성: AuthUtil.getUserId() → IdGenerator.newId() → INSERT → findById로 재조회하여 반환.
  2. 평균 평점 조회: mapper.getAvgRating → null 체크 → JsonUtil.lowerKeys()로 키 소문자화 → 응답 머지.
  3. 메모 upsert:
    • 사전 SELECT (user_id, restaurant_id) →
    • 존재하면 UPDATE, 미존재하면 INSERT (IdGenerator로 새 ID) →
    • 최종 SELECT 후 반환.
  4. 즐겨찾기 토글:
    • findFavoriteId(userId, restaurantId)
    • 존재하면 DELETE → false 반환, 미존재하면 INSERT → true 반환.
  5. 권한 검증 (수정/삭제): WHERE 절에 user_id = ?를 함께 포함하여 본인 행만 영향. 영향행 0이면 NOT_FOUND (의도된 모호화: 권한/존재 동시 처리).

9. 엣지케이스 & 에러 처리

  • 타인 리뷰 수정/삭제 시도: WHERE 사용자 ID 불일치 → 0행 영향 → HTTP 404. 권한 누설 방지.
  • 존재하지 않는 식당 ID로 리뷰 작성: FK 제약 위반 → SQLException → 500 (현재 별도 매핑 없음, 향후 400 매핑 검토).
  • rating 음수/범위 초과: 현재 미검증, DB에 그대로 저장. → Bean Validation 추가 권장.
  • 메모 동시 upsert 경합: 양쪽 트랜잭션이 SELECT에서 미존재 판정 → 둘 다 INSERT → 유니크 제약 위반. → 한쪽 500 전파.
  • 즐겨찾기 동시 토글: 동일 패턴, 유니크 충돌 가능. 트랜잭션 격리 SERIALIZABLE 또는 unique upsert 재시도 권장.
  • 빈 텍스트/null 리뷰: 현재 허용. 공백 정규화 미적용.
  • visited_at 파싱 실패: DateTimeParseException → Spring 기본 400 응답.
  • 인증 누락: AuthUtil.getUserId()가 401 throw (필터 단계에서 차단 가정).

10. 테스트 계획

  • 단위
    • ReviewService.toggleFavorite 기존 존재/미존재 분기 (Mapper 모킹)
    • ReviewService.getAvgRating null 반환 시 기본값 처리
    • MemoService.upsert 신규 INSERT vs UPDATE 분기
    • ReviewService.update/delete 0행 시 false 반환
  • 통합 (MyBatis + Testcontainers Oracle 또는 H2 Oracle mode)
    • 리뷰 작성 → 평균 평점이 (기존 평균 × N + 새 평점)/(N+1) 일치
    • 메모 동일 (user, restaurant) 재요청 시 행 수 1 유지, 내용만 갱신
    • 즐겨찾기 토글 두 번 호출 → 원상 복귀 (행 수 0)
    • 타 사용자 ID로 update 시 404
  • API: MockMvc로 권한/페이지네이션/응답 키(snake_case) 검증.
  • 현재 테스트 디렉토리 없음 → TBD.

11. 리스크 & 대안 검토

  • 선택: upsert/토글을 애플리케이션 레벨 SELECT → IF로 분기.
    • 대안 A: Oracle MERGE 문 단일 SQL → 동시성 안전.
    • 대안 B: 유니크 충돌 시 재시도 루프 → 코드 복잡도.
    • 트레이드오프: 현재 방식은 명확하지만 경합 시 500. 다중 사용자 동시성이 낮은 현 단계에서 수용 가능. 트래픽 증가 시 MERGE 전환 ADR 후보.
  • 권한 검증: WHERE 절에 user_id 포함 vs 사전 SELECT 검증.
    • 현재(WHERE 포함)는 1쿼리로 처리 + 권한/미존재 모호화. 단점: 감사 로그용 구분 어려움.
  • rating 검증 부재: Bean Validation (@Min(0) @Max(5)) 도입 권장 — 별도 작업 분리.
  • N+1 가능성: 리뷰 목록에서 user_nickname/avatar_url을 join으로 fetch (XML 조인 가정). 다국어/대량 사용자 시 캐시 검토.

12. 미해결 질문 (Open Questions)

  • 리뷰 작성 시 평점 범위 검증을 서비스 레벨로 끌어올릴지, DB CHECK 제약으로 위임할지?
  • 리뷰 이미지/사진 첨부 도입 시 별도 테이블 + 스토리지 정책 (#TBD).
  • 같은 사용자가 한 식당에 리뷰를 여러 개 작성 가능? (현재 무제한) 정책 결정 필요.
  • 즐겨찾기/메모를 단일 "내 식당" 개념으로 통합할지, 분리 유지할지?
  • 리뷰 신고/모더레이션 워크플로 도입 시 status 컬럼 + 관리자 UI 필요.