# 설계서: 백엔드 - 리뷰/메모 (#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) - [x] `GET /api/restaurants/{id}/reviews?limit&offset` 호출 시 `reviews[]` + `avg_rating` + `review_count` 동시 반환 - [x] 인증된 사용자만 `POST /api/restaurants/{id}/reviews`로 리뷰 작성 가능, 응답은 HTTP 201 + 생성된 Review - [x] 작성자 본인이 아닌 `PUT/DELETE /api/reviews/{id}` 시도 시 HTTP 404 ("Review not found or not yours") - [x] `POST /api/restaurants/{id}/memo` 동일 (user_id, restaurant_id) 재호출 시 INSERT가 아닌 UPDATE (upsert), 단건 보장 - [x] `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(restaurantId, limit, offset)` | 식당ID, 페이지 | List | DB 오류 → 전파 | 단순 | | `ReviewService.getAvgRating` | 평균 평점/리뷰 수 집계 | `Map(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(userId, limit, offset)` | 사용자, 페이지 | List | DB 오류 → 전파 | 단순 | | `ReviewService.isFavorited` | 즐겨찾기 여부 | `boolean(userId, restaurantId)` | 사용자/식당 | true/false | DB 오류 → 전파 | 단순 | | `ReviewService.toggleFavorite` | 즐겨찾기 토글 | `boolean(userId, restaurantId)` | 사용자/식당 | 토글 후 상태 | 동시성 시 유니크 충돌 가능 | **복잡** | | `ReviewService.getUserFavorites` | 내 즐겨찾기 식당 목록 | `List(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(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 필요.