시설 전국 확장·대기질 연동 — FE 설계 (모바일 앱)
Background
근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/PRD.md
근거 BE TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/TDD.md
이 문서는 B2C 모바일 앱(mobile/, React Native + Expo Router)의 FE 설계다. FE 범위는 다음이다.
- FR-7/FR-12: 모바일 시설 상세(
mobile/app/facility/[id]/index.tsx)에 시/도 표시 + 대기질(PM10/PM2.5 수치·등급 배지) 정보성 노출. 4상태 + 실패 폴백. - FR-13: 예약 생성(
mobile/app/booking/new.tsx)에서 대상 시설 대기질 대표 등급이 “나쁨(BAD)” 이상이면 예약 확정 전 경고 + 사용자 확인 단계(확인 버튼). 확인 시 정상 진행(차단 없음). - FR-14: 대기질 조회 실패·타임아웃 시 예약 화면은 “대기질 정보를 불러올 수 없습니다” 폴백 + 경고 없이 정상 진행.
Overview
- 무엇을: 모바일 시설 상세에 시/도·대기질 카드를 얹고, 예약 화면에 대기질 경고·확인 게이트를 넣는다.
- 왜: 야외 시설 예약자가 당일 대기질을 확인하고, 나쁠 때 인지한 뒤 예약하도록 한다(차단 없이 정보·확인만).
- 어떻게: 기존 모바일 관례(TanStack Query v5 +
lib/use*.ts훅 +api/*.ts함수 + axios be-client)를 그대로 따른다. 대기질은 BE 경유로만 호출한다(외부 API 직접 호출 금지 규칙rules/fe-external-api-via-was.md). 모바일에는 테마 시스템이 전무하므로(모든 색 하드코딩), 신규 컴포넌트가 소비할 경량 테마 토큰 모듈 +useTheme()훅을 도입해 라이트/다크를 지원한다(no-single-mode준수). 기존 화면은 이번 범위에서 리팩터하지 않는다(선기술 부채).
Terminology
| 용어 | 정의 |
|---|---|
| 대표 등급 | PM10·PM2.5 중 더 나쁜 등급 (BE representativeGrade) |
| BAD 이상 | BAD 또는 VERY_BAD — 예약 경고 트리거 |
| useTheme | 신규 도입 테마 훅. useColorScheme() 기반 light/dark 토큰 객체 반환 |
| AQ 토큰 | 대기질 등급 시맨틱 색 토큰 (테마 객체의 airGood·airBad 등) |
Define Problem
AS-IS (실제 코드 근거)
- 시설 상세:
mobile/app/facility/[id]/index.tsx—useFacilityDetail(facilityId)(lib/useFacility.ts,useQuery(['facilities', id]))로 조회. gu 표시(:56-59"구" + data.gu). loading/error/success 3상태(empty 미분기). 대기질 코드 전무. 하단 고정 “예약하기” 버튼(:79) →/booking/new?facilityId=. - 예약 생성:
mobile/app/booking/new.tsx—useLocalSearchParams<{facilityId}>()(:68)로 facilityId만 수신.useSlots(facilityId)+useCreateBooking().handleBook(:78-102)에서 슬롯 선택 검증 →createBooking(...)→ 성공 시/payment이동. 4상태 처리됨. - 좌표: BE는 이미 노출, 모바일 TS 타입만 누락(핵심 이슈): BE
backend/.../presentation/facility/dto/response/FacilityResponse.kt:11-12에val lat: Double·val lng: Double가 이미 존재하고, 모바일 경로FacilityApiController.kt:51 @GetMapping("/{id}")가 이 DTO를 반환한다 — 즉 서버는 좌표를 이미 내려준다. 결핍은 모바일 TS 타입뿐이다:mobile/api/types.tsFacilityResponse(:133-141)가 id/name/gu/type/address/parking/phone만 선언(lat/lng없음,tel→phone오기).SlotResponse(:104-111)에도 좌표 없음. → 대기질GET /air-quality?lat&lng호출에 필요한 위경도는 BE 변경 없이 모바일 TS 타입에 lat/lng를 추가(FE-11 소관) 하면 두 대상 화면 모두 확보된다. (좌표 포함 별도 타입api/external-features.ts의FacilitySummary는/facilities/near전용·필드 상이 — 이번엔 미사용.) - API 흐름:
api/be-client.tsaxios 싱글턴(BFF 없이 BE 직접,Authorization: Bearer자동, 401 refresh). 엔드포인트 함수는api/*.ts(예getFacility(id)→GET /facilities/{id}). 훅은lib/use*.ts가useQuery/useMutation얇게 래핑, queryKey 배열·enabled가드·staleTime상속. - 테마 전무:
useTheme/useColorScheme사용처 0건. 모든 색StyleSheet.create하드코딩(primary#007AFF, danger#FF3B30/#B00020, bg#F2F2F7등). 시맨틱 토큰·라이트/다크 없음. - 상태관리: Zustand는
lib/auth.ts세션 스토어 1개만. 테마 스토어 없음. - 테스트: Jest +
@testing-library/react-native.components/**만 커버리지 수집(app/**미수집). 접근성 셀렉터(accessibilityLabel/accessibilityRole) 광범위. - 타입:
api/types.ts단일 파일. 웹과 공유 안 함(모노레포 아님).
TO-BE
- 시설 상세: gu 옆 시/도 표시 +
AirQualityCard(PM 수치·대표 등급 배지, 4상태·UNKNOWN 폴백). - 예약 화면: 대상 시설 좌표로 대기질 조회 → BAD 이상이면
AirQualityWarning(경고 배너 + 확인 버튼) 게이트. 확인 후 예약 진행. 조회 실패 시 경고 없이 진행(FR-14). - 좌표 확보: BE 변경 불필요 — BE FacilityResponse는 이미 lat/lng를 노출한다. 모바일 TS 타입에 lat/lng를 추가(FE-11)하면 상세는
data.lat/lng, 예약은useFacilityDetail(facilityId)재사용으로 좌표를 획득한다. - 테마: 경량
theme/모듈(light/dark 토큰 +useTheme()) 신규. 신규 컴포넌트만 소비.
Architecture Benchmarking (토스 벤치마킹)
| 대상 | 참고 패턴 | 적용 | 미참고 |
|---|---|---|---|
| 토스 — 확인 흐름 | 되돌릴 수 없거나 주의가 필요한 행동 전 명확한 1스텝 확인(경고 문구 + 단일 CTA) | 예약 확정 전 대기질 BAD면 경고 배너 + “확인하고 예약” 버튼 1개. 차단 아님, 인지 후 진행 | 모달 남발·다중 스텝 없음 |
| 토스 — 정보 카드 | 부가 정보는 절제된 카드, 상태색 1곳 | 상세 대기질 카드, 배지 색 1곳 | 본체와 뒤섞지 않음 |
| 에어코리아 CAI | 4단계 색 위계 | 배지 색 위계 AQ 토큰 | 예보 그래프(범위 밖) |
| 네이버 지도 결합 카드 | 실패해도 본체 정상 | 대기질 실패 시 카드만 폴백, 예약은 정상 진행 | 강제 차단 없음 |
Possible Solutions
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| 좌표: 모바일 TS 타입에 lat/lng 추가 (채택, BE 변경 불필요) | BE FacilityResponse가 이미 lat/lng를 노출(FacilityResponse.kt:11-12) → 모바일 타입만 추가(FE-11) | 채택 — 상세는 data.lat/lng 바로 사용, 예약은 useFacilityDetail 재사용으로 획득. 두 화면 일관. FR-12/13 모두 좌표 필요. 서버 계약 변경 0 |
| air-quality API가 facilityId 수용 | GET /air-quality?facilityId= | 미채택 — BE가 이미 좌표를 내려주므로 불필요. BE TDD 계약이 lat/lng 기반이라 그대로 사용 |
| gu 기반 조회 | gu 문자열로 대기질 | 미채택 — gu는 측정소 좌표 매핑 부정확, BE Gateway가 좌표 기반. 부정확 |
| 예약 경고: 클라이언트 UX 게이트 (채택) | 대기질 조회는 조회 전용 API, 경고/확인은 화면 로직 | 채택 — FR-13 “경고까지만·차단 없음”에 부합. 예약 UseCase 무결합(BE TDD와 일치) |
| 예약 API에 대기질 결합 | 서버가 경고 플래그 반환 | 미채택 — 예약이 외부 API 장애에 결합. BE TDD도 조회 전용 분리 채택 |
| 테마: 경량 신규 모듈 (채택) | 신규 컴포넌트만 useTheme 소비, 기존 화면 유지 | 채택 — no-single-mode 충족하며 전면 리팩터 회피(범위 통제). 향후 테마 확산의 씨앗 |
| 전 화면 테마 마이그레이션 | 모든 하드코딩 색 토큰화 | 미채택 — 범위 초과·대규모. 이 과제 밖 |
Detail Design
화면 목록 · 변경 범위
| 화면/모듈 | 파일 | 변경 |
|---|---|---|
| 시설 상세 | mobile/app/facility/[id]/index.tsx | 시/도 표시 + AirQualityCard 통합 |
| 예약 생성 | mobile/app/booking/new.tsx | 대기질 조회 + AirQualityWarning 게이트 |
| 테마 | 신규 mobile/theme/tokens.ts, mobile/theme/useTheme.ts | light/dark 토큰 + 훅 |
| 대기질 데이터 | 신규 mobile/api/air-quality.ts, mobile/lib/useAirQuality.ts, mobile/api/types.ts(AirQualityResponse + FacilityResponse lat/lng), 신규 mobile/lib/air-quality-format.ts | api·훅·타입·라벨 매핑 |
| 컴포넌트 | 신규 mobile/components/AirQualityBadge.tsx, AirQualityCard.tsx, AirQualityWarning.tsx | 재사용 UI |
텍스트 와이어프레임 (토스 패턴)
시설 상세 (토스: 부가 정보 카드)
┌───────────────────────────┐
│ ← 뒤로 │
│ 한강 축구장 │ data.name
│ 시/도 서울특별시 │ ← 신규
│ 구 광진구 │ 기존
│ 유형 야외 │
│ 주소 서울 광진구 … │
│ ┌─ 현재 대기질 [나쁨] ─┐ │ ← AirQualityCard (배지 색 1곳)
│ │ PM10 92 ㎍/㎥ │ │
│ │ PM2.5 41 ㎍/㎥ │ │
│ │ 광진구 측정소·14:00 │ │
│ └───────────────────────┘ │
│ [ 예약하기 ] │ 하단 고정
└───────────────────────────┘
예약 생성 — 대기질 경고 (토스: 확인 1스텝)
┌───────────────────────────┐
│ 예약하기 │
│ ┌ 대기질 경고 (BAD 이상) ┐ │ ← AirQualityWarning (조건부)
│ │ ⚠ 현재 대기질이 나쁨 │ │
│ │ 입니다 (PM10 92). │ │
│ │ 야외 활동에 유의하세요.│ │
│ └────────────────────────┘ │
│ 슬롯 선택 │
│ ○ 09:00–10:00 │
│ ● 10:00–11:00 │
│ 결제수단 ● 카드 ○ … │
│ [ 확인하고 예약 ] ← BAD면 │ 경고 확인 게이트
│ [ 예약 진행 ] ← 그 외 │
└───────────────────────────┘
- 대기질 UNKNOWN/실패 → 경고 배너 미표시, 버튼은 기본 “예약 진행”(FR-14).
- 확인 게이트: BAD 이상일 때만 버튼 라벨이 “확인하고 예약”으로 바뀌고, 첫 탭에서 경고를 인지시키는 확인 상태로 전환(내부 confirmed 플래그) 후 예약 mutation 실행. 별도 모달 없이 인라인 확인(토스식 단순화). — 인터랙션 확정은 사용자 확인 대상(체크박스 vs 버튼 라벨 전환).
화면별 상태 표 (loading/empty/error/success)
| 화면 | loading | empty | error | success |
|---|---|---|---|---|
| 시설 상세 | ”로딩 중” 스피너 | 시설 미존재 → “시설을 찾을 수 없습니다”(신규 empty 분기 추가) | “오류 발생” + 메시지 | 시설 정보(시/도 포함) + 대기질 카드 |
| 대기질 카드(상세 내) | “대기질 정보를 불러오는 중…“ | pm 모두 null → 폴백 문구 | 조회 실패 → “대기질 정보를 불러올 수 없습니다” | 수치 + 배지 + 측정소·시각 |
| 예약 생성 | 슬롯 로딩 스피너 | 슬롯 0건 → “예약 가능한 시간이 없습니다” | 슬롯 조회 에러 | 슬롯·결제수단 선택 + (조건부)경고 |
| 대기질 경고(예약 내) | 조회 중 배너 미표시(기본 버튼) | UNKNOWN → 배너 미표시 | 실패 → 배너 미표시·정상 진행(FR-14) | BAD 이상 → 경고 배너 + 확인 게이트 |
테마 토큰 — 대기질 등급 (라이트/다크 매핑, 의무)
mobile/theme/tokens.ts에 light/dark 두 객체를 정의하고 useTheme()가 useColorScheme() 기준으로 반환. 신규 컴포넌트는 하드코딩 색 대신 이 토큰만 사용.
| 토큰 키 | 용도 | light | dark |
|---|---|---|---|
background | 화면 배경 | #F2F2F7 | #000000 |
surface | 카드 배경 | #FFFFFF | #1C1C1E |
textPrimary | 본문 | #1C1C1E | #F2F2F7 |
textSecondary | 보조 | #8E8E93 | #98989F |
border | 경계선 | #E5E5EA | #38383A |
accent | 강조/CTA | #007AFF | #0A84FF |
danger | 위험/에러 | #FF3B30 | #FF453A |
airGoodBg / airGoodFg | 좋음 배지 | #E6F0FF / #1565C0 | #10243D / #7FB3FF |
airModerateBg / airModerateFg | 보통 | #E4F6EA / #1B7A3D | #122A1B / #6FD08A |
airBadBg / airBadFg | 나쁨 | #FFF0E0 / #C65A00 | #2E1B08 / #FFB166 |
airVeryBadBg / airVeryBadFg | 매우나쁨 | #FDE4E4 / #C62828 / | #2E1212 / #FF8A8A |
airUnknownBg / airUnknownFg | 정보없음 | #ECEEF0 / #6B7280 | #26282B / #9AA0A6 |
색 위계: 좋음=파랑, 보통=초록, 나쁨=주황, 매우나쁨=빨강(에어코리아 CAI). 기존 하드코딩 팔레트(primary
#007AFF, danger#FF3B30)와 일관되게 accent/danger 토큰 채택.useColorScheme기본, 향후 사용자 오버라이드 여지.
등급 → 라벨 매핑 (mobile/lib/air-quality-format.ts)
BE가 등급 계산 → FE는 라벨·토큰키만 매핑(순수 함수). 색 자체는 컴포넌트가 useTheme로 해석.
| grade (BE) | 라벨 | 토큰 키 접두 |
|---|---|---|
GOOD | 좋음 | airGood |
MODERATE | 보통 | airModerate |
BAD | 나쁨 | airBad |
VERY_BAD | 매우나쁨 | airVeryBad |
UNKNOWN | 정보없음 | airUnknown |
isBadOrWorse(grade): grade === 'BAD' || grade === 'VERY_BAD' — 예약 경고 트리거 판정(순수 함수, 테스트 대상).
컴포넌트 트리 (컨테이너/프레젠테이션 분리)
app/facility/[id]/index.tsx (컨테이너)
├─ useFacilityDetail(id) ← 기존 훅 (lat/lng 포함 응답)
├─ useAirQuality(lat, lng) ← 신규 훅
├─ 시설 정보 rows (시/도 추가)
└─ <AirQualityCard status pm10 pm25 grade stationName measuredAt/>
└─ <AirQualityBadge grade/> (useTheme 소비)
app/booking/new.tsx (컨테이너)
├─ useFacilityDetail(facilityId) ← 좌표 획득용 재사용
├─ useAirQuality(lat, lng)
├─ useSlots / useCreateBooking ← 기존
├─ <AirQualityWarning grade pm10 onConfirm/> (조건부, BAD 이상)
└─ 예약 버튼 (경고 확인 게이트)
재사용 컴포넌트: AirQualityBadge(카드·경고 공용), AirQualityCard(상세), AirQualityWarning(예약).
상태관리 설계
| 상태 | 저장 | 근거 |
|---|---|---|
| 시설/슬롯/대기질 서버 데이터 | TanStack Query (useFacilityDetail, useSlots, useAirQuality) | 레포 표준. 서버 상태는 Query 캐시가 SSOT (no-global-by-default) |
| 예약 경고 확인 여부(confirmed) | 화면 useState | 화면 지역 상태. 전역 불필요 |
| 선택 슬롯·결제수단 | 화면 useState(기존) | 기존 유지 |
| 테마 모드 | useColorScheme()(RN 시스템) | 전역 스토어 불필요. 시스템 값 소비 |
| 전역 클라이언트 상태 | 신규 없음 | 세션(auth) 외 전역 승격 근거 없음 |
API 연동 표
| 화면 | 엔드포인트(BE 직접) | api 함수 / 훅 | 에러 처리 |
|---|---|---|---|
| 시설 상세 | GET /facilities/{id} | getFacility / useFacilityDetail (기존, lat/lng 포함) | 기존 3상태 + empty 추가 |
| 대기질 | GET /air-quality?lat&lng | 신규 getAirQuality(lat,lng) / useAirQuality(lat,lng) | 실패/UNKNOWN → 폴백. enabled: lat!=null&&lng!=null |
| 예약 좌표 | GET /facilities/{facilityId} | useFacilityDetail(facilityId) 재사용 | 실패 시 대기질 미조회 → 경고 없이 진행 |
| 예약 생성 | POST /bookings | createBooking / useCreateBooking (기존) | 기존 |
대기질 호출은 BE 경유만(외부 직접 호출 금지 규칙).
useAirQuality는 좌표가 확정되기 전(lat/lngnull) 미조회.
라우팅 · 내비게이션 흐름
flowchart LR Search[검색/딥링크] --> Detail[시설 상세] Detail --> Aq1[대기질 카드] Detail --> Booking[예약 생성] Booking --> Coord[시설 재조회 좌표] Coord --> Aq2[대기질 조회] Aq2 --> Warn[BAD 경고·확인] Warn --> Create[예약 생성] Create --> Payment[결제]
Testing Plan (implementer TDD 입력)
Jest + @testing-library/react-native. 접근성 셀렉터 사용. 재사용 컴포넌트(components/**)는 커버리지 수집 대상 → 로직을 컴포넌트로 추출. 테스트명에 티켓ID 금지.
| 대상 | 레벨 | 핵심 케이스 |
|---|---|---|
air-quality-format | 유닛 | 등급별 라벨·토큰키, isBadOrWorse(BAD/VERY_BAD true, GOOD/MODERATE/UNKNOWN false), 미지정 grade 방어 |
useTheme | 훅 | light/dark 토큰 반환, colorScheme 변경 반영 |
AirQualityBadge | 컴포넌트 | 등급별 라벨·토큰 적용, UNKNOWN 배지 미표시 |
AirQualityCard | 컴포넌트 | success 수치·배지·측정소, loading 문구, UNKNOWN/실패 폴백, pm 일부 null |
AirQualityWarning | 컴포넌트 | BAD 이상 경고 문구·확인 버튼 렌더, onConfirm 콜백, UNKNOWN·GOOD이면 미렌더 |
useAirQuality | 훅 | 성공 반환, 실패 시 폴백 상태, 좌표 null이면 미조회 |
| 시설 상세 | 시나리오 | 시/도 표시, 대기질 카드 success/폴백, empty 분기 |
| 예약 생성 | 시나리오 | BAD면 경고+확인 게이트 후 예약, UNKNOWN이면 경고 없이 예약, 실패 시 정상 진행(FR-14), 좌표 없으면 경고 없이 진행 |
Release Scenario (기능 플래그·점진 공개)
- FE는 BE 좌표 필드·
/air-quality배포 후 활성. 좌표 없으면(구 응답)useAirQuality미조회 → 대기질·경고 미표시로 자연 폴백(앱 정상). - 롤백: 대기질 카드·경고를 조건부 렌더 제거 또는 엔드포인트 미호출로 즉시 비활성(BE TDD 롤백의 “FE 플래그”에 해당). 예약 흐름은 대기질과 무결합이라 무영향.
웹/앱 공유 로직 경계 (양 문서 동일)
| 항목 | 공유 여부 | 비고 |
|---|---|---|
| 타입(AirQuality 응답, grade enum) | 공유 안 함(각 플랫폼 정의) | web/·mobile/는 별도 패키지·모노레포 미구성(mobile tsconfig 내부만 매핑). 웹은 lib/portal/, 모바일은 api/types.ts에 각자 정의 |
| 등급 → 라벨/색 매핑 | 공유 안 함(각 정의) | BE가 등급 계산 → FE는 소량 매핑만. 웹은 Tailwind 토큰 클래스, 모바일은 useTheme 토큰 객체로 상이 |
| 시/도 옵션(17건) | 공유 안 함(각 상수) | 값(코드·명칭)은 동일해야 하며 BE regions seed가 원천. 웹은 sido-options.ts, 모바일은 이번 범위에 시/도 입력 없어 미사용(표시만) |
| API 클라이언트 | 공유 안 함 | 웹=BFF Route+fetch, 모바일=axios be-client 직접. 구조 자체가 다름 |
| 컴포넌트 | 공유 안 함(플랫폼 전용) | 웹 React DOM vs RN. 강제 공유 안 함 |
| BE API 계약(엔드포인트·필드) | 공유(SSOT=BE TDD) | 양 플랫폼이 동일 계약 소비. 필드·타입은 BE TDD와 일치 |
API 계약 — 소비 / 역제안
소비(BE TDD·기존 코드 기준)
GET /air-quality?lat&lng→{ pm10, pm25, pm10Grade, pm25Grade, representativeGrade, stationName, measuredAt }(실패 시 200 + UNKNOWN·null).GET /facilities/{id}FacilityResponse —lat/lng는 이미 노출(FacilityResponse.kt:11-12). 지역 필드(sidoCode, sidoName, sigunguCode, sigunguName)는 BE-06이 공유 FacilityResponse에 추가.
역제안(BE에 요청)
- 좌표: BE 변경 불필요 — FacilityResponse가 이미 lat/lng를 노출하므로 서버 계약 추가 요청 없음. 모바일 TS 타입에 lat/lng·
tel(현재phone오기) 정합만 FE-11에서 해소. - 지역 필드(
sidoName등)는 BE-06이 공유 FacilityResponse에 추가하므로 별도 역제안 불필요 — 모바일 타입에 반영만(FE-11, FR-7 표시용).
Open Questions
- 예약 경고 확인 인터랙션: “확인하고 예약” 버튼 라벨 전환(인라인) vs 체크박스(“대기질이 나쁨을 확인했습니다”) vs 모달 — 현행 설계는 인라인 버튼 전환. 사용자 확인 대상.
- 좌표: BE FacilityResponse가 이미 lat/lng 노출 → BE 조율 불필요(모바일 타입 정합만 FE-11). 미해결 없음.
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-04 | 최초 작성 — AS-IS 코드 근거, 경량 테마 모듈 도입, 예약 경고 인라인 확인 게이트, AQ 토큰 라이트/다크 매핑, 티켓 7건 분해 |
| 2026-07-04 | senior-pm 검증 정정 — 좌표 AS-IS 오류 수정: BE FacilityResponse(FacilityResponse.kt:11-12)가 이미 lat/lng 노출, 결핍은 모바일 TS 타입뿐. “역제안(BE 필수 lat/lng 추가)” 삭제→“BE 변경 불필요, FE-11 타입 추가만”. 티켓 무변경 |