Files
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

154 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 기능 설계서. 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 필요.