한정판 상품 구매 화면 FE 설계 (design-fe-app)

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/마케팅 이벤트 고부하 대응/PRD.md (검수 PASS) 근거 BE TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/마케팅 이벤트 고부하 대응/TDD.md — API 계약을 입력으로 소비.

goods 도메인에 LimitedDrop(한정판 판매 회차) 게이트가 추가된다. FE는 이 회차의 구매 화면을 담당한다 — 판매 시작 카운트다운, 판매 전/재고 소진/구매 성공·실패 상태, 20000TPS 스파이크 상황의 대기·거부 UX. 대상 플랫폼은 **모바일(React Native / Expo)**이다. 웹(web/)은 운영자 포털이라 소비자 구매 surface가 없다.

Overview

  • 무엇을: mobile/에 한정판 회차 상세·카운트다운 화면과 구매 진행·결과 화면을 추가한다. BE의 POST /limited-drops/{id}/orders(202/425/409/429/403) 응답을 사용자 관점 UX로 매핑한다.
  • : 등록 즉시 구매 가능한 기존 상품과 달리, “특정 시각부터 선착순”을 표현할 화면이 없다. 스파이크 시 서버가 반환하는 429 Throttled(완충)·409 SoldOut(즉시 거부)를 사용자에게 이해 가능한 대기·마감 경험으로 전달해야 한다.
  • 어떻게: 서버 상태는 TanStack Query(회차 조회 폴링 + 구매 mutation), 카운트다운·구매 phase는 지역 상태. 현재 mobile에 테마 시스템이 전무하므로, 라이트/다크 토큰 시스템(theme/)과 테마 프리미티브를 선행 티켓으로 신설하고 신규 화면은 전부 두 모드로 구현한다.

Terminology

용어정의
LimitedDrop한정판 판매 회차. dropId·productId·openAt·closeAt·limitedQuantity·remaining·status 보유
판매 시작 게이트now < openAt이면 구매 버튼 비활성 + 카운트다운 노출
구매 성공주문 PENDING 생성(202) 시점. 결제는 별개 단계(기존 /payments/prepare 흐름 재사용)
Throttled UX서버 429(완충 초과) 수신 시 “접속 혼잡 — 잠시 후 자동 재시도” 화면
SoldOut UX서버 409 SoldOut(즉시 거부) 수신 시 “마감” 화면
테마 토큰시맨틱 색 토큰(라이트/다크 값 매핑). 신규 theme/ 모듈이 SSOT

Define Problem

AS-IS (실제 코드 근거)

요소현재 상태근거
테마·다크모드전무. 전 화면이 iOS 색(#007AFF·#F2F2F7·#1C1C1E·#FF3B30·#8E8E93)을 StyleSheet에 하드코딩. 탭바도 #007AFF 하드코딩app/product/[id]/index.tsx, app/event/[id]/order.tsx, app/(tabs)/_layout.tsx
API 계층api/*.ts 엔드포인트 함수(getBeClient() axios 싱글턴, BE 직접) + lib/use*.ts query 훅. Idempotency-Key·X-User-Id 헤더 이미 사용api/goodsOrders.ts, api/goods.ts#useCreateGoodsOrder, lib/useGoodsOrders.ts
구매 흐름 템플릿phase 머신(confirm/selecting/purchasing/done) + Alert로 오류 처리app/event/[id]/order.tsx
결제 연계구매 성공 후 /payment/new?orderType=...&orderId=...&amount=...로 이동, 외부 WebViewapp/payment/new.tsx
라우팅expo-router 파일 기반. 라우트 상수 ROUTES, Stack 등록은 _layout.tsxlib/navigation.ts, app/_layout.tsx
상태관리서버 상태 Query(staleTime 30분), 세션은 Zustand(useAuthStore)lib/query-client.ts, lib/auth.ts
userIduseCurrentUserId() 임시 반환(1), X-User-Id 헤더 부착 (AUTH-04 전까지)api/goods.ts#useCurrentUserId

문제점

  1. 카운트다운·판매 시작 게이트를 표현할 화면이 없다.
  2. 스파이크 시 서버가 주는 429/409를 사용자 언어로 전달할 UX가 없다 (기존은 Alert 단순 실패).
  3. 테마 토큰이 없어 신규 화면을 컨벤션(라이트/다크 의무)에 맞게 만들 수 없다 → 선행 신설 필요.

TO-BE

  • 시맨틱 테마 토큰 + useTheme() + 테마 프리미티브(ThemedView/ThemedText/PrimaryButton) 신설, 라이트/다크 both.
  • 한정판 상세/카운트다운 화면: SCHEDULED(카운트다운)/OPEN(구매)/SOLD_OUT/CLOSED/loading/error 전 상태 처리.
  • 구매 진행/결과 화면: idle → submitting → (admitted|throttled|soldOut|closed|tooEarly|limit|error) phase 머신, 스파이크 대기·거부 UX.
  • 구매 성공 시 기존 결제 흐름(/payment/new)으로 이관.

Architecture Benchmarking (토스 벤치마킹)

사례참고 패턴본 설계 반영
토스 — 한 화면 한 과업, 단일 CTA, 절제된 accent상세 화면은 “회차 상태 + 단일 구매 CTA”만. 색은 accent 1곳(구매 버튼)·나머지는 중립상세 화면 CTA 1개, 카운트다운을 hero로, 부가정보는 secondary 톤
토스 송금 — 처리 중 전면 로딩 + 결과 화면 분리제출 후 전면 “구매 처리 중” → 성공/실패 결과를 별도 뷰로 명확히purchase.tsx의 submitting 오버레이 + 결과 뷰
Nike SNKRS Line (shoeprize)“사이즈 선택 → 짧은 대기 → 성공/실패 확정”수량 선택 → 처리 중 → 202/실패 매핑 (BE “구매 성공=선점” 정의와 정합)
토스 — 혼잡 안내서버 혼잡 시 “잠시 후 다시” 재시도 유도(비난조 금지)429 → warning 톤 “접속이 몰리고 있어요” + 자동 재시도 카운트

Detail Design

화면 목록

ID화면경로목적
S1한정판 상세·카운트다운app/limited-drop/[id]/index.tsx회차 상태 표시 + 구매 진입 (상태별 CTA)
S2구매 진행·결과app/limited-drop/[id]/purchase.tsx수량 선택 → 제출 → 결과(대기·거부·성공)

진입점: 스토어 탭/상품 상세에서 한정판 회차가 있는 상품에 “한정판 구매” 링크(진입점 배선은 통합 티켓). 구매 성공(S2) → 기존 /payment/new.

텍스트 와이어프레임

S1 — 한정판 상세·카운트다운 (토스: 한 화면 한 과업 + 단일 CTA)

[< 뒤로]
────────────────────────────
  [상품 이미지]
  나이키 한정판 스니커즈            ← ThemedText title
  129,000원                        ← accent 가격
  [배지: 오픈예정 | 판매중 | 마감 | 종료]   ← DropStatusBadge

  ┌───────────── 상태별 hero ─────────────┐
  · SCHEDULED: "판매 시작까지"  02:14:53   ← CountdownTimer (hero)
                "7월 5일 20:00 오픈"
  · OPEN:      "판매 중"  남은 수량 32/100  ← RemainingStockBar
  · SOLD_OUT:  "재고 소진"  (회색 톤)
  · CLOSED:    "판매 종료"
  └────────────────────────────────────┘

  [ 구매하기 ]   ← PrimaryButton (단일 CTA)
   상태별: SCHEDULED=비활성"판매 시작 전"
          OPEN=활성   SOLD_OUT=비활성"재고 소진"
          CLOSED=비활성"판매 종료"
  · 1인당 최대 {perUserLimit}개 구매 안내 (secondary)

S2 — 구매 진행·결과 (토스: 처리 중 전면 + 결과 분리)

[< 뒤로]                          (submitting/결과 중에는 뒤로 잠금)
  수량   [ − ]  1  [ + ]           ← perUserLimit 상한
  합계   129,000원
  [ 구매 확정 ]  ← 단일 CTA

── 제출 후 phase 머신 ──
  submitting: [전면 스피너] "구매 처리 중..."   (서버 완충/게이트 대기)
  admitted(202): "구매 성공! 결제를 진행해 주세요" → [결제하기] (→ /payment/new)
  throttled(429): (warning) "접속이 몰리고 있어요"
                  "3초 후 자동으로 다시 시도합니다"  [지금 재시도]
  soldOut(409):   (neutral) "아쉽게도 마감됐어요"     [상세로]
  closed(409):    "판매가 종료됐어요"                [상세로]
  tooEarly(425):  "아직 판매 전이에요 · {openAt}"    [상세로]
  limit(403):     "1인당 {limit}개까지 구매할 수 있어요" [상세로]
  error(5xx/네트워크): "일시 오류예요"               [다시 시도]

화면별 상태 표 (loading / empty / error / success)

화면loadingemptyerrorsuccess
S1 상세회차 조회 중 스켈레톤/스피너 “불러오는 중”(해당 없음 — 단건 조회, 없으면 error)404 “존재하지 않는 회차” / 5xx “불러오지 못했어요 [다시 시도]“상태별 hero + CTA 렌더 (SCHEDULED/OPEN/SOLD_OUT/CLOSED)
S2 구매submitting 전면 오버레이 “구매 처리 중”(해당 없음)error phase: 5xx/네트워크 “일시 오류 [다시 시도]”; 정상 실패(429/409/403/425)는 각 결과 뷰admitted phase: “구매 성공 [결제하기]”

정상 실패(425/409/403)는 오류 상태가 아니라 결과 상태로 표시한다(비난조 금지, BE NFR: 5xx 아님). 429는 재시도 가능 결과, 5xx/네트워크만 진짜 error 취급.

테마 토큰 정의 표 (시맨틱 → 라이트/다크) — 의무

신규 theme/tokens.ts가 SSOT. useTheme()가 현재 스킴의 토큰 객체를 반환한다. 값은 기존 앱의 iOS 팔레트(라이트)와 그 다크 대응(iOS system dark)에 맞춘다.

시맨틱 토큰라이트다크용도
background#FFFFFF#000000화면 최하단 배경
surface#F2F2F7#1C1C1E카드/그룹 배경
surfaceElevated#FFFFFF#2C2C2E카드 위 카드
textPrimary#1C1C1E#FFFFFF제목·본문
textSecondary#6C6C70#AEAEB2보조 텍스트
textMuted#8E8E93#8E8E93힌트·placeholder
border#E5E5EA#38383A구분선
accent#007AFF#0A84FF단일 CTA·가격 강조 (화면당 1곳)
accentText#FFFFFF#FFFFFFaccent 위 텍스트
danger#FF3B30#FF453A오류·소진
warning#FF9500#FF9F0A혼잡(429)
success#34C759#30D158구매 성공
disabled#C7C7CC#48484A비활성 버튼/배경
  • 웹/RN 구분: 본 과제는 RN만. useColorScheme() 기본 + 사용자 오버라이드(useThemeStore) 허용. 두 모드 모두 렌더·확인해야 화면 완료.

컴포넌트 트리 (컨테이너/프레젠테이션 분리)

LimitedDropDetailScreen (S1, 컨테이너)      // useLimitedDrop + useCountdown
├─ ThemedView (background)
├─ BackButton
├─ ProductHeader (프레젠테이션: 이미지/이름/가격)
├─ DropStatusBadge (프레젠테이션: status → 라벨/톤)
├─ 상태별 hero
│   ├─ CountdownTimer (프레젠테이션: remainingMs → HH:MM:SS)   // SCHEDULED
│   └─ RemainingStockBar (프레젠테이션: remaining/limited)      // OPEN
└─ PrimaryButton (구매하기, status 기반 disabled)

LimitedDropPurchaseScreen (S2, 컨테이너)    // usePurchaseLimitedDrop + phase reducer
├─ QuantityStepper (프레젠테이션: perUserLimit 상한)
├─ PriceSummary (프레젠테이션)
├─ PrimaryButton (구매 확정)
├─ SubmittingOverlay (프레젠테이션: 전면 스피너)
└─ PurchaseResultView (프레젠테이션: phase → 결과 UI + 액션)

재사용 컴포넌트: ThemedView/ThemedText/PrimaryButton(테마 프리미티브, 전 화면 공용), DropStatusBadge/CountdownTimer/RemainingStockBar/QuantityStepper(한정판 전용, components/limitedDrop/).

상태관리 설계

상태종류저장 위치채택 근거
회차 정보(status·remaining·openAt)서버Query useLimitedDrop(dropId)캐시 SSOT. OPEN·openAt 근접 시 refetchInterval로 remaining 동기화. 스토어 복사 금지
카운트다운 남은 시간지역useCountdown(openAt) (setInterval)초당 갱신 순수 UI 상태. 전역 불필요
구매 phase지역S2 useReducer (idle/submitting/결과)화면 지역 흐름. mutation 상태 + 응답코드 매핑
Idempotency-Key지역S2 useRef(구매 시도당 1회 생성)429 재시도 시 동일 키 재사용 → BE 멱등과 정합
테마(스킴 오버라이드)전역Zustand useThemeStore정말 전역(앱 전체). useColorScheme() 기본 + 사용자 오버라이드
userId세션기존 useAuthStore / useCurrentUserId()기존 패턴 재사용, X-User-Id 헤더

낙관적 업데이트: 미채택. 구매 성공은 서버 202 확정이 진실이며(오버셀 0 도메인), 낙관 표시 후 롤백은 사용자 혼란만 유발. 재시도는 429에 한해 동일 idempotencyKey로 자동 1~2회.

API 연동 표 (BE TDD 계약 소비)

화면메서드·경로요청응답 → UI에러 처리
S1GET /limited-drops/{dropId}useLimitedDrop200 {dropId,productId,status,openAt,closeAt,remaining} → hero/CTA404→“없는 회차”, 5xx→error 상태+재시도. OPEN·openAt 임박 시 폴링
S2POST /limited-drops/{dropId}/ordersusePurchaseLimitedDropheader X-User-Id·Idempotency-Key; body {quantity}202 {orderId,dropId,status} → admitted→결제425{openAt}→tooEarly, 409 SoldOut→soldOut, 409 Closed→closed, 429→throttled(자동재시도, 동일 key), 403→limit, 5xx/네트워크→error
(결제)기존 /payments/prepare기존 payment 흐름orderId/amount기존기존

BE 계약 정합: 위 4개 필드·상태코드·헤더는 BE TDD “API 계약” 표와 1:1 일치. X-User-Id는 mobile 기존 임시 패턴(useCurrentUserId) 재사용, AUTH-04 통합 시 함께 제거.

라우팅·내비게이션 흐름 (Mermaid)

flowchart LR
    Store["스토어/상품상세"] --> Detail["S1 limited-drop/[id]"]
    Detail -->|"구매하기(OPEN)"| Purchase["S2 purchase"]
    Purchase -->|"202 admitted"| Pay["payment/new (기존)"]
    Purchase -->|"429 throttled"| Purchase
    Purchase -->|"409/403/425"| Detail
    Pay --> Result["결제 결과 (기존)"]

실패 경로·엣지 (해피 패스만 있는 설계는 미완성)

시나리오처리
openAt 도달 순간카운트다운 0 도달 시 CTA 활성화 + 즉시 1회 refetch로 status/remaining 동기화(로컬-서버 경계 오차 방지)
제출 중 뒤로가기submitting/결과 phase에서 뒤로 잠금(중복 제출 방지)
429 자동 재시도 한도최대 2회 자동(동일 idempotencyKey), 이후 “지금 재시도” 수동 버튼
네트워크 끊김error phase + OfflineBanner(기존 컴포넌트) 노출, 재시도 버튼
다크모드 전환모든 신규 화면 useTheme() 소비 — 두 모드 스냅샷 확인

Testing Plan (implementer TDD 입력 — 사용자 관점 동작 검증)

대상케이스
useCountdown (훅)openAt까지 남은 시간을 초당 감소; openAt 도달 시 isOpen=true; 이미 지난 openAt은 즉시 0
useLimitedDrop (훅)200 응답 매핑; 404 error; OPEN일 때 refetchInterval 활성
usePurchaseLimitedDrop (훅)202→admitted; 409→soldOut; 429→throttled; 403→limit; 425→tooEarly; 동일 idempotencyKey 재시도
CountdownTimer (컴포넌트)remainingMs를 HH:MM:SS로 표시; 0이면 “오픈” 표기; 라이트/다크 렌더
DropStatusBadgestatus별 라벨/톤(SCHEDULED/OPEN/SOLD_OUT/CLOSED); 다크 대비
PrimaryButton/ThemedTextdisabled 시 접근성 state; 라이트/다크 색 토큰 적용
S1 상세loading 스피너; 404 error UI; SCHEDULED 카운트다운+비활성 CTA; OPEN 활성 CTA+남은수량; SOLD_OUT 비활성
S2 구매수량 perUserLimit 상한; 202→“결제하기” 노출; 429→“몰리고 있어요”+자동재시도; 409→“마감”; 5xx→“다시 시도”
a11y모든 인터랙티브에 accessibilityLabel+accessibilityRole; 카운트다운 라이브 리전

핵심 실패 경로: ① 429 수신 시 동일 idempotencyKey로 재요청되는가 ② soldOut/closed/limit이 error가 아닌 결과 뷰로 뜨는가 ③ submitting 중 중복 제출이 막히는가.

Release Scenario — 점진 공개

  • BE 피처 플래그(limited-drop.enabled)와 정합: 회차가 없거나 플래그 OFF면 진입점(스토어 링크) 미노출 → 신규 화면 자연 비활성.
  • 신규 라우트는 순수 가산(기존 화면 무변경). 롤백: 진입점 링크 제거 1건으로 즉시 비노출.
  • 테마 프리미티브는 신규 화면에서만 사용 시작 — 기존 화면 하드코딩 색은 본 과제에서 건드리지 않음(별도 마이그레이션). 회귀 위험 0.

Open Questions

  • 진입점 위치: 스토어 탭 별도 “한정판” 섹션 vs 상품 상세 내 배너 — 사용자 확인 필요(와이어프레임 확인 대상).
  • 가상 대기실(순번 표시)은 PRD Non-Goals. 본 설계는 서버 완충의 클라이언트 표현(429 대기 UX)만 다룸.

Document History

날짜변경 내용
2026-07-03최초 작성 — mobile 대상, 테마 토큰 신설, 상세/카운트다운·구매 phase 머신, BE API 계약 소비