근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/PRD.md
facility 도메인은 지역을 gu(자치구) 단일 문자열로만 보유해 서울 전용으로 동작한다(domain/facility/entity/Facility.kt:29, 복합 인덱스 idx_gu_typeFacility.kt:18). 대량 적재 소스 data.go.kr 공공체육시설 API는 이미 전국 데이터를 반환하지만(DataGoKrPublicFacilityGatewayImpl.kt) 저장·조회 스키마가 시/도 개념이 없어 동명 자치구(서울 중구 vs 부산 중구)를 구분하지 못한다. 동시에 야외 시설 예약 시 참고할 대기질(PM10/PM2.5) 정보가 어디에도 없다. 이 과제는 ① facility를 행정표준코드 기반 전국 구조로 정규화하고 ② 에어코리아 실시간 대기질을 신규 Gateway로 연동한다.
Overview
무엇을: facility에 시/도·시군구 행정표준코드 필드를 추가(기존 gu 유지)하고, MySQL Region 마스터를 정적 seed하며, 기존 Mongo 문서를 주소 파싱으로 백필한다. 에어코리아 3단계 체이닝 Gateway로 좌표 기준 대기질을 조회하는 읽기 전용 API를 신설한다.
왜: 전국 데이터 정합성 확보 + 야외 예약 시 대기질 경고 UX 지원.
어떻게: 행정표준코드는 RegionResolveGateway(facility 도메인 소유 인터페이스, MySQL regions 조회)로 주소→코드 해석을 캡슐화한다. 대기질은 신규 airquality 바운디드 컨텍스트로 분리하고, 3단계 API 체인 결과를 Redis 캐시(TTL 10분)로 감싸 500/day 한도와 P95 1.5초를 보호한다. 대기질은 예약 UseCase에 넣지 않고 조회 전용 엔드포인트로 분리해 graceful degrade와 booking 무결합을 보장한다.
시설 생성 3경로 시/도 부재: owner-create RegisterFacilityRequest.kt(gu만)→RegisterMyFacilityCommand.kt→FacilityOwnerDomainService.kt#registerForOwner; 대량 적재 PublicFacilityImportService.kt#importAll→PublicSportsFacilityGateway.PublicFacility.toAttributes(gu만, PublicSportsFacilityGateway.kt:23); CSV LegacyToFacilityMapper.kt#map(gu만).
Mongo 마이그레이션 러너 부재: Mongock 없음, MongoConfig.kt가 autoIndexCreation()=true. 신규 복합 인덱스는 mongosh 스크립트로 background 생성 필요(src/main/resources/mongo/migration/ 신설).
외부 연동 규약: application.ymlexternal.*(base-url+api-key) env 스위치, 키 없으면 localhost:9102 mock(mock-servers/data-go-kr/server.js, 현재 서울 8구만 생성). ExternalRestClientFactory(connect 3s/read 5s 타임아웃). Redis 캐시 패턴 존재(PopularProductsRedisRepository: 도메인 인터페이스 + StringRedisTemplate 구현).
예약 생성: CreateBookingUseCase.kt는 slotId 기반, facility·대기질과 무결합. BookingApiController POST /bookings.
TO-BE
Facility에 sidoCode/sidoName/sigunguCode/sigunguName 4필드 추가(비정규화, gu 유지). 복합 인덱스 idx_sido_sigungu_type 추가(idx_gu_type 유지 = expand).
MySQL regions 마스터를 Flyway seed(17개 시도 + 시군구). facility 도메인이 소유하는 RegionResolveGateway가 주소→표준코드 해석을 캡슐화(다른 데이터소스라 Gateway로 모델).
시설 생성/적재 도메인 서비스가 RegionResolveGateway로 지역을 내부 해석(UseCase는 오케스트레이션만). 미해석 시 UNSPECIFIED 보존.
기존 문서는 관리자 트리거 온라인 백필 UseCase가 페이지 단위로 재해석.
신규 airquality 바운디드 컨텍스트: AirQualityGateway(3단계 체인) + CAI 등급 VO + Redis 캐시. 읽기 전용 GET /air-quality.
좌표를 소수 3자리로 라운딩한 그리드키로 3단계 결과(측정값+측정소+측정시각)를 통째 캐시. 히트 시 API 0회
가장 단순. 3단계 전부 스킵 → P95·500/day 동시 보호. 개인 프로젝트 저 RPS에 충분
—
2단계 캐시(측정소 해석 장기 + 측정값 단기)
좌표→측정소는 30일, 측정값은 10분 별도 캐시
—
캐시 인터페이스 2개·무효화 복잡. 지금 규모에 과함
캐시 없음
매 요청 3단계 호출
—
500/day 즉시 소진, P95 1.5초 위협(3콜 체인)
방안 비교 — 대기질 예약 경계
방안
설명
왜 채택
미채택 대안
조회 전용 API 분리(채택)
GET /air-quality?lat&lng 신설. 모바일이 시설 상세·예약 화면에서 호출, 경고/확인은 클라이언트 UX 게이트
FR-13 “경고까지만·차단 없음”에 정확히 부합. CreateBookingUseCase 무변경(10줄 유지), 외부 degradable 의존을 예약 트랜잭션 경로에서 배제 → graceful degrade 보장
—
예약 UseCase에 대기질 주입
CreateBookingUseCase가 대기질 조회 후 경고 플래그 반환
—
예약이 외부 API 장애에 결합, UseCase 10줄 초과, 차단 안 하는데 서버 경로에 둘 이유 없음
Detail Design
도메인 바운디드 컨텍스트 경계 판단 (의무)
대상
결정
근거
지역(Region)
기존 facility 컨텍스트에 합류 (신규 도메인 미분리)
정적 조회 외 독립 행위·라이프사이클 없음, 유일 소비자가 facility. RegionResolveGateway(facility 소유 인터페이스)로 흡수. 미채택(신규 region 도메인): 과설계
대기질(AirQuality)
신규 바운디드 컨텍스트 분리
외부 소스 상이(한국환경공단 vs 기상청), 독립 데이터 소유(측정소 캐시), 다른 변경 주기(시간 단위 측정 vs 예보), 자체 degradation·quota 정책. weather에 합류 시 두 공급자를 한 컨텍스트에 혼입해 비대화. 도메인 간 참조는 원시 좌표(lat/lng)만 → 결합 0
서버 토폴로지 설계 (의무)
서버
배치
근거
단일 API 서버
모든 신규 기능을 기존 API 서버에 배치
대기질은 요청-응답(모바일이 조회), 지역 정규화도 동기 CRUD. 별도 워커/소켓 불필요
스케줄러/배치 서버
미분리
백필은 관리자 1회성 온라인 트리거(POST /admin/facilities/backfill-region), Region seed는 부팅 시 Flyway. 대상 수천 건 규모에 전용 배치 서버는 과설계. 페이지 단위 순회 + 분산 락으로 API 서버 내 처리
대기질 3단계 체인: 각 RestClient 호출은 ExternalRestClientFactory(connect 3s/read 5s) 타임아웃. 임의 단계에서 RestClientException·타임아웃·빈 응답 → AirQualityMeasurement.empty() 반환(예외 전파 금지, weather Gateway와 동일 방어). DomainService가 empty→AirQuality.empty()(representativeGrade=UNKNOWN)로 매핑. FR-14 폴백 = 이 empty 응답.
부분 값: pm10 또는 pm25 중 하나만 null이면 가용한 등급으로 대표 등급 산정(UNKNOWN은 worseOf에서 무시).
캐시: 그리드키(lat·lng 소수 3자리 라운딩) TTL 10분 + jitter(±60s, stampede 방지). 캐시 미스만 API 호출. Redis 장애 시 캐시를 건너뛰고 직접 호출(캐시는 최적화, 정합 아님).
백필 멱등: 동일 주소는 항상 동일 코드로 결정적 재해석 → 재실행 안전. assignRegion()은 덮어쓰기. UNSPECIFIED 문서도 삭제·스킵하지 않고 재시도 대상 유지.
백필 동시성: 관리자 중복 트리거 방지 위해 DistributedLock(키 lock:facility:region-backfill, 기존 RedisDistributedLock 재사용). 락 획득 실패 시 즉시 409 반환. 페이지 단위 저장으로 장시간 트랜잭션 회피(문서별 save).
Region seed 부재 방어: regions 미적재 상태에서 resolve 호출 시 전부 UNSPECIFIED 반환(예외 없음) → seed 마이그레이션 선행이 배포 순서로 보장.
하위 호환: 신규 region 필드는 기존 문서에 없어 조회 시 null → FacilityRepositoryImpl 매핑에서 null→UNSPECIFIED 기본값 보정. gu 파라미터·응답 필드 완전 유지.
sequenceDiagram
participant M as Mobile booking/new
participant C as AirQualityApiController
participant U as GetAirQualityUseCase
participant S as AirQualityDomainService
participant G as AirKoreaGatewayImpl
participant K as MeasurementCache
participant A as 에어코리아 API
M->>C: GET /air-quality?lat&lng
C->>U: execute(lat,lng)
U->>S: current(lat,lng)
S->>G: current(lat,lng)
G->>K: findBy(gridKey)
alt 캐시 미스
G->>A: getTMStdrCrdnt → getNearbyMsrstnList → getMsrstnAcctoRltmMesureDnsty
A-->>G: pm10/pm25 (또는 실패)
G->>K: save(gridKey, measurement)
end
G-->>S: measurement (성공 or empty)
S-->>U: AirQuality (등급 or UNKNOWN)
U-->>C: AirQuality
C-->>M: 200 AirQualityResponse (실패 시 정보없음)
Sequence Diagram — 지역 백필
sequenceDiagram
participant Adm as Admin
participant C as AdminFacilityApiController
participant U as BackfillFacilityRegionUseCase
participant S as FacilityRegionBackfillService
participant L as DistributedLock
participant R as FacilityRepository
participant G as RegionResolveGateway
Adm->>C: POST /admin/facilities/backfill-region
C->>U: execute(pageSize)
U->>S: backfill(pageSize)
S->>L: acquire(lock:facility:region-backfill)
loop 페이지 순회
S->>R: findAllForBackfill(page)
S->>G: resolve(address, null)
G-->>S: FacilityRegion (or UNSPECIFIED)
S->>R: save(facility.assignRegion(region))
end
S->>L: release
S-->>U: BackfillResult(updated, unspecified)
ERD
erDiagram
regions {
bigint id PK
varchar sido_code "2자리"
varchar sido_name
varchar sigungu_code "5자리, UK"
varchar sigungu_name
datetime created_at
}
facilities {
string id PK
string code
string gu "유지(하위호환)"
string sido_code "신규, 비정규화"
string sido_name "신규"
string sigungu_code "신규"
string sigungu_name "신규"
string type
point location
}
regions ||..|| facilities : "코드값 참조(FK 없음)"
Flyway regions → 역방향 DDL DROP TABLE regions(참조가 코드값이라 안전, 마이그레이션 주석에 명시).
백필 데이터 → region 필드 제거 역스크립트(mongosh $unset), 또는 재백필로 수렴(멱등).
재적재 runbook (Open Question 대응): 법정동 개편 발생 시 ① 신규 시군구 코드로 regions seed 마이그레이션 추가(V39+) → ② POST /admin/facilities/backfill-region 재실행(멱등). 담당·주기 미정, 수동 트리거.