Files
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
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 (백로그)
2026-06-15 11:08:18 +09:00

287 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (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 처리 미정.