핫구역: 설계, 구현, 부하 실측

무엇을 세기로 했고 어떻게 저장하는지, 그리고 캐시가 만료되는 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초마다 아주 짧게 일어나는 지연이고, 그 주기는 핫구역 목록 캐시의 수명과 같다.

2,00060초 유지 RPS (실패 0%)
0.75 ms그 구간 응답 중앙값
17만료 위상의 꼬리 지연
45 ms재계산 1회 (격자 46,697)
결론부터 조회 경로는 지금 고칠 것이 없다. 현재 데이터 규모에서 재계산은 1ms에도 못 미치고, 30초마다의 지연은 관측되지 않는 수준이다. 다만 이 결론의 근거는 "구조가 안전해서"가 아니라 "데이터가 적어서"다. 격자가 만 단위로 늘면 재계산이 수십 ms가 되고 그때는 다시 봐야 한다. 측정이 찾아낸 별개 결함 하나(포화 시 폐기 로그가 업로드 응답을 붙잡는 문제)는 MSG-330으로 고쳤다(7절).

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 Setmember = "41642_110458" (gridId), score = 업로드 건수마지막 증분 뒤 54시간UTC 6시간 구간 하나의 집계 원장
hotzone:topSorted Setmember = gridId, score = 최근 8버킷 합계30초조회용 합산 캐시
notification:hotzone:lastSet직전 주기의 핫구역 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).

실패 정책은 "업로드를 살리고 점수 1건을 버린다" Redis 오류와 큐 포화는 구현 안에서 잡아 로그만 남긴다. 핫스코어가 원본이 아니라 근사 순위이기 때문이다. 애플리케이션 종료 때는 최대 5초 큐를 비우고 못 끝낸 작업은 버린다. 포화 시 로깅 자체가 응답을 붙잡던 문제는 7절과 MSG-330 참조.

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 명령 통계를 읽었다.

명령호출 수의미
EVALSHA36,000요청마다 캐시 확인 스크립트가 돈다
ZUNIONSTORE6실제 합산. 180초 ÷ TTL 30초 = 6과 정확히 일치

36,000번 스크립트가 실행됐지만 합산은 딱 6번, 캐시가 만료된 횟수와 같다. 중복은 0건이다.

그러면 관측된 지연은 무엇이었나 재계산 한 번이 Redis를 45ms 동안 붙잡는다. Redis는 명령을 하나씩 처리하니 그동안 도착한 요청은 전부 줄을 선다. 200 RPS라면 그 45ms에 아홉 건쯤이 쌓이고, 그 요청들이 차례로 풀리며 각자 최대 45ms를 기다린 셈이 된다. 흔히 말하는 head-of-line 지연이다. 현상은 실재하고 주기도 캐시 수명과 같지만, 원인은 "여럿이 중복 계산해서"가 아니라 "하나가 오래 잡고 있어서"다.

이 차이는 말장난이 아니다. 고칠 대상이 달라진다. 중복이 문제였다면 잠금 장치를 넣으면 됐겠지만, 실제로는 잠금이 이미 있으므로 손댈 곳은 재계산 자체의 비용이다.

4. 어떻게 쟀나

4.1 환경

항목내용
실행로컬 macOS (Apple Silicon), 앱은 bootRun local 프로파일
Redisredis:7-alpine 컨테이너, 포트 6379
DBpostgis/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 표본도 사용)
절대 수치는 옮겨 쓰지 말 것 노트북에서 잰 값이라 서버 성능을 대표하지 않는다. 이 문서에서 의미 있는 건 절대값이 아니라 같은 환경에서의 상대 비교다. 격자 946개와 46,697개의 차이, 만료 위상과 평시 위상의 차이가 근거이지 "45ms"라는 숫자 자체가 근거는 아니다.

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,000946규모 감도 비교 (5.3절)
10,0008,741규모 감도 비교 (5.3절)
100,00046,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으로 나눈 나머지로 요청을 묶었다. 캐시가 원인이라면 특정 나머지에만 느린 요청이 몰릴 것이고, 다른 이유라면 고르게 흩어질 것이다.

배율만으로는 결론을 내지 않는다 "가장 느린 위상 하나를 고른 뒤 나머지와 비교"하면 캐시와 무관한 단발 정지(GC나 스케줄링)에도 큰 배율이 나온다. 실제로 24,000건 중 단 한 번의 80ms 지연만 넣은 합성 데이터에서 배율이 27배로 나왔다. 그래서 분석기는 배율에 더해 주기마다 같은 위상이 반복되는지를 함께 본다. 위 합성 데이터는 재현율 25%가 나와 "증거 부족"으로 걸러졌고, 30초마다 지연을 넣은 합성 데이터는 재현율 100%로 통과했다.

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,2001.8 ms 56.18 ms108.2 ms
나머지 위상 (중앙값)약 1,200씩1.8 ms 3.22 ms10 ms 내외

중앙값은 두 줄이 같다. 만료 순간이든 아니든 대부분의 요청은 2ms 안에 끝난다. 갈리는 건 꼬리이고, 만료 위상의 p99는 나머지의 17.4배다. 여섯 번의 주기 중 네 번에서 같은 위상이 가장 느렸다(재현율 67%).

Prometheus 그래프: p99.9 지연이 30초마다 바닥으로 떨어졌다가 다시 오른다
Prometheus에서 본 p99.9 지연. 골짜기가 30초 간격으로 반복된다. 캐시가 새로 채워진 직후에 지연이 바닥으로 떨어지고, 다음 만료가 올 때까지 다시 올라간다.
Grafana 대시보드: 요청량, 백분위 지연, 최대 지연, 응답 상태 4개 패널
같은 시간대의 Grafana. 앞쪽이 200 RPS 고정 구간, 뒤쪽이 2,000까지 올린 뒤 60초 유지한 구간이다. 두 번째 패널에서 p50·p95·p99는 바닥에 붙어 있고 p99.9(붉은 선)만 20~90ms를 오르내린다. 마지막 패널이 보여주듯 실패 응답은 전 구간에서 한 건도 없었다.
왜 p99가 아니라 p99.9인가 재계산은 30초에 한 번, 수십 밀리초 동안 일어난다. 200 RPS라면 그 창에 걸리는 요청은 열 건 남짓이고, 3분 동안 쌓인 36,000건에 견주면 0.1% 아래다. p99는 상위 1%를 보는 값이라 이만한 비율은 묻힌다. 위상별로 갈라 보면 그 안에서는 상위 1%에 들어오기 때문에 같은 현상이 p99로도 드러난다. 두 수치가 다른 게 아니라 보는 창이 다른 것이다.

5.3 이 현상의 크기는 데이터 양이 정한다

재계산 비용을 앱을 거치지 않고 Redis에서 직접 쟀다. 빈 스크립트를 같은 방식으로 재서 docker exec과 CLI 왕복 같은 고정비를 빼고, 3회 측정의 중앙값을 적는다.

좌표 표본고유 격자합산만 운영 스크립트 전체
1,0009460.311 ms0.388 ms
10,0008,7413.461 ms3.170 ms
100,00046,69730.039 ms29.407 ms

격자 수에 거의 비례한다. 946개에서 0.3ms이던 것이 46,697개에서 30ms가 된다. 그리고 이 비용이 곧 "Redis가 막히는 시간"이다. 막히는 시간이 길수록 그 사이에 쌓이는 요청이 많아지고, 뒤로 갈수록 오래 기다린 요청이 생긴다.

격자를 아무리 늘려도 응답 크기는 그대로다. 상위 50개만 담기 때문이다. 커지는 건 합산 비용뿐이다.

표의 30ms와 5.1절의 45ms가 다른 이유 표는 같은 명령을 연속으로 100회 돌린 값이라 CPU 캐시가 데워진 상태다. 45ms는 실제 부하 중 Redis가 스스로 기록한 값(cmdstat_zunionstore)으로, 30초에 한 번 차갑게 도는 조건이 반영돼 있다. 운영에서 겪는 값은 뒤쪽에 가깝다.

5.4 처리량은 병목이 아니다

100 RPS에서 시작해 2,000까지 올리고, 마지막에 2,000을 60초 유지했다. 유지 구간을 둔 이유는 단순하다. 상승만 하고 바로 내려오면 "2,000을 버텼다"고 말할 근거가 없다.

구간실측 RPS중앙값 p95p99최대
100 → 2501761.77 ms2.643.5857.64
250 → 5003761.44 ms2.182.9551.98
500 → 1,0007510.94 ms1.742.1442.12
1,000 → 2,0001,5020.70 ms1.372.2548.19
2,000 유지 (60초)1,9990.75 ms1.512.2451.86

전체 213,998건, 실패 0건, 목표 도착률을 못 맞춰 버려진 요청은 1건이다. 2,000 RPS를 60초 유지하는 동안 119,914건을 처리했고 중앙값은 0.75ms였다. 부하가 올라갈수록 오히려 중앙값이 낮아지는데, 요청이 촘촘해지면서 캐시 적중이 늘고 JIT가 데워진 영향으로 보인다.

모든 구간에서 최대값이 40~60ms대로 비슷하다. 이 값이 부하와 무관하게 유지되는 것도 원인이 재계산이라는 방증이다. 부하가 원인이라면 부하에 따라 커져야 한다.

5.5 집계 워커

업로드가 일어나면 격자 점수를 Redis에 더하는 워커가 있다. 스레드 하나에 대기열 1만 칸이고, 코드에는 "밀리면 스레드 수를 늘린다"는 메모만 있었다. 그 지점을 쟀다.

5,072처리율 (건/초)
0.0015 ms업로드 응답에 얹히는 비용
약 2대기열이 버티는 버스트
81.8%포화 시 유실 (로그 끈 조건)

적재 비용이 건당 0.0015ms다. 업로드 요청 입장에서는 대기열에 넣기만 하고 바로 돌아온다는 뜻이고, 설계가 의도한 "응답 경로와 분리"가 정상 상태에서는 그대로 동작한다.

반대편 한계는 초당 5,000건 언저리다. 업로드가 그보다 빠르게 들어오면 대기열이 차고 넘친 신호는 버려진다. 이 유실은 설계가 허용한 동작이다. 핫구역 점수는 근사값이고 신호 몇 건이 빠져도 순위가 뒤집히지 않는다는 전제로 만들어졌다. 다만 그 전제가 성립하는 범위가 숫자로 확인됐다.

6. 판단

지금은 아무것도 고치지 않는다 현재 서비스의 격자 수에서 재계산은 1ms 아래다. 그 규모에서는 만료 순간의 지연이 관측되지 않는다. 막을 것이 없는 상태에서 장치를 넣으면 코드만 복잡해진다.

설계 문서(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으로 분리했다.

해소됨 (2026-08-07, MSG-330 · PR #129) 폐기를 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. 이 측정의 한계

결론을 읽을 때 같이 알아야 할 것들이다.

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 교차 리뷰 지적에서 나왔다.