FillMap backend explainer

Redis 핫구역 구현, 코드에서 응답까지

현재 코드는 커밋된 영상 업로드를 100m 격자별로 세고, 최근 시간대의 전국 상위 격자를 지도 화면에 돌려준다. 여기서 Redis는 단순 응답 캐시가 아니다. 시간 버킷별 집계 원장과 30초짜리 조회 캐시를 함께 맡는다.

Date
2026-08-07
Input shape
concept
Subject
현재 Redis 핫구역 집계 및 조회 구현, MSG-183, MSG-184, MSG-233
한 문장으로 보면 이렇다. 업로드 1건을 해당 격자의 점수 1점으로 기록하고, UTC 6시간 버킷 8개를 합쳐 전국 상위 50개 중 점수 3 이상만 반환한다.

전체 흐름부터 잡기

FillMap 핫구역 전체 흐름 영상 업로드 커밋 후 비동기 워커가 Redis 시간 버킷 점수를 올리고, 조회 요청은 여덟 버킷을 합산한 캐시에서 전국 상위 50개를 읽은 뒤 점수와 뷰포트로 필터한다. VideoServiceImpl DB 트랜잭션 커밋 afterCommit 콜백 단일 데몬 워커 LinkedBlockingQueue 최대 10,000건 Redis 버킷 ZSET hotzone:{bucketId} member = gridId score = 업로드 수, TTL 54h enqueue Lua GET /api/hotzones 남서, 북동 좌표 인증은 공통 정책 캐시 보장 Lua 없으면 최근 8버킷 ZUNIONSTORE hotzone:top, TTL 30s 전국 상위 50 ZREVRANGE WITHSCORES score 3 미만 제거 뷰포트 밖 제거 HotZoneView gridId, gridY, gridX score 내림차순 현재 포함 8개 키를 읽음 핫구역 알림은 별도 하류 기능 10분마다 전국 결과와 notification:hotzone:last SET을 비교

쓰기와 읽기는 서로 다른 경로다. 쓰기는 영상 업로드가 커밋된 뒤 대기열에 신호를 넣고 바로 빠진다. 읽기는 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 = gridId
score = 최근 8버킷 합계
30초 조회용 합산 캐시
notification:hotzone:last Set member = 직전 주기의 핫구역 gridId 명시 TTL 없음 알림 진입 전이 비교용 스냅숏

gridIdGridEncoder가 위도와 경도를 격자 간격으로 나눈 뒤 내림해 만든 "{gridY}_{gridX}" 문자열이다. Redis에는 좌표나 사용자 ID를 따로 넣지 않는다. Spring 쪽에서는 StringRedisTemplate을 쓰므로 키와 member는 문자열, Sorted Set의 score는 Redis 내부 부동소수점 수다. 현재 증분이 항상 정수 1이라 조회할 때 Math.roundlong에 옮긴다.

bucketId = floor(현재 UTC epochSeconds / 21,600)

TTL[2]은 48시간 판정 장치가 아니라 오래된 키를 치우는 장치다. 실제 판정 범위는 조회 시 선택하는 버킷 ID 8개가 정한다. 그래서 54시간이 지나지 않은 예전 키가 Redis에 남아 있어도 합산 대상에서 빠진다.

쓰기 로직은 커밋, 대기열, Lua 세 단계다

// VideoServiceImpl.saveVideo afterCommit(() -> hotScoreCommandService.recordUpload(gridId)); // HotScoreCommandServiceImpl.recordUpload String key = "hotzone:" + clock.instant().getEpochSecond() / 21600L; executor.execute(() -> increment(key, gridId)); -- Redis Lua redis.call('ZINCRBY', KEYS[1], 1, ARGV[1]) redis.call('EXPIRE', KEYS[1], ARGV[2]) return 1
1. 커밋 뒤 실행
트랜잭션이 롤백되면 Redis에 유령 점수가 남지 않는다. 트랜잭션이 없는 테스트에서는 즉시 실행한다.
2. 버킷은 호출 시각에 확정
워커가 밀려 6시간 경계를 넘겨도 원래 호출 시각의 키에 기록한다.
3. Lua로 묶음
ZINCRBYEXPIRE를 원자 실행[3]해 TTL 없는 버킷 키가 남는 틈을 없앤다.

대기열은 LinkedBlockingQueue<Runnable> 10,000칸, 워커는 데몬 스레드 하나다. recordUpload가 Redis 응답을 기다리지 않으므로 정상 상태의 업로드 응답은 Redis 왕복 시간과 분리된다. 애플리케이션 종료 때는 최대 5초 동안 큐를 비우고, 그 안에 끝나지 않은 작업은 버린다.

실패 정책은 “업로드를 살리고 점수 1건을 버린다”

Redis 오류와 큐 포화 예외는 구현 안에서 잡아 경고 로그만 남긴다. 핫스코어가 원본 데이터가 아니라 근사 순위이기 때문이다. 다만 큐 포화 때 요청 스레드가 예외 스택 로그를 쓰므로 극단적인 버스트에서는 응답 분리 효과가 약해진다는 실측 결과가 있다.

“최근 48시간”은 고정 버킷 8개의 근사다

C-76시간
C-66시간
C-56시간
C-46시간
C-36시간
C-26시간
C-16시간
C진행 중

조회는 현재 버킷 C와 앞의 7개를 더한다. 현재 버킷은 아직 다 차지 않았으므로 실제 포함 범위는 조회 시각에 따라 42시간에서 48시간 사이다. 정확히 현재 시각에서 48시간 전까지를 자르는 슬라이딩 윈도우가 아니다. 대신 6시간 버킷 8개만 합치면 돼 구조가 단순하다.

hotzone:top = ZUNIONSTORE(C-7, C-6, C-5, C-4, C-3, C-2, C-1, C)

각 버킷 가중치는 모두 1이다. 오래된 업로드를 덜 치는 시간 감쇠도 없다. 1시간 버킷이면 경계 오차는 줄지만 합칠 키가 48개로 늘어난다. 현재 설계는 핫구역을 정밀 통계가 아닌 탐색 신호로 보며, 정밀도보다 합산 키 수와 구현 단순성을 택했다.

조회는 합산 캐시를 만든 뒤 전국 상위 50개만 본다

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 Set<TypedTuple<String>> top = redisTemplate.opsForZSet() .reverseRangeWithScores("hotzone:top", 0, topK - 1L); for (TypedTuple<String> tuple : top) { if (score < minScore) continue; if (outsideViewport) continue; hotZones.add(new HotZoneView(gridId, gridY, gridX, score)); }
캐시가 있으면
Lua는 EXISTS만 확인하고 합산을 건너뛴다.
캐시가 없으면
최근 8개 ZSET을 합쳐 hotzone:top을 만들고 30초 TTL을 건다.
필터 순서가 중요
전국 상위 K를 먼저 자르고 점수와 뷰포트를 뒤에서 거른다.
  1. 남서와 북동 좌표가 유한한지, 범위가 뒤집히지 않았는지 검사한다.
  2. hotzone:top을 보장한다. 비어 있으면 빈 목록을 반환한다.
  3. 점수 내림차순으로 전국 상위 topK, 기본 50개를 가져온다.
  4. 점수 minScore, 기본 3점 미만을 버린다.
  5. 격자 ID를 정수 인덱스로 풀어 요청 뷰포트 밖 격자를 버린다.

뷰포트 상위 50이 아니라 전국 상위 50이다

예를 들어 어떤 격자가 현재 화면 안에서 가장 뜨겁더라도 전국 순위가 51위면 응답에 없다. 반대로 전국 상위 50 중 화면 안에 들어온 격자만 남으므로 결과가 50개보다 훨씬 적거나 0개일 수 있다. 이 순서 덕분에 애플리케이션 필터 비용은 항상 최대 50건이다.

캐시 확인과 합산, TTL 설정도 Lua 한 번으로 묶였다. Redis는 한 Lua 스크립트를 실행하는 동안 다른 명령을 끼워 넣지 않는다. 첫 요청이 합산 중이면 뒤 요청은 기다렸다가, 캐시가 이미 생긴 것을 보고 합산을 건너뛴다. 그래서 캐시 스탬피드[4]는 없지만, 합산이 오래 걸리면 뒤 요청이 줄 서는 head-of-line 지연[5]은 생길 수 있다.

API가 돌려주는 값

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 } ] } }

서비스 내부 자료형은 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 절대 성능으로 옮기면 안 된다.

2,000 RPS

60초 유지, 실패 0건, 중앙값 0.75ms. 조회 처리량 자체는 병목이 아니었다.

합산 0.4ms → 29.4ms

고유 격자 946개와 46,697개를 비교한 운영 Lua 전체 중앙값. 격자 수에 거의 비례했다.

워커 약 5,072건/s

단일 워커 처리율. 10,000칸 큐는 이 속도에서 약 2초짜리 버스트를 흡수한다.

46,697개 조건의 실제 부하 중 ZUNIONSTORE 한 번은 평균 44.97ms였다. 중복 합산은 없었지만 그 시간 동안 Redis의 뒤 요청이 기다렸다. 반면 현재 운영 Redis의 실제 격자 수는 이 측정에서 세지 않았다. “지금은 1ms 아래일 것”이라는 판단은 현재 규모가 수천 단위라는 추정에 기댄다.

언제 다시 봐야 하나

활성 고유 격자가 만 단위에 접근하거나, 30초 만료 시점의 꼬리 지연이 SLO에 가까워질 때다. 그때 먼저 줄여야 할 것은 잠금이 아니라 ZUNIONSTORE가 합치는 데이터 양이다. 현재 Lua가 이미 합산을 한 번만 실행하게 만든다.

읽고 나서 기억할 경계

코드 근거

용어 각주

  1. [1] Sorted Set: member마다 숫자 score를 붙여 정렬해 두는 Redis 자료구조다. 같은 member의 점수를 올릴 수 있고 상위 순위를 바로 읽을 수 있어 격자별 집계에 맞는다.
  2. [2] TTL: 키가 자동으로 삭제되기까지 남은 시간이다. 여기서는 버킷 청소와 합산 캐시 갱신 주기를 정하지만, 48시간 판정 자체는 버킷 룩백이 맡는다.
  3. [3] 원자 실행: Lua 안의 명령 묶음 사이에 다른 Redis 명령이 끼어들지 않는다. 점수만 오르고 TTL이 빠지는 중간 상태, 합산 캐시만 생기고 TTL이 빠지는 중간 상태를 막는다.
  4. [4] 캐시 스탬피드: 캐시가 사라진 순간 여러 요청이 같은 값을 동시에 다시 계산하는 현상이다. 현재 Lua는 첫 합산이 끝난 뒤 후속 요청이 캐시 존재를 보게 해 중복 합산을 막는다.
  5. [5] head-of-line 지연: 앞선 긴 작업 하나 때문에 뒤의 짧은 작업까지 줄 서서 기다리는 현상이다. 큰 ZUNIONSTORE가 실행되는 동안 같은 Redis의 뒤 명령이 기다리는 상황이 여기에 해당한다.
  6. [6] polling: 클라이언트가 일정 시점마다 서버에 최신 값을 다시 묻는 방식이다. 핫구역은 30초 정도의 지연을 허용해 서버가 먼저 밀어주는 WebSocket 대신 이 방식을 쓴다.