캐시 만료 순간 DB로 몰리는 요청, single-flight로 막기
캐시가 만료되는 찰나 수많은 요청이 한꺼번에 DB로 쏟아지는 Cache Stampede를, single-flight로 한 번만 계산하게 막는 백엔드 패턴을 정리했어요.
캐시 만료 순간 DB로 몰리는 요청, single-flight로 막기
캐시를 걸어뒀는데 특정 시각마다 DB CPU가 튀는 걸 본 적 없나요? 평소엔 조용하다가 딱 그 순간만 요청이 몰려요. 저도 처음엔 트래픽이 갑자기 늘었나 싶었는데, 로그를 보니 원인은 따로 있었어요.
캐시가 비는 찰나가 문제예요
캐시에 TTL을 60초로 걸어뒀다고 해볼게요. 이 값이 만료되는 그 순간, 마침 들어온 요청들이 전부 캐시 미스가 나요. 그러면 "내가 다시 계산해서 채워야지" 하고 전부 DB로 달려가요. 동시에 1000개 요청이 있었다면 1000개가 그대로 DB를 때려요. 이게 Cache Stampede(캐시 스탬피드)예요.
문제는 DB 커넥션 풀이 보통 20~30개 수준이라는 거예요. 남는 요청은 커넥션을 기다리다 타임아웃이 나고, 그동안 응답이 밀리면서 서비스 전체가 느려져요. 정작 계산 결과는 다 똑같은 값 하나인데 말이죠.
single-flight = 한 명만 다녀오기
핵심은 간단해요. 같은 키에 여러 요청이 동시에 미스가 나면, 딱 한 요청만 실제로 DB에 다녀오고 나머지는 그 결과를 같이 받아쓰게 하는 거예요. Go의 singleflight 패키지가 이 방식이에요. 언어가 달라도 원리는 같아서, 키 단위 락으로 직접 만들 수 있어요.
동작을 그려보면 이래요. 첫 요청이 락을 잡고 DB를 조회해요. 그사이 들어온 같은 키의 요청들은 "누가 이미 가져오는 중"임을 확인하고 대기해요. 첫 요청이 값을 채우면 대기하던 요청들이 그 값을 그대로 돌려받아요. DB에 나가는 건 딱 1번이에요.
직접 만들어보니 챙길 게 있었어요
로컬에서 1000개 동시 요청으로 부하를 걸어봤더니, single-flight 없이는 DB 조회가 수백 번 찍혔는데 적용 후엔 키당 1번으로 줄었어요. 다만 몇 가지를 놓치기 쉬웠어요.
- 락 자체에도 만료: 첫 요청이 죽으면 나머지가 영원히 기다려요. Redis
SETNX로 분산 락을 쓴다면 3~5초 TTL을 꼭 걸어두세요. - 키 단위로 격리: 전역 락 하나로 묶으면 서로 다른 키까지 줄을 서요. 락은 반드시 캐시 키별로 나눠야 해요.
- 한 대냐 여러 대냐: 프로세스 안에서만 막을 거면 메모리 락으로 충분하지만, 서버가 여러 대면 Redis 같은 공유 락이 필요해요.
- 락 TTL 필수
- 키 단위 격리
- 분산이면 공유 락
저는 플랜씨(plan-c)의 금융계산기처럼 로그인 없이 많이 쓰이는 화면에서 이런 몰림을 특히 신경 써요. 같은 계산 결과를 여러 명이 동시에 조회하는 상황이라, 캐시가 만료되는 순간이 그대로 위험 구간이 되거든요.
마무리
Cache Stampede는 트래픽이 늘어서가 아니라 캐시가 비는 찰나에 생겨요. 그래서 TTL을 늘리는 것만으론 안 풀리고, "동시에 미스 난 요청을 어떻게 한 번으로 모을까"를 고민해야 해요. DB가 규칙적으로 튄다면, 다음엔 single-flight부터 의심해보세요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.