핫구역: 설계, 구현, 부하 실측
무엇을 세기로 했고 어떻게 저장하는지, 그리고 캐시가 만료되는 30초마다 무슨 일이 벌어지는지
MSG-321 측정 완료 · 대상: 백엔드 팀원 · 2026-08-06 측정 · 2026-08-08 설계·구현 절 통합, MSG-330 반영
0. 한눈에
핫구역은 업로드 1건을 해당 격자의 1점으로 세고, UTC 6시간 버킷 8개를 합쳐 전국 상위
50개 중 3점 이상만 돌려주는 기능이다. 이 문서는 그 설계(1절)와 구현(2절)을 먼저 적고,
지도 홈의 핫구역 칩이 부르는 GET /api/hotzones에 부하를 건 실측(3~7절)을 잇는다.
측정 결과부터 말하면 처리량은 문제가 아니었다. 2,000 RPS를 60초 유지하는 동안 응답 중앙값이 0.75ms였고 실패는 한 건도 없었다. 눈에 띄는 건 30초마다 아주 짧게 일어나는 지연이고, 그 주기는 핫구역 목록 캐시의 수명과 같다.
1. 설계
1.1 무엇을 세는가
| 구분 | 내용 |
|---|---|
| 세는 것 | saveVideo 트랜잭션이 커밋된 영상 업로드. 같은 사용자가 같은 격자에 다시 올려도 매번 1점이다 (재방문 인정, 도배 방어 없음. 2026-07-31 확정) |
| 세지 않는 것 | 영상 교체·삭제, 조회 수, 좋아요, 현재 위치, 고유 사용자 수. 삭제해도 이미 오른 점수는 빼지 않는다 |
| 정확성 수준 | 통계 원장이 아니라 근사 순위다. Redis 신호 유실과 삭제 후 잔존을 제품 요구사항이 허용한다 |
점수를 올리는 기준은 인코딩 완료가 아니라 DB 커밋이다. 영상이 나중에 인코딩 실패가 돼도 점수는
그대로다. 공개 범위로도 분기하지 않으므로 PRIVATE 업로드도 센다. userId를 Redis에
저장하지 않아서 사용자별 중복 제거도 없다. 여기서 "방문"은 위치 센서가 잡은 체류가 아니라
그 격자에서 영상을 업로드해 videos 행이 커밋된 사건이다.
1.2 48시간은 버킷 8개의 근사
조회는 현재 진행 중인 UTC 6시간 버킷과 그 앞 7개, 합쳐서 8개를 더한다. 현재 버킷은 아직 다 차지 않았으므로 실제 포함 범위는 조회 시각에 따라 42~48시간이다. 현재 시각에서 정확히 48시간 전까지 자르는 슬라이딩 윈도우가 아니다. 각 버킷 가중치는 전부 1이고 오래된 업로드를 덜 치는 시간 감쇠도 없다. 1시간 버킷이면 경계 오차는 줄지만 합칠 키가 48개로 늘어난다. 핫구역을 정밀 통계가 아닌 탐색 신호로 보고, 정밀도보다 합산 키 수와 구현 단순성을 택했다.
순위는 뷰포트 상위가 아니라 전국 상위 50을 먼저 자른다. 어떤 격자가 지금 화면
안에서 가장 뜨거워도 전국 51위면 응답에 없다. 반대로 전국 상위 50 중 화면 안 격자만 남으므로
결과가 50개보다 훨씬 적거나 0개일 수 있다. 이 순서 덕분에 애플리케이션 필터 비용은 항상 최대
50건이다. 최소 임계는 3점(minScore)이다.
1.3 왜 Redis Sorted Set인가
| 설계 요구 | 선택 | 얻은 것 | 치른 대가 |
|---|---|---|---|
| 업로드마다 빠른 증분 | ZINCRBY | 격자별 O(log N) 누적, 읽기 쉬운 순위 | 사용자별 중복 제거 없음 |
| 시간이 지나면 자동 제외 | UTC 6시간 키 분할 | 삭제 배치 없이 룩백만 바꾸면 됨 | 정확한 48시간이 아니라 42~48시간 |
| 전국 상위 몇 개 | Sorted Set 역순 조회 | 별도 정렬 없이 상위 50 | 뷰포트 지역 순위가 아닌 전국 순위 |
| 조회 부하 흡수 | hotzone:top 30초 캐시 | 적중 시 합산 없이 캐시 확인과 상위 조회만 | 만료 순간 합산이 Redis를 잠시 점유 (5절 실측) |
| 업로드 성공 우선 | 비동기 유계 큐, 실패 비전파 | 정상 Redis 지연을 응답에서 분리 | 포화·장애 때 점수 유실 허용 |
PostgreSQL에 집계 테이블을 만들지 않은 이유도 같은 맥락이다. 원본 업로드는 이미
videos에 있고, 핫구역은 48시간 안에 사라지는 파생 순위다. DB 정합성과 영구 보관을
추가할 이득보다 DDL·쓰기 경합·청소 배치 비용이 크다. 다만 Redis 집계를 videos에서
다시 만드는 복구 작업도 없다. Redis 유실 시 48시간 동안 비어 보이는 것을 허용한다.
조회 방식은 30초 낡은 결과를 허용하는 polling이라 WebSocket과 분산 pub/sub도 만들지 않았다.
2. 구현
2.1 Redis에 저장되는 것
| 키 | 자료형 | member와 score | 수명 | 역할 |
|---|---|---|---|---|
hotzone:{bucketId} | Sorted Set | member = "41642_110458" (gridId), score = 업로드 건수 | 마지막 증분 뒤 54시간 | UTC 6시간 구간 하나의 집계 원장 |
hotzone:top | Sorted Set | member = gridId, score = 최근 8버킷 합계 | 30초 | 조회용 합산 캐시 |
notification:hotzone:last | Set | 직전 주기의 핫구역 gridId | 명시 TTL 없음 | 알림 진입 감지용 스냅숏 (10분 주기 하류 기능) |
bucketId = floor(UTC epochSeconds / 21,600). TTL 54시간은 48시간 판정 장치가
아니라 오래된 키를 치우는 청소 장치다. 실제 판정 범위는 조회 시 선택하는 버킷 ID 8개가 정하므로,
54시간이 안 지난 옛 키가 남아 있어도 합산에서 빠진다. 좌표와 사용자 ID는 Redis에 넣지 않는다.
2.2 쓰기 경로: 커밋, 대기열, Lua 세 단계
// VideoServiceImpl.saveVideo : 트랜잭션 커밋 뒤에만 실행
afterCommit(() -> hotScoreCommandService.recordUpload(gridId));
// HotScoreCommandServiceImpl.recordUpload : 버킷 키는 호출 시각에 확정, 큐에 넣고 바로 반환
String key = "hotzone:" + clock.instant().getEpochSecond() / 21600L;
executor.execute(() -> increment(key, gridId));
-- 워커가 실행하는 Lua : 증분과 TTL을 원자로 묶는다
redis.call('ZINCRBY', KEYS[1], 1, ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
커밋 뒤 실행이라 롤백된 업로드의 유령 점수가 남지 않고, 버킷 키를 호출 시각에 확정하므로 워커가
밀려 6시간 경계를 넘겨도 원래 시각의 버킷에 기록된다. Lua로 묶어 TTL 없는 버킷 키가 남는 틈을
없앤다. 대기열은 LinkedBlockingQueue 10,000칸에 데몬 워커 스레드 하나다.
recordUpload는 Redis 응답을 기다리지 않으므로 정상 상태의 업로드 응답은 Redis
왕복과 분리된다 (실측 5.5절: 적재 비용 건당 0.0015ms).
2.3 읽기 경로와 응답
조회는 hotzone:top 캐시를 보장하는 Lua(3절에 전문이 있다)를 먼저 실행한다. 캐시가
있으면 EXISTS만 확인하고, 없으면 최근 8버킷을 ZUNIONSTORE로 합쳐
30초 TTL을 건다. 그다음 ZREVRANGE WITHSCORES로 전국 상위 50을 가져와
점수 3 미만과 뷰포트 밖 격자를 애플리케이션에서 거른다. 필터 순서가 전국 상위 → 점수 → 뷰포트라
필터 비용 상한이 50건이다.
GET /api/hotzones?swLat=37.50&swLng=127.00&neLat=37.55&neLng=127.05
{ "developCode": 200, "message": "성공",
"data": { "hotZones": [
{ "gridId": "41677_110443", "gridY": 41677, "gridX": 110443, "score": 7 }
] } }
| 상황 | 동작 |
|---|---|
| 핫구역 없음 | hotZones: [] 정상 응답 |
| 필수 좌표 누락 | HTTP 400 (공통 처리기) |
| NaN·Infinity·뒤집힌 범위 | HTTP 400, INVALID_VIEWPORT (8400) |
| 쓰기 중 Redis 장애 | 업로드 성공 유지, 신호 1건 유실 허용, 로그만 |
| 조회 중 Redis 장애 | 예외를 삼키지 않고 요청 경로로 전파 |
코드 위치: 배선 VideoServiceImpl, 쓰기 HotScoreCommandServiceImpl,
조회 HotZoneServiceImpl, 설정(topK=50, minScore=3)
HotZoneProperties, 알림 감지 HotZoneEntryDetector. 줄 단위 해설은
explainer 참조.
3. 처음엔 틀리게 봤다
이 문서의 첫 판은 관측된 지연을 캐시 스탬피드로 설명했다. 캐시가 비는 순간 여러 요청이 동시에 같은 합산을 중복 실행해서 서로를 막는다는 그림이었다. 교차 리뷰에서 이 설명이 틀렸다는 지적을 받았고, 실제로 세어 보니 지적이 맞았다.
재계산 코드는 이렇게 생겼다. EXISTS 확인부터 합산과 만료 설정까지가 Lua 스크립트
하나에 들어 있다.
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('ZUNIONSTORE', KEYS[1], #KEYS - 1, unpack(KEYS, 2))
redis.call('EXPIRE', KEYS[1], ARGV[1])
end return 1
Redis는 Lua 스크립트를 통째로 원자 실행한다. 그래서 첫 요청이 합산하는 동안 뒤따르는 요청은
스크립트를 시작조차 못 하고 기다린다. 기다렸다가 자기 차례가 오면 EXISTS가 이미
1이라 합산을 건너뛴다. 순서를 강제하는 장치가 이미 있는 셈이고, 중복 재계산은
구조적으로 일어나지 않는다.
실제로 세어 봤다. 3분 동안 200 RPS로 요청 36,000건을 보내고 Redis 명령 통계를 읽었다.
| 명령 | 호출 수 | 의미 |
|---|---|---|
EVALSHA | 36,000 | 요청마다 캐시 확인 스크립트가 돈다 |
ZUNIONSTORE | 6 | 실제 합산. 180초 ÷ TTL 30초 = 6과 정확히 일치 |
36,000번 스크립트가 실행됐지만 합산은 딱 6번, 캐시가 만료된 횟수와 같다. 중복은 0건이다.
이 차이는 말장난이 아니다. 고칠 대상이 달라진다. 중복이 문제였다면 잠금 장치를 넣으면 됐겠지만, 실제로는 잠금이 이미 있으므로 손댈 곳은 재계산 자체의 비용이다.
4. 어떻게 쟀나
4.1 환경
| 항목 | 내용 |
|---|---|
| 실행 | 로컬 macOS (Apple Silicon), 앱은 bootRun local 프로파일 |
| Redis | redis:7-alpine 컨테이너, 포트 6379 |
| DB | postgis/postgis:16-3.4-alpine (핫구역 경로는 DB를 타지 않는다) |
| 관측 | Prometheus 2.54 + Grafana 11.3, 스크랩 간격 5초 |
| 부하 도구 | k6 v2.1.0 |
| 측정일 | 2026-08-06 |
| 시드 | 좌표 표본 100,000개 → 고유 격자 46,697개 (규모 감도 절은 1,000·10,000 표본도 사용) |
4.2 데이터셋
측정에 쓴 데이터는 공공데이터나 운영 스냅숏이 아니라 seed-hotzone.sh가
만드는 합성 좌표다. 서울 시내를 덮는 사각형(위도 37.45~37.65, 경도 126.85~127.15)에서
난수 좌표를 복원추출하고, 운영 코드와 같은 격자 상수(LAT_STEP 0.0009,
LNG_STEP 0.00115)와 내림 규칙으로 gridId를 만들어 Redis 버킷 8개에 흩뿌린다.
복원추출이라 고유 격자 수는 표본 수보다 적고, 스크립트가 그 값을 출력한다.
| 좌표 표본 | 고유 격자 | 용도 |
|---|---|---|
| 1,000 | 946 | 규모 감도 비교 (5.3절) |
| 10,000 | 8,741 | 규모 감도 비교 (5.3절) |
| 100,000 | 46,697 | 본 측정 (expiry·cap 시나리오) |
46,697은 운영을 흉내 낸 값이 아니라 일부러 크게 잡은 스트레스 값이다. 현재 운영 격자 수는 수천 단위로 추정되며(실측 아님, 9절 한계 참조), 이 데이터셋은 "격자가 그만큼 늘면 무슨 일이 생기나"를 미리 보는 용도다. 점수 분포는 균등 난수라 실제 운영의 쏠림(핫플레이스 집중)보다 평평하다. 합산 비용은 member 수에 좌우되므로 이 측정의 목적에는 영향이 없다.
4.3 도구
| 파일 | 하는 일 |
|---|---|
seed-hotzone.sh |
좌표 표본 수를 인자로 받아 Redis 버킷 8개에 격자를 흩뿌린다. 난수 좌표를 복원추출하므로 고유 격자 수는 표본 수보다 적고, 스크립트가 그 값을 따로 출력한다 |
k6/hotzone-benchmark.js |
부하 시나리오 4종 (smoke · expiry · cap · scale) |
analyze-expiry.py |
k6 결과를 캐시 수명 주기로 접어 만료 시점에 지연이 몰리는지 본다 |
measure-zunionstore.sh |
재계산 비용을 격자 규모별로 잰다. 앱을 거치지 않고 Redis만 본다 |
HotScoreThroughputBenchmark.java |
집계 워커의 처리율과 큐 포화점. 환경변수가 있을 때만 실행된다 |
주기로 접어 보는 방법을 쓴 이유가 있다. 캐시 만료는 30초에 한 번, 수십 밀리초 동안만 일어난다. 전체 구간을 뭉뚱그린 백분위로 보면 "p99가 좀 높네" 정도로만 보이고 원인이 구분되지 않는다. 그래서 측정 시각을 30으로 나눈 나머지로 요청을 묶었다. 캐시가 원인이라면 특정 나머지에만 느린 요청이 몰릴 것이고, 다른 이유라면 고르게 흩어질 것이다.
4.4 히스토그램 버킷
처음에는 서버 쪽 백분위를 볼 수 없었다. Micrometer가 기본으로 내보내는 건 요청 수와 합계,
최대값뿐이라 p99를 계산할 재료가 없다. 그래서 측정 중에 application.yml에
버킷 경계를 추가했다.
management:
metrics:
distribution:
slo:
http.server.requests: 1ms,2ms,3ms,5ms,8ms,10ms,15ms,25ms,50ms,75ms,
100ms,150ms,200ms,300ms,500ms,800ms,1s
흔히 쓰는 percentiles-histogram: true는 쓰지 않았다. 그 옵션은 미리 정의된 버킷
수십 개를 만들어 시계열이 한 자리 수 배로 늘어난다. 관측에 필요한 경계만 직접 주면 17개로
끝난다. 경계는 실측에 맞춰 골랐다. 평시가 1ms 언저리라 아래쪽을 촘촘하게 두고, 만료 시점의
지연이 어느 칸에 떨어지는지 보이도록 100~300ms를 남겼다. 300ms와 800ms는
MSG-134가 정한 응답 목표선(p95 300ms, p99 800ms)이라 경계로 넣었다.
경계가 없으면 목표 준수 여부가 구간 보간에 좌우된다.
비용은 짚어 둘 필요가 있다. 이 설정은 http.server.requests 전체에 걸리고
엔드포인트별로 좁혀지지 않는다. Prometheus에는 +Inf가 더해져 URI·메서드·상태·결과
조합마다 18개 시계열이 생긴다. 지금 API 수에서는 감당 가능하다고 보고 켰다.
5. 결과
5.1 재계산은 만료 횟수만큼만 일어난다
3절에 적은 그대로다. 요약하면 이렇다.
| 측정 | 값 |
|---|---|
| 부하 | 200 RPS · 3분 · 요청 36,000건 |
스크립트 실행 (EVALSHA) | 36,000회 |
실제 합산 (ZUNIONSTORE) | 6회 |
| 이론상 만료 횟수 | 180초 ÷ 30초 = 6회 |
| 합산 1회 평균 (Redis 자체 통계) | 44.97 ms |
5.2 그래도 만료 시점에는 지연이 있다
같은 실행을 캐시 수명으로 접어 봤다. 전체를 뭉뚱그리면 평범하다. 중앙값 1.77ms, p99 3.37ms. 그런데 가장 느린 요청은 108ms였다.
| 구분 | 요청 수 | 중앙값 | p99 | 최대 |
|---|---|---|---|---|
| 만료 위상 (0초) | 1,200 | 1.8 ms | 56.18 ms | 108.2 ms |
| 나머지 위상 (중앙값) | 약 1,200씩 | 1.8 ms | 3.22 ms | 10 ms 내외 |
중앙값은 두 줄이 같다. 만료 순간이든 아니든 대부분의 요청은 2ms 안에 끝난다. 갈리는 건 꼬리이고, 만료 위상의 p99는 나머지의 17.4배다. 여섯 번의 주기 중 네 번에서 같은 위상이 가장 느렸다(재현율 67%).
5.3 이 현상의 크기는 데이터 양이 정한다
재계산 비용을 앱을 거치지 않고 Redis에서 직접 쟀다. 빈 스크립트를 같은 방식으로 재서
docker exec과 CLI 왕복 같은 고정비를 빼고, 3회 측정의 중앙값을 적는다.
| 좌표 표본 | 고유 격자 | 합산만 | 운영 스크립트 전체 |
|---|---|---|---|
| 1,000 | 946 | 0.311 ms | 0.388 ms |
| 10,000 | 8,741 | 3.461 ms | 3.170 ms |
| 100,000 | 46,697 | 30.039 ms | 29.407 ms |
격자 수에 거의 비례한다. 946개에서 0.3ms이던 것이 46,697개에서 30ms가 된다. 그리고 이 비용이 곧 "Redis가 막히는 시간"이다. 막히는 시간이 길수록 그 사이에 쌓이는 요청이 많아지고, 뒤로 갈수록 오래 기다린 요청이 생긴다.
격자를 아무리 늘려도 응답 크기는 그대로다. 상위 50개만 담기 때문이다. 커지는 건 합산 비용뿐이다.
cmdstat_zunionstore)으로, 30초에 한 번 차갑게 도는
조건이 반영돼 있다. 운영에서 겪는 값은 뒤쪽에 가깝다.
5.4 처리량은 병목이 아니다
100 RPS에서 시작해 2,000까지 올리고, 마지막에 2,000을 60초 유지했다. 유지 구간을 둔 이유는 단순하다. 상승만 하고 바로 내려오면 "2,000을 버텼다"고 말할 근거가 없다.
| 구간 | 실측 RPS | 중앙값 | p95 | p99 | 최대 |
|---|---|---|---|---|---|
| 100 → 250 | 176 | 1.77 ms | 2.64 | 3.58 | 57.64 |
| 250 → 500 | 376 | 1.44 ms | 2.18 | 2.95 | 51.98 |
| 500 → 1,000 | 751 | 0.94 ms | 1.74 | 2.14 | 42.12 |
| 1,000 → 2,000 | 1,502 | 0.70 ms | 1.37 | 2.25 | 48.19 |
| 2,000 유지 (60초) | 1,999 | 0.75 ms | 1.51 | 2.24 | 51.86 |
전체 213,998건, 실패 0건, 목표 도착률을 못 맞춰 버려진 요청은 1건이다. 2,000 RPS를 60초 유지하는 동안 119,914건을 처리했고 중앙값은 0.75ms였다. 부하가 올라갈수록 오히려 중앙값이 낮아지는데, 요청이 촘촘해지면서 캐시 적중이 늘고 JIT가 데워진 영향으로 보인다.
모든 구간에서 최대값이 40~60ms대로 비슷하다. 이 값이 부하와 무관하게 유지되는 것도 원인이 재계산이라는 방증이다. 부하가 원인이라면 부하에 따라 커져야 한다.
5.5 집계 워커
업로드가 일어나면 격자 점수를 Redis에 더하는 워커가 있다. 스레드 하나에 대기열 1만 칸이고, 코드에는 "밀리면 스레드 수를 늘린다"는 메모만 있었다. 그 지점을 쟀다.
적재 비용이 건당 0.0015ms다. 업로드 요청 입장에서는 대기열에 넣기만 하고 바로 돌아온다는 뜻이고, 설계가 의도한 "응답 경로와 분리"가 정상 상태에서는 그대로 동작한다.
반대편 한계는 초당 5,000건 언저리다. 업로드가 그보다 빠르게 들어오면 대기열이 차고 넘친 신호는 버려진다. 이 유실은 설계가 허용한 동작이다. 핫구역 점수는 근사값이고 신호 몇 건이 빠져도 순위가 뒤집히지 않는다는 전제로 만들어졌다. 다만 그 전제가 성립하는 범위가 숫자로 확인됐다.
6. 판단
설계 문서(MSG-233 §D4)의 "락 불요"는 결과적으로 옳다. 다만 이유가 문서에 적힌 것과 다르다. 문서는 "동시에 재계산해도 결과가 같아서"라고 적었는데, 실제로는 Lua의 원자성 덕분에 동시 재계산 자체가 일어나지 않아서다. 결과가 같다는 건 부차적이고, 애초에 한 번만 돈다.
그래서 나중에 문제가 되더라도 손댈 곳은 잠금이 아니다. 재계산이 Redis를 붙잡는 시간을 줄이는 쪽이다. 다시 볼 시점의 선택지는 이렇다.
| 선택지 | 내용 | 대가 |
|---|---|---|
| 합산 대상 줄이기 | 버킷 8개를 매번 전부 합치는 대신, 직전 결과에 새 버킷만 반영한다 | 가장 직접적이다. 만료된 버킷을 빼는 처리가 필요해 구현이 늘어난다 |
| 확률적 조기 갱신 | 만료가 가까워지면 일부 요청이 미리 캐시를 새로 만든다 | 구현이 가볍다. 다만 재계산 자체가 Redis를 막는 건 그대로라 지연의 크기는 안 줄고 시점만 흩어진다 |
| TTL 늘리기 | 30초를 늘려 재계산 빈도를 낮춘다 | 가장 싸다. 핫구역 목록이 그만큼 늦게 갱신되는 걸 감수해야 한다 |
| 재계산 잠금 | 한 요청만 계산하게 만든다 | 이미 그렇게 동작하므로 의미 없다. 처음에 이 문서가 잘못 제안했던 방향이다 |
셋 다 지금 필요하지 않다. 격자가 만 단위에 접근하거나, 만료 위상 배율이 다시 재서 크게 나오면 그때 이 표를 꺼내면 된다. 재현 도구를 레포에 남겨 둔 이유가 이것이다.
7. 포화되면 로그가 상황을 악화시킨다 (MSG-330으로 해소)
워커 대기열이 가득 차면 신호를 버리는데, 버릴 때마다 예외 스택 추적을 통째로 경고 로그에 남긴다. 이 로깅이 얼마나 비싼지 재려고 포화 실험을 로그를 끈 것과 켠 것으로 나눠 돌렸다.
| 조건 | 버스트 | 적재 속도 | 유실 |
|---|---|---|---|
| 로그 끔 (워커 자체 한계) | 60,000건 | 339,189 건/초 | 81.8% |
| 로그 켬 (운영 조건) | 20,000건 | 16,211 건/초 | 23.4% |
유실률만 보면 로그를 켠 쪽이 나아 보이지만 착시다. 유입 속도가 21분의 1로 떨어졌기 때문에 그만큼 덜 버린 것뿐이다. 폐기 한 건당 0.26ms가 들었는데, 정상 적재가 0.0015ms이니 170배다.
recordUpload는 업로드
트랜잭션이 커밋된 뒤 요청 스레드에서 호출된다. 즉 대기열이 넘치는 순간
업로드 응답이 로그 쓰기에 붙잡힌다. 워커를 별도 스레드로 뺀 이유가 "Redis가 느려도 업로드
응답은 안 늦어지게" 하려는 것이었는데, 정작 포화 상황에서 그 보호가 로그 때문에 깨진다.
게다가 로그가 쏟아지는 시점은 이미 시스템이 밀리고 있는 시점이라 상황을 더 나쁘게 만든다.
대기열이 가득 찼다는 것은 예외적 사고가 아니라 설계가 예상한 흐름이다. 예상한 흐름에 스택 추적을 남길 이유는 없다. 이 티켓 범위 밖이라 MSG-330으로 분리했다.
RejectedExecutionException 전용 catch로 분리하고, 스택 추적 대신 건수만
세서 60초 주기로 한 줄 요약한다. 같은 버스트 60,000건 재측정에서 로그 켠 조건의 유입률이
12,596건/초에서 419,661건/초로 올라 로그 끈 상한(360,692건/초)과 같은
수준이 됐고, 폐기 로그는 48,975줄에서 1줄이 됐다. 수치가 이 절의 최초 측정과
다른 것은 재측정 시점의 머신 상태 차이다. 근거는 언제나 같은 실행 안의 상대 비교다.
남은 한계(업로드가 끊기면 마지막 요약이 다음 업로드나 종료까지 지연)는 MSG-330 문서에 있다.
8. 재현 방법
Redis와 앱이 떠 있는 상태에서 아래 순서로 돌리면 이 문서의 수치가 다시 나온다.
# 1. 격자 데이터를 채운다 (숫자는 좌표 표본 수, 고유 격자 수는 스크립트가 출력한다)
./load-test/seed-hotzone.sh 100000
# 2. 재계산 비용만 따로 잰다 (1천·1만·10만 표본 순회, 기준선 차감)
./load-test/measure-zunionstore.sh
# 3. 만료 주기를 관측한다 (결과를 JSON 으로 남겨야 4번 분석이 가능하다)
TOKEN="<jwt>" k6 run -e SCENARIO=expiry -e RATE=200 -e DURATION=3m \
--out json=result.json load-test/k6/hotzone-benchmark.js
# 4. 캐시 수명 주기로 접어 본다
./load-test/analyze-expiry.py result.json
# 5. 재계산이 실제로 몇 번 돌았는지 확인한다 (만료 횟수와 같아야 정상)
docker exec fillmap-local-redis redis-cli INFO commandstats | grep zunionstore
# 6. 한계 처리량 (100 → 2,000 RPS, 마지막 60초 유지)
TOKEN="<jwt>" k6 run -e SCENARIO=cap load-test/k6/hotzone-benchmark.js
# 7. 집계 워커 처리율·포화점 (로그 끈 것과 켠 것 모두)
HOTZONE_BENCH=true ./gradlew test --tests '*HotScoreThroughputBenchmark'
5번을 반드시 같이 볼 것. 4번은 상관만 보여주고 원인을 증명하지 못한다.
토큰은 로컬·dev 프로파일에만 열려 있는 개발용 로그인으로 받는다. 유효기간이 1시간이라 긴 측정 중에 만료될 수 있다.
curl -s -X POST http://localhost:8080/api/auth/dev/social-login \
-H 'Content-Type: application/json' \
-d '{"provider":"KAKAO","oid":"loadtest"}'
이 문서의 그래프를 그린 Grafana 대시보드는 load-test/grafana/hotzone-dashboard.json에
있다. 아래 한 줄이면 올라간다.
curl -X POST http://localhost:3000/api/dashboards/db -u admin:admin \
-H "Content-Type: application/json" \
-d @load-test/grafana/hotzone-dashboard.json
관측 스택 자체를 띄우는 방법은 MSG-128에 있다.
9. 이 측정의 한계
결론을 읽을 때 같이 알아야 할 것들이다.
- 노트북 한 대에서 쟀다. 부하 도구와 앱과 Redis가 같은 기계에서 CPU를 나눠 쓴다. 절대 수치는 서버와 다르다.
- 원시 측정 파일은 레포에 없다. 표의 숫자는 이 문서가 유일한 기록이라 독립 검증이 안 된다. 재현 절차를 남긴 것으로 대신했다.
- "현재 격자 수는 수천 단위"는 추정이다. 운영 Redis를 실제로 세어 본 값이 아니다. 판단의 전제이므로 배포 후 확인이 필요하다.
- 시더는 서울 좌표만 만든다. 격자 인코딩은 운영 코드와 같은 상수와 내림 규칙을 쓰지만, 음수 좌표는 시드하지 않는다.
- 주기 분석은 상관만 본다. 만료 시점에 지연이 몰린다는 것과 재계산이 원인이라는 것은 다른 명제다. 후자는 재계산 횟수를 직접 세어 확인했다(5.1절).
10. 용어
| 말 | 뜻 |
|---|---|
| head-of-line 지연 | 앞선 작업 하나가 오래 걸려서 뒤에 줄선 작업이 전부 밀리는 현상. 여기서는 재계산 한 번이 Redis를 붙잡는 동안 다른 요청이 기다리는 것 |
| 캐시 스탬피드 | 캐시가 만료되는 순간 여러 요청이 동시에 같은 값을 중복 계산하는 현상. 이 서비스에서는 일어나지 않는다(3절) |
| 위상 | 주기적인 사건 안에서의 위치. 여기서는 측정 시각을 캐시 수명(30초)으로 나눈 나머지 |
| p99 / p99.9 | 요청을 느린 순서로 줄 세웠을 때 상위 1% / 0.1% 지점의 값. 평균이 멀쩡해도 이쪽이 나쁘면 일부 사용자가 체감한다 |
| RPS | 초당 처리하는 요청 수 |
| ZUNIONSTORE | Redis에서 여러 집합을 하나로 합치는 명령. 핫구역은 6시간짜리 버킷 8개를 이걸로 합쳐 48시간 창을 만든다 |
| Sorted Set | member마다 숫자 score를 붙여 정렬해 두는 Redis 자료구조. 같은 member의 점수를 ZINCRBY로 올리고 상위 순위를 바로 읽을 수 있어 격자별 집계에 맞는다 |
| TTL | 키가 자동 삭제되기까지 남은 시간. 여기서 버킷 54시간 TTL은 청소 장치이고, 48시간 판정은 조회가 고르는 버킷 8개가 맡는다 (2.1절) |
| 히스토그램 버킷 | "몇 ms 이내로 끝난 요청이 몇 건"을 구간별로 세어 둔 것. 이게 있어야 서버 쪽에서 백분위를 계산할 수 있다 |
MSG-321 · 2026-08-06 측정 · 측정 도구는 load-test/,
워커 벤치는 src/test/java/com/msg/fillmap/hotzone/service/HotScoreThroughputBenchmark.java
3절의 정정은 Codex 교차 리뷰 지적에서 나왔다.