Redis 조회수를 DB로 옮길 때 증가분이 사라지는 문제 (Lua 델타 차감)
Redis 조회수를 DB로 옮길 때 DEL을 쓰면 16.85%가 유실됐습니다. 읽은 만큼만 DECRBY 하면 0건이 됩니다.
[배경 - 조회수가 조금씩 비는 것 같다]
아주이벤트는 아주대학교 공지사항을 크롤링해서 푸시로 알려주는 서비스입니다. 공지마다 조회수가 붙고, 이 값이 인기 공지 랭킹의 재료가 돼요.
처음에는 조회 요청마다 DB의 view_count 컬럼을 UPDATE 했습니다. 인기 공지에 트래픽이 몰리면 같은 행을 두고 락 경합이 생겼어요. 조회 API가 조회수 갱신 때문에 느려지는 상황이었습니다.
그래서 Redis로 옮겼어요. 조회는 Redis에서 INCR 로 받고, 주기적으로 DB에 반영하는 구조로 바꿨습니다. 여기까지는 흔한 설계예요.
문제는 반영 로직에서 나왔어요. 처음 짠 코드는 이랬습니다.
1. Dirty Set 에서 대상 eventId 를 읽는다
2. 각 키의 값을 읽어 DB 에 더한다
3. 키를 DEL 로 지운다
돌려보니 조회수가 미세하게 비었습니다. 큰 차이는 아니었지만 분명히 덜 들어가 있었어요.
[문제 상황 분석 - 2번과 3번 사이의 틈]
읽는 시점과 지우는 시점이 다릅니다
문제는 명확했어요. 값을 읽은 뒤 지우기까지 사이에 시간이 있습니다. 그 틈에 들어온 조회는 그대로 날아가요.

t1과 t2의 조회 2건이 사라집니다. DB에는 100만 들어갔고 Redis는 통째로 비워졌으니까요.
조회 트래픽이 적으면 티가 안 납니다. 반영 주기 사이에 조회가 없으면 틈이 생길 일도 없으니까요. 그런데 인기 공지는 정확히 그 반대예요. 가장 많이 조회되는 글에서 가장 많이 유실됩니다. 랭킹을 만드는 게 목적인데 랭킹 상위가 제일 부정확해지는 셈이었어요.
락으로 막을 수 있을까?
읽기부터 삭제까지를 잠그는 방법을 먼저 떠올렸어요. 그런데 이건 방향이 틀렸습니다.
그 구간에는 DB 쓰기가 들어가요. DB 왕복이 끝날 때까지 해당 공지의 조회를 전부 막게 됩니다. 조회 API를 빠르게 만들려고 시작한 작업인데 다시 느리게 만드는 꼴이에요.
애초에 조회를 막을 이유가 없었어요. 필요한 건 읽은 만큼만 정확히 빼는 것이었습니다.
[해결 방법 - 통째로 지우지 말고 읽은 만큼만 빼기]
증가 스크립트
먼저 조회 시점부터 봅니다. 중복 판별과 카운트 증가를 Lua로 묶었어요.
-- KEYS[1]: dedupKey (ClubEvent:dedup:{eventId}:u:{email} 또는 :a:{hash})
-- KEYS[2]: viewKey (ClubEvent:views:{eventId})
-- KEYS[3]: dirtyKey (ClubEvent:dirty)
-- ARGV[1]: dedup TTL (초)
-- ARGV[2]: eventId
-- ARGV[3]: viewKey 안전장치 TTL (초)
local isNew = redis.call('SET', KEYS[1], '1', 'NX', 'EX', tonumber(ARGV[1]))
if isNew then
redis.call('INCR', KEYS[2])
redis.call('SADD', KEYS[3], ARGV[2])
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
return 1
end
return 0
SET NX EX 의 반환값으로 신규 여부를 판단하고, 신규일 때만 나머지를 실행해요. 중복 체크와 증가 사이에 다른 요청이 끼어들 수 없습니다.
MULTI/EXEC 나 파이프라인으로는 이게 안 됩니다. 조건 분기를 서버 쪽에서 처리해야 하는데, 트랜잭션은 명령을 묶어줄 뿐 중간 결과로 분기하지 못하니까요. Lua를 고른 이유가 여기 있습니다.
viewKey 에 거는 안전장치 TTL도 짚고 갈게요. 반영이 실패해서 Dirty Set에서 빠지지 못한 키가 영원히 남는 상황을 막아줍니다.
차감 스크립트
핵심은 이쪽입니다. DEL 대신 DECRBY 를 씁니다.
-- KEYS[1..n] : view count keys
-- KEYS[n+1] : dirty set key
-- ARGV[i*2-1] : delta (뺄 양)
-- ARGV[i*2] : eventId (dirty set 제거용)
local n = #KEYS - 1
local dirtyKey = KEYS[n + 1]
for i = 1, n do
local remaining = redis.call('DECRBY', KEYS[i], tonumber(ARGV[(i - 1) * 2 + 1]))
if remaining <= 0 then
redis.call('DEL', KEYS[i])
redis.call('SREM', dirtyKey, ARGV[(i - 1) * 2 + 2])
end
end
return 1
바뀐 흐름은 이렇습니다.
시각 동작 Redis 값
---- ---------------------------- --------
t0 스케줄러: 값 읽기 (100) 100
t1 사용자 조회 → INCR 101
t2 사용자 조회 → INCR 102
t3 스케줄러: DB 에 100 더함 102
t4 스케줄러: DECRBY 100 2
t4 이후 Redis에 2가 남습니다. 이 2는 다음 주기에 반영돼요. 유실이 사라졌습니다.
remaining <= 0 조건도 중요해요. 차감 후 0보다 크면 아직 반영할 게 남았다는 뜻이니 키를 지우지 않습니다. 반대로 0 이하면 정리하고 Dirty Set에서도 뺍니다.
청크 단위로 나눠 호출하는 것도 의도된 부분입니다. Dirty Set이 커져도 한 번의 Lua 실행이 길어지지 않아요. Redis는 싱글 스레드라 긴 스크립트가 곧 다른 요청의 대기 시간이 됩니다.
반영 주기
@Scheduled(cron = "0 0/3 * * * *")
public void updateDBFromRedis() {
clubEventViewCountService.flushViewCountsToDatabase();
}
3분입니다. 랭킹이 최대 3분 늦게 반영된다는 뜻이에요. 조회수는 실시간 정확도보다 상대적 순위가 중요한 지표라서 이 지연을 받아들였습니다.
[성과 - 개선 전후 비교]
저장소의 increment_view.lua 와 subtract_view_chunk.lua 를 그대로 로드해서 두 방식을 비교했습니다. Redis 7, 조회 스레드 8개가 계속 조회를 넣는 동안 flush를 20회 돌렸어요. 실제 DB 왕복을 흉내내려고 값을 읽은 뒤 반영까지 20ms 지연을 뒀습니다.
유실은 이렇게 계산했어요. 실제 조회 수 - (DB 반영 수 + Redis 잔여) 입니다.
| 방식 | 실제 조회 | DB 반영 | Redis 잔여 | 유실 |
|---|---|---|---|---|
| DEL | 70,682건 | 58,758건 | 14건 | 11,910건 (16.85%) |
| DECRBY (실제 코드) | 71,833건 | 71,222건 | 611건 | 0건 (0.00%) |
DEL 방식에서 6건 중 1건이 사라졌습니다. 예상보다 훨씬 큰 수치였어요.
생각해보면 당연합니다. 값을 읽고 DB에 반영하는 20ms 동안 8개 스레드가 계속 INCR 을 때리고 있으니까요. 그 20ms에 쌓인 증가분이 DEL 한 번에 전부 날아갑니다. 조회가 몰릴수록 유실도 같이 커져요.
DECRBY 방식에서 눈여겨볼 숫자는 잔여 611건입니다. 이 값이 유실이 아니라는 게 핵심이에요. 마지막 flush 이후에 들어온 조회들이고, 다음 주기에 그대로 반영됩니다. 읽은 만큼만 뺐기 때문에 남아 있는 거예요.
[결론]
카운터를 캐시에 모아 배치로 반영할 때, 통째로 지우는 순간 그 사이의 증가분이 사라집니다. 읽은 값만큼만 빼면 이 틈이 없어져요.
정리하면 규칙은 두 줄입니다.
- 반영할 때
DEL이 아니라DECRBY <읽은 값>을 쓴다 - 차감 결과가 0 이하일 때만 키를 정리한다
남은 한계도 적어둘게요.
첫째, 멱등하지 않은 구간이 남아 있습니다. DB에 더하는 것과 Redis에서 빼는 것은 별개 연산이에요. DB 반영 후 차감 전에 죽으면 다음 주기에 이중 반영되고, 차감 후 DB 반영이 실패하면 유실됩니다. 지금은 배치 실패 빈도가 낮아 감수하고 있지만, 정확도가 더 중요해지면 반영 이력을 남겨 멱등하게 만들어야 해요.
둘째, 비회원 식별이 거칠습니다. IP와 User-Agent 해시를 쓰는데, 학교 네트워크나 모바일 캐리어 NAT 뒤에서는 여러 사용자가 한 명으로 집계돼요. 과소 집계를 인정하고 쓰는 중입니다. 반대로 시크릿 모드나 UA 변경으로 어뷰징하는 것도 완전히 막지는 못해요.
셋째, Redis 장애 구간은 유실됩니다. 3분 주기이므로 최악의 경우 3분치가 날아가요. 조회수라는 지표의 성격상 허용 가능하다고 봤습니다.
넷째, 반영 스케줄러에 분산 락이 없습니다. 스케일 아웃하면 두 인스턴스가 동시에 차감을 시도할 수 있어요. 차감 자체는 원자적이라 값이 깨지지는 않지만, DB에 이중으로 더해질 여지는 있습니다. 지금은 단일 인스턴스라 문제가 없지만 확장 시 손봐야 할 지점이에요.
캐시에서 DB로 값을 옮기는 흔한 패턴인데, 지우는 방식 하나로 정확도가 갈린다는 게 인상적이었습니다. 알고 나면 당연한데 모르면 반드시 밟는 종류의 문제였어요.