시설 전국 확장·대기질 연동 TDD

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/PRD.md

facility 도메인은 지역을 gu(자치구) 단일 문자열로만 보유해 서울 전용으로 동작한다(domain/facility/entity/Facility.kt:29, 복합 인덱스 idx_gu_type Facility.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 무결합을 보장한다.

Terminology

용어정의
행정표준코드행정안전부 행정표준코드관리시스템 법정동코드. 10자리 = 시도(2) + 시군구(3) + 읍면동(3) + 리(2)
시도코드(sidoCode)법정동코드 앞 2자리. 예: 서울 11, 부산 26
시군구코드(sigunguCode)법정동코드 앞 5자리. 예: 서울 종로구 11110, 부산 해운대구 26350
UNSPECIFIED주소 파싱 실패·소스 미제공 시 보존용 미지정 코드. sidoCode 00 / sigunguCode 00000 / 명칭 미지정
CAI통합대기환경지수(Comprehensive Air-quality Index). 좋음/보통/나쁨/매우나쁨 4단계
대표 등급PM10 등급과 PM2.5 등급 중 더 나쁜 쪽
Region 마스터시도·시군구 표준코드+명칭을 담는 MySQL 참조 테이블(정적 seed)

Define Problem

AS-IS

  • Facility 스키마 서울 전용: Facility.ktgu: String만 보유(:29), 복합 인덱스 idx_gu_type={'gu':1,'type':1}(:18). 시/도 개념 없음.
  • 필터·통계·API·MCP 전부 gu 가정: FacilityCriteria.kt(gu·type만), FacilityRepositoryImpl.kt#aggregateGuType(_id.gu,_id.type 그룹), FacilityApiController.kt(?gu=, /stats/gu-type), McpFacilityTools.kt#getFacilities(gu 파라미터), McpFacilityStatsTools.kt.
  • 시설 생성 3경로 시/도 부재: owner-create RegisterFacilityRequest.kt(gu만)→RegisterMyFacilityCommand.ktFacilityOwnerDomainService.kt#registerForOwner; 대량 적재 PublicFacilityImportService.kt#importAllPublicSportsFacilityGateway.PublicFacility.toAttributes(gu만, PublicSportsFacilityGateway.kt:23); CSV LegacyToFacilityMapper.kt#map(gu만).
  • 대기질 미연동: domain/weather는 기상청 단기예보(KmaWeatherGatewayImpl.kt)만. PM10/PM2.5 없음. WeatherGateway.shortForecast(lat,lng) 패턴만 존재.
  • Mongo 마이그레이션 러너 부재: Mongock 없음, MongoConfig.ktautoIndexCreation()=true. 신규 복합 인덱스는 mongosh 스크립트로 background 생성 필요(src/main/resources/mongo/migration/ 신설).
  • 외부 연동 규약: application.yml external.*(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.

Architecture Benchmarking (의무)

제품/사례해결 방식참고할 패턴미참고 사유
에어코리아(한국환경공단) 측정소정보 조회 서비스 data.go.kr 15073877getTMStdrCrdnt(TM 기준좌표) → getNearbyMsrstnList(근접측정소) → getMsrstnAcctoRltmMesureDnsty(측정소별 실시간) 3단계 API. 등급 코드 1~4(좋음/보통/나쁨/매우나쁨)3단계 체이닝을 단일 Gateway 뒤로 캡슐화, CAI 4등급 채택예보(범위 밖), 통계/영문 서비스는 미사용
A.P.T 미세먼지 프로젝트(GreenTech 9기) velog동별 위경도→TM 좌표 변환 결과를 CSV로 선캐싱해 반복 요청 시 API 재호출 회피, 실패 시 위경도 직접 변환 fallback좌표→측정소 해석 결과를 캐시해 3단계 중 앞 2단계 반복 호출 제거 → 500/day 한도·P95 보호. 실패 fallback정적 CSV 대신 Redis TTL 캐시 채택(측정소 개편 반영 위해 만료 필요). 우리는 좌표가 시설 단위로 고정이라 그리드 라운딩 키로 캐시
행정표준코드관리시스템(행안부) code.go.kr법정동코드 10자리에서 앞자리 절단으로 시도/시군구 표준코드 추출시도=앞2, 시군구=앞5 절단 규칙을 Region 마스터 seed 스키마의 근거로 채택읍면동(8자리)·리(10자리)는 시설 정규화에 불필요해 미보유. 주기 동기화 배치 미채택(정적 seed)
네이버 지도 결합형 정보 카드 분석장소에 날씨+대기질을 부가 정보 카드로 결합 노출시설 상세에 대기질을 “부가 정보”로 얹되 조회 실패해도 본체(시설)는 정상 노출예약 차단·강제 결합은 미참고(FR: 경고까지만)

Possible Solutions

방안 비교 — 지역 정규화 모델링

방안설명왜 채택미채택 대안
A. facility 도메인 소유 RegionResolveGateway(채택)주소→표준코드 해석을 facility 도메인이 정의한 인터페이스로 캡슐화. 구현체(infra)가 MySQL regions를 조회. facility 도메인 서비스가 직접 주입해 내부 해석facility 도메인 순수성 유지(다른 도메인 패키지 import 0), UseCase 10줄 규칙 준수(도메인 서비스가 해석), regions는 facility와 다른 저장소(MySQL vs Mongo)라 Gateway 모델이 자연스러움
B. 신규 region 바운디드 컨텍스트 + RegionDomainServiceregion을 독립 도메인으로 분리, RegionDomainService.resolve() 노출region은 정적 조회 외 행위·독립 라이프사이클이 없어 전체 도메인은 과설계(단순함 우선). facility가 유일 소비자
C. UseCase에서 region 해석 오케스트레이션UseCase가 region service + facility service 둘 다 호출대량 적재·CSV·백필이 루프 내부에서 행별 해석 → 도메인 서비스 내부에 있어야 자연스러움. UseCase 10줄 초과 위험
D. Facility에 gu만 두고 조회 시 join코드 저장 안 하고 매 조회 시 regions joinMongo↔MySQL 크로스 저장소 join 불가, 인덱스 불가. 비정규화 필드 저장이 필수

방안 비교 — 대기질 캐싱

방안설명왜 채택미채택 대안
단일 측정값 캐시(그리드키, TTL 10분)(채택)좌표를 소수 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 서버 내 처리
대기질 조회 캐싱Redis(기존 인스턴스 재사용)500/day 한도·P95 보호. 신규 인프라 없음

시스템 역할 경계 (의무)

단위역할소유 데이터/책임노출 인터페이스의존
regions 테이블(MySQL)시도·시군구 표준코드 마스터sidoCode/sidoName/sigunguCode/sigunguName. 정적 seed(없음, Gateway 뒤)Flyway
RegionResolveGateway (domain/facility/gateway)주소→표준코드 해석 계약해석 규칙의 계약resolve(address, sidoHint): FacilityRegion(없음)
RegionResolveGatewayImpl (infra)주소 파싱 + regions 조회파서·lookup·UNSPECIFIED 폴백위 구현RegionJpaRepository
FacilityRegion VO (domain/facility/vo)지역 4필드 값객체 + 등급 없음코드/명칭 캡슐화, UNSPECIFIED 상수isUnspecified(), 팩토리(없음)
Facility (domain/facility/entity)시설 Rich Domainregion 4필드 비정규화 보유, assignRegion()상태 전이·질의 메서드(없음)
FacilityDomainService조회·통계·CSV 적재 오케스트레이션지역 해석 후 저장list/aggregate/register/bulkImportFacilityRepository, RegionResolveGateway
FacilityOwnerDomainServiceowner 등록/수정등록 시 지역 해석registerForOwner 등FacilityRepository, RegionResolveGateway, GeocodingGateway
PublicFacilityImportServicedata.go.kr 대량 적재페이지 순회 + 행별 지역 해석importAllPublicSportsFacilityGateway, FacilityRepository, RegionResolveGateway
FacilityRegionBackfillService (신규)기존 문서 지역 백필페이지 순회 + 멱등 재해석, 분산 락backfill(pageSize)FacilityRepository, RegionResolveGateway, DistributedLock
AirQualityGateway (domain/airquality/gateway)좌표→실시간 측정 계약3단계 체인의 계약current(lat,lng): AirQualityMeasurement(없음)
AirKoreaAirQualityGatewayImpl (infra)3단계 API 체인 + 캐시 + degrade캐시·타임아웃·빈결과 폴백위 구현RestClient, AirQualityMeasurementCache
AirQualityMeasurementCache (domain/airquality/repository)측정 결과 캐시 계약그리드키 TTL 캐시findBy(gridKey) / save(gridKey, m)(없음)
AirQualityDomainService측정→등급 VO 조립CAI 등급 적용, 대표 등급current(lat,lng): AirQualityAirQualityGateway
GetAirQualityUseCase조회 오케스트레이션10줄execute(lat,lng): AirQualityAirQualityDomainService
AirQualityApiController라우팅Request→호출→ResponseGET /air-qualityUseCase

인터페이스 시그니처 (구현자 간 해석 차이 제거)

// domain/facility/vo/FacilityRegion.kt
data class FacilityRegion(
    val sidoCode: String,      // 2자리, UNSPECIFIED="00"
    val sidoName: String,      // "부산광역시", UNSPECIFIED="미지정"
    val sigunguCode: String,   // 5자리, UNSPECIFIED="00000"
    val sigunguName: String,   // "해운대구", UNSPECIFIED="미지정"
) {
    fun isUnspecified(): Boolean = sidoCode == UNSPECIFIED_SIDO
    companion object {
        const val UNSPECIFIED_SIDO = "00"
        const val UNSPECIFIED_SIGUNGU = "00000"
        val UNSPECIFIED = FacilityRegion("00", "미지정", "00000", "미지정")
        fun of(sidoCode: String, sidoName: String, sigunguCode: String, sigunguName: String): FacilityRegion
    }
}
 
// domain/facility/gateway/RegionResolveGateway.kt
interface RegionResolveGateway {
    // 우선순위: sidoHint(입력값) + 주소 파싱. 실패 시 FacilityRegion.UNSPECIFIED 반환(예외 금지)
    fun resolve(address: String, sidoHint: String?): FacilityRegion
}
 
// domain/facility/vo/FacilityAttributes.kt (기존 + region)
data class FacilityAttributes(
    /* 기존 필드 유지 */ val gu: String,
    val region: FacilityRegion = FacilityRegion.UNSPECIFIED, // 해석 결과(도메인 서비스가 채움)
    val sidoHint: String? = null,                            // 입력값 우선 힌트(FR-6)
    /* ... */
)
 
// domain/facility/repository/FacilityRepository.kt (추가 시그니처)
fun findAll(sidoCode: String?, sigunguCode: String?, gu: String?, type: String?, pageable: Pageable): Page<Facility>
fun aggregateRegionType(): List<RegionTypeCount>
fun findAllForBackfill(pageable: Pageable): Page<Facility>   // deletedAt is null 전체
 
// domain/facility/entity/Facility.kt (추가 메서드)
fun assignRegion(region: FacilityRegion)   // private var region 필드 갱신
 
// domain/airquality/gateway/AirQualityGateway.kt
interface AirQualityGateway {
    // 3단계 실패·타임아웃 시 AirQualityMeasurement.empty() 반환(예외 전파 금지)
    fun current(lat: Double, lng: Double): AirQualityMeasurement
}
 
// domain/airquality/vo/AirQualityMeasurement.kt
data class AirQualityMeasurement(
    val pm10: Int?, val pm25: Int?, val stationName: String?, val measuredAt: ZonedDateTime?,
) { companion object { fun empty(): AirQualityMeasurement } }
 
// domain/airquality/repository/AirQualityMeasurementCache.kt
interface AirQualityMeasurementCache {
    fun findBy(gridKey: String): AirQualityMeasurement?
    fun save(gridKey: String, measurement: AirQualityMeasurement)
}
 
// domain/airquality/vo/AirQualityGrade.kt
enum class AirQualityGrade { GOOD, MODERATE, BAD, VERY_BAD, UNKNOWN;
    companion object {
        fun ofPm10(value: Int?): AirQualityGrade  // 0-30 GOOD / 31-80 MODERATE / 81-150 BAD / 151+ VERY_BAD / null UNKNOWN
        fun ofPm25(value: Int?): AirQualityGrade  // 0-15 / 16-35 / 36-75 / 76+
        fun worseOf(a: AirQualityGrade, b: AirQualityGrade): AirQualityGrade
    }
    fun isBadOrWorse(): Boolean = this == BAD || this == VERY_BAD
}
 
// domain/airquality/vo/AirQuality.kt
data class AirQuality(
    val pm10: Int?, val pm25: Int?,
    val pm10Grade: AirQualityGrade, val pm25Grade: AirQualityGrade,
    val representativeGrade: AirQualityGrade,
    val stationName: String?, val measuredAt: ZonedDateTime?,
) { companion object { fun of(m: AirQualityMeasurement): AirQuality; fun empty(): AirQuality } }

클래스 역할 정의

도메인 모델

클래스명역할핵심 책임
FacilityRegion VO지역 값객체4필드 캡슐화, UNSPECIFIED 판별
Facility Entity시설 Rich Domainregion 비정규화 보유, assignRegion() 상태전이, create() 검증
RegionTypeCount dto시도·시군구·유형별 집계 결과집계 투영
AirQualityGrade VO(enum)CAI 등급농도→등급, 대표 등급(worseOf), isBadOrWorse()
AirQuality / AirQualityMeasurement VO대기질 값측정값 보유, empty 폴백, 등급 조립

서비스 클래스

클래스명역할입력 → 출력의존
RegionResolveGatewayImpl주소 해석(address, sidoHint) → FacilityRegionRegionJpaRepository
FacilityDomainService조회·통계·CSVcriteria → Page/집계; rows → BulkImportResultFacilityRepository, RegionResolveGateway
FacilityOwnerDomainServiceowner 등록(attributes, ownerId) → FacilityFacilityRepository, RegionResolveGateway, GeocodingGateway
PublicFacilityImportService대량 적재(maxPages, numOfRows) → BulkImportResultPublicSportsFacilityGateway, FacilityRepository, RegionResolveGateway
FacilityRegionBackfillService백필pageSize → BackfillResultFacilityRepository, RegionResolveGateway, DistributedLock
AirKoreaAirQualityGatewayImpl3단계 체인(lat,lng) → AirQualityMeasurementRestClient, AirQualityMeasurementCache
AirQualityDomainService등급 조립(lat,lng) → AirQualityAirQualityGateway

실패 경로·동시성·멱등

  • 대기질 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 파라미터·응답 필드 완전 유지.

상태 전이 표

지역 해석 결과 (Facility region 필드)

현재 상태 × 이벤트다음 상태거부/비고
region 없음/UNSPECIFIED × 주소 파싱 성공(sido·sigungu 코드·명칭 세팅)
region 없음/UNSPECIFIED × 주소 파싱 실패UNSPECIFIED 보존경고 로그, 삭제·스킵 금지
해석됨 × 백필 재실행(주소 동일)동일 코드 재세팅멱등, 변화 없음
UNSPECIFIED × 백필 재실행(regions 보강 후)재해석 성공 시 코드 세팅재백필 가능

대기질 조회

현재 상태 × 이벤트다음 상태거부/비고
요청 × 캐시 히트캐시 측정값 반환API 0회
요청 × 캐시 미스 + 3단계 성공측정값 캐시 저장 + 등급 반환
요청 × 임의 단계 실패/타임아웃AirQuality.empty()(UNKNOWN)예외 전파 금지
요청 × pm 일부 null가용 등급으로 대표 등급UNKNOWN은 대표 산정에서 제외

Component Diagram

flowchart LR
    subgraph Presentation
        FacCtl[FacilityApiController]
        AqCtl[AirQualityApiController]
        AdmCtl[AdminFacilityApiController]
        Mcp[McpFacilityTools]
    end
    subgraph Application
        ListUC[ListFacilitiesUseCase]
        AqUC[GetAirQualityUseCase]
        BackfillUC[BackfillFacilityRegionUseCase]
    end
    subgraph Domain
        FacSvc[FacilityDomainService]
        BackSvc[FacilityRegionBackfillService]
        AqSvc[AirQualityDomainService]
        FacRepo[FacilityRepository]
        RegGw[RegionResolveGateway]
        AqGw[AirQualityGateway]
        AqCache[AirQualityMeasurementCache]
    end
    subgraph Infrastructure
        FacRepoImpl[FacilityRepositoryImpl]
        RegGwImpl[RegionResolveGatewayImpl]
        AqGwImpl[AirKoreaAirQualityGatewayImpl]
        AqCacheImpl[AirQualityRedisCache]
    end
    FacCtl --> ListUC --> FacSvc
    AqCtl --> AqUC --> AqSvc
    AdmCtl --> BackfillUC --> BackSvc
    Mcp --> ListUC
    FacSvc --> FacRepo
    FacSvc --> RegGw
    BackSvc --> FacRepo
    BackSvc --> RegGw
    AqSvc --> AqGw
    FacRepoImpl -.->|impl| FacRepo
    RegGwImpl -.->|impl| RegGw
    AqGwImpl -.->|impl| AqGw
    AqGwImpl --> AqCache
    AqCacheImpl -.->|impl| AqCache

Sequence Diagram — 대기질 조회(예약 화면)

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 없음)"
  • regions: MySQL, Flyway seed(17 시도 + 시군구). PK id, 참조는 코드값. 상세 컬럼·인덱스·시군구 seed 데이터는 senior-dba design-db + 마이그레이션 소관.
  • facilities: Mongo. region 4필드 비정규화(join 회피). 복합 인덱스 idx_sido_sigungu_type 추가(idx_gu_type 유지).
  • 대기질: 영속화 없음(Redis 캐시만) — ERD 해당 없음.

Testing Plan

레벨대상핵심 시나리오
domainAirQualityGradePM10/PM2.5 경계값(30/31/80/81/150/151, 15/16/35/36/75/76), null→UNKNOWN, worseOf(대표 등급), 한쪽 null 대표 산정
domainFacilityRegionUNSPECIFIED 판별, of 팩토리
domainFacilityassignRegion 상태전이, create 신규 region 반영, gu 유지
domainAirQualityDomainServicemeasurement→AirQuality 등급 매핑, empty→UNKNOWN(gateway 모킹)
domainFacilityRegionBackfillService해석 성공 세팅/파싱 실패 UNSPECIFIED 보존/멱등 재실행/락 실패 시 중단(모킹)
domainFacilityDomainService·PublicFacilityImportService지역 해석 후 저장, 미해석 UNSPECIFIED, gu 하위호환(RegionResolveGateway 모킹)
applicationGetAirQualityUseCase·BackfillFacilityRegionUseCase·ListFacilitiesUseCaseDomainService 위임, 10줄 준수(모킹)
infrastructureRegionResolveGatewayImpl주소 파싱→코드 조회(TestContainers MySQL, seed), 미매핑 UNSPECIFIED, sidoHint 우선
infrastructureFacilityRepositoryImplregion 필터 findAll, aggregateRegionType, findAllForBackfill, null region→UNSPECIFIED 보정(TestContainers Mongo), idx 활용
infrastructureAirKoreaAirQualityGatewayImpl3단계 체인 파싱, 캐시 히트 시 API 0회, 단계 실패 시 empty, 타임아웃 degrade(mock/WireMock + Redis TestContainers)
presentationAirQualityApiController정상 200, 실패 시 정보없음 200, 잘못된 좌표 400(MockMvc)
presentationFacilityApiController·McpFacilityToolssido/sigungu 필터, /stats/region-type, gu 하위호환, MCP scope
presentationAdminFacilityApiController백필 트리거 200, 중복 트리거 409
scenarioE2E부산 시설 적재→시도 필터 조회→백필→대기질 조회 경고 흐름 전체

테스트는 전 레이어 Kotest(BehaviorSpec/DescribeSpec). 실 DB 없는 Mock-only 통합 테스트 금지. RED→GREEN→REFACTOR.

Release Scenario — 무중단 배포 (의무)

expand-contract + env 스위치. region 필드는 추가만(파괴 없음), 대기질은 신규 경로라 기존 트래픽 무영향.

  1. 스키마 확장(선): Flyway V38 regions 테이블+seed 배포(부팅 시 자동, 하위호환). Mongo 인덱스 mongosh 스크립트 background:true로 idx_sido_sigungu_type 생성(온라인, 조회 차단 없음) — 코드 배포 전 실행.
  2. 코드 배포: Facility에 region 필드(nullable→매핑 시 UNSPECIFIED 기본), region 해석 write 경로, 대기질 Gateway(초기 env=mock). 기존 gu 경로 유지 → 클라이언트 무영향.
  3. 백필(온라인): POST /admin/facilities/backfill-region 관리자 트리거. 분산 락 + 페이지 순회. 서비스 조회 트래픽 무중단. 완료 후 UNSPECIFIED 비율 로그·대시보드 관측.
  4. 대기질 실연동 전환: DATA_GO_KR_SERVICE_KEY + EXTERNAL_AIR_QUALITY_BASE_URL을 실 host로 교체 후 재기동. 코드 무변경(env 스위치).
  5. 전환 조건: 각 단계는 앞 단계 성공 후 진행. 백필은 UNSPECIFIED 비율이 관측 목표 이하일 때 완료 판정.

롤백(단계별)

  • 대기질 실연동 → env를 mock host로 되돌려 재기동(즉시).
  • 대기질 기능 전체 → 모바일이 엔드포인트 미호출(FE 플래그) 또는 컨트롤러 제거. 백엔드 신규 경로라 기존 무영향.
  • region 코드 배포 → 이전 이미지로 compose 재기동(private-deploy-convention). region 필드는 무시되어도 gu 경로 정상.
  • Mongo 인덱스 → db.facilities.dropIndex("idx_sido_sigungu_type") (역방향 스크립트 주석).
  • Flyway regions → 역방향 DDL DROP TABLE regions(참조가 코드값이라 안전, 마이그레이션 주석에 명시).
  • 백필 데이터 → region 필드 제거 역스크립트(mongosh $unset), 또는 재백필로 수렴(멱등).

재적재 runbook (Open Question 대응): 법정동 개편 발생 시 ① 신규 시군구 코드로 regions seed 마이그레이션 추가(V39+) → ② POST /admin/facilities/backfill-region 재실행(멱등). 담당·주기 미정, 수동 트리거.

Observability

  • 대기질 Gateway 호출 성공률(2xx+유효값)·P95 레이턴시 지표(옵저버빌리티 스택). 성공률 95%·P95 1.5초 목표.
  • 에어코리아 500/day 80% 도달 알림.
  • 백필 완료 후 UNSPECIFIED 비율 로그·대시보드.

Open Questions

  • 시군구 seed 전량(약 250건) 확정 범위 — senior-dba가 실 소스(code.go.kr 다운로드) 기준으로 design-db에서 확정. 최소 커버 시/도 10개(Success Metric) 충족 범위 우선.
  • 그리드키 라운딩 자릿수(소수 3자리≈110m)와 캐시 TTL 10분이 P95·정합 균형에 적정한지 실측 후 조정.

Document History

날짜변경 내용
2026-07-04최초 작성 — AS-IS 코드 근거 확립, region=facility 합류/airquality 분리 결정, RegionResolveGateway·단일 측정 캐시·조회전용 대기질 API 채택, expand-contract 배포·롤백