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 (백로그)
설계서: 프론트 - 리뷰/메모 UI (#281)
상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #281 · 관련 ADR: 없음 · 구현 파일:
frontend/src/components/ReviewSection.tsx,frontend/src/components/MemoSection.tsx,frontend/src/components/MyReviewsList.tsx· 테스트: TBD (현재 없음)
1. 목적 (Why)
식당 상세에서 사용자가 별점/방문기록을 공개(리뷰) 또는 비공개(메모)로 남기고, 마이페이지에서 자신의 기록을 한눈에 회람할 수 있게 하여 "한번 가본 곳을 다시 찾는 사용자 경험"을 완성한다.
2. 범위 (Scope)
- 포함:
- 식당 상세 화면 리뷰 섹션: 평균 별점·리뷰 수, 리뷰 목록, 본인 리뷰 작성/수정/삭제, 별점 하프 단위 선택
- 식당 상세 화면 메모 섹션: 본인만 보이는 비공개 메모, 업서트(upsert) 저장, 삭제
- 내 기록 리스트: 작성한 리뷰/메모를 탭으로 분리 표시, 항목 클릭 시 상세로 이동 콜백 호출
- 로그인 상태 분기 (비로그인 시 작성 버튼 미노출 / 메모 섹션 자체 비노출)
- 제외 (out of scope):
- 리뷰/메모 데이터 API·DB 스키마 (백엔드 #28x)
- 좋아요/댓글/신고 기능
- 사진 첨부, 마크다운 렌더링
- 무한 스크롤 / 페이지네이션 (현재는 한 식당당 전체 fetch)
3. 인수조건 (이미 구현된 동작 기준)
- 로그인 사용자는 식당당 본인 리뷰가 없을 때 "리뷰 작성" 버튼이 노출되고, 이미 작성한 경우 노출되지 않는다.
- 별점은 0.5 단위로 선택 가능 (별을 다시 누르면 0.5점 차감).
- 리뷰 작성/수정 시 별점·리뷰 텍스트·방문일을 입력하고 저장하면 목록이 즉시 갱신된다.
- 본인 리뷰에만 수정/삭제 버튼이 노출되고, 삭제는 confirm 후 실행된다.
- 평균 별점은 소수 1자리, 리뷰 수와 함께 표시되며, 리뷰가 0개일 때는 평균 영역이 숨겨진다.
- 메모는 본인에게만 보이며 "비공개" 뱃지가 표시된다.
- 메모는 식당당 1건의 upsert(저장/수정 동일 API)로 동작한다.
- 내 기록 리스트는
리뷰/메모탭 전환을 지원하며, 항목 수를 탭 라벨에 표시한다. - 내 기록 항목 클릭 시
onSelectRestaurant(restaurantId)콜백이 호출된다. - 로딩 중에는 스켈레톤 UI가 표시된다.
4. 컨텍스트 & 제약
- 프레임워크: Next.js 16 App Router,
"use client"컴포넌트 - 언어/타입: TypeScript (strict), React 함수형 + Hooks
- 스타일: Tailwind CSS + Saffron 디자인 토큰(
bg-brand-500,border-brand-200,bg-brand-50/30등), Pretendard 폰트 - 상태/인증:
@/lib/auth-context의useAuth()로user객체 취득.user가 null이면 작성/수정/삭제 진입점 비노출 (메모 섹션은 전체 null 반환) - 데이터 호출:
@/lib/api의api.getReviews / createReview / updateReview / deleteReview / getMemo / upsertMemo / deleteMemo(Bearer 토큰은 api 레이어 책임) - 아이콘:
@/components/Icon(Tabler 매핑) —edit_note,rate_review,add,close - 터치 영역: 별 버튼에
p-1.5 touch-manipulation적용, 모바일 44px 목표 가이드 준수 - 가정: 리뷰 응답은
{ reviews, avg_rating, review_count }형태. 본인 식별은review.user_id === user.id.
5. 아키텍처 개요
- 모듈/파일:
ReviewSection.tsx— 식당 상세 임베드용 공개 리뷰 섹션 (목록 + 폼)MemoSection.tsx— 식당 상세 임베드용 비공개 메모 섹션 (단일 항목 + 폼)MyReviewsList.tsx— 마이페이지/사이드패널용 내 기록 통합 리스트 (탭)- 내부 헬퍼:
StarDisplay,StarSelector,ReviewForm(ReviewSection 내부)
[RestaurantDetail]
├─ <ReviewSection restaurantId>
│ ├─ useAuth() ── user
│ ├─ useEffect → api.getReviews ─→ { reviews, avg_rating, review_count }
│ ├─ ReviewForm (작성/수정)
│ │ └─ api.createReview / updateReview
│ └─ api.deleteReview
│
└─ <MemoSection restaurantId>
├─ useAuth() ── user (null이면 return null)
├─ useEffect → api.getMemo ─→ Memo | null
├─ form upsert → api.upsertMemo
└─ api.deleteMemo
[Page sidebar]
└─ <MyReviewsList reviews memos onSelectRestaurant onClose>
├─ tab state (reviews | memos)
└─ onSelectRestaurant(restaurantId) → 부모가 상세 열기
- I/O ↔ 순수 로직 경계:
- I/O:
api.*호출,confirm()다이얼로그,localStorage토큰 (api 레이어 내부) - 순수:
StarDisplay/StarSelector렌더 로직, 평균 별점 반올림 (Math.round(avgRating * 2) / 2), 본인 리뷰 판별, 탭 분기
- I/O:
6. 데이터 모델
TypeScript 타입 (구현에서 import 또는 정의):
// frontend/src/lib/api.ts (외부)
interface Review {
id: string;
user_id: string;
user_nickname: string | null;
user_avatar_url: string | null;
rating: number; // 0.5 단위 (0.5 ~ 5)
review_text: string | null;
visited_at: string | null; // 'YYYY-MM-DD'
created_at: string; // ISO
}
interface Memo {
id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
}
// MyReviewsList 내부 확장
interface MyReview extends Review { restaurant_id: string; restaurant_name: string | null; }
interface MyMemo extends Memo { restaurant_id: string; restaurant_name: string | null; }
// 폼 페이로드
type ReviewPayload = { rating: number; review_text?: string; visited_at?: string };
type MemoPayload = { rating: number; memo_text?: string; visited_at?: string };
- 경계 검증:
rating: 0.5 ~ 5.0, 0.5 단위 (UI에서만 제한; 백엔드 검증 가정)review_text/memo_text: 빈 문자열이면undefined로 전송visited_at:<input type="date">이 보장하는 YYYY-MM-DD 또는undefined- 본인 판별: 클라이언트의
user.id비교 — 신뢰 경계는 백엔드(JWT)에 있음
7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|---|---|---|---|---|---|---|
ReviewSection |
식당 리뷰 섹션 컨테이너 | ({ restaurantId }) => JSX |
restaurantId: string |
JSX | API 실패 시 setReviews([]) |
복잡 (I/O+상태) |
MemoSection |
식당 메모 섹션 컨테이너 | ({ restaurantId }) => JSX |
restaurantId: string |
JSX | null | API 실패 시 setMemo(null) |
복잡 |
MyReviewsList |
내 기록 통합 탭 리스트 | (props) => JSX |
reviews, memos, onClose, onSelectRestaurant |
JSX | 부모가 fetch 책임 | 단순 |
StarDisplay |
별점 읽기 전용 표시 | ({ rating }) => JSX |
rating: number |
JSX (5개 span) | 없음 | 단순 |
StarSelector |
별점 선택 (0.5 단위) | ({ value, onChange }) => JSX |
value: number, onChange: (v) => void |
JSX | 없음 | 단순 |
ReviewForm |
리뷰 입력 폼 | ({ initial*, onSubmit, onCancel, submitLabel }) => JSX |
초기값 + 콜백 | JSX | submit 중 disabled | 단순 |
loadReviews |
리뷰 목록 조회 후 상태 갱신 | useCallback(() => void) |
restaurantId |
void (setState) | catch → 빈 배열 | 단순 |
loadMemo |
본인 메모 조회 | useCallback(() => void) |
restaurantId, user |
void (setState) | catch → null | 단순 |
handleCreate |
리뷰 생성 핸들러 | (data) => Promise<void> |
ReviewPayload |
void | upstream throw | 단순 |
handleUpdate |
리뷰 수정 핸들러 | (reviewId, data) => Promise<void> |
id + payload | void | upstream throw | 단순 |
handleDelete |
리뷰 삭제 (confirm) | (reviewId) => Promise<void> |
id | void | confirm 취소 시 no-op | 단순 |
handleSubmit (Memo) |
메모 upsert | (e) => Promise<void> |
FormEvent | void | finally로 submitting 해제 | 단순 |
handleDelete (Memo) |
메모 삭제 (confirm) | () => Promise<void> |
- | void | confirm 취소 시 no-op | 단순 |
startEdit (Memo) |
메모 편집 폼 초기화 | () => void |
- | void | - | 단순 |
복잡 기준: ReviewSection/MemoSection은 외부 I/O + 사용자 인증 분기 + 폼 상태기계가 결합되어 통합 테스트 가치가 높음.
8. 흐름 / 알고리즘
① 리뷰 작성 (신규)
useEffect에서loadReviews()호출 → 목록·평균·카운트 세팅user && !myReview && !showForm→ "리뷰 작성" 버튼 노출- 버튼 클릭 →
setShowForm(true) ReviewForm마운트,initialDate = today (YYYY-MM-DD)- submit →
handleCreate({rating, review_text?, visited_at?})→api.createReview→setShowForm(false)→loadReviews()
② 리뷰 수정
- 본인 리뷰 카드의 "수정" →
setEditingId(review.id) - 해당 카드 내부에서
ReviewForm이 초기값(기존 별점/텍스트/방문일) 채워 렌더 - submit →
handleUpdate(id, data)→api.updateReview→setEditingId(null)→ reload
③ 리뷰 삭제
- "삭제" →
confirm("리뷰를 삭제하시겠습니까?") - OK →
api.deleteReview→ reload
④ 메모 upsert
loadMemo()(로그인 시) →Memo | null- 메모 없음 → "메모 작성" 점선 버튼 →
startEdit()(기본값 3점, 오늘) - 메모 있음 → 카드 표시 + "수정"/"삭제"
- 폼 submit →
api.upsertMemo→ 응답을setMemo로 즉시 반영
⑤ 별점 토글 (StarSelector)
onChange(value === v ? v - 0.5 : v)— 같은 별 재클릭 시 0.5점 차감
⑥ 내 기록 리스트
- 부모가
reviews,memos로딩 → props 주입 - 탭 클릭 →
setTab("reviews" | "memos") - 항목 클릭 →
onSelectRestaurant(id)호출 (부모가 상세 시트/모달 오픈)
9. 엣지케이스 & 에러 처리
- 비로그인:
ReviewSection은 목록만 표시 (작성 버튼 숨김),MemoSection은return null - 리뷰 0개: "아직 리뷰가 없습니다" 안내, 평균 영역 숨김
avgRating === null: 평균/카운트 영역 비노출 (null 가드)- API 실패:
getReviews→setReviews([]),getMemo→setMemo(null)(조용한 실패; 사용자 알림 없음) - create/update/delete 실패: 현재 try/catch 없음 → 호출자(상위) catch 또는 unhandled rejection. 개선 여지로 토스트 도입 필요 (미해결 질문 참조)
- 본인 리뷰가 이미 있는데 다른 사용자로 로그인 전환:
user.id비교로 자동 분기 (백엔드가 중복 차단 가정) - avatar_url 누락:
<img>자체를 조건부 렌더, alt만 빈 문자열 - 방문일 미입력:
visited_at: undefined로 전송 → 백엔드 nullable - 별점 0점 제출: 현재 0점은 UI상 표시 안 됨(0이면 0.5도 아님). 폼은
initialRating = 3기본값으로 0 제출 회피 - 긴 텍스트:
<textarea rows={3} resize-none>로 시각적 제한, 백엔드 길이 검증에 의존 MyReviewsList에서 restaurant_name = null: "알 수 없는 식당"으로 표시
10. 테스트 계획
현재 자동화 테스트 없음 (TBD). 도입 시 권장:
- 단위 (Vitest + React Testing Library):
StarSelector— 같은 별 재클릭 → 0.5 토글 (인수조건 별점 0.5 단위)StarDisplay—rating=3.5입력 시 3개 노랑 + 1개 반쪽 표현 검증ReviewForm— 빈 텍스트 submit 시review_text: undefined로 전달되는지ReviewSection—user가 본인 리뷰를 이미 가진 경우 "리뷰 작성" 버튼 미노출MemoSection—user === null→null반환MyReviewsList— 탭 전환 후 빈 상태 메시지 / 항목 클릭 콜백 호출
- 통합 (Playwright/MSW):
getReviewsmock → 작성→수정→삭제 사이클 e2eupsertMemomock → 첫 작성과 재저장이 동일 엔드포인트로 가는지
- 모킹 전략:
@/lib/api를 모듈 모킹,@/lib/auth-contextProvider를 테스트용으로 래핑
11. 리스크 & 대안 검토
- 상태 동기화: 작성 후 매번
loadReviews()전체 재요청 → 단순하지만 네트워크 낭비. 대안: 낙관적 업데이트(optimistic) — 채택 안 함 (단일 식당 N=작아 비용 낮음) - 에러 사일런스:
catch(() => setReviews([]))는 네트워크 오류와 "리뷰 0개"를 시각적으로 구별 불가. 대안: 에러 상태 분리. 현 단계 미채택 — UX 단순화 우선 - 별점 정밀도: 0.5 단위는 클라이언트에서만 강제. 백엔드가 임의 소수를 받으면 부정확한 평균이 가능 → 백엔드 검증 의존
- 메모 1건 가정: 현재 식당당 1메모. 다중 메모(시간순 일기)로 확장 시 데이터 모델 변경 필요 → ADR 후보
MyReviewsList부모 fetch 의존: 컴포넌트 자체는 stateless하여 재사용 쉬움. 대안인 self-fetch는 화면 컨텍스트별 캐시 충돌 우려로 미채택- 접근성: 별 버튼에
title만 제공,aria-label없음 → 스크린리더 개선 여지 - 본인 식별을 클라이언트가 함: 보안 경계는 백엔드. 클라이언트는 UX 분기만 — 안전
12. 미해결 질문 (Open Questions)
- 리뷰/메모 작성·수정·삭제 실패 시 사용자에게 어떻게 알릴까? (현재 토스트 없음 — alert? 인라인 메시지?)
- 비로그인 사용자에게도 "리뷰 작성" 버튼을 노출하고 클릭 시 로그인 유도하는 게 좋은가?
- 사진 첨부/메뉴별 평점 등 확장 요구가 들어올 때 데이터 모델 변경 범위는?
- 평균 별점을 백엔드 캐시(Redis)에 미리 저장 vs 매 요청 집계 — 트래픽 임계점은?
MyReviewsList의 정렬 기준(최근 작성 vs 최근 방문)을 사용자 선택으로 제공할 필요가 있는가?