최근 Redis를 활용한 캐시 아키텍처를 들여다보다가 Cache Stampede 방지 대책에 대해 고민해 보게 되었습니다.

처음에는 단순히 “동시에 DB를 한 번만 조회하게 만드는 기술” 정도로만 알고 있었는데, 공부를 해보니 동작 원리나 상황별 부작용까지 세부적으로 뜯어보게 되더라고요.

나중에 다시 봤을 때 빠르게 기억을 되살릴 수 있도록 기록해 둡니다.

Cache Expire와 DB 폭주 (Cache Stampede)

Cache Stampede는 캐시가 만료되거나(Cache Expire), 아직 생성되지 않은 상태(Cache Miss)에서 동일한 데이터를 향해 매우 많은 요청이 동시에 몰리는 상황을 의미합니다.

예를 들어 상품 상세 페이지처럼 조회수가 높은 데이터를 Redis에 캐싱하고 있다고 가정해 보겠습니다.

평소에는 다음과 같이 처리됩니다.

[사용자 요청] ──> [Redis (Cache HIT)] ──> [즉시 응답]

이 구조에서는 DB에 전혀 부하를 주지 않습니다.

하지만 캐시가 만료되는 순간 문제가 발생하게 됩니다.

동시에 1,000명의 사용자가 같은 상품을 조회하려 할 때 캐시가 비어 있다면 어떤 일이 일어날까…

                       ┌──> [DB 조회] (요청 1)
[사용자 요청 1,000개] ──┼──> [DB 조회] (요청 2)
                       ├──> [DB 조회] (요청 3)
                       └──> ... [DB 조회] (요청 1,000)

모든 요청이 동시에 Cache Miss를 확인하고, 동일한 데이터를 가져오기 위해 각자 DB를 찌르게 되는 겁니다.

결과적으로 단 하나의 데이터 스냅샷을 얻기 위해 DB가 1,000번씩 중복 조회됩니다.

데이터가 손상되는 것은 아니지만, DB 서버가 일시적으로 엄청난 과부하를 받게 된다는 점이 본질적인 문제였습니다.

부하를 줄이기 위한 Warmup과 TTL Jitter의 활용

사실 저도 이전 프로젝트들에서 Warmup이나 TTL Jitter를 적용해 본 적이 있습니다.

물론 이 기술들이 Cache Stampede 자체를 완벽하게 막아주는 동시성 제어 도구가 아니라는 트레이드오프는 알고 있었습니다. 단지 동시 만료 시점의 부하를 조금이라도 덜어내기 위한 현실적인 경감책으로 썼던 것이거든요.

미리 캐시를 구워두는 Warmup은 초기 미스율을 확실히 낮춰주고, 랜덤한 만료 시간을 얹어주는 TTL Jitter는 특정 타이밍에 수백 개의 키가 한꺼번에 증발하는 충격을 분산해 주니까요.

하지만 특정 대형 이벤트나 핫딜 상품처럼 단 하나의 캐시 키에 수천 명의 사용자가 동시에 물려 있는 순간에는, 이 키가 만료되자마자 DB 조회가 쏟아지는 걸 완전히 막을 방법이 없었습니다. 부하를 흩뿌려 완화하는 예방책일 뿐, 근본적으로 동일 데이터에 대한 동시 DB 진입 자체를 차단하진 못하니까요.

Distributed Lock과 SET NX

앞서 언급한 예방 조치들만으로 해결할 수 없는 영역에서 Distributed Lock이 등장하게 됩니다.

동일한 캐시 생성 작업을 오직 하나의 프로세스만 수행하도록 잠금 장치를 거는 메커니즘입니다. 핵심은 DB 조회를 최초 1회로 한정 짓는 데 있습니다.

Redis에서는 단일 명령어로 간단히 락을 구현할 수 있습니다.

SET lock:goods:100 UUID NX PX 3000

여기서 사용된 옵션들의 의미는 대략 이렇습니다.

  • NX: Key가 존재하지 않을 때만 생성에 성공함
  • PX 3000: 3초 후에 자동으로 락이 파기되도록 설정함 (서버 다운 시 데드락 방지)

Redis는 Single Thread로 명령을 처리하기 때문에 동시에 1,000개의 락 획득 시도가 몰려도 오직 하나의 스레드만 성공하게 됩니다.

락 기반 제어의 흐름

전체적인 처리 흐름은 대략 다음과 같은 순서로 흘러갑니다.

1. Redis 캐시를 먼저 조회
   ├── [Cache HIT] ──> 데이터를 즉시 응답하고 종료
   └── [Cache MISS] ─> Lock 획득 시도
                        ├── [Lock 획득 성공] ──> DB 조회 후 캐시 적재 및 Lock 해제
                        └── [Lock 획득 실패] ──> 일정 시간 대기 후 다시 Redis 캐시 조회

Lock을 얻은 첫 번째 스레드만 DB로 올라가고, 나머지 999개의 요청은 Lock 획득에 실패한 채 대기 상태로 들어갑니다.

먼저 올라간 스레드가 DB에서 가져온 값으로 캐시를 채워놓으면, 대기하던 요청들은 락이 풀린 뒤 Redis에서 채워진 캐시 데이터를 읽어 나가는 구조입니다.

놓칠 뻔했던 Double Check Locking

분산 락을 고민하면서 머릿속으로 로직을 그리다 보니 한 가지 놓칠 뻔했던 지점이 있었습니다. 바로 락을 획득한 뒤에 캐시를 한 번 더 체크하는 Double Check Locking이었습니다.

스레드 A가 먼저 락을 쥐고 DB를 조회해서 캐시를 채워넣은 뒤 락을 풀면, 뒤이어 대기 중이던 스레드 B가 락을 획득하게 됩니다.

이때 스레드 B가 이미 채워진 캐시가 있는지 확인하지 않고 곧장 DB를 조회하면 락을 쓴 의미가 없어지거든요.

그래서 락을 얻은 직후에 캐시를 찔러보는 2차 검증 단계가 꼭 들어가야겠더라고요. 실제로 저도 코드를 직접 짜다 보면 무심코 빼먹기 딱 좋은 조건 분기라는 생각이 들었습니다.

Redisson RLock을 사용하는 이유

단순히 SET NX 명령어 하나만으로도 잠금 자체는 걸 수 있지만, 안정적인 시스템을 만들려면 추가적인 고민거리가 쏟아집니다.

  • 락 획득에 실패한 요청들을 어떻게 부하 없이 대기시킬 것인가
  • DB 조회가 지정한 락 만료 시간(TTL)보다 길어지면 어떻게 락 유실을 막을 것인가
  • 내가 쥔 락을 엉뚱한 서버가 해제하지 못하도록 보장할 수 있는가

이러한 문제들을 밑바닥부터 다 직접 짜는 수고를 덜기 위해 보통 Redisson 라이브러리를 얹어서 사용합니다.

Watchdog 기능

만약 락의 임계 시간을 3초로 잡았는데 예기치 않게 DB 조회가 5초 이상 걸린다면, 작업 중에 락이 풀려 다른 서버가 진입하게 됩니다. Redisson은 백그라운드 스레드(Watchdog)를 돌려 작업이 완료될 때까지 락의 유효 시간을 계속 연장해 주더군요.

Pub/Sub 메커니즘

락이 없어서 대기할 때 무작위로 계속 캐시가 생겼는지 찔러보는 Polling 방식은 Redis에 엄청난 트래픽 부하를 줍니다. Redisson은 락이 해제되는 순간 대기 중인 스레드에 메시지를 브로드캐스팅해 알려주는 방식으로 Polling 비용을 줄입니다.

진짜 헷갈렸던 지점: Mutex Lock과 RedLock의 경계

공부하면서 제가 가장 명확하게 구분하지 못하고 헷갈렸던 부분은 RedLock과 일반적인 **Mutex Lock(Redisson RLock)**의 차이였습니다.

처음에는 단순히 RedLock이 Redisson RLock의 최적화된 상위 버전이거나 기능 확장 버전 정도로 어렴풋이 짐작하고 있었거든요. 그런데 둘은 설계 목적과 적용해야 하는 비즈니스 맥락이 완전히 다른 녀석이더라고요.

Redisson의 RLock은 단일 마스터 노드를 기준으로 임계 영역에 단 하나의 프로세스만 진입하게 보장하는 Mutex Lock입니다. 오직 DB의 불필요한 중복 조회를 막고 캐시 성능을 보호하기 위한 용도라면, 이 RLock 수준으로도 충분하다는 사실을 이제야 확실히 짚고 갈 수 있었습니다.

반면 RedLock은 Redis 분산 환경에서 마스터 노드가 중단되거나 복제 지연이 발생하더라도 락의 강력한 신뢰성을 유지하기 위한 특수 알고리즘이었습니다. 여러 독립된 Redis 마스터 노드 중 과반수 이상에게서 성공적으로 락을 획득해야만 전체 잠금 성공으로 인정하는 방식이죠.

그래서 RedLock은 ‘캐시 조회 부하 차단’이 아니라, 락의 유실로 인한 중복 처리가 금전적 손실로 이어지는 ‘선착순 쿠폰 발급’, ‘결제’, ‘재고 차감’ 같은 원자적 비즈니스 정합성을 강제하는 상황에서 검토해야 하는 성격의 기술이었습니다.

결국 “Redis Lock”이라는 하나의 단어로 뭉뚱그려 생각하다 보니 두 개념이 얽혀 있었던 셈인데, 이번 기회에 각 도구가 해결하고자 하는 진짜 물리적인 골칫거리가 무엇인지 선명하게 분류할 수 있게 된 것 같습니다.