RoomPick의 인기 숙소 API에 Redis 캐시를 적용하면서 조회 성능은 크게 개선됐다.
하지만 테스트 도중 이상한 상황을 발견했다.
캐시가 존재할 때는 문제가 없었다.
문제는 캐시가 만료되는 순간이었다.
동일한 API 요청 10건을 동시에 보내자 Redis 랭킹 조회와 MySQL 조회가 각각 10번씩 발생했다.
캐시를 적용했는데 순간적으로 오히려 DB에 요청이 몰릴 수 있는 구조였다.
이번 글에서는 이 Cache Stampede 문제를 Single Flight 방식으로 해결한 과정을 정리한다.
인기 숙소 조회는 Cache Aside 구조를 사용하고 있었다.
Request
↓
Cache 조회
↓
HIT ─────────→ Response
MISS
↓
Redis 랭킹 조회
↓
MySQL 숙소 조회
↓
DTO 생성
↓
Cache 저장
↓
Response
평상시에는 문제가 없었다.
하지만 TTL이 만료된 직후 여러 요청이 동시에 들어오면 상황이 달라진다.
예를 들어 요청 10건이 거의 동시에 들어오면 모든 요청이 캐시가 존재하지 않는다는 사실을 확인한다.
Request 1 → Cache MISS → 원본 조회
Request 2 → Cache MISS → 원본 조회
Request 3 → Cache MISS → 원본 조회
...
Request 10 → Cache MISS → 원본 조회
모든 요청이 똑같은 데이터를 가져오기 위해 Redis와 MySQL을 반복해서 조회하는 것이다.
실제 테스트에서도
동일 요청 : 10건
Redis 랭킹 조회 : 10회
MySQL SELECT : 10회
가 발생했다.
이것이 Cache Stampede다.
10개의 요청이 원하는 결과는 완전히 동일하다.
그렇다면 최초 요청 하나만 실제 데이터를 조회하고 나머지 요청은 그 결과를 기다렸다가 공유하면 된다.
Request 1 ─────────────→ 실제 조회
│
Request 2 ─────────────────┤
Request 3 ─────────────────┤
Request 4 ─────────────────┤
↓
결과 공유
이런 방식을 Single Flight 방식으로 구현했다.
동일한 작업이 현재 진행 중인지 관리하기 위해 ConcurrentHashMap을 사용했다.
그리고 최초 요청의 결과를 여러 요청이 함께 기다릴 수 있도록 CompletableFuture를 사용했다.
전체 흐름은 다음과 같다.
요청
↓
동일 Key 작업 진행 중?
↓
NO ---------------------- YES
↓ ↓
최초 요청 기존 Future 대기
↓ ↓
Redis / DB 조회 동일 결과 반환
↓
Future 완료
↓
진행 중 작업에서 제거
Single Flight의 Key에는
기간
기준 날짜
조회 개수
를 포함했다.
단순히 API 주소가 같다는 이유만으로 묶는 것이 아니라 실제로 동일한 결과를 요구하는 요청만 하나의 작업을 공유하도록 했다.
처음에는 정상적인 Redis 조회만 Single Flight 대상으로 생각할 수도 있다.
하지만 Redis 장애가 발생하면 DB fallback이 실행된다.
만약 fallback 로직이 Single Flight 밖에 있다면
Redis 장애
Request 1 → DB fallback
Request 2 → DB fallback
Request 3 → DB fallback
...
Request 10 → DB fallback
처럼 장애 순간에 DB로 요청이 몰릴 수 있다.
결국 Cache Stampede의 위치만 Redis 장애 상황으로 이동하는 셈이다.
그래서 정상 Redis 조회부터 Redis 장애 시 DB fallback까지를 하나의 최종 작업으로 묶었다.
Single Flight
↓
Redis 조회
↓
성공 → 결과 반환
실패
↓
DB fallback
↓
결과 반환
이렇게 하면 Redis가 정상일 때뿐 아니라 장애가 발생한 상황에서도 동일 요청에 대한 DB fallback이 중복 실행되는 것을 막을 수 있다.
Single Flight를 적용하면 하나의 요청 결과를 여러 요청이 기다리게 된다.
그런데 최초 요청에 문제가 생겨 작업이 끝나지 않는다면 나머지 요청도 계속 기다릴 수 있다.
그래서 최대 대기시간을 5초로 제한했다.
5초 이내 완료
→ 정상 응답
5초 초과
→ HTTP 503
Thread Interrupt
→ Interrupt 상태 복구
→ HTTP 503
또한 대기 중인 요청 하나가 Timeout 됐다는 이유로 최초 작업 자체를 취소하지 않도록 했다.
하나의 최초 작업을 여러 요청이 기다리고 있을 수 있기 때문이다.
작업이 끝나면 진행 중인 요청을 저장하고 있는 Map에서 제거해야 한다.
하지만 단순히
remove(key);
와 같이 제거하면 타이밍에 따라 같은 Key에 새롭게 등록된 다른 작업까지 제거할 가능성이 있다.
그래서 현재 Map에 등록되어 있는 작업이 자신이 생성한 작업과 동일한 경우에만 제거하도록 했다.
remove(cacheKey, currentRequest);
최초 작업이 성공하거나 실패해 실제 작업이 종료된 경우에만 안전하게 정리할 수 있도록 했다.
Cold Cache 상태에서 동일한 요청 10건을 동시에 발생시켰다.
Redis 랭킹 조회 : 10회
MySQL SELECT : 10회
평균 응답시간 : 70.52ms
Redis 랭킹 조회 : 1회
MySQL SELECT : 1회
평균 응답시간 : 20.86ms
결과적으로
원본 조회
10회 → 1회
90% 감소
평균 응답시간
70.52ms → 20.86ms
약 70% 개선
이라는 결과를 확인했다.
Single Flight를 구현할 때 Redis 분산 락도 고려할 수 있었다.
하지만 현재 구조에서는 먼저 하나의 애플리케이션 인스턴스 내부에서 발생하는 중복 요청을 해결하는 것이 목적이었다.
그래서 네트워크 통신이 필요한 분산 락 대신
ConcurrentHashMap
+
CompletableFuture
를 이용해 JVM 내부에서 해결했다.
다만 이 구조는 단일 JVM 범위에서만 동일 요청을 합칠 수 있다.
향후 여러 애플리케이션 인스턴스를 운영하면서 인스턴스 간 Cache Stampede까지 제어해야 한다면 Redis 기반 분산 제어 등을 추가로 고려할 수 있다.
처음에는
"Redis 캐시를 적용했으니 DB 부하는 줄어들 것이다."
라고 단순하게 생각했다.
하지만 캐시에는 반드시 없는 순간과 만료되는 순간이 존재한다.
캐시 HIT 상황만 테스트해서는 실제 서비스에서 발생할 수 있는 문제를 모두 발견할 수 없었다.
이번 경험을 통해 캐시를 설계할 때는
Cache HIT
Cache MISS
Cache Expire
Redis 장애
동시 요청
까지 함께 고려해야 한다는 것을 배웠다.
특히 캐시를 적용하는 것만큼 캐시가 존재하지 않는 순간 시스템이 어떻게 동작하는지 역시 중요하다는 것을 알게 됐다.