Files
tasteby/docs/design/281-frontend-review-memo/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

14 KiB

설계서: 프론트 - 리뷰/메모 UI (#281)

상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #281 · 관련 ADR: 없음 · 구현 파일: frontend/src/components/ReviewSection.tsx, frontend/src/components/MemoSection.tsx, frontend/src/components/MyReviewsList.tsx · 테스트: TBD (현재 없음)

1. 목적 (Why)

식당 상세에서 사용자가 별점/방문기록을 공개(리뷰) 또는 비공개(메모)로 남기고, 마이페이지에서 자신의 기록을 한눈에 회람할 수 있게 하여 "한번 가본 곳을 다시 찾는 사용자 경험"을 완성한다.

2. 범위 (Scope)

  • 포함:
    • 식당 상세 화면 리뷰 섹션: 평균 별점·리뷰 수, 리뷰 목록, 본인 리뷰 작성/수정/삭제, 별점 하프 단위 선택
    • 식당 상세 화면 메모 섹션: 본인만 보이는 비공개 메모, 업서트(upsert) 저장, 삭제
    • 내 기록 리스트: 작성한 리뷰/메모를 탭으로 분리 표시, 항목 클릭 시 상세로 이동 콜백 호출
    • 로그인 상태 분기 (비로그인 시 작성 버튼 미노출 / 메모 섹션 자체 비노출)
  • 제외 (out of scope):
    • 리뷰/메모 데이터 API·DB 스키마 (백엔드 #28x)
    • 좋아요/댓글/신고 기능
    • 사진 첨부, 마크다운 렌더링
    • 무한 스크롤 / 페이지네이션 (현재는 한 식당당 전체 fetch)

3. 인수조건 (이미 구현된 동작 기준)

  • 로그인 사용자는 식당당 본인 리뷰가 없을 때 "리뷰 작성" 버튼이 노출되고, 이미 작성한 경우 노출되지 않는다.
  • 별점은 0.5 단위로 선택 가능 (별을 다시 누르면 0.5점 차감).
  • 리뷰 작성/수정 시 별점·리뷰 텍스트·방문일을 입력하고 저장하면 목록이 즉시 갱신된다.
  • 본인 리뷰에만 수정/삭제 버튼이 노출되고, 삭제는 confirm 후 실행된다.
  • 평균 별점은 소수 1자리, 리뷰 수와 함께 표시되며, 리뷰가 0개일 때는 평균 영역이 숨겨진다.
  • 메모는 본인에게만 보이며 "비공개" 뱃지가 표시된다.
  • 메모는 식당당 1건의 upsert(저장/수정 동일 API)로 동작한다.
  • 내 기록 리스트는 리뷰 / 메모 탭 전환을 지원하며, 항목 수를 탭 라벨에 표시한다.
  • 내 기록 항목 클릭 시 onSelectRestaurant(restaurantId) 콜백이 호출된다.
  • 로딩 중에는 스켈레톤 UI가 표시된다.

4. 컨텍스트 & 제약

  • 프레임워크: Next.js 16 App Router, "use client" 컴포넌트
  • 언어/타입: TypeScript (strict), React 함수형 + Hooks
  • 스타일: Tailwind CSS + Saffron 디자인 토큰(bg-brand-500, border-brand-200, bg-brand-50/30 등), Pretendard 폰트
  • 상태/인증: @/lib/auth-contextuseAuth()user 객체 취득. user가 null이면 작성/수정/삭제 진입점 비노출 (메모 섹션은 전체 null 반환)
  • 데이터 호출: @/lib/apiapi.getReviews / createReview / updateReview / deleteReview / getMemo / upsertMemo / deleteMemo (Bearer 토큰은 api 레이어 책임)
  • 아이콘: @/components/Icon (Tabler 매핑) — edit_note, rate_review, add, close
  • 터치 영역: 별 버튼에 p-1.5 touch-manipulation 적용, 모바일 44px 목표 가이드 준수
  • 가정: 리뷰 응답은 { reviews, avg_rating, review_count } 형태. 본인 식별은 review.user_id === user.id.

5. 아키텍처 개요

  • 모듈/파일:
    • ReviewSection.tsx — 식당 상세 임베드용 공개 리뷰 섹션 (목록 + 폼)
    • MemoSection.tsx — 식당 상세 임베드용 비공개 메모 섹션 (단일 항목 + 폼)
    • MyReviewsList.tsx — 마이페이지/사이드패널용 내 기록 통합 리스트 (탭)
    • 내부 헬퍼: StarDisplay, StarSelector, ReviewForm (ReviewSection 내부)
[RestaurantDetail]
  ├─ <ReviewSection restaurantId>
  │     ├─ useAuth() ── user
  │     ├─ useEffect → api.getReviews ─→ { reviews, avg_rating, review_count }
  │     ├─ ReviewForm (작성/수정)
  │     │     └─ api.createReview / updateReview
  │     └─ api.deleteReview
  │
  └─ <MemoSection restaurantId>
        ├─ useAuth() ── user (null이면 return null)
        ├─ useEffect → api.getMemo ─→ Memo | null
        ├─ form upsert → api.upsertMemo
        └─ api.deleteMemo

[Page sidebar]
  └─ <MyReviewsList reviews memos onSelectRestaurant onClose>
        ├─ tab state (reviews | memos)
        └─ onSelectRestaurant(restaurantId) → 부모가 상세 열기
  • I/O ↔ 순수 로직 경계:
    • I/O: api.* 호출, confirm() 다이얼로그, localStorage 토큰 (api 레이어 내부)
    • 순수: StarDisplay/StarSelector 렌더 로직, 평균 별점 반올림 (Math.round(avgRating * 2) / 2), 본인 리뷰 판별, 탭 분기

6. 데이터 모델

TypeScript 타입 (구현에서 import 또는 정의):

// frontend/src/lib/api.ts (외부)
interface Review {
  id: string;
  user_id: string;
  user_nickname: string | null;
  user_avatar_url: string | null;
  rating: number;          // 0.5 단위 (0.5 ~ 5)
  review_text: string | null;
  visited_at: string | null;  // 'YYYY-MM-DD'
  created_at: string;         // ISO
}

interface Memo {
  id: string;
  rating: number | null;
  memo_text: string | null;
  visited_at: string | null;
  created_at: string;
}

// MyReviewsList 내부 확장
interface MyReview extends Review { restaurant_id: string; restaurant_name: string | null; }
interface MyMemo extends Memo     { restaurant_id: string; restaurant_name: string | null; }

// 폼 페이로드
type ReviewPayload = { rating: number; review_text?: string; visited_at?: string };
type MemoPayload   = { rating: number; memo_text?: string;  visited_at?: string };
  • 경계 검증:
    • rating: 0.5 ~ 5.0, 0.5 단위 (UI에서만 제한; 백엔드 검증 가정)
    • review_text / memo_text: 빈 문자열이면 undefined로 전송
    • visited_at: <input type="date">이 보장하는 YYYY-MM-DD 또는 undefined
    • 본인 판별: 클라이언트의 user.id 비교 — 신뢰 경계는 백엔드(JWT)에 있음

7. 함수 명세 (Function Specs)

함수 책임(1줄) 시그니처(잠정) 입력 출력 에러/실패 복잡?
ReviewSection 식당 리뷰 섹션 컨테이너 ({ restaurantId }) => JSX restaurantId: string JSX API 실패 시 setReviews([]) 복잡 (I/O+상태)
MemoSection 식당 메모 섹션 컨테이너 ({ restaurantId }) => JSX restaurantId: string JSX | null API 실패 시 setMemo(null) 복잡
MyReviewsList 내 기록 통합 탭 리스트 (props) => JSX reviews, memos, onClose, onSelectRestaurant JSX 부모가 fetch 책임 단순
StarDisplay 별점 읽기 전용 표시 ({ rating }) => JSX rating: number JSX (5개 span) 없음 단순
StarSelector 별점 선택 (0.5 단위) ({ value, onChange }) => JSX value: number, onChange: (v) => void JSX 없음 단순
ReviewForm 리뷰 입력 폼 ({ initial*, onSubmit, onCancel, submitLabel }) => JSX 초기값 + 콜백 JSX submit 중 disabled 단순
loadReviews 리뷰 목록 조회 후 상태 갱신 useCallback(() => void) restaurantId void (setState) catch → 빈 배열 단순
loadMemo 본인 메모 조회 useCallback(() => void) restaurantId, user void (setState) catch → null 단순
handleCreate 리뷰 생성 핸들러 (data) => Promise<void> ReviewPayload void upstream throw 단순
handleUpdate 리뷰 수정 핸들러 (reviewId, data) => Promise<void> id + payload void upstream throw 단순
handleDelete 리뷰 삭제 (confirm) (reviewId) => Promise<void> id void confirm 취소 시 no-op 단순
handleSubmit (Memo) 메모 upsert (e) => Promise<void> FormEvent void finally로 submitting 해제 단순
handleDelete (Memo) 메모 삭제 (confirm) () => Promise<void> - void confirm 취소 시 no-op 단순
startEdit (Memo) 메모 편집 폼 초기화 () => void - void - 단순

복잡 기준: ReviewSection/MemoSection은 외부 I/O + 사용자 인증 분기 + 폼 상태기계가 결합되어 통합 테스트 가치가 높음.

8. 흐름 / 알고리즘

① 리뷰 작성 (신규)

  1. useEffect에서 loadReviews() 호출 → 목록·평균·카운트 세팅
  2. user && !myReview && !showForm → "리뷰 작성" 버튼 노출
  3. 버튼 클릭 → setShowForm(true)
  4. ReviewForm 마운트, initialDate = today (YYYY-MM-DD)
  5. submit → handleCreate({rating, review_text?, visited_at?})api.createReviewsetShowForm(false)loadReviews()

② 리뷰 수정

  1. 본인 리뷰 카드의 "수정" → setEditingId(review.id)
  2. 해당 카드 내부에서 ReviewForm이 초기값(기존 별점/텍스트/방문일) 채워 렌더
  3. submit → handleUpdate(id, data)api.updateReviewsetEditingId(null) → reload

③ 리뷰 삭제

  1. "삭제" → confirm("리뷰를 삭제하시겠습니까?")
  2. OK → api.deleteReview → reload

④ 메모 upsert

  1. loadMemo() (로그인 시) → Memo | null
  2. 메모 없음 → "메모 작성" 점선 버튼 → startEdit() (기본값 3점, 오늘)
  3. 메모 있음 → 카드 표시 + "수정"/"삭제"
  4. 폼 submit → api.upsertMemo → 응답을 setMemo로 즉시 반영

⑤ 별점 토글 (StarSelector)

  • onChange(value === v ? v - 0.5 : v) — 같은 별 재클릭 시 0.5점 차감

⑥ 내 기록 리스트

  1. 부모가 reviews, memos 로딩 → props 주입
  2. 탭 클릭 → setTab("reviews" | "memos")
  3. 항목 클릭 → onSelectRestaurant(id) 호출 (부모가 상세 시트/모달 오픈)

9. 엣지케이스 & 에러 처리

  • 비로그인: ReviewSection은 목록만 표시 (작성 버튼 숨김), MemoSectionreturn null
  • 리뷰 0개: "아직 리뷰가 없습니다" 안내, 평균 영역 숨김
  • avgRating === null: 평균/카운트 영역 비노출 (null 가드)
  • API 실패: getReviewssetReviews([]), getMemosetMemo(null) (조용한 실패; 사용자 알림 없음)
  • create/update/delete 실패: 현재 try/catch 없음 → 호출자(상위) catch 또는 unhandled rejection. 개선 여지로 토스트 도입 필요 (미해결 질문 참조)
  • 본인 리뷰가 이미 있는데 다른 사용자로 로그인 전환: user.id 비교로 자동 분기 (백엔드가 중복 차단 가정)
  • avatar_url 누락: <img> 자체를 조건부 렌더, alt만 빈 문자열
  • 방문일 미입력: visited_at: undefined로 전송 → 백엔드 nullable
  • 별점 0점 제출: 현재 0점은 UI상 표시 안 됨(0이면 0.5도 아님). 폼은 initialRating = 3 기본값으로 0 제출 회피
  • 긴 텍스트: <textarea rows={3} resize-none>로 시각적 제한, 백엔드 길이 검증에 의존
  • MyReviewsList에서 restaurant_name = null: "알 수 없는 식당"으로 표시

10. 테스트 계획

현재 자동화 테스트 없음 (TBD). 도입 시 권장:

  • 단위 (Vitest + React Testing Library):
    1. StarSelector — 같은 별 재클릭 → 0.5 토글 (인수조건 별점 0.5 단위)
    2. StarDisplayrating=3.5 입력 시 3개 노랑 + 1개 반쪽 표현 검증
    3. ReviewForm — 빈 텍스트 submit 시 review_text: undefined로 전달되는지
    4. ReviewSectionuser가 본인 리뷰를 이미 가진 경우 "리뷰 작성" 버튼 미노출
    5. MemoSectionuser === nullnull 반환
    6. MyReviewsList — 탭 전환 후 빈 상태 메시지 / 항목 클릭 콜백 호출
  • 통합 (Playwright/MSW):
    • getReviews mock → 작성→수정→삭제 사이클 e2e
    • upsertMemo mock → 첫 작성과 재저장이 동일 엔드포인트로 가는지
  • 모킹 전략: @/lib/api를 모듈 모킹, @/lib/auth-context Provider를 테스트용으로 래핑

11. 리스크 & 대안 검토

  • 상태 동기화: 작성 후 매번 loadReviews() 전체 재요청 → 단순하지만 네트워크 낭비. 대안: 낙관적 업데이트(optimistic) — 채택 안 함 (단일 식당 N=작아 비용 낮음)
  • 에러 사일런스: catch(() => setReviews([]))는 네트워크 오류와 "리뷰 0개"를 시각적으로 구별 불가. 대안: 에러 상태 분리. 현 단계 미채택 — UX 단순화 우선
  • 별점 정밀도: 0.5 단위는 클라이언트에서만 강제. 백엔드가 임의 소수를 받으면 부정확한 평균이 가능 → 백엔드 검증 의존
  • 메모 1건 가정: 현재 식당당 1메모. 다중 메모(시간순 일기)로 확장 시 데이터 모델 변경 필요 → ADR 후보
  • MyReviewsList 부모 fetch 의존: 컴포넌트 자체는 stateless하여 재사용 쉬움. 대안인 self-fetch는 화면 컨텍스트별 캐시 충돌 우려로 미채택
  • 접근성: 별 버튼에 title만 제공, aria-label 없음 → 스크린리더 개선 여지
  • 본인 식별을 클라이언트가 함: 보안 경계는 백엔드. 클라이언트는 UX 분기만 — 안전

12. 미해결 질문 (Open Questions)

  • 리뷰/메모 작성·수정·삭제 실패 시 사용자에게 어떻게 알릴까? (현재 토스트 없음 — alert? 인라인 메시지?)
  • 비로그인 사용자에게도 "리뷰 작성" 버튼을 노출하고 클릭 시 로그인 유도하는 게 좋은가?
  • 사진 첨부/메뉴별 평점 등 확장 요구가 들어올 때 데이터 모델 변경 범위는?
  • 평균 별점을 백엔드 캐시(Redis)에 미리 저장 vs 매 요청 집계 — 트래픽 임계점은?
  • MyReviewsList의 정렬 기준(최근 작성 vs 최근 방문)을 사용자 선택으로 제공할 필요가 있는가?