<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (reverse-engineering) -->

# 설계서: 프론트 - 식당 상세 시트 (#279)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #279 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx`, `frontend/src/components/BottomSheet.tsx`, `frontend/src/components/RestaurantList.tsx` · 테스트: TBD (현재 없음)

## 1. 목적 (Why)
식당 한 곳에 대한 메타 정보(위치·평점·예약)와 컨텍스트(관련 YouTube 영상, 음식 태그, 평가, 게스트)를 한 화면에서 빠르게 제공해, 탐색→방문 결정 전환을 돕는다. 모바일에서는 지도와 공존 가능하도록 BottomSheet 로 띄운다.

## 2. 범위 (Scope)
- **포함**
  - `RestaurantDetail`: 식당 메타 + 관련 영상 + 찜 토글 + 리뷰/메모 섹션 마운트.
  - `BottomSheet`: 모바일 전용 3-snap (PEEK 40% / HALF 55% / FULL 92%) 드래그 시트, 백드롭 클릭/플릭으로 닫기.
  - `RestaurantList`: 식당 카드 리스트 (3줄 레이아웃 — 이름/지역/별점 · 카테고리/가격/채널 · 음식 태그).
  - 데스크탑: 사이드바 내 inline 상세. 모바일: BottomSheet 내 상세.
  - 외부 링크: Google Maps, 네이버 지도(국내만), 테이블링, 캐치테이블, 전화걸기.
  - 로그인 시 찜 토글 (`POST /favorites/{id}/toggle`).
- **제외 (out of scope)**
  - 리뷰·메모 작성 UI 자체 (각각 `ReviewSection`, `MemoSection` — #281).
  - 지도 마커·인포윈도우 (#278).
  - 검색·필터 UI (#280).
  - 결제·예약 트랜잭션 (외부 링크로 위임).

## 3. 인수조건 (Acceptance Criteria)
### RestaurantDetail
- [x] `restaurant.id` 변경 시 `api.getRestaurantVideos(id)` 호출, 로딩 스켈레톤 표시 후 비디오 렌더링.
- [x] 로그인 토큰 있을 때만 `api.getFavoriteStatus(id)` 호출 후 찜 하트 색상 결정.
- [x] 비로그인 사용자에게는 찜 버튼 자체가 숨겨진다.
- [x] 찜 토글 중에는 버튼 disabled, API 응답으로 상태 동기화.
- [x] 평점 있을 때 별 (`★`) × round(rating) + 별점 숫자 + 카운트 표시.
- [x] 영업 상태 뱃지: `CLOSED_PERMANENTLY` → 빨간 "폐업", `CLOSED_TEMPORARILY` → 노란 "임시휴업".
- [x] `google_place_id` 있을 때 Google Maps 외부 검색 링크 노출. 한국 지역이거나 region 없으면 네이버 지도 링크 동시 노출.
- [x] `tabling_url`, `catchtable_url` 값이 "NONE" 이 아니면 각각 컬러 풀폭 CTA 버튼 노출.
- [x] 비디오 없으면 "관련 영상이 없습니다", 있으면 각 비디오에 채널 뱃지·발행일·제목 링크·`foods_mentioned` 태그·`evaluation.text`·`guests` 표시.
- [x] 비디오 1개 이상이면 하단에 크리에이터 응원 문구 박스 표시.
- [x] 우상단 X 버튼 클릭 시 `onClose` 호출.

### BottomSheet
- [x] `open=true` 시 PEEK(40vh) 로 열림. `open=false` 면 null 반환 (DOM 미존재).
- [x] 핸들 또는 시트 영역 터치 드래그로 높이 변경, 종료 시 PEEK/HALF/FULL 중 최근접 스냅.
- [x] 빠른 하향 플릭 (velocity > 0.5) 이고 HALF 미만이면 `onClose` 호출.
- [x] PEEK*0.6 = 24vh 이하로 드래그되면 `onClose` 호출.
- [x] FULL 상태에서 컨텐츠 스크롤 중일 때 (scrollTop > 0) 터치 인터셉트하지 않음 → 컨텐츠 스크롤 우선.
- [x] 백드롭 (검은 반투명) 클릭 시 닫힘. 백드롭 투명도는 높이 비례 (`Math.min(1, (height-0.2)*2)`).
- [x] 데스크탑(md+) 에서는 `md:hidden` 으로 숨김.

### RestaurantList
- [x] `loading=true` 시 `RestaurantListSkeleton`.
- [x] 빈 배열이면 "표시할 식당이 없습니다".
- [x] 카드 클릭 시 `onSelect(r)`.
- [x] `selectedId === r.id` 면 brand-50 배경 + brand-300 보더 하이라이트.
- [x] `keyPrefix` 로 데스크탑(`d-`)/모바일(`m-`) 키 충돌 방지.
- [x] `foods_mentioned` 가 5개 초과면 5개 + "+N" 표시.

## 4. 컨텍스트 & 제약
- **런타임**: Next.js 16 App Router, TypeScript, "use client".
- **외부 의존성**
  - `@/lib/api`: `api.getRestaurantVideos`, `api.getFavoriteStatus`, `api.toggleFavorite`, `getToken()`.
  - 자식 컴포넌트: `ReviewSection`, `MemoSection`, `RestaurantDetailSkeleton`, `RestaurantListSkeleton`.
  - `@/lib/cuisine-icons` `getCuisineIcon` (Material Symbols 매핑).
  - `@/components/Icon` (Material Symbols Rounded).
- **데이터 컨트랙트**
  - `Restaurant` (id, name, rating, rating_count, cuisine_type, address, region, price_range, phone, business_status, google_place_id, tabling_url, catchtable_url, channels[], foods_mentioned[]).
  - `VideoLink` (video_id, title, url, published_at, foods_mentioned[], evaluation: Record<string,string>, guests[], channel_name, channel_id).
- **UI/UX 제약**
  - Tailwind, `brand-*` 색상 토큰 (favorited = rose-500).
  - 모바일 터치 영역 ≥ 44×44 px — 찜 버튼 (`p-1.5 -m-1.5`) 으로 패딩 확장, 카드 전체가 버튼.
  - 다크모드 지원 (`dark:` 변형 클래스).
  - 모바일 BottomSheet `bg-surface/85 backdrop-blur-xl` (Saffron 디자인 시스템).
- **성능**
  - 비디오 페치는 `restaurant.id` 변경 시에만 (의존성 배열).
  - BottomSheet 드래그는 transition 비활성 (`dragging ? "none" : "0.3s"`).
  - 클로즈 애니메이션 없음 (즉시 unmount) — 단점이지만 단순성 우선.
- **가정**
  - 백엔드는 `evaluation` 을 `{ text: "..." }` 형태로 정규화 (`JsonUtil.normalizeEvaluation`, 300자 제한, 평문→래핑).
  - `getToken()` 은 동기 함수, localStorage 또는 메모리 토큰 반환.
  - 외부 링크 URL "NONE" 문자열은 백엔드 명시적 미존재 마커.

## 5. 아키텍처 개요
- **모듈/파일 구조**
  - `RestaurantDetail.tsx` (265 LOC)
    - 상태: `videos`, `loading`, `favorited`, `favLoading`.
    - 자식: `<ReviewSection>`, `<MemoSection>`.
  - `BottomSheet.tsx` (117 LOC)
    - 상수: `SNAP_POINTS = { PEEK:0.4, HALF:0.55, FULL:0.92 }`, `VELOCITY_THRESHOLD = 0.5`.
    - 상태: `height`, `dragging`, ref `dragState`.
  - `RestaurantList.tsx` (104 LOC) — stateless 표현형 컴포넌트.
- **데이터 흐름**

```
        ┌──────────────── page.tsx ─────────────────┐
        │ state: selected, showDetail               │
        │ handlers: handleSelectRestaurant,         │
        │           handleCloseDetail               │
        └───────┬──────────────────────────┬────────┘
                │ desktop sidebar          │ mobile
                ▼                          ▼
       ┌─────────────────┐    ┌─────────────────────────┐
       │ <RestaurantList │    │ <BottomSheet open onClose│
       │  restaurants    │    │  ┌─────────────────────┐│
       │  onSelect …>    │    │  │ <RestaurantDetail   ││
       └────────┬────────┘    │  │   restaurant onClose││
                │             │  └─────────────────────┘│
                ▼             └─────────────────────────┘
       ┌─────────────────┐
       │ <RestaurantDetail (inline, no sheet)         │
       │  ├─ useEffect: getRestaurantVideos(id)       │
       │  ├─ useEffect: getFavoriteStatus(id) if auth │
       │  ├─ ReviewSection                            │
       │  └─ MemoSection                              │
       └────────────────────────────────────────────────
```

- **I/O ↔ 순수 경계**
  - **I/O**: 4개 API 콜 (`getRestaurantVideos`, `getFavoriteStatus`, `toggleFavorite`, 외부 링크), Touch 이벤트, `window.innerHeight`, `Date.now()`.
  - **순수**: BottomSheet 의 `snapTo` 로직 (입력 height·velocity → 최근접 스냅 또는 close), 카드 렌더링.

## 6. 데이터 모델

```ts
// RestaurantDetail props
interface RestaurantDetailProps {
  restaurant: Restaurant;
  onClose: () => void;
}

// BottomSheet props
interface BottomSheetProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

// 내부 상태
type DragState = {
  startY: number; startH: number; lastY: number; lastTime: number;
};

// RestaurantList props
interface RestaurantListProps {
  restaurants: Restaurant[];
  selectedId?: string;
  onSelect: (r: Restaurant) => void;
  loading?: boolean;
  keyPrefix?: string;
}

// API 응답 (api.ts)
export interface VideoLink {
  video_id: string; title: string; url: string;
  published_at: string | null;
  foods_mentioned: string[];
  evaluation: Record<string, string>;   // { text: "..." } 형태 기대
  guests: string[];
  channel_name: string | null; channel_id: string | null;
}
```

- **경계 검증**
  - `restaurant.id` (UUID 32자) — 비어 있으면 API 호출 실패 — `setVideos([])` 로 안전 기본값.
  - `evaluation` 키 `text` 만 사용. 다른 키는 무시.
  - `tabling_url === "NONE"` / `null` / `""` 모두 미노출 처리 (`!== "NONE"` + 진릿값).
  - `foods_mentioned` 가 5개 초과 시 잘라내고 "+N" 표시.
  - BottomSheet height 클램프: `[0.1, 0.92]`.

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `RestaurantDetail` | 식당 메타+영상+찜+리뷰/메모 마운트 | `(props: RestaurantDetailProps) => JSX` | restaurant, onClose | JSX | 비디오 페치 실패 시 빈 배열, 찜 페치 실패 시 false 유지 | **복잡** (2개 useEffect, 4개 상태) |
| `useEffect (load videos)` | id 변경 시 비디오 페치 | `() => void` (deps: [restaurant.id]) | id | setVideos | catch → `[]` | 단순 |
| `useEffect (load favorite)` | 토큰 있을 때 찜 상태 페치 | `() => void` (deps: [restaurant.id]) | id, token | setFavorited | catch → 무시 | 단순 |
| `handleToggleFavorite` | 찜 토글 → 서버 응답으로 상태 동기화 | `async () => void` | — | void | 토큰 없으면 early-return, API 에러 catch | 단순 |
| `BottomSheet` | 3-snap 모바일 바텀 시트 | `(props: BottomSheetProps) => JSX \| null` | open, onClose, children | JSX/null | open=false → null | **복잡** (3 터치 핸들러, 드래그 상태기계) |
| `snapTo` | 드래그 종료 위치+속도 → 최근접 스냅/close | `(h: number, velocity: number) => void` | 높이비율, 속도 | void | h < PEEK*0.6 → close. velocity>0.5 & h<HALF → close | **복잡** (분기 결정) |
| `onTouchStart` | 드래그 시작점 기록, 컨텐츠 스크롤 인터셉트 방지 | `(e: TouchEvent) => void` | 터치 | void | scrollTop>0 & FULL 근접 시 무시 | 단순 |
| `onTouchMove` | 델타Y → 높이 비율 갱신 | `(e: TouchEvent) => void` | 터치 | void | dragging=false 시 early-return | 단순 |
| `onTouchEnd` | 드래그 종료, velocity 계산 후 snapTo | `() => void` | — | void | dragging=false 시 early-return | 단순 |
| `RestaurantList` | 식당 카드 리스트 렌더 | `(props: RestaurantListProps) => JSX` | restaurants, ... | JSX | loading → Skeleton, 빈 배열 → 안내 문구 | 단순 |

> 복잡 표시 함수는 fn-도큐먼트 분리 후보. 특히 `BottomSheet` 드래그 상태기계는 ADR/fn-doc 가치 있음.

## 8. 흐름 / 알고리즘

### 8.1 데스크탑 상세 표시 흐름
1. 사용자가 사이드바 카드 (또는 지도 마커) 클릭.
2. `handleSelectRestaurant(r)` → `setSelected(r)`, `setShowDetail(true)`.
3. 사이드바 컨텐츠가 `RestaurantList` → `RestaurantDetail` 로 교체.
4. `RestaurantDetail` 마운트 → 비디오/찜 페치.
5. X 또는 다른 카드 클릭 시 `onClose` / 새로운 selected 로 전환.

### 8.2 모바일 상세 표시 흐름 (BottomSheet)
1. 카드/마커 클릭 → `showDetail=true, selected=r`.
2. `<BottomSheet open=true>` 마운트 → `useEffect` 가 `setHeight(0.4)` (PEEK).
3. 사용자 핸들 드래그 → `onTouchMove` 가 height 라이브 업데이트.
4. 터치 종료 → `onTouchEnd` 가 velocity 계산:
   ```
   dt = (now - lastTime)/1000 || 0.1
   dy = (startY - lastY) / vh
   velocity = -dy / dt   // 양수 = 하향
   ```
5. `snapTo(height, velocity)`:
   - `velocity > 0.5 && height < 0.55` → onClose
   - `height < 0.24` (PEEK*0.6) → onClose
   - else → {0.4, 0.55, 0.92} 중 최근접 스냅 (setHeight)
6. FULL 상태 + 컨텐츠 스크롤 중이면 `onTouchStart` 가 드래그 시작을 막아 컨텐츠 스크롤이 우선.

### 8.3 찜 토글 흐름
1. `getToken()` 있으면 마운트 시 `getFavoriteStatus(id)` 호출.
2. 응답 `{ favorited: bool }` → setFavorited.
3. 사용자가 하트 클릭 → `favLoading=true` → `toggleFavorite(id)` → 응답으로 favorited 갱신 → `favLoading=false`.
4. 토큰 없으면 버튼 렌더되지 않아 클릭 자체 불가능.

### 8.4 영상 카드 렌더링
- `videos.map((v) => …)`:
  - 채널 뱃지 (있을 때) + 발행일 (slice(0,10)).
  - 제목 링크 (외부 새 탭, `noopener noreferrer`).
  - `foods_mentioned` 태그 (brand-50 배경).
  - `evaluation.text` 본문.
  - `guests.length>0` 시 "게스트: A, B".
- `videos.length>0` 시 "구독·좋아요 응원" 안내 박스 출력.

## 9. 엣지케이스 & 에러 처리

| 경계 | 처리 |
|------|------|
| 비디오 페치 실패 | catch → `setVideos([])` → "관련 영상이 없습니다" 메시지 |
| 찜 상태 페치 실패 | catch → 무시 (favorited=false 유지) |
| 토글 API 실패 | catch → 상태 변경 없음, `favLoading=false` |
| 평점 null | 별점 row 미노출 |
| 주소·전화·가격 null | 각 row 미노출 (단축 렌더링) |
| `tabling_url === "NONE"` | CTA 미노출 (`url !== "NONE"` && truthy 가드) |
| `evaluation` 형식 비표준 (`{text:undef}`) | `v.evaluation?.text` optional chain → undefined → falsy 미렌더 |
| `foods_mentioned` 빈 배열 | 태그 row 미렌더 (`v.foods_mentioned?.length > 0`) |
| BottomSheet open 토글 시 컨텐츠 깜빡임 | 닫힐 때 즉시 unmount (애니메이션 X) — 단순성 우선 |
| 드래그 중 빠른 시간차 (dt=0) | `dt = ... || 0.1` 가드로 NaN 방지 |
| `vh` 변동 (모바일 주소창) | `window.innerHeight` 매 터치마다 재조회 → 안전 |
| 데스크탑에서 BottomSheet 노출 | `md:hidden` 클래스로 차단 |
| Region 한국이 아닌데 google_place_id 있음 | 네이버 링크 미노출 (`region.split("|")[0] === "한국"` 가드) |
| 토큰 만료 (401) | 토글/페치 catch 만 처리 — UI 는 변경 안 함 (재로그인 유도는 미구현) |

## 10. 테스트 계획
- **현재 자동화 테스트 없음 (TBD)** — 수동 QA + 향후 RTL+Jest 후보:
  - [Unit·예상] `snapTo(0.45, 0)` → setHeight(0.4) (PEEK 가 더 가까움).
  - [Unit·예상] `snapTo(0.5, 0)` → setHeight(0.55) (HALF).
  - [Unit·예상] `snapTo(0.3, 0.6)` → onClose (빠른 하향 + HALF 미만).
  - [Unit·예상] `snapTo(0.2, 0)` → onClose (PEEK*0.6 미만).
  - [Unit·예상] `RestaurantList`: loading 시 Skeleton, 빈 배열 시 안내, selectedId 일치 시 하이라이트.
- **수동 QA**
  - 비로그인: 찜 하트 미노출.
  - 로그인 후 토글: 하트 색 즉시 변화.
  - 영상 0개 식당 진입 → "관련 영상이 없습니다".
  - 폐업 식당 진입 → 빨간 뱃지.
  - 모바일 BottomSheet — 핸들 드래그, 백드롭 클릭, 빠른 플릭, FULL 컨텐츠 스크롤.
  - 외부 링크 — 새 탭 열림, referrer 차단.
- **모킹/드라이런**
  - `api.*` 함수는 MSW 또는 jest mock 으로 대체.
  - `getToken()` 모킹으로 비로그인 분기 검증.
  - 터치 이벤트는 `@testing-library/user-event` 또는 Playwright.

## 11. 리스크 & 대안 검토
- **선택 1**: BottomSheet 를 직접 구현.
  - 장점: 의존성 0, 디자인 자유, 번들 사이즈 최소.
  - 단점: 접근성 (포커스 트랩, ESC 키 닫기, ARIA) 미흡, 키보드 사용자 배려 부족.
  - **대안**: `react-spring-bottom-sheet`, `@radix-ui/react-dialog`, `vaul`. 추후 접근성 요구가 강해지면 vaul 로 마이그레이션 검토.
- **선택 2**: RestaurantDetail 이 자체적으로 API 페치.
  - 장점: 컴포넌트 자급자족, 캐싱은 브라우저에 위임.
  - 단점: 동일 식당 반복 진입 시 매번 페치, prefetch 어려움.
  - **대안**: React Query/SWR 도입 (캐싱·재시도). 사용량 증가 시 ADR.
- **선택 3**: 외부 링크 (Google/네이버/테이블링/캐치테이블) 직접 노출.
  - 장점: 사용자 친숙, 백엔드 부담 없음.
  - 단점: 트래픽이 외부로 빠짐, 전환 추적 어려움.
- **되돌리기 어려운 결정**: BottomSheet 스냅포인트 0.4/0.55/0.92 — 변경 시 사용자 근육 기억 교란. ADR 후보.

## 12. 미해결 질문 (Open Questions)
- 데스크탑에서도 모바일과 동일한 시트 UX 제공 여부 (현재 inline sidebar 만).
- 비디오 카드 클릭 시 임베드 플레이어 인-앱 재생할지, 새 탭만 유지할지?
- 찜 외에 "방문 예정", "재방문" 같은 다중 상태 지원할지?
- evaluation 점수(1-5)도 함께 보여줄지 (현재 text 만 노출).
- 영상이 10개 이상인 경우 "더보기" 페이지네이션 필요?
- ESC 키, 백버튼(모바일 안드로이드)으로 BottomSheet 닫기 — 구현 필요?
- 동일 식당 (지점 다수) 통합 표시 정책.
- 다국어 (영어/일본어 식당 이름) i18n 처리 미정.
