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 (백로그)
18 KiB
18 KiB
설계서: 프론트 - 식당 상세 시트 (#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).
- 결제·예약 트랜잭션 (외부 링크로 위임).
- 리뷰·메모 작성 UI 자체 (각각
3. 인수조건 (Acceptance Criteria)
RestaurantDetail
restaurant.id변경 시api.getRestaurantVideos(id)호출, 로딩 스켈레톤 표시 후 비디오 렌더링.- 로그인 토큰 있을 때만
api.getFavoriteStatus(id)호출 후 찜 하트 색상 결정. - 비로그인 사용자에게는 찜 버튼 자체가 숨겨진다.
- 찜 토글 중에는 버튼 disabled, API 응답으로 상태 동기화.
- 평점 있을 때 별 (
★) × round(rating) + 별점 숫자 + 카운트 표시. - 영업 상태 뱃지:
CLOSED_PERMANENTLY→ 빨간 "폐업",CLOSED_TEMPORARILY→ 노란 "임시휴업". google_place_id있을 때 Google Maps 외부 검색 링크 노출. 한국 지역이거나 region 없으면 네이버 지도 링크 동시 노출.tabling_url,catchtable_url값이 "NONE" 이 아니면 각각 컬러 풀폭 CTA 버튼 노출.- 비디오 없으면 "관련 영상이 없습니다", 있으면 각 비디오에 채널 뱃지·발행일·제목 링크·
foods_mentioned태그·evaluation.text·guests표시. - 비디오 1개 이상이면 하단에 크리에이터 응원 문구 박스 표시.
- 우상단 X 버튼 클릭 시
onClose호출.
BottomSheet
open=true시 PEEK(40vh) 로 열림.open=false면 null 반환 (DOM 미존재).- 핸들 또는 시트 영역 터치 드래그로 높이 변경, 종료 시 PEEK/HALF/FULL 중 최근접 스냅.
- 빠른 하향 플릭 (velocity > 0.5) 이고 HALF 미만이면
onClose호출. - PEEK*0.6 = 24vh 이하로 드래그되면
onClose호출. - FULL 상태에서 컨텐츠 스크롤 중일 때 (scrollTop > 0) 터치 인터셉트하지 않음 → 컨텐츠 스크롤 우선.
- 백드롭 (검은 반투명) 클릭 시 닫힘. 백드롭 투명도는 높이 비례 (
Math.min(1, (height-0.2)*2)). - 데스크탑(md+) 에서는
md:hidden으로 숨김.
RestaurantList
loading=true시RestaurantListSkeleton.- 빈 배열이면 "표시할 식당이 없습니다".
- 카드 클릭 시
onSelect(r). selectedId === r.id면 brand-50 배경 + brand-300 보더 하이라이트.keyPrefix로 데스크탑(d-)/모바일(m-) 키 충돌 방지.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-iconsgetCuisineIcon(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 디자인 시스템).
- Tailwind,
- 성능
- 비디오 페치는
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, refdragState.
- 상수:
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), 카드 렌더링.
- I/O: 4개 API 콜 (
6. 데이터 모델
// 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 데스크탑 상세 표시 흐름
- 사용자가 사이드바 카드 (또는 지도 마커) 클릭.
handleSelectRestaurant(r)→setSelected(r),setShowDetail(true).- 사이드바 컨텐츠가
RestaurantList→RestaurantDetail로 교체. RestaurantDetail마운트 → 비디오/찜 페치.- X 또는 다른 카드 클릭 시
onClose/ 새로운 selected 로 전환.
8.2 모바일 상세 표시 흐름 (BottomSheet)
- 카드/마커 클릭 →
showDetail=true, selected=r. <BottomSheet open=true>마운트 →useEffect가setHeight(0.4)(PEEK).- 사용자 핸들 드래그 →
onTouchMove가 height 라이브 업데이트. - 터치 종료 →
onTouchEnd가 velocity 계산:dt = (now - lastTime)/1000 || 0.1 dy = (startY - lastY) / vh velocity = -dy / dt // 양수 = 하향 snapTo(height, velocity):velocity > 0.5 && height < 0.55→ onCloseheight < 0.24(PEEK*0.6) → onClose- else → {0.4, 0.55, 0.92} 중 최근접 스냅 (setHeight)
- FULL 상태 + 컨텐츠 스크롤 중이면
onTouchStart가 드래그 시작을 막아 컨텐츠 스크롤이 우선.
8.3 찜 토글 흐름
getToken()있으면 마운트 시getFavoriteStatus(id)호출.- 응답
{ favorited: bool }→ setFavorited. - 사용자가 하트 클릭 →
favLoading=true→toggleFavorite(id)→ 응답으로 favorited 갱신 →favLoading=false. - 토큰 없으면 버튼 렌더되지 않아 클릭 자체 불가능.
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 = ... |
vh 변동 (모바일 주소창) |
window.innerHeight 매 터치마다 재조회 → 안전 |
| 데스크탑에서 BottomSheet 노출 | md:hidden 클래스로 차단 |
| Region 한국이 아닌데 google_place_id 있음 | 네이버 링크 미노출 (`region.split(" |
| 토큰 만료 (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 일치 시 하이라이트.
- [Unit·예상]
- 수동 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 처리 미정.