# 설계서: 프론트 - 식당 상세 시트 (#279) > **상태**: Approved > **작성**: [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, 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`. - 자식: ``, ``. - `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 ▼ ▼ ┌─────────────────┐ ┌─────────────────────────┐ │ │ │ │ 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; // { 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 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. `` 마운트 → `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 처리 미정.