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 (백로그)
11 KiB
11 KiB
설계서: 백엔드 - 리뷰/메모 (#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,MemoMapperXML (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+ XMLdomain/Review.java,domain/Memo.javasecurity/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. 흐름 / 알고리즘
- 리뷰 작성: AuthUtil.getUserId() → IdGenerator.newId() → INSERT → findById로 재조회하여 반환.
- 평균 평점 조회:
mapper.getAvgRating→ null 체크 →JsonUtil.lowerKeys()로 키 소문자화 → 응답 머지. - 메모 upsert:
- 사전 SELECT (user_id, restaurant_id) →
- 존재하면 UPDATE, 미존재하면 INSERT (IdGenerator로 새 ID) →
- 최종 SELECT 후 반환.
- 즐겨찾기 토글:
findFavoriteId(userId, restaurantId)→- 존재하면 DELETE → false 반환, 미존재하면 INSERT → true 반환.
- 권한 검증 (수정/삭제): 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.getAvgRatingnull 반환 시 기본값 처리MemoService.upsert신규 INSERT vs UPDATE 분기ReviewService.update/delete0행 시 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 후보.
- 대안 A: Oracle
- 권한 검증: 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 필요.