대기질 측정값 Redis 키 계약 (AirQualityMeasurementCache)

근거 문서

  • TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/시설 전국 확장·대기질 연동/TDD.md §Detail Design(AirQualityMeasurementCache 인터페이스, AirQualityMeasurement VO), §실패 경로·동시성·멱등, §상태 전이 표(대기질 조회), Component/Sequence Diagram
  • design-db: .../design-db.md §5.3 (영속화 없음, Redis 캐시 — 이 문서 범위)
  • 규칙(SSOT): ~/.claude/rules/private-redis-convention.md
  • 기존 패턴 참조: backend/src/main/kotlin/com/sportsapp/infrastructure/goods/redis/PopularProductsRedisRepository.kt (String+JSON 캐시 패턴), docker-compose.ymlredis 서비스 정의

이 문서는 BE-05(AirQualityMeasurementCache 구현체) 및 AirKoreaAirQualityGatewayImpl 구현의 선행 계약입니다. RedisTemplate·CacheConfig 등 앱 코드는 이 문서 범위 밖입니다.


1. 키 패턴

airquality:measurement:{gridKey}
  • {서비스} 세그먼트 생략 판단: 이 레포는 단일 서비스(sports-application)이며 기존 키(popular:products:*, seat:lock:*)도 서비스 세그먼트를 두지 않는 레포 관례를 따름 — 컨벤션 형식 {서비스}:{도메인}:{엔티티}:{식별자}의 도메인 세그먼트를 최상위로 사용.
  • gridKey 산출: 좌표를 소수 3자리로 라운딩(≈110m 격자, TDD/design-db §5.3 채택안)해 "%.3f_%.3f" 형식으로 조합. Locale.ROOT 명시 필수(로케일에 따라 소수점이 콤마로 바뀌는 버그 방지).
// BE-05 구현 시 참고 시그니처 (계약, 코드 아님)
fun gridKeyOf(lat: Double, lng: Double): String =
    String.format(Locale.ROOT, "%.3f_%.3f", lat, lng)
  • 반올림 규칙: BigDecimal.setScale(3, RoundingMode.HALF_UP) 후 포맷 — trailing zero 보존(129.160129.16으로 축약하지 않음, 키 canonical 형태 고정).

예시 키

좌표(lat, lng)gridKey전체 키
37.5665, 126.9780 (서울시청)37.567_126.978airquality:measurement:37.567_126.978
35.1587, 129.1604 (해운대)35.159_129.160airquality:measurement:35.159_129.160
37.5003, 127.000237.500_127.000airquality:measurement:37.500_127.000
  • KEYS 전제 설계 아님 — 운영 점검 시 SCAN 0 MATCH "airquality:measurement:*" 사용(§6 검증에서 실증).

2. 자료구조 선택 — String(JSON)

String + JSON 직렬화를 채택한다.

  • 근거(1줄): AirQualityMeasurement는 pm10/pm25/stationName/measuredAt 4필드가 항상 함께 읽고 함께 쓰이는 단일 값객체(부분 필드 갱신·부분 필드 조회 요구 없음)이므로 Hash로 필드를 쪼갤 이유가 없고, 기존 레포 관례(PopularProductsRedisRepository가 동일하게 String+JSON, StringRedisTemplate.opsForValue())와 일치시켜 일관성을 확보한다.
  • Hash 미채택 사유: Hash는 HGET으로 필드 단위 부분 조회가 필요할 때 이점이 있으나, 이 캐시는 Gateway가 항상 4필드 전체를 한 번에 읽어 AirQuality로 조립하므로 이점이 없고 직렬화 하나(JSON)가 구현이 더 단순하다.

3. TTL 정책 — 10분 + jitter ±60초

  • 기본 TTL: 600초(10분). Jitter: ±60초(균등분포) → 실제 저장 TTL 범위 540~660초.
  • 계산 시점: 쓰기 시점(save)마다 매번 새로 계산 — 같은 그리드키라도 캐시가 재적재될 때마다 TTL이 달라져 다수 키의 만료 시각이 흩어진다(스탬피드 방지).
  • 근거: 시설 좌표가 특정 격자(도심 밀집 지역)에 몰릴 경우 동시에 다수 키가 같은 시각 만료되면 순간적으로 3단계 API 체인이 몰려 500/day 한도·P95 1.5초를 동시에 위협한다 — TDD §실패 경로 “캐시 … TTL 10분 + jitter(±60s, stampede 방지)“를 그대로 반영.
  • TTL은 무효화의 보조 수단이다(§4) — 원본 갱신을 감지해 즉시 지우는 로직은 없고, 만료가 유일한 신선도 보장 수단이므로 TTL 값 자체가 최신성 SLA다. 에어코리아 측정소 갱신 주기(시간 단위)보다 훨씬 짧은 10분을 택해 최신성 오차를 좁게 유지.
// 계약 참고 (코드 아님)
val ttl = Duration.ofSeconds(600 + Random.nextLong(-60, 61))
stringRedisTemplate.opsForValue().set(key, json, ttl)

4. 무효화 트리거 — 명시적 쓰기 무효화 없음(시간 만료만)

  • 이 캐시에는 다른 쓰기 이벤트가 이 키를 지우는 무효화 트리거가 존재하지 않는다. 원본 데이터(에어코리아 실시간 측정값)는 우리 시스템 바깥의 외부 소스가 자체 주기로 갱신하며, 우리 도메인 내부의 어떤 쓰기(시설 등록·예약 등)도 이 측정값의 “원본 변경 이벤트”가 될 수 없다 — 따라서 look-aside 캐시의 일반형(원본 쓰기 시 캐시 삭제)이 적용될 지점 자체가 없다.
  • 결과적으로 TTL 만료가 유일한 신선도 보장 메커니즘이다(§3). DEL/UNLINK를 호출하는 코드 경로를 두지 않는다 — 있다면 그것은 설계 위반(무효화 트리거 오해)으로 간주.
  • 캐시 population 규칙(성공/실패 모두 캐시): TDD Sequence Diagram(대기질 조회)은 alt 캐시 미스 분기 안에서 3단계 API 호출 결과가 성공이든 실패(AirQualityMeasurement.empty())든 관계없이 save(gridKey, measurement)를 무조건 호출하도록 그려져 있다. 이를 그대로 채택한다.
    • 근거: 대기질 API가 일시 장애·타임아웃 상태일 때 캐시하지 않으면 모든 요청이 매번 3단계 체인을 완주 시도하게 되어, 장애 지속 시간 동안 500/day 쿼터를 요청 수 × 3배속으로 소진한다(Possible Solutions “3단계 전부 스킵 → P95·500/day 동시 보호”의 취지에 반함). 실패 결과도 캐시해 두면 장애 중에도 재시도 간격이 TTL(10분±jitter)로 제한되어 쿼터가 보호된다.
    • 실패 캐시 값은 4필드가 전부 null인 JSON으로 자연 표현된다(별도 sentinel 불필요, §5 예시 3 참고). AirQualityDomainService가 이를 읽으면 그대로 AirQuality.empty()(UNKNOWN)로 조립되어 TDD 상태 전이 표와 일치한다.
    • BE-05 확인 필요 항목: 이 해석(실패도 동일 TTL로 캐시)이 최대 11분(660s) 동안 “복구된 API를 확인하지 못하는” 트레이드오프를 수반함을 인지해야 한다. 만약 실패 결과에 더 짧은 TTL(예: 30~60초)을 별도로 두고 싶다면 이는 TDD 변경(캐시 정책 이원화, Possible Solutions의 “2단계 캐시” 방안 재검토)이 필요하므로 senior-be 확인 후 결정한다. 이 문서는 TDD 원문(단일 TTL, 무조건 save)을 기본 계약으로 확정한다.

5. Redis 장애 시 정책

  • 캐시는 최적화이지 정합성 보장 대상이 아니다(TDD §실패 경로 “Redis 장애 시 캐시를 건너뛰고 직접 호출”). AirQualityMeasurementCache.findBy(gridKey) / save(gridKey, measurement)가 Redis 연결 실패 등으로 예외를 던질 수 있다는 전제하에, 이 인터페이스의 구현체(infra, BE-05 소관)가 예외를 흡수해야 한다:
    • findBy 예외 시 → 캐시 미스로 간주하고 AirKoreaAirQualityGatewayImpl이 3단계 API를 직접 호출한다(예외를 GatewayImpl 밖으로 전파하지 않음).
    • save 예외 시 → 캐시 적재 실패를 무시하고 API 호출 결과(성공/empty)를 그대로 반환한다. 캐시 쓰기 실패가 사용자 응답 실패로 이어지면 안 된다.
  • 이 문서(Redis 설계 계약)는 키·TTL·자료구조만 정의하며, 위 예외 흡수 로직 자체는 BE-05의 구현 책임이다 — 여기서는 계약으로만 명시한다.

6. 필드 테이블

필드타입필수설명성공 예시실패(empty) 예시
pm10Int? (nullable)아니오PM10 농도(㎍/㎥). 측정소 미보고·3단계 실패 시 null42null
pm25Int? (nullable)아니오PM2.5 농도(㎍/㎥). 측정소 미보고·3단계 실패 시 null18null
stationNameString? (nullable)아니오근접 측정소명. 3단계 실패 시 null"중구"null
measuredAtString? (nullable, ISO-8601, ZonedDateTime)아니오측정 시각(타임존 포함, no-local-datetime 컨벤션 준수). 3단계 실패 시 null"2026-07-04T09:15:00+09:00"null
  • 4필드 전부 null이면 AirQualityMeasurement.empty()가 캐시된 상태를 의미한다(§4).
  • 일부만 null인 것도 유효한 상태다 — 측정소가 pm10만 보고하고 pm25는 미보고인 경우(예시 2) TDD “부분 값” 규칙에 따라 가용 등급만으로 대표 등급을 산정한다.

예시 payload

// 1) 정상 성공 — 전체 필드 보유
// key: airquality:measurement:37.567_126.978
{"pm10":42,"pm25":18,"stationName":"중구","measuredAt":"2026-07-04T09:15:00+09:00"}
 
// 2) 부분 결측 성공 — pm25 미보고
// key: airquality:measurement:35.159_129.160
{"pm10":55,"pm25":null,"stationName":"해운대구","measuredAt":"2026-07-04T09:10:00+09:00"}
 
// 3) 3단계 전체 실패(캐시됨, §4 근거) — AirQualityMeasurement.empty()
// key: airquality:measurement:37.500_127.000
{"pm10":null,"pm25":null,"stationName":null,"measuredAt":null}

7. 서버 설정 (docker compose) — 변경 불필요

결론: docker-compose.ymlredis 서비스 설정을 변경하지 않는다. design-db §5.3(“신규 인프라 없음”) 및 TDD 서버 토폴로지(“대기질 조회 캐싱 | Redis(기존 인스턴스 재사용)“)를 그대로 따른다.

기존 설정(docker-compose.yml:57-71):

redis-server --maxmemory 256mb --maxmemory-policy noeviction --save "" --appendonly no

검토한 잠재 충돌과 판단 근거

기존 compose 파일의 redis 서비스 주석은 “순수 캐시 키가 이후 추가되면 같은 인스턴스에 volatile-lru로 섞지 말고 별도 인스턴스/논리 DB로 분리 검토”라고 명시하고 있고, airquality:measurement:*는 정확히 그 “순수 캐시 키”에 해당한다. 이 우려를 아래 근거로 검토했다:

  1. 메모리 풋프린트가 무시할 수준이다. 그리드키는 시설 좌표(design-db §5.2 “전국 확장 후 목표: 수천1만 건”) 기준으로 결정되며, 여러 시설이 동일 0.001° 격자에 몰릴 수 있어 실제 distinct 그리드키 수는 시설 수보다 작거나 같다. 최악값(1만 건 전부 서로 다른 격자)으로 잡아도 키당 크기 ≈ 키(약 40B) + JSON 값(약 100150B) + Redis 오버헤드(약 100B) ≈ 300B → 1만 키 × 300B ≈ 3MB. maxmemory 256mb 대비 약 1.2% 수준으로, 기존 게이트 권위 데이터(seat:lock:*, goods:limited-drop:*:remaining)의 OOM 실패 리스크를 유의미하게 높이지 않는다.
  2. noeviction이 이 캐시에도 안전하게 동작한다. noeviction은 메모리가 가득 찼을 때 쓰기가 실패(OOM 에러)하는 정책이지 “캐시를 못 쓴다”는 의미가 아니다. 위 1번 근거로 메모리 압박 자체가 발생할 가능성이 낮으므로, 이 캐시가 게이트 데이터의 OOM 실패를 유발할 위험은 현재 스케일에서 실질적으로 0에 가깝다. 반대로 캐시 쓰기가 실패해도 §5(Redis 장애 시 정책)에 따라 API 직접 호출로 폴백하므로 기능적으로도 안전하다.
  3. 논리 DB(SELECT N) 분리는 이번 범위에서 이점이 없다. Redis의 maxmemory-policy서버 전역 설정이라 논리 DB별로 다르게 줄 수 없다 — 즉 논리 DB를 나눠도 eviction 정책 충돌은 해결되지 않는다(진짜 격리는 별도 컨테이너/인스턴스가 필요). 이번 캐시는 위 1·2번으로 격리 없이도 안전해, 별도 인스턴스라는 신규 인프라를 도입할 근거가 없다(design-db “신규 인프라 없음”과 일치).

후속 검토 트리거(주석 유지 권고, 변경 없음): 이후 순수 캐시 기능이 더 추가되어 누적 풋프린트가 커지거나(예: 수십만 키 규모), 게이트 데이터의 메모리 여유가 실측으로 타이트해지면 그때 기존 주석의 권고(별도 인스턴스)를 재검토한다. 이번 티켓 범위에서는 해당하지 않는다.

  • persistence(RDB/AOF): 기존 --save "" --appendonly no 유지 — 대기질 캐시는 순수 최적화이며 유실 시 다음 요청이 API를 재호출해 즉시 자연 복구되므로 영속화가 불필요하다는 기존 근거가 이 캐시에도 그대로 적용된다.

8. 로컬 Redis 검증

docker run으로 compose와 동일한 이미지 계열(redis:7-alpine, 로컬 캐시 이미지)·동일 커맨드 옵션(--maxmemory 256mb --maxmemory-policy noeviction --save "" --appendonly no)로 검증용 컨테이너(aq-redis-verify)를 별도 기동해 확인했다(기존 QA/개발 컨테이너 불건드림).

8.1 키·TTL·자료구조 검증

$ docker exec aq-redis-verify redis-cli SETEX "airquality:measurement:37.567_126.978" 630 '{"pm10":42,"pm25":18,"stationName":"중구","measuredAt":"2026-07-04T09:15:00+09:00"}'
OK
$ docker exec aq-redis-verify redis-cli SETEX "airquality:measurement:35.159_129.160" 555 '{"pm10":55,"pm25":null,"stationName":"해운대구","measuredAt":"2026-07-04T09:10:00+09:00"}'
OK
$ docker exec aq-redis-verify redis-cli SETEX "airquality:measurement:37.500_127.000" 655 '{"pm10":null,"pm25":null,"stationName":null,"measuredAt":null}'
OK

=== TYPE ===
airquality:measurement:37.567_126.978 -> string
airquality:measurement:35.159_129.160 -> string
airquality:measurement:37.500_127.000 -> string

=== TTL (기대 범위 540~660초) ===
airquality:measurement:37.567_126.978 -> 629
airquality:measurement:35.159_129.160 -> 554
airquality:measurement:37.500_127.000 -> 654

=== GET ===
airquality:measurement:37.567_126.978 -> {"pm10":42,"pm25":18,"stationName":"중구","measuredAt":"2026-07-04T09:15:00+09:00"}
airquality:measurement:35.159_129.160 -> {"pm10":55,"pm25":null,"stationName":"해운대구","measuredAt":"2026-07-04T09:10:00+09:00"}
airquality:measurement:37.500_127.000 -> {"pm10":null,"pm25":null,"stationName":null,"measuredAt":null}

3개 키의 TTL(629/554/654초)이 모두 설계값(600±60초 = 540~660초) 범위 안에 있음을 실측 확인. TYPE은 설계대로 string.

8.2 SCAN(KEYS 미사용) 검증

$ docker exec aq-redis-verify redis-cli SCAN 0 MATCH "airquality:measurement:*" COUNT 100
0
airquality:measurement:37.500_127.000
airquality:measurement:35.159_129.160
airquality:measurement:37.567_126.978

8.3 TTL 만료(시간 기반 무효화) 재현

$ docker exec aq-redis-verify redis-cli SETEX "airquality:measurement:test-expiry" 2 '{"pm10":10,"pm25":5,"stationName":"테스트","measuredAt":"2026-07-04T09:00:00+09:00"}'
OK
$ docker exec aq-redis-verify redis-cli TTL "airquality:measurement:test-expiry"
2
$ sleep 3
$ docker exec aq-redis-verify redis-cli GET "airquality:measurement:test-expiry"
(nil)
$ docker exec aq-redis-verify redis-cli EXISTS "airquality:measurement:test-expiry"
0

TTL 경과 후 키가 자연 소멸함을 확인 — §4의 “TTL 만료가 유일한 신선도 보장 메커니즘”이 실제로 동작함을 재현.

8.4 서버 설정 확인 (§7 근거 재현)

$ docker exec aq-redis-verify redis-cli CONFIG GET maxmemory-policy
maxmemory-policy
noeviction
$ docker exec aq-redis-verify redis-cli CONFIG GET maxmemory
maxmemory
268435456

268435456 bytes = 256MB — compose 설정(--maxmemory 256mb --maxmemory-policy noeviction)과 검증 환경이 일치함을 확인.


9. BE-05 인계 사항

  • AirQualityMeasurementCache 구현체(infra, 예: AirQualityRedisCache)는 StringRedisTemplate + ObjectMapper로 §2·§3·§6을 그대로 구현한다(기존 PopularProductsRedisRepository 패턴 참고).
  • gridKey 생성 로직(§1)은 Gateway 또는 Cache 구현체 중 한 곳에 단일 위치로 둔다(중복 구현 금지) — Locale.ROOT 명시 필수.
  • TTL jitter(§3)는 매 save 호출 시 재계산.
  • save는 성공/empty 관계없이 무조건 호출(§4) — TDD Sequence Diagram과 일치시킨다. 이 정책을 바꾸고 싶다면 TDD 변경(senior-be 확인)이 선행되어야 한다.
  • Redis 예외 흡수(§5)는 Cache 구현체 또는 GatewayImpl 중 한 곳에서 명확히 책임진다 — 인터페이스 시그니처(findBy/save)는 예외를 던지지 않는 것으로 간주하고 호출부를 작성하지 않는다(양쪽 다 흡수 안 하면 도메인 서비스까지 예외가 전파되어 TDD “예외 전파 금지”를 위반).
  • docker compose 변경 없음(§7) — BE-05 작업 시 별도 인프라 설정 불필요.

Document History

날짜변경 내용
2026-07-04최초 작성 — 키 패턴/gridKey 산출식, String+JSON 채택, TTL 10분±60s jitter, 무효화(시간 만료만 + 성공/실패 동시 캐시 근거), Redis 장애 폴백 계약, 필드 테이블, docker compose 변경 불필요 판단(근거 3가지), 로컬 redis-cli 검증 4종