시설 전국 확장·대기질 연동 — 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가 응답으로 제공
BFFNext.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:91gu<Input type="text"> 자유 입력(placeholder “예: 광진구”), 드롭다운 아님.
    • 폼 스키마: web/app/portal/facilities/facility-form-schema.ts:13gu: 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:39gu: input.gu 무변환 전달. location "lat,lng"lat/lng number 분해(:18-50). 인증은 httpOnly access_token 쿠키 → Authorization: Bearer 자동 부착(web/lib/portal/be-client.ts).
  • 타입: web/lib/portal/types.ts:28 MyFacilitygu만, 지역 필드 없음. location: string(lat,lng)은 존재(:35) → 웹은 대기질 조회용 좌표를 이미 확보.
  • 테마: web/app/globals.css — CSS 변수 시맨틱 토큰(HSL 트리플 형식) :root/.dark 양쪽 정의됨(--background, --destructive 등). Tailwind darkMode:["class"]. 다크 토큰은 정의만 되고 토글 UI는 없음(현재 라이트 고정). danger/상태색(좋음/나쁨) 토큰은 없음. AirQualityCard/AirQualityBadge/useAirQuality 컴포넌트·훅 부재.
  • UI 컴포넌트: web/components/ui/badge/button/dialog/input/tabs/toast. Select 컴포넌트는 없음 → 시/도 드롭다운은 네이티브 <select> 래퍼로 신규 작성.

TO-BE

  • 웹 전 계층에 시/도(sido 표준코드)를 추가: 폼 드롭다운(SidoSelect), 스키마 sido 필드, CSV sido 컬럼, BFF payload sido, 목록·상세 표시. 기존 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.tsxsido 컬럼 추가·미리보기
BFFapi/portal/facilities/route.ts, [id]/route.ts, lib/portal/schemas.tspayload 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)

화면loadingemptyerrorsuccess
시설 목록”불러오는 중…”(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/facilitiesGET /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&lngGET /air-quality?lat&lnguseAirQuality(lat,lng)실패/UNKNOWN → 폴백 문구, 본체 무영향
시설 등록POST /api/portal/facilitiesPOST /api/facility-owner/facilities폼 컨테이너 fetch. payload에 sido400 필드 에러 매핑
시설 수정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건 분해