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 (백로그)
287 lines
18 KiB
Markdown
287 lines
18 KiB
Markdown
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (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 처리 미정.
|