시설 전국 확장·대기질 연동 — FE 설계 (웹 포털)
Background
근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/PRD.md
근거 BE TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/TDD.md
이 문서는 B2B 운영자 웹 포털(web/, Next.js App Router)의 FE 설계다. facility 도메인이 서울 전용(gu 자유 문자열)에서 행정표준코드 기반 전국 구조로 확장되고, 에어코리아 대기질(PM10/PM2.5)이 신규 연동됨에 따라 웹에서 처리할 FE 범위는 다음이다.
- FR-6: 시설 생성 3경로 중 웹 2경로 — CSV 임포트
sido컬럼 추가, 단건 폼 시/도 입력(드롭다운), BFF payload에sido전달. - FR-7/FR-12: 웹 시설 목록·상세에 시/도 표시 + 시설 상세에 대기질(PM10/PM2.5 수치·등급 배지) 정보성 노출. 4상태(loading/empty/error/success) 처리.
웹은 B2C 예약 생성 화면이 없어(
web/app/portal/bookings는 운영자 조회·취소 전용) FR-13/FR-14(예약 대기질 경고)는 웹 대상 아님 — 모바일 설계(design-fe-app.md)에서 다룬다.
Overview
- 무엇을: 웹 포털의 시설 목록/상세/폼/CSV 임포트/BFF에 시/도(행정표준코드) 개념을 추가하고, 시설 상세에 대기질 카드를 얹는다.
- 왜: 전국 시설을 서울과 혼동 없이 등록·표시하고, 야외 시설 운영자가 상세에서 대기질을 확인하게 한다.
- 어떻게: 기존 웹 관례(useState+useEffect+fetch → BFF Route Handler)를 따르되, 대기질 조회는 컴포넌트에서 직접 fetch하지 않도록
useAirQuality훅으로 캡슐화한다(no-direct-fetch·no-logic-in-component준수). 대기질 등급 색은 하드코딩 대신 신규 시맨틱 토큰(--aq-*)으로 정의하고 라이트/다크 매핑을 모두 채운다.
Terminology
| 용어 | 정의 |
|---|---|
| sido / sidoCode | 시/도 표준코드(법정동코드 앞 2자리). 서울 11, 부산 26 |
| sigungu / sigunguCode | 시군구 표준코드(앞 5자리). BE가 응답으로 제공 |
| BFF | Next.js Route Handler(web/app/api/portal/**/route.ts)를 통한 서버 경유 프록시 |
| 대표 등급 | PM10 등급과 PM2.5 등급 중 더 나쁜 쪽 (BE가 representativeGrade로 계산해 반환) |
| AQ 토큰 | 대기질 등급 시맨틱 색 토큰 (--aq-good 등) |
Define Problem
AS-IS (실제 코드 근거)
- 시/도 개념 전무 — 전 계층
gu자유 문자열- 폼:
web/app/portal/facilities/_components/FacilityForm.tsx:91—gu가<Input type="text">자유 입력(placeholder “예: 광진구”), 드롭다운 아님. - 폼 스키마:
web/app/portal/facilities/facility-form-schema.ts:13—gu: z.string().min(1), 화이트리스트 없음. - 폼 훅:
web/app/portal/facilities/_hooks/useFacilityForm.ts— react-hook-form 미사용,useState기반. 훅은 API 호출 안 함, 컨테이너(new/page.tsx,[id]/page.tsx)가onSubmit으로 실행. - 목록:
web/app/portal/facilities/FacilitiesListClient.tsx:30—<td>{facility.gu}</td>3번째 컬럼. TanStack Query 미사용,useState+useEffect+fetch('/api/portal/facilities'). 4상태(loading/error/empty/success) 완비(:91-171). - 상세:
web/app/portal/facilities/[id]/page.tsx:266—<dt>구</dt><dd>{facility.gu}</dd>. 클라이언트 컴포넌트,useEffect+fetch. 대기질 관련 코드 전무. - CSV:
web/app/portal/facilities-import/parseCsvFacilities.ts— 헤더code,name,gu,type,address,lat,lng,parking,tel,homePage,eduYn,meta,gu는 인덱스 2(:73), 필수 검증(:86). 러너 Vitest. - BFF:
web/app/api/portal/facilities/route.ts:39—gu: input.gu무변환 전달.location "lat,lng"→lat/lngnumber 분해(:18-50). 인증은 httpOnlyaccess_token쿠키 →Authorization: Bearer자동 부착(web/lib/portal/be-client.ts).
- 폼:
- 타입:
web/lib/portal/types.ts:28MyFacility에gu만, 지역 필드 없음.location: string(lat,lng)은 존재(:35) → 웹은 대기질 조회용 좌표를 이미 확보. - 테마:
web/app/globals.css— CSS 변수 시맨틱 토큰(HSL 트리플 형식):root/.dark양쪽 정의됨(--background,--destructive등). TailwinddarkMode:["class"]. 다크 토큰은 정의만 되고 토글 UI는 없음(현재 라이트 고정).danger/상태색(좋음/나쁨) 토큰은 없음.AirQualityCard/AirQualityBadge/useAirQuality컴포넌트·훅 부재. - UI 컴포넌트:
web/components/ui/에badge/button/dialog/input/tabs/toast. Select 컴포넌트는 없음 → 시/도 드롭다운은 네이티브<select>래퍼로 신규 작성.
TO-BE
- 웹 전 계층에 시/도(
sido표준코드)를 추가: 폼 드롭다운(SidoSelect), 스키마sido필드, CSVsido컬럼, BFF payloadsido, 목록·상세 표시. 기존gu자유 문자열은 유지(하위 호환). - 시설 상세에
AirQualityCard(PM10/PM2.5 수치 + 대표 등급 배지) 추가. 4상태 + UNKNOWN(정보없음) 폴백. - 대기질 등급 색을
--aq-*시맨틱 토큰으로 정의(라이트/다크 매핑).
Architecture Benchmarking (토스 벤치마킹)
| 대상 | 참고 패턴 | 적용 | 미참고 |
|---|---|---|---|
| 토스 — 입력 폼 | 한 화면 한 과업, 필드별 단일 라벨·즉시 인라인 에러, 셀렉트는 명확한 기본값 | SidoSelect를 폼 상단(지역 그룹)에 배치, 미선택 시 “선택 안 함(주소로 자동 판별)” 기본 옵션으로 서버 자동 보간 의도를 명시 | 과한 스텝 분할 없음(기존 단일 폼 유지) |
| 토스 — 정보 카드 | 부가 정보는 본체 아래 절제된 카드로, 색은 상태 표현에만 1곳 | 시설 상세 <dl> 아래 AirQualityCard를 부가 정보 카드로. accent 색은 등급 배지 1곳에만 | 대기질을 본체 정보와 뒤섞지 않음 |
| 에어코리아 CAI | 좋음/보통/나쁨/매우나쁨 4단계 색 위계 | 배지 색 위계를 AQ 토큰으로. BE가 등급 계산 → FE는 표시만 | 항목별 상세 그래프(범위 밖) |
| 네이버 지도 결합 카드 | 장소에 대기질을 부가로 얹되 실패해도 본체 정상 | 대기질 실패 시 카드만 폴백, 시설 상세 본체는 정상 | 예약 결합(웹 대상 아님) |
Possible Solutions
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
대기질 조회를 useAirQuality 훅 + BFF Route로 (채택) | 컴포넌트는 훅만 소비, 훅이 fetch('/api/portal/air-quality?lat&lng') (useState+useEffect) | 채택 — 웹 레포 관례(BFF+fetch)와 no-direct-fetch·no-logic-in-component를 동시에 만족. TanStack Query 미도입(레포 미사용, 단일 조회에 과함) |
| 컴포넌트에서 직접 fetch | 상세 페이지가 대기질도 직접 fetch | 미채택 — no-direct-fetch·no-logic-in-component 위반, 상세 페이지 비대화 |
| 웹에 TanStack Query 신규 도입 | 표준 스택 도입 | 미채택 — 레포 전반이 useState+fetch. 이 과제만을 위한 신규 라이브러리 도입은 과함(단순함 우선). 레포 관례 우선 규칙 적용 |
시/도 드롭다운을 FE 상수(SIDO_OPTIONS 17건) (채택) | 17개 시도 코드+명칭을 FE 상수로, 드롭다운 소비 | 채택 — 시도 단위는 개편이 극히 드묾(PRD Non-Goal). 코드값은 BE regions seed와 동일. 별도 조회 왕복 제거(단순함). 단 코드는 BE seed와 반드시 일치 |
| 시/도 목록을 BE에서 조회 | GET /regions/sido 신설 후 조회 | 미채택(현행) — SSOT는 깔끔하나 정적 17건에 왕복·엔드포인트·로딩 상태 추가. 향후 개편 잦아지면 전환. 역제안으로 남김 |
| 시군구까지 드롭다운 | 시/도 선택 후 시군구 종속 드롭다운 | 미채택 — FR-6은 시/도 입력만 요구. 시군구는 서버가 주소 파싱으로 보간. 종속 드롭다운은 범위 초과 |
Detail Design
화면 목록 · 변경 범위
| 화면/모듈 | 파일 | 변경 |
|---|---|---|
| 시설 목록 | FacilitiesListClient.tsx | 시/도 컬럼 추가 표시 |
| 시설 상세 | facilities/[id]/page.tsx | 시/도 표시 + AirQualityCard 통합 + 수정 폼 시/도 전달 |
| 시설 폼 | _components/FacilityForm.tsx, facility-form-schema.ts, _hooks/useFacilityForm.ts, new/page.tsx | 시/도 드롭다운 입력 |
| CSV 임포트 | parseCsvFacilities.ts(+테스트), FacilitiesImportClient.tsx | sido 컬럼 추가·미리보기 |
| BFF | api/portal/facilities/route.ts, [id]/route.ts, lib/portal/schemas.ts | payload sido + 신규 api/portal/air-quality/route.ts |
| 공통 | lib/portal/types.ts, globals.css, tailwind.config.ts, 신규 SidoSelect·AirQualityCard·AirQualityBadge·useAirQuality·sido-options·air-quality(매핑) | 토대 |
텍스트 와이어프레임 (토스 패턴)
시설 폼 — 지역 입력 (토스: 한 화면 한 과업, 명확한 라벨)
┌─ 시설 등록 ─────────────────────────┐
│ 시설 코드 * [____________] │ (create 모드만)
│ 시설명 * [____________] │
│ ┌─ 지역 ────────────────────────┐ │
│ │ 시/도 [ 선택 안 함 ▾]│ │ ← SidoSelect: 17시도 + "선택 안 함"
│ │ └ 미선택 시 주소로 자동 판별됩니다│ │ (help text)
│ │ 구 * [예: 광진구___]│ │ ← 기존 gu 자유 입력 유지
│ └───────────────────────────────┘ │
│ 유형 * [실내 ▾] 주소 * [________] │
│ ...(기존 필드)... │
│ [취소] [등록하기] │
└─────────────────────────────────────┘
시설 상세 — 대기질 카드 (토스: 부가 정보 카드, 색 1곳)
┌─ 시설 상세 정보 ────────────────────┐
│ 코드 ABC001 유형 [야외] │
│ 시/도 부산광역시 구 해운대구 │ ← 시/도 신규 표시
│ 주소 부산 해운대구 ... │
│ ...(기존 dl)... │
├─────────────────────────────────────┤
│ 현재 대기질 [나쁨] ← 배지 │ ← AirQualityCard
│ 미세먼지(PM10) 92 ㎍/㎥ │
│ 초미세먼지(PM2.5) 41 ㎍/㎥ │
│ 해운대구 측정소 · 14:00 기준 │
└─────────────────────────────────────┘
상태별 카드 하단:
- loading → “대기질 정보를 불러오는 중…” (스켈레톤/스피너)
- error/UNKNOWN → “대기질 정보를 불러올 수 없습니다” (muted, 배지 숨김)
화면별 상태 표 (loading/empty/error/success)
| 화면 | loading | empty | error | success |
|---|---|---|---|---|
| 시설 목록 | ”불러오는 중…”(aria-busy) | “등록된 시설이 없습니다” + 등록 링크 | role="alert" 재시도 안내 | 표(시/도 컬럼 포함) — 기존 4상태 유지, 컬럼만 추가 |
| 시설 상세 | 스피너 | 없음/미존재 → “시설을 찾을 수 없습니다” | 조회 실패 메시지 | <dl>(시/도 포함) + 대기질 카드 |
| 대기질 카드(상세 내부) | “대기질 정보를 불러오는 중…“ | pm10·pm25 모두 null → “대기질 정보를 불러올 수 없습니다” | fetch 실패 → 동일 폴백 문구 | 수치 + 등급 배지 + 측정소·시각 |
| 시설 폼 | 제출 중 버튼 disabled | — | 필드별 인라인 에러(role="alert") | 성공 토스트 → 목록/상세 이동 |
| CSV 임포트 | phase importing 진행률(role="progressbar") | 유효 행 0 → 등록 버튼 비활성 | 행별 오류 미리보기 + 실패 누적 | phase done 요약(role="status") |
UNKNOWN 등급은 BE가 실패·타임아웃 시 200 +
representativeGrade:"UNKNOWN"+ pm null로 반환한다(TDD 실패 경로). FE는 이를 empty/error와 동일하게 “대기질 정보를 불러올 수 없습니다”로 표시한다.
테마 토큰 — 대기질 등급 (라이트/다크 매핑, 의무)
globals.css에 HSL 트리플 형식(기존 컨벤션)으로 :root/.dark 양쪽 추가하고 tailwind.config.ts colors에 노출한다. 각 등급은 배경(bg)·전경(fg) 쌍.
| 시맨틱 토큰 | 용도(등급) | 라이트 (H S% L%) | 다크 (H S% L%) |
|---|---|---|---|
--aq-good / -foreground | 좋음 배경/글자 | 211 100% 96% / 211 90% 35% | 211 60% 20% / 211 90% 80% |
--aq-moderate / -foreground | 보통 | 142 60% 94% / 142 70% 28% | 142 40% 18% / 142 60% 75% |
--aq-bad / -foreground | 나쁨 | 35 100% 94% / 30 90% 40% | 30 60% 20% / 35 90% 70% |
--aq-verybad / -foreground | 매우나쁨 | 0 90% 96% / 0 75% 45% | 0 55% 22% / 0 85% 78% |
--aq-unknown / -foreground | 정보없음 | 210 16% 93% / 215 16% 47% | 217 20% 22% / 215 20% 65% |
Tailwind 노출 예: colors: { "aq-good": "hsl(var(--aq-good))", "aq-good-foreground": "hsl(var(--aq-good-foreground))", ... }. 배지는 bg-aq-bad text-aq-bad-foreground 형태로만 색을 쓴다(하드코딩 0건).
색 위계 근거: 좋음=파랑, 보통=초록, 나쁨=주황, 매우나쁨=빨강 — 에어코리아 CAI 관례. 다크 값은 채도↓·명도 대비 확보. 현재 다크 토글 UI는 없지만(라이트 고정) 컨벤션(
no-single-mode)에 따라 두 모드 토큰을 모두 정의한다.
등급 → 라벨/토큰 매핑 (lib/portal/air-quality.ts)
BE가 등급을 계산해 반환하므로 FE는 재분류하지 않는다. enum → 표시값만 매핑.
| grade (BE) | 라벨 | 배지 토큰 |
|---|---|---|
GOOD | 좋음 | bg-aq-good text-aq-good-foreground |
MODERATE | 보통 | bg-aq-moderate text-aq-moderate-foreground |
BAD | 나쁨 | bg-aq-bad text-aq-bad-foreground |
VERY_BAD | 매우나쁨 | bg-aq-verybad text-aq-verybad-foreground |
UNKNOWN | 정보없음 | bg-aq-unknown text-aq-unknown-foreground (배지 숨기고 폴백 문구 우선) |
컴포넌트 트리 (컨테이너/프레젠테이션 분리)
facilities/[id]/page.tsx (컨테이너·클라이언트)
├─ useAirQuality(lat, lng) ← 훅(데이터·상태)
├─ <dl> 시설 정보 (시/도 <dt>/<dd> 추가)
└─ <AirQualityCard status pm10 pm25 grade stationName measuredAt/> (프레젠테이션)
└─ <AirQualityBadge grade/> (프레젠테이션)
facilities/new/page.tsx (컨테이너) · [id]/page.tsx(edit)
└─ <FacilityForm/> (프레젠테이션, useFacilityForm)
└─ <SidoSelect value onChange/> (프레젠테이션, 재사용)
facilities-import/FacilitiesImportClient.tsx
└─ parseCsvFacilities() → 미리보기 테이블(시/도 컬럼)
FacilitiesListClient.tsx → 표(시/도 컬럼)
재사용 컴포넌트: SidoSelect(폼·향후 필터 재사용), AirQualityBadge(카드·향후 목록 재사용), AirQualityCard.
상태관리 설계
| 상태 | 저장 | 근거 |
|---|---|---|
| 시설 목록/상세 서버 데이터 | 기존 관례 — 컴포넌트 useState+useEffect+fetch(BFF) | 레포에 TanStack Query·Zustand 없음. 이 과제만을 위한 도입은 과함 |
| 대기질 서버 데이터 | useAirQuality 훅 내부 useState(data/status) | 컴포넌트에서 fetch 분리(no-direct-fetch). 훅이 lat/lng 변경 시 재조회 |
| 폼 입력값(sido 포함) | useFacilityForm useState | 기존 폼 훅 확장. 전역 아님 |
| 전역 클라이언트 상태 | 없음 | 시/도·대기질 모두 화면 지역 상태. 전역 승격 근거 없음(no-global-by-default) |
API 연동 표
| 화면 | 엔드포인트(BFF → BE) | 훅/호출 | 에러 처리 |
|---|---|---|---|
| 시설 목록 | GET /api/portal/facilities → GET /api/facility-owner/facilities | 기존 fetch | 기존 4상태 |
| 시설 상세 | GET /api/portal/facilities/{id} → GET /api/facility-owner/facilities/{id} | 기존 fetch. 응답에 지역 4필드 + location | 기존 |
| 대기질 | GET /api/portal/air-quality?lat&lng → GET /air-quality?lat&lng | useAirQuality(lat,lng) | 실패/UNKNOWN → 폴백 문구, 본체 무영향 |
| 시설 등록 | POST /api/portal/facilities → POST /api/facility-owner/facilities | 폼 컨테이너 fetch. payload에 sido | 400 필드 에러 매핑 |
| 시설 수정 | PATCH /api/portal/facilities/{id} | 폼 컨테이너 fetch. payload에 sido | 동일 |
| CSV 임포트 | 유효 행별 POST /api/portal/facilities | 순차 fetch | 행별 성공/실패 누적 |
좌표 확보: 웹
MyFacility.location(“lat,lng”)을 상세 페이지에서 split → number로useAirQuality에 전달. 파싱 실패 시 훅 미호출(대기질 카드 표시 안 함). 웹은 모바일과 달리 좌표 역제안 불필요.
라우팅 · 내비게이션 흐름
flowchart LR List[시설 목록] --> Detail[시설 상세] List --> New[시설 등록] List --> Import[CSV 임포트] Detail --> Edit[상세 내 수정 모드] Detail --> Aq[대기질 카드 조회] New --> Sido[SidoSelect 입력] Edit --> Sido Import --> Csv[sido 컬럼 파싱]
Testing Plan (implementer TDD 입력)
Vitest + @testing-library/react. 사용자 관점 동작 검증. DOM 필요 파일은 // @vitest-environment jsdom. 테스트명에 티켓ID 금지.
| 대상 | 레벨 | 핵심 케이스 |
|---|---|---|
SidoSelect | 컴포넌트 | 17개 옵션 렌더, “선택 안 함” 기본, onChange 값 전달, 라벨 접근성 |
AirQualityBadge | 컴포넌트 | 등급별 라벨·토큰 클래스, UNKNOWN 배지 미표시 |
AirQualityCard | 컴포넌트 | success 수치·배지·측정소 표시, loading 문구, UNKNOWN/실패 폴백 문구, pm 일부 null 표시 |
useAirQuality | 훅(renderHook) | 성공 데이터 반환, 실패 시 error 상태, lat/lng 없으면 미조회, UNKNOWN 응답 처리 |
air-quality 매핑 | 유닛 | 각 등급 라벨·토큰, 미지정 grade 방어 |
sido-options | 유닛 | 17건 존재, 코드 형식(2자리), 서울 11·부산 26 포함 |
parseCsvFacilities | 유닛 | sido 컬럼 파싱(신규 헤더), sido 미입력 허용(optional), 기존 필드 회귀, type enum·위경도 회귀 |
FacilityForm | 컴포넌트 | SidoSelect 렌더·값 반영, sido 미선택 제출 허용, 기존 필드 검증 회귀 |
FacilitiesListClient | 컴포넌트 | 시/도 컬럼 표시, 지역 미확인(“미지정”) 표시, 기존 4상태 회귀 |
| 상세 페이지 | 시나리오 | 시/도 표시 + 대기질 카드 success/폴백, 좌표 파싱 실패 시 카드 미표시 |
| BFF route | 유닛 | POST payload에 sido 포함, air-quality route 프록시·쿼리 전달, sido 미입력 시 전달 형태 |
Release Scenario (기능 플래그·점진 공개)
- FE는 BE 신규 경로가 배포된 뒤 활성. 대기질 카드는 좌표 없거나 UNKNOWN이면 자연 폴백 → BE 미배포 시에도 웹 깨지지 않음(graceful).
- 시/도 입력은 optional(미선택 시 서버 주소 파싱 보간) → 기존 등록 흐름 하위 호환.
- 롤백: 대기질 카드는 상세 페이지에서 조건부 렌더 제거로 즉시 비활성. 시/도 컬럼·필드는 추가만이라 제거해도 기존 gu 경로 정상.
웹/앱 공유 로직 경계 (양 문서 동일)
| 항목 | 공유 여부 | 비고 |
|---|---|---|
| 타입(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).- FacilityResponse(owner) 지역 필드:
sidoCode, sidoName, sigunguCode, sigunguName(+ 기존gu,location). - RegisterFacilityRequest
sido필드(표준코드, optional — 미입력 시 서버 주소 파싱 보간).
역제안(BE에 요청)
- FacilityResponse(owner)에 지역 4필드 노출 확인(TDD 명시됨). 웹
MyFacility타입에 반영. - (선택)
GET /regions/sido— 17시도{code,name}목록. 현행은 FE 상수로 처리하되, 개편 대응·SSOT 필요 시 BE 제공 권장.
Open Questions
- 시/도 드롭다운 “선택 안 함” 기본값 UX가 운영자에게 “자동 판별”로 충분히 전달되는지(help text 문구) — 사용자 확인 대상.
- 대기질 카드 배치(현행:
<dl>아래 별도 카드) 확정 — 와이어프레임 확인 대상.
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-04 | 최초 작성 — AS-IS 코드 근거, useAirQuality 훅+BFF route 채택, AQ 토큰 라이트/다크 매핑, 시/도 드롭다운 FE 상수, 티켓 9건 분해 |