핫구역 설계 (정의 · 산식 · 저장 구조)¶
Owner: 공동 (hotzone 신규 패키지 = A, video 업로드 훅 배선 = B, 계약 인터페이스 신설 2건)
PRD:
docs/prd/MSG-233-hotzone-prd.md(검토됨, 2026-07-31 성민 확정) — 요구사항 정본. 설계 티켓이다. 이 문서 자체가 산출물이며 이 티켓에서 코드는 작성하지 않는다. 완료 조건 = 아래 D-결정으로 후속 구현 티켓 MSG-183(집계)·MSG-184(조회) 가 착수 가능해지는 것.
개요¶
사용자 업로드 신호로 "최근 48시간 뜨는 격자"를 감지해 지도 홈 핫구역 칩에 노출한다. 핫구역 정의·핫스코어 산식·Redis 저장 구조·유지 기간·계약 인터페이스 시그니처를 여기서 확정한다.
배경 · 목표¶
- 외부 데이터(팝업 애그리게이터 88% 서울, 야외 행사 0.3%)로는 지역 실시간 커버가 불가능하다. FillMap의 위치 검증된 업로드 이벤트가 유일한 자체 신호원 — "핫구역 = 팝업 탐지기" (설계검토 2026-07-20).
- 지도 홈 개편(2026-07-25 확정)으로 상단 칩 4종 중 핫구역이 확정됐으나 산식·저장·판정이 미정이라 MSG-183·184가 착수 불가 상태. 이 문서가 그 블로커를 푼다.
성공 기준¶
- 산식(버킷·합산)·K·임계값·Redis 키 스키마·계약 시그니처·훅 배선 지점이 구체 값으로 확정됨
- MSG-183·184가 이 문서만 입력으로 TDD 착수 가능
- PRD FR-1~FR-11·비기능과 모순 없음
D-결정 (본체)¶
| # | 결정 | 확정 내용 |
|---|---|---|
| D1 | 핫구역 정의 | 최근 48h 업로드 신호 기준, 핫스코어 상위 K(50) 중 최소 임계(3) 이상인 격자 |
| D2 | 산식 | 업로드 확정 1건 = +1, UTC 6시간 버킷 ZINCRBY, 조회 시 최근 8버킷 균등 합산(감쇠 없음) |
| D3 | 판정 값 | K = 50, 최소 임계 = 3 (application.yml 프로퍼티로 오버라이드 가능) |
| D4 | 저장 | Redis 전용(DDL 없음). hotzone:{bucketId} Sorted Set, member=gridId, TTL 54h |
| D5 | 계약 | HotZoneService(read, 예약 이행) + HotScoreCommandService(write, B가 소비) 신설 |
| D6 | 훅 배선 | VideoServiceImpl.saveVideo 훅 체인 뒤 afterCommit 1줄, 실패는 구현 내부에서 삼킴 |
| D7 | 조회 API | GET /api/hotzones (뷰포트 필수), polling 서빙 — MSG-134 D1·D3 준수 |
D1. 핫구역 정의¶
- 범위 단위 = 격자 (FR-2, glossary "격자" 재사용). 별도 "구역" 개념·인접 격자 묶음을 만들지 않는다 (묶음 표현은 FE 몫).
- 신호 = 업로드(방문 이벤트)만 (FR-3). 좋아요는 MSG-275 후행이라 데이터가 없다 — D2의 확장 지점으로만 예약.
- 도배 방어 없음 (2026-07-31 성민 확정) — 같은 사용자의 재방문 업로드도 전부 인정. 스트릭의 재방문 인정(glossary 2026-07-29)과 일관. 도배가 관측되면 그때 재설계.
D2. 핫스코어 산식 — 시간버킷·합산¶
- 버킷 = UTC 6시간 고정,
bucketId = epochSeconds / 21600(정수 나눗셈). - UTC 정수 연산인 이유: 스트릭과 달리 사용자 노출 날짜 개념이 아니라 rolling 윈도우 근사라 KST 자정 정렬이 불필요하고, KST 변환을 끼우면 MSG-222에서 실측된 JVM 타임존 스큐 부류의 버그 표면만 는다.
- 6h인 이유: 48h 윈도우 = 8버킷 — 조회 합산 키 수가 적다. 3h(16키)·1h(48키)는 정밀도 이득 대비 합산 비용만 는다. 근사 오차는 아래 룩백 항목 참조.
- 증분: 업로드 확정 1건 =
ZINCRBY hotzone:{bucketId} 1 {gridId}(지도 홈 개편 확정 표기와 일치). - 합산: 조회 시 현재 버킷 포함 최근 8버킷을
ZUNIONSTORE(균등 가중 1.0) → 결과를 캐시(D4). - 시간 감쇠 없음 — MVP는 균등 합산. 버킷 구조가 이미 있으므로 감쇠가 필요해지면 버킷별 가중치 조정만으로 도입 가능(확장 지점, 재설계 불요).
- 윈도우 커버 = 42~48h 근사 (현재 버킷이 부분 채워짐). 48h를 초과하는 신호는 절대 섞이지 않으므로 FR-5("최근 48시간 신호만") 충족. 하한 42h는 "핫스코어는 근사값"(PRD 비기능)으로 허용.
- 좋아요 확장 지점 (예약만): MSG-275 후
ZINCRBY hotzone:{bucketId} w {gridId}를 좋아요 이벤트에 추가하면 산식 변경 없이 신호가 합류한다. w 값은 그때 결정 — 지금 정하지 않는다.
D3. 상위 K · 최소 임계 — 구체 값¶
| 값 | 확정 | 근거 |
|---|---|---|
| K (상위 개수) | 50 | 전국 상위 50 → 뷰포트 필터 후 화면당 수 개 수준. 앱 필터 O(50) 무시 가능, FE 렌더 부담 없음 |
| 최소 임계 | 3 (48h 합산 핫스코어 ≥ 3) | FR-10 "업로드 1건이 핫이 되지 않는다" → 2 이상 필수. 2는 한 사용자 재방문 두 번으로 도달해 우연성이 높다(도배 방어 생략이므로). 3부터 신호로 본다 |
- 두 값 모두 초기 데이터를 보고 튜닝할 것이 명백하므로 application.yml 프로퍼티로 뺀다
(예:
fillmap.hotzone.top-k: 50,fillmap.hotzone.min-score: 3— 키 이름은 MSG-184 구현 재량, 기본값은 위 확정치). - 판정 순서: 합산 ZSET에서 상위 K 조회 → 임계 미만 제거 → 뷰포트 필터. 결과 0개면 빈 목록(FR-9) — 에러가 아니다. 초기 데이터가 적을 때의 기본 상태.
D4. Redis 키 스키마 · TTL · 만료 방식¶
| 키 | 자료구조 | member / value | TTL | 용도 |
|---|---|---|---|---|
hotzone:{bucketId} |
Sorted Set | member=gridId("{gridY}_{gridX}"), score=신호 수 |
54h (증분 시마다 EXPIRE 재설정, 멱등) | 버킷별 신호 원장 |
hotzone:top |
Sorted Set | ZUNIONSTORE 결과 | 30s | 8버킷 합산 캐시 — polling 부하 흡수 |
- 네임스페이스
hotzone:— 기존 auth 키(refresh:{userId}:{deviceId}등)와 같은 Redis 인스턴스 공유, 접두어로 분리.RedisRefreshTokenStore선례(StringRedisTemplate,@Profile("!test"))를 따른다. - 역할 분리 (중요): 48h 윈도우 판정은 TTL이 아니라 조회 룩백(최근 8버킷 선택)이 보장한다. TTL은 메모리 청소 전용. 그래서 TTL이 다소 넉넉해도(54h = 48h + 버킷폭 6h 여유) 판정에 영향 없다. 마지막 증분 시점 기준 54h 후 자동 소멸 — 수동 정리 배치 없음(FR-5).
hotzone:topTTL 30s 근거: MSG-134 D3 stale≤30s와 정합. 만료 시 다음 조회가 재계산 — 동시 재계산은 같은 소스의 같은 결과를 덮어쓰므로 무해(락 불요).- Redis 유실 허용 (PRD 비기능): 원본은
videos테이블. 유실 시 핫구역이 최대 48h 비어 보이는 것을 허용하고 재적재하지 않는다. - 영상 삭제 시 차감 없음 (PRD 비기능): 윈도우 만료로 자연 소멸. 스탬프 비회수와 같은 이벤트 기록 성격 — 방문(이벤트)은 있었던 사실이다.
D5. 계약 인터페이스 시그니처¶
신규 패키지 com.msg.fillmap.hotzone (Owner A). infrastructure.md의 grid/service/ 배치 예약은
PRD §7 확정에 따라 독립 패키지로 이행한다 (badge·streak·mission이 티켓별 독립 패키지로 신설된
전례와 동일 — CLAUDE.md Owner A 목록에 hotzone.* 추가는 스펙 머지 시 후속 docs 조치).
// com.msg.fillmap.hotzone.service — Owner A 제공 (status.md ❌ 미생성 예약 이행, FR-11)
public interface HotZoneService {
/**
* viewport 안 핫구역 목록 — 핫스코어 내림차순, 상위 K·최소 임계 적용 후 뷰포트 필터.
* 없으면 빈 목록(에러 아님). 뒤집힌 bbox → HotZoneErrorCode.INVALID_VIEWPORT.
* userId 를 받지 않는다 — 집계 결과는 사용자 무관(개인화 없음), 인증만 공통 정책으로 요구.
*/
List<HotZoneView> getHotZones(ViewportBounds bounds);
}
// com.msg.fillmap.hotzone.service — Owner A 제공, Owner B(video)가 소비. 실질 크로스오너 경계.
public interface HotScoreCommandService {
/**
* 업로드 확정 신호 +1 (현재 버킷 ZINCRBY + EXPIRE). Redis 실패는 구현 내부에서 삼키고
* warn 로깅만 한다 — 호출자에게 예외를 전파하지 않는다 (FR-6).
*/
void recordUpload(String gridId);
}
ViewportBounds는grid.dto의 기존 값객체 재사용 (둘 다 Owner A — 크로스오너 아님).HotZoneView는OccupiedGridView패턴의 내부 뷰 record:(String gridId, int gridY, int gridX, long score). user_id는 어디에도 싣지 않는다 (PRD 보안 비기능). HTTP DTO 변환은 컨트롤러 책임(기존 관례).- 검증: 뒤집힌 bbox만 검사.
VIEWPORT_TOO_LARGE류 면적 상한은 두지 않는다 — 결과가 K=50으로 이미 상한이라 스캔 폭발이 없다(GridQueryService와 다른 지점). - 에러 코드:
hotzone/exception/HotZoneErrorCode신설, 8xxx 대역 (1xxx user · 2xxx auth · 3xxx video · 4xxx grid · 5xxx search · 6xxx region · 7xxx badge 다음 빈 대역). MVP 상수는INVALID_VIEWPORT(8400, BAD_REQUEST)하나.
D6. 업로드 훅 배선 · 실패 비전파¶
- 지점:
VideoServiceImpl.saveVideo()의 기존 훅 체인(badge→streak→mission,:131-149) 뒤 —missionAwardService.awardOnUpload(...)다음에 1줄:
// 핫스코어 (MSG-233): 커밋 후 증분 — 롤백 시 유령 증분 방지. 실패는 구현이 삼킨다 (FR-6).
afterCommit(() -> hotScoreCommandService.recordUpload(gridId));
- afterCommit인 이유 (기존
afterCommit헬퍼 재사용 —triggerEncodingAfterCommit과 동일 패턴): - 업로드 트랜잭션 롤백 시 Redis 증분이 남는 유령 신호 차단.
- Redis RTT가 업로드 응답 지연에 포함되지 않음 (비기능 "O(1) 연산 하나" 충족).
- 다른 훅(badge·streak·mission)과 달리 응답에 실을 결과가 없어 동기일 이유가 없다.
- 실패 비전파는
HotScoreCommandServiceImpl내부 try-catch(Exception) + warn 로그로 구현한다. 호출부가 아니라 구현 내부에 두는 이유: 호출부는 1줄로 유지되고, 미래의 다른 호출자(좋아요 등)도 자동으로 같은 보호를 받는다. 핫스코어는 부가 신호이지 업로드 트랜잭션의 일부가 아니다 (FR-6). - 교체(
replaceVideo)는 배선하지 않는다 — 새 방문(visit) 이벤트가 아니라 파일 교체다. 스트릭이 replace에 recordUpload를 걸지 않는 것과 동일 판단. - 삭제는 차감 없음 — D4 참조. 삭제 경로에 hotzone 코드가 등장하지 않는다.
D7. 조회 흐름 (MSG-184 구현 대상)¶
GET /api/hotzones
→ HotZoneService.getHotZones(bounds)
1. hotzone:top 존재? 없으면 최근 8버킷 ZUNIONSTORE hotzone:top + EXPIRE 30s
2. ZREVRANGE hotzone:top 0 K-1 WITHSCORES
3. score < 임계(3) 제거
4. gridId → (gridY, gridX) 디코드(GridEncoder), bounds 안만 필터
5. HotZoneView 목록 (없으면 빈 목록)
- 서빙은 polling (지도 홈 개편 확정 + MSG-134 D1·D2 — websocket은 Phase 2 유예).
- SLO는 MSG-134 D3 상속: p95<300ms · p99<800ms · 5xx<1% · stale≤30s. 캐시 히트 경로는 Redis 명령 1~2회라 여유가 크다.
API 명세 (MSG-184 몫)¶
GET /api/hotzones — 인증 필수(공통 정책), userId 미사용.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
swLat swLng neLat neLng |
double | ✅ | 뷰포트 남서·북동 (GridController 파라미터 관례와 동일) |
- 성공:
SuccessResponse<HotZoneListResponseDto>— 항목(gridId, gridY, gridX, score), 핫스코어 내림차순. 핫구역 없음 = 빈 목록 + 200 (FR-9). - 에러: 뒤집힌 bbox →
8400 INVALID_VIEWPORT(BAD_REQUEST). 파라미터 누락 → 공통BAD_REQUEST. - DTO 네이밍·
ApiResponseDto래핑은 response-pattern.md 준수. 응답에 user_id 없음.
도메인 로직¶
- glossary 대조: 핫구역은 현재 glossary "🚧 Phase 2+ 미확정" 항목이다("저장은 Redis Sorted Set 예정"과 본 설계 일치). 이 스펙 확정으로 MVP 편입 — glossary 확정 섹션 이동이 후속 docs 조치 (스트릭 편입 전례와 동일 절차).
- 용어 사용: 신호는 "방문"(업로드 이벤트, N회 반복)이지 "점령"(첫 업로드 상태)이 아니다 — 재방문도 전부 신호이므로 코드·문서에서 점령 계열 표현을 쓰지 않는다.
데이터 모델¶
- DDL 없음. Flyway 마이그레이션 불필요. 저장은 Redis 전용(D4), 원본 데이터는 기존
videos. - JPA 엔티티 신설 없음. dev/prod Redis 기존 인스턴스 공유(운영 비기능) — 신규 인프라 없음.
계약 변경¶
신설 2건 — 상대 팀원 확인 필수 지점 (시그니처 전문은 D5):
| 인터페이스 | 방향 | 상태 |
|---|---|---|
HotZoneService.getHotZones(ViewportBounds): List<HotZoneView> |
A 제공 read (FR-11 — hotzone 내부 접근 금지 경계) | status.md ❌ 예약의 이행. 현 시점 소비자는 hotzone 컨트롤러뿐이나 계약으로 유지 |
HotScoreCommandService.recordUpload(String gridId): void |
A 제공 write ← B(video) 소비 | 신설 — 실질 크로스오너 경계. RegionStatsCommandService.refresh B→A 소비 전례와 동형 |
기존 계약(GridQueryService, UserGridQueryService, UserOidcCommandService) 시그니처 변경 없음.
테스트 시나리오¶
이 티켓(MSG-233)이 구현하는 것이 아니다 — 후속 구현 티켓이 TDD 착수 시 쓸 검증 목록이다.
MSG-183 (집계) 몫
업로드가_확정되면_현재_버킷의_해당_격자_핫스코어가_1_증가한다같은_사용자의_재방문_업로드도_핫스코어에_반영된다(도배 방어 생략 확정)핫스코어_증분이_실패해도_업로드_응답은_성공한다(FR-6 — Redis down 시뮬레이션)업로드_트랜잭션이_롤백되면_핫스코어가_증가하지_않는다(afterCommit 배선)버킷_키에_54시간_TTL이_설정된다영상_교체는_핫스코어를_올리지_않는다영상_삭제는_핫스코어를_차감하지_않는다
MSG-184 (조회) 몫
임계_이상_상위_격자가_핫스코어_내림차순으로_반환된다상위_K_안이라도_임계_미만_격자는_제외된다업로드_1건_격자는_핫구역이_아니다(임계=3, FR-10)뷰포트_밖_핫구역은_제외된다핫구역이_하나도_없으면_빈_목록을_반환한다(FR-9)룩백_8버킷_밖의_신호는_판정에서_제외된다(48h 윈도우, FR-5)뒤집힌_뷰포트는_INVALID_VIEWPORT_에러다(8400)응답에_user_id가_포함되지_않는다(보안 비기능)합산_캐시는_30초_안에_재계산된다(stale≤30s)
미해결 질문¶
없음. 칩 빈 상태 문구·최소 노출 개수는 PRD §8이 FE·디자인 몫으로 정리했고 BE는 빈 목록 응답만
보장한다(FR-9). 문서 후속 조치 2건(비차단): glossary 핫구역 항목 확정 섹션 이동, CLAUDE.md
Owner A 목록에 hotzone.* 추가.