한정판 상품 구매 화면 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=...로 이동, 외부 WebView | app/payment/new.tsx |
| 라우팅 | expo-router 파일 기반. 라우트 상수 ROUTES, Stack 등록은 _layout.tsx | lib/navigation.ts, app/_layout.tsx |
| 상태관리 | 서버 상태 Query(staleTime 30분), 세션은 Zustand(useAuthStore) | lib/query-client.ts, lib/auth.ts |
| userId | useCurrentUserId() 임시 반환(1), X-User-Id 헤더 부착 (AUTH-04 전까지) | api/goods.ts#useCurrentUserId |
문제점
- 카운트다운·판매 시작 게이트를 표현할 화면이 없다.
- 스파이크 시 서버가 주는
429/409를 사용자 언어로 전달할 UX가 없다 (기존은Alert단순 실패). - 테마 토큰이 없어 신규 화면을 컨벤션(라이트/다크 의무)에 맞게 만들 수 없다 → 선행 신설 필요.
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)
| 화면 | loading | empty | error | success |
|---|---|---|---|---|
| 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 | #FFFFFF | accent 위 텍스트 |
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 | 에러 처리 |
|---|---|---|---|---|---|
| S1 | GET /limited-drops/{dropId} | useLimitedDrop | — | 200 {dropId,productId,status,openAt,closeAt,remaining} → hero/CTA | 404→“없는 회차”, 5xx→error 상태+재시도. OPEN·openAt 임박 시 폴링 |
| S2 | POST /limited-drops/{dropId}/orders | usePurchaseLimitedDrop | header 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이면 “오픈” 표기; 라이트/다크 렌더 |
DropStatusBadge | status별 라벨/톤(SCHEDULED/OPEN/SOLD_OUT/CLOSED); 다크 대비 |
PrimaryButton/ThemedText | disabled 시 접근성 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 계약 소비 |