<!-- 기능 설계서. design/272-backend-review-memo/README.md
     작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

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

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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<Review>(restaurantId, limit, offset)` | 식당ID, 페이지 | List<Review> | 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<Review> | DB 오류 → 전파 | 단순 |
| `ReviewService.isFavorited` | 즐겨찾기 여부 | `boolean(userId, restaurantId)` | 사용자/식당 | true/false | DB 오류 → 전파 | 단순 |
| `ReviewService.toggleFavorite` | 즐겨찾기 토글 | `boolean(userId, restaurantId)` | 사용자/식당 | 토글 후 상태 | 동시성 시 유니크 충돌 가능 | **복잡** |
| `ReviewService.getUserFavorites` | 내 즐겨찾기 식당 목록 | `List<Restaurant>(userId)` | 사용자 | List<Restaurant> | 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<Memo> | 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 필요.
