# 설계서: 프론트 - 리뷰/메모 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. 인수조건 (이미 구현된 동작 기준) - [x] 로그인 사용자는 식당당 본인 리뷰가 없을 때 "리뷰 작성" 버튼이 노출되고, 이미 작성한 경우 노출되지 않는다. - [x] 별점은 0.5 단위로 선택 가능 (별을 다시 누르면 0.5점 차감). - [x] 리뷰 작성/수정 시 별점·리뷰 텍스트·방문일을 입력하고 저장하면 목록이 즉시 갱신된다. - [x] 본인 리뷰에만 수정/삭제 버튼이 노출되고, 삭제는 confirm 후 실행된다. - [x] 평균 별점은 소수 1자리, 리뷰 수와 함께 표시되며, 리뷰가 0개일 때는 평균 영역이 숨겨진다. - [x] 메모는 본인에게만 보이며 "비공개" 뱃지가 표시된다. - [x] 메모는 식당당 1건의 upsert(저장/수정 동일 API)로 동작한다. - [x] 내 기록 리스트는 `리뷰` / `메모` 탭 전환을 지원하며, 항목 수를 탭 라벨에 표시한다. - [x] 내 기록 항목 클릭 시 `onSelectRestaurant(restaurantId)` 콜백이 호출된다. - [x] 로딩 중에는 스켈레톤 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] ├─ │ ├─ useAuth() ── user │ ├─ useEffect → api.getReviews ─→ { reviews, avg_rating, review_count } │ ├─ ReviewForm (작성/수정) │ │ └─ api.createReview / updateReview │ └─ api.deleteReview │ └─ ├─ useAuth() ── user (null이면 return null) ├─ useEffect → api.getMemo ─→ Memo | null ├─ form upsert → api.upsertMemo └─ api.deleteMemo [Page sidebar] └─ ├─ tab state (reviews | memos) └─ onSelectRestaurant(restaurantId) → 부모가 상세 열기 ``` - **I/O ↔ 순수 로직 경계**: - I/O: `api.*` 호출, `confirm()` 다이얼로그, `localStorage` 토큰 (api 레이어 내부) - 순수: `StarDisplay`/`StarSelector` 렌더 로직, 평균 별점 반올림 (`Math.round(avgRating * 2) / 2`), 본인 리뷰 판별, 탭 분기 ## 6. 데이터 모델 TypeScript 타입 (구현에서 import 또는 정의): ```ts // 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`: ``이 보장하는 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` | `ReviewPayload` | void | upstream throw | 단순 | | `handleUpdate` | 리뷰 수정 핸들러 | `(reviewId, data) => Promise` | id + payload | void | upstream throw | 단순 | | `handleDelete` | 리뷰 삭제 (confirm) | `(reviewId) => Promise` | id | void | confirm 취소 시 no-op | 단순 | | `handleSubmit` (Memo) | 메모 upsert | `(e) => Promise` | FormEvent | void | finally로 submitting 해제 | 단순 | | `handleDelete` (Memo) | 메모 삭제 (confirm) | `() => Promise` | - | void | confirm 취소 시 no-op | 단순 | | `startEdit` (Memo) | 메모 편집 폼 초기화 | `() => void` | - | void | - | 단순 | > 복잡 기준: ReviewSection/MemoSection은 외부 I/O + 사용자 인증 분기 + 폼 상태기계가 결합되어 통합 테스트 가치가 높음. ## 8. 흐름 / 알고리즘 **① 리뷰 작성 (신규)** 1. `useEffect`에서 `loadReviews()` 호출 → 목록·평균·카운트 세팅 2. `user && !myReview && !showForm` → "리뷰 작성" 버튼 노출 3. 버튼 클릭 → `setShowForm(true)` 4. `ReviewForm` 마운트, `initialDate = today (YYYY-MM-DD)` 5. submit → `handleCreate({rating, review_text?, visited_at?})` → `api.createReview` → `setShowForm(false)` → `loadReviews()` **② 리뷰 수정** 1. 본인 리뷰 카드의 "수정" → `setEditingId(review.id)` 2. 해당 카드 내부에서 `ReviewForm`이 초기값(기존 별점/텍스트/방문일) 채워 렌더 3. submit → `handleUpdate(id, data)` → `api.updateReview` → `setEditingId(null)` → reload **③ 리뷰 삭제** 1. "삭제" → `confirm("리뷰를 삭제하시겠습니까?")` 2. OK → `api.deleteReview` → reload **④ 메모 upsert** 1. `loadMemo()` (로그인 시) → `Memo | null` 2. 메모 없음 → "메모 작성" 점선 버튼 → `startEdit()` (기본값 3점, 오늘) 3. 메모 있음 → 카드 표시 + "수정"/"삭제" 4. 폼 submit → `api.upsertMemo` → 응답을 `setMemo`로 즉시 반영 **⑤ 별점 토글 (StarSelector)** - `onChange(value === v ? v - 0.5 : v)` — 같은 별 재클릭 시 0.5점 차감 **⑥ 내 기록 리스트** 1. 부모가 `reviews`, `memos` 로딩 → props 주입 2. 탭 클릭 → `setTab("reviews" | "memos")` 3. 항목 클릭 → `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 누락**: `` 자체를 조건부 렌더, alt만 빈 문자열 - **방문일 미입력**: `visited_at: undefined`로 전송 → 백엔드 nullable - **별점 0점 제출**: 현재 0점은 UI상 표시 안 됨(0이면 0.5도 아님). 폼은 `initialRating = 3` 기본값으로 0 제출 회피 - **긴 텍스트**: `