Files
tasteby/docs/design/279-frontend-restaurant-detail/README.md
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

18 KiB
Raw Blame History

설계서: 프론트 - 식당 상세 시트 (#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

  • 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=trueRestaurantListSkeleton.
  • 빈 배열이면 "표시할 식당이 없습니다".
  • 카드 클릭 시 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-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. 데이터 모델

// 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([]) 로 안전 기본값.
    • evaluationtext 만 사용. 다른 키는 무시.
    • 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. 사이드바 컨텐츠가 RestaurantListRestaurantDetail 로 교체.
  4. RestaurantDetail 마운트 → 비디오/찜 페치.
  5. X 또는 다른 카드 클릭 시 onClose / 새로운 selected 로 전환.

8.2 모바일 상세 표시 흐름 (BottomSheet)

  1. 카드/마커 클릭 → showDetail=true, selected=r.
  2. <BottomSheet open=true> 마운트 → useEffectsetHeight(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=truetoggleFavorite(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 = ...
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 일치 시 하이라이트.
  • 수동 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 처리 미정.