FillMap backend explainer
Redis 핫구역 구현, 코드에서 응답까지
현재 코드는 커밋된 영상 업로드를 100m 격자별로 세고, 최근 시간대의 전국 상위 격자를 지도 화면에 돌려준다. 여기서 Redis는 단순 응답 캐시가 아니다. 시간 버킷별 집계 원장과 30초짜리 조회 캐시를 함께 맡는다.
전체 흐름부터 잡기
쓰기와 읽기는 서로 다른 경로다. 쓰기는 영상 업로드가 커밋된 뒤 대기열에 신호를 넣고 바로 빠진다. 읽기는 HTTP 요청마다 합산 캐시가 있는지 확인한 뒤 상위 격자만 가져온다. 알림은 이 조회 결과를 10분마다 소비하는 별도 기능이지, 핫구역 점수를 만드는 로직은 아니다.
무엇을 핫스코어로 세는가
saveVideo 트랜잭션이 커밋된 영상 업로드. 같은 사용자가 같은 격자에 다시 올려도 매번 1점이다.
영상 교체와 삭제, 조회 수, 좋아요, 현재 위치, 고유 사용자 수는 점수에 들어가지 않는다.
통계 원장이 아니라 근사 순위다. Redis 신호 유실과 삭제 후 잔존을 제품 요구사항이 허용한다.
점수를 올리는 기준은 인코딩 완료가 아니라 DB 커밋이다. 영상이 나중에 인코딩 실패 상태가 돼도
이미 기록한 점수는 빼지 않는다. 공개 범위로도 분기하지 않으므로 현재 코드는 PRIVATE
업로드도 센다. userId를 Redis에 저장하지 않아서 사용자별 중복 제거도 없다.
여기서 “방문”의 뜻
핫구역 문맥의 방문은 위치 센서가 잡은 체류가 아니다. 사용자가 그 격자에서 영상을 업로드해
videos 행이 커밋된 사건이다. 서비스의 점령 여부와 무관해서 재방문 업로드도 센다.
Redis에는 이렇게 저장된다
핵심 자료구조는 Redis Sorted Set[1]이다. 같은 격자 ID를 member로 두고 점수를
업로드 수로 쓰면 ZINCRBY 한 번으로 누적할 수 있고, 점수 내림차순 상위 조회도
같은 자료구조에서 해결된다.
| 키 | Redis 자료형 | member와 score | 수명 | 역할 |
|---|---|---|---|---|
hotzone:{bucketId} |
Sorted Set | member = "41642_110458"score = 업로드 건수 |
마지막 증분 뒤 54시간 | UTC 6시간 구간 하나의 집계 원장 |
hotzone:top |
Sorted Set | member = gridIdscore = 최근 8버킷 합계 |
30초 | 조회용 합산 캐시 |
notification:hotzone:last |
Set | member = 직전 주기의 핫구역 gridId |
명시 TTL 없음 | 알림 진입 전이 비교용 스냅숏 |
gridId는 GridEncoder가 위도와 경도를 격자 간격으로 나눈 뒤
내림해 만든 "{gridY}_{gridX}" 문자열이다. Redis에는 좌표나 사용자 ID를 따로
넣지 않는다. Spring 쪽에서는 StringRedisTemplate을 쓰므로 키와 member는 문자열,
Sorted Set의 score는 Redis 내부 부동소수점 수다. 현재 증분이 항상 정수 1이라 조회할 때
Math.round로 long에 옮긴다.
TTL[2]은 48시간 판정 장치가 아니라 오래된 키를 치우는 장치다. 실제 판정 범위는 조회 시 선택하는 버킷 ID 8개가 정한다. 그래서 54시간이 지나지 않은 예전 키가 Redis에 남아 있어도 합산 대상에서 빠진다.
쓰기 로직은 커밋, 대기열, Lua 세 단계다
트랜잭션이 롤백되면 Redis에 유령 점수가 남지 않는다. 트랜잭션이 없는 테스트에서는 즉시 실행한다.
워커가 밀려 6시간 경계를 넘겨도 원래 호출 시각의 키에 기록한다.
ZINCRBY와 EXPIRE를 원자 실행[3]해 TTL 없는 버킷 키가 남는 틈을 없앤다.
대기열은 LinkedBlockingQueue<Runnable> 10,000칸, 워커는 데몬 스레드
하나다. recordUpload가 Redis 응답을 기다리지 않으므로 정상 상태의 업로드 응답은
Redis 왕복 시간과 분리된다. 애플리케이션 종료 때는 최대 5초 동안 큐를 비우고, 그 안에 끝나지
않은 작업은 버린다.
실패 정책은 “업로드를 살리고 점수 1건을 버린다”
Redis 오류와 큐 포화 예외는 구현 안에서 잡아 경고 로그만 남긴다. 핫스코어가 원본 데이터가 아니라 근사 순위이기 때문이다. 다만 큐 포화 때 요청 스레드가 예외 스택 로그를 쓰므로 극단적인 버스트에서는 응답 분리 효과가 약해진다는 실측 결과가 있다.
“최근 48시간”은 고정 버킷 8개의 근사다
조회는 현재 버킷 C와 앞의 7개를 더한다. 현재 버킷은 아직 다 차지 않았으므로 실제
포함 범위는 조회 시각에 따라 42시간에서 48시간 사이다. 정확히 현재 시각에서 48시간 전까지를
자르는 슬라이딩 윈도우가 아니다. 대신 6시간 버킷 8개만 합치면 돼 구조가 단순하다.
각 버킷 가중치는 모두 1이다. 오래된 업로드를 덜 치는 시간 감쇠도 없다. 1시간 버킷이면 경계 오차는 줄지만 합칠 키가 48개로 늘어난다. 현재 설계는 핫구역을 정밀 통계가 아닌 탐색 신호로 보며, 정밀도보다 합산 키 수와 구현 단순성을 택했다.
조회는 합산 캐시를 만든 뒤 전국 상위 50개만 본다
Lua는
EXISTS만 확인하고 합산을 건너뛴다.최근 8개 ZSET을 합쳐
hotzone:top을 만들고 30초 TTL을 건다.전국 상위 K를 먼저 자르고 점수와 뷰포트를 뒤에서 거른다.
- 남서와 북동 좌표가 유한한지, 범위가 뒤집히지 않았는지 검사한다.
hotzone:top을 보장한다. 비어 있으면 빈 목록을 반환한다.- 점수 내림차순으로 전국 상위
topK, 기본 50개를 가져온다. - 점수
minScore, 기본 3점 미만을 버린다. - 격자 ID를 정수 인덱스로 풀어 요청 뷰포트 밖 격자를 버린다.
뷰포트 상위 50이 아니라 전국 상위 50이다
예를 들어 어떤 격자가 현재 화면 안에서 가장 뜨겁더라도 전국 순위가 51위면 응답에 없다. 반대로 전국 상위 50 중 화면 안에 들어온 격자만 남으므로 결과가 50개보다 훨씬 적거나 0개일 수 있다. 이 순서 덕분에 애플리케이션 필터 비용은 항상 최대 50건이다.
캐시 확인과 합산, TTL 설정도 Lua 한 번으로 묶였다. Redis는 한 Lua 스크립트를 실행하는 동안 다른 명령을 끼워 넣지 않는다. 첫 요청이 합산 중이면 뒤 요청은 기다렸다가, 캐시가 이미 생긴 것을 보고 합산을 건너뛴다. 그래서 캐시 스탬피드[4]는 없지만, 합산이 오래 걸리면 뒤 요청이 줄 서는 head-of-line 지연[5]은 생길 수 있다.
API가 돌려주는 값
서비스 내부 자료형은 List<HotZoneView>이고, 각 항목은
record HotZoneView(String gridId, int gridY, int gridX, long score)다. 컨트롤러가
이를 HotZoneResponseDto로 옮긴다. 사용자 ID와 좌표 원본은 응답에 없다.
| 상황 | 현재 동작 |
|---|---|
| 핫구역이 없음 | hotZones: [], 정상 응답 |
| 필수 좌표 누락 | @RequestParam과 전역 처리기가 HTTP 400 처리 |
| NaN, Infinity, 뒤집힌 범위 | HTTP 400, INVALID_VIEWPORT, 코드 8400 |
| 쓰기 중 Redis 장애 | 업로드 성공 유지, 점수 신호 1건 유실 가능, 경고 로그 |
| 조회 중 Redis 장애 | HotZoneServiceImpl은 예외를 삼키지 않으므로 요청 경로로 전파 |
조회 방식은 polling[6]이다. 핫구역 변화에 초 단위 실시간성이 필요하지 않고 30초 정도의 낡은 결과를 허용하므로 WebSocket 연결과 분산 pub/sub을 만들지 않았다.
왜 Redis와 Sorted Set을 택했나
| 설계 요구 | 현재 선택 | 얻은 것 | 치른 대가 |
|---|---|---|---|
| 업로드마다 빠른 증분 | ZINCRBY | 격자별 O(log N) 누적, 읽기 쉬운 순위 | 사용자별 중복 제거 없음 |
| 시간이 지나면 자동 제외 | UTC 6시간 키 분할 | 삭제 배치 없이 룩백만 바꾸면 됨 | 정확한 48시간이 아니라 42~48시간 |
| 전국 상위 몇 개 | Sorted Set 역순 조회 | 별도 정렬 없이 상위 50 | 뷰포트 지역 순위가 아닌 전국 순위 |
| 조회 부하 흡수 | hotzone:top 30초 캐시 | 적중 시 합산 없이 캐시 확인과 상위 조회만 실행 | 만료 순간 합산이 Redis를 잠시 점유 |
| 업로드 성공 우선 | 비동기 유계 큐, 실패 비전파 | 정상 Redis 지연을 응답에서 분리 | 포화와 장애 때 점수 유실 허용 |
PostgreSQL에 집계 테이블을 만들지 않은 이유도 같은 맥락이다. 원본 업로드는 이미
videos에 있고, 핫구역은 48시간 안에 사라지는 파생 순위다. DB 트랜잭션 정합성과
영구 보관을 추가할 이득보다 DDL, 쓰기 경합, 청소 배치 비용이 더 크다고 본 설계다. 다만 Redis
집계를 videos에서 다시 만드는 복구 작업도 현재는 없다. Redis 유실 시 48시간 동안
비어 보이는 것을 허용한다.
실측으로 확인된 현재 천장
2026-08-06 로컬 부하 측정은 구조가 당장 병목은 아니라는 근거를 남겼다. 숫자는 같은 노트북에서의 상대 비교로만 봐야 하며 EC2 절대 성능으로 옮기면 안 된다.
60초 유지, 실패 0건, 중앙값 0.75ms. 조회 처리량 자체는 병목이 아니었다.
고유 격자 946개와 46,697개를 비교한 운영 Lua 전체 중앙값. 격자 수에 거의 비례했다.
단일 워커 처리율. 10,000칸 큐는 이 속도에서 약 2초짜리 버스트를 흡수한다.
46,697개 조건의 실제 부하 중 ZUNIONSTORE 한 번은 평균 44.97ms였다. 중복 합산은
없었지만 그 시간 동안 Redis의 뒤 요청이 기다렸다. 반면 현재 운영 Redis의 실제 격자 수는 이
측정에서 세지 않았다. “지금은 1ms 아래일 것”이라는 판단은 현재 규모가 수천 단위라는 추정에
기댄다.
언제 다시 봐야 하나
활성 고유 격자가 만 단위에 접근하거나, 30초 만료 시점의 꼬리 지연이 SLO에 가까워질 때다.
그때 먼저 줄여야 할 것은 잠금이 아니라 ZUNIONSTORE가 합치는 데이터 양이다.
현재 Lua가 이미 합산을 한 번만 실행하게 만든다.
읽고 나서 기억할 경계
- 정의 핫구역은 실시간 인구 밀도가 아니라 최근 업로드 이벤트 순위다.
- 원본
videos가 영구 원본이고 Redis 점수는 복구하지 않는 근사 집계다. - 시간 48시간은 정확한 슬라이딩 창이 아니라 6시간 버킷 8개의 42~48시간 근사다.
- 순위 뷰포트별 상위가 아니라 전국 상위 50을 먼저 자른다.
- 정합성 DB 커밋 뒤 점수를 올리지만 인코딩 실패와 영상 삭제는 되돌리지 않는다.
- 장애 쓰기 실패는 업로드를 살리려고 삼키고, 조회 실패는 현재 서비스 밖으로 전파한다.
- 캐시 버킷 ZSET은 집계 원장이고
hotzone:top만 30초 응답 캐시다.
코드 근거
VideoServiceImpl.java:139-185, 357-369, 업로드 저장과afterCommit배선HotScoreCommandServiceImpl.java:35-107, 버킷 키, Lua 증분, 대기열과 종료 드레인HotZoneServiceImpl.java:30-112, 8버킷 합산 캐시와 판정 순서GridEncoder.java:18-28,gridId문자열 생성과 해석HotZoneProperties.java:13-25,topK=50,minScore=3설정HotZoneEntryDetector.java:25-103, 10분 진입 감지와 Redis SET 스냅숏docs/spec/MSG-233.md, 산식과 저장 구조의 설계 정본docs/explainers/MSG-321-hotzone-load-test.html, 조회와 워커 부하 실측../LLM-WIKI/04-decisions/ADR viewport polling SLO.md, polling 채택 근거
용어 각주
- [1] Sorted Set: member마다 숫자 score를 붙여 정렬해 두는 Redis 자료구조다. 같은 member의 점수를 올릴 수 있고 상위 순위를 바로 읽을 수 있어 격자별 집계에 맞는다.
- [2] TTL: 키가 자동으로 삭제되기까지 남은 시간이다. 여기서는 버킷 청소와 합산 캐시 갱신 주기를 정하지만, 48시간 판정 자체는 버킷 룩백이 맡는다.
- [3] 원자 실행: Lua 안의 명령 묶음 사이에 다른 Redis 명령이 끼어들지 않는다. 점수만 오르고 TTL이 빠지는 중간 상태, 합산 캐시만 생기고 TTL이 빠지는 중간 상태를 막는다.
- [4] 캐시 스탬피드: 캐시가 사라진 순간 여러 요청이 같은 값을 동시에 다시 계산하는 현상이다. 현재 Lua는 첫 합산이 끝난 뒤 후속 요청이 캐시 존재를 보게 해 중복 합산을 막는다.
- [5] head-of-line 지연: 앞선 긴 작업 하나 때문에 뒤의 짧은 작업까지 줄 서서 기다리는 현상이다. 큰
ZUNIONSTORE가 실행되는 동안 같은 Redis의 뒤 명령이 기다리는 상황이 여기에 해당한다. - [6] polling: 클라이언트가 일정 시점마다 서버에 최신 값을 다시 묻는 방식이다. 핫구역은 30초 정도의 지연을 허용해 서버가 먼저 밀어주는 WebSocket 대신 이 방식을 쓴다.