<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지. -->

# 설계서: 프론트 - 리뷰/메모 UI (#281)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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]
  ├─ <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`), 본인 리뷰 판별, 탭 분기

## 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`: `<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. 흐름 / 알고리즘
**① 리뷰 작성 (신규)**
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 누락**: `<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)**:
  1. `StarSelector` — 같은 별 재클릭 → 0.5 토글 (인수조건 별점 0.5 단위)
  2. `StarDisplay` — `rating=3.5` 입력 시 3개 노랑 + 1개 반쪽 표현 검증
  3. `ReviewForm` — 빈 텍스트 submit 시 `review_text: undefined`로 전달되는지
  4. `ReviewSection` — `user`가 본인 리뷰를 이미 가진 경우 "리뷰 작성" 버튼 미노출
  5. `MemoSection` — `user === null` → `null` 반환
  6. `MyReviewsList` — 탭 전환 후 빈 상태 메시지 / 항목 클릭 콜백 호출
- **통합 (Playwright/MSW)**:
  - `getReviews` mock → 작성→수정→삭제 사이클 e2e
  - `upsertMemo` mock → 첫 작성과 재저장이 동일 엔드포인트로 가는지
- **모킹 전략**: `@/lib/api`를 모듈 모킹, `@/lib/auth-context` Provider를 테스트용으로 래핑

## 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 최근 방문)을 사용자 선택으로 제공할 필요가 있는가?
