실시간 카운터는 Redis로
안 읽은 메시지 개수를 DB에서 셌습니다. SELECT COUNT(*) ... WHERE read = false 였고, 인덱스도 걸어뒀습니다. 인덱스를 걸었으니 됐다고 생각했는데, 부하가 컸습니다.
인덱스는 찾는 일을 빠르게 합니다. 세는 일을 없애주지는 않습니다.
COUNT는 실제로 셉니다
인덱스가 있으면 조건에 맞는 행을 빨리 찾아갑니다. 하지만 개수를 알려면 결국 매칭된 행을 훑어야 합니다. 커버링 인덱스여서 테이블까지 안 가더라도, 비용은 매칭 행 수에 비례합니다.
그래서 이상한 일이 벌어집니다. 안 읽은 메시지가 많이 쌓인 유저일수록 그 유저의 화면이 더 무겁습니다. 가장 활발한 유저가 가장 비싼 쿼리를 만듭니다.
진짜 문제는 빈도입니다
쿼리 하나가 느린 게 아니었습니다. 이 값은 읽기와 쓰기 양쪽 모두 고빈도였습니다.
- 읽기 — 화면을 켤 때마다, 폴링할 때마다 날아갑니다. 접속자가 많으면 초당 수백에서 수천 번입니다.
- 쓰기 — 메시지가 올 때마다, 읽음 처리할 때마다 상태가 바뀝니다.
느린 쿼리 한 개는 눈에 띕니다. 싼 쿼리가 초당 수천 번 도는 건 잘 안 보입니다. 슬로우 쿼리 로그에도 안 걸립니다. 그런데 DB가 하는 일의 총량으로 보면 이쪽이 더 클 수 있습니다.
비유하자면 개표와 같습니다. 몇 표인지 물어볼 때마다 처음부터 다시 세는 겁니다. 정확하긴 한데, 물어보는 사람이 많아지면 개표소가 마비됩니다.
세지 말고, 세어둔 값을 들고 있으세요
계수기를 하나 쥐여주면 됩니다. 메시지가 오면 딸깍 올리고, 물어보면 지금 숫자를 읽어줍니다.
INCR user:5:unread 메시지 도착 — 원자적으로 +1
GET user:5:unread 화면 열기 — 메모리에서 한 방
DEL user:5:unread 전부 읽음 처리
DB는 이 경로에서 거의 빠집니다.
Redis가 이걸 잘하는 이유
원자 연산이 내장돼 있습니다. INCR은 읽고 더하고 쓰는 걸 하나로 처리합니다. 같은 걸 DB에서 하려면 락을 잡거나(SELECT ... FOR UPDATE 후 UPDATE) 원자적 갱신 연산을 써야 하고, 동시 요청이 몰리면 그 자리가 경합 지점이 됩니다.
메모리에서 끝납니다. 디스크로 안 갑니다.
TTL이 있습니다. 만료를 값 자체에 붙일 수 있어서, 세션처럼 수명이 있는 값에 편합니다.
대신 포기하는 것
Redis 카운터는 정본이 아닙니다. 재시작하거나 장애가 나면 날아갈 수 있습니다. 영속화 설정을 켜도 마지막 몇 초는 유실 구간으로 남습니다.
그래서 판단 기준은 성능이 아니라 이겁니다.
이 값은 잠깐 틀리거나 날아가도 괜찮은가?
안 읽은 개수는 괜찮습니다. 잠깐 3이 4로 보여도 서비스가 무너지지 않고, 날아가면 메시지 테이블에서 다시 만들 수 있습니다. 진실은 메시지 테이블에 있고, 카운터는 그걸 빠르게 보여주는 사본입니다. 정본이 필요하면 주기적으로 DB에 write-back하되, 그 주기만큼은 유실 구간이라는 걸 받아들이고 정해야 합니다.
이 패턴이 맞는 값, 맞지 않는 값
맞는 값 — 자주 바뀌고, 최신값이 빨리 필요하고, 조금 틀려도 되는 것. 안 읽은 개수, 좋아요와 조회수, 실시간 대시보드 숫자.
맞지 않는 값 — 코인 잔액, 재고, 정산 금액. 여기서 "조금 틀려도 된다"는 성립하지 않습니다. 같은 INCR이라도 이쪽에 쓰면 사고가 됩니다.
재미있는 건 두 경우에 정반대 처방이 나온다는 점입니다. 코인 잔액이 자꾸 틀어졌다에서는 저장해둔 총량을 믿지 못해서 히스토리에서 다시 세는 쪽으로 갔습니다. 여기서는 매번 세는 게 비싸서 세어둔 값을 들고 있는 쪽으로 갑니다.
모순처럼 보이지만 기준은 하나입니다. 틀렸을 때 무엇을 잃는가. 돈은 정확성을 사기 위해 비용을 내고, 안 읽은 개수는 그 반대입니다.