현재 캐시 유효 시간(TTL)을 최대 하루로 설정하였다. 만약 10000명의 많은 사용자들의 캐시 유효시간이 만료되어 동시에 DB에 다시 접근하여 다시 DB에 접근하게 된다면?
이런 현상은 캐시 쇄도(Cache Stamped)라 한다.
동시에 많은 사용자들이 DB에 접근하게 된다면 DB의 부담이 커져서 서비스 장애로 이어질 수 있다.
해결방안
- Randomized TTL
- 캐시 만료 시간에 랜덤 값을 추가하여 캐시의 동시 만료를 방지하고, 캐시 갱신 시점을 분산한다.
- 예시 : 캐시 만료 시간 + (0~60)초로 무작위 시간 추가(base_ttl + random(0, jitter)
- 선제적 갱신(Early Refresh)
- 캐시가 만료되기 전에 선제적으로 갱신
- 분산락(DistributedLock)
- 최초 하나의 요청만 락을 획득하여 DB 조회 & 캐시 갱신 수행
이전 AOP를 구현해놓은 @DistributedLock을 통해 분산락을 구현
- 캐시 미스 발생 : 1만 명의 요청이 동시에 @Cacheable 프록시를 통과합니다.(아직 OO시 캐시가 없으므로 모두 통과)
-> 획득한 락이 있는지 확인 - 락 획득 경쟁 : 1만 명의 요청이 다음 프록시인 @DistributedLock을 만난다. -> 락 획득
- 1명 성공, 9999명 대기 : 1번 유저가 락을 얻고 DB를 조회한다. 나머지 9999명은 락을 기다린다.
- 문제의 발생 : 1번 유저가 조회를 마치고 빠져나가면 @Cachedable이 Redis에 데이터를 저장한다.
이제 2번 유저가 락을 얻었다. 2번 유저는 캐시를 다시 확인할까? - 결과 : 아니다. 확인하지 않는다. 2번 유저는 이미 @Cacheable 로직을 통과해서 들어와서 락을 기다리고 있었기 때문이다. 결국 대기하던 9,999명이 차례대로 락을 얻고 DB를 9,999번 순차적으로 접근하게 된다. -> 병렬 처리가 직렬 처리로 바뀌어 시스템이 완전히 멈춘다.
Double-Checked Locking
이 문제를 분산 락으로 풀려면, 락을 획득한 직후에 캐시에 데이터가 이미 생겼는지 한 번 더 확인하는 로직이 코드 내부에 있어야 합니다.
하지만 어노테이션 두 개를 단순히 겹쳐 쓰는 것만으로는 이 패턴을 구현할 수 없다.
왜 안될까요?
순서를 지정하지 않았다면 일반적으로 동작하는 아키텍처
[Client 요청] -> 1. @Cacheable 프록시 -> 2. @DistibutedLock 프록시 -> 3. 실제 서비스 로직(베스트 셀러 조회) 메서드(DB 조회)
만약 Cache확인을 먼저한다면?(1. @DistibuteLock 프록시 → 2. @Cacheable 프록시)
@DistibuteLock 프록시를 통해 락을 얻고 나서 @Cacheable 프록시를 통해 Redis를 확인하면 DB에 접속을 하지 않게 된다.
평상시에 유저가 캐시된 데이터를 읽으러 올 때마다, 캐시를 읽기도 전에 무조건 분산 락부터 획득하려고 줄을 서야 하기 때문이다. Redis는 0.001초 만에 읽을 수 있는데, 락 대기열 때문에 응답 속도가 10초가 걸리게 된다. 캐시를 쓰는 의미가 완전하게 파괴된다.
Spring이 준비한 sync = true
sync = true는 어떻게 동작하나요?
- 이 옵션을 켜면 Spring 내부적으로 1만 명의 요청이 들어왔을 때, 단 1개의 스레드만 실제 메서드(DB 조회)를 실행하도록 내부적인 락(Local Lock)을 건다. 나머지 9,999개의 스레드는 대기하다가, Double-Checked Locking이 내부적으로 구현되어 있기 때문에 첫 번째 스레드가 캐시에 데이터를 채워 넣으면 DB를 찌르고 않고 캐시에서 데이터를 꺼내서 반환한다.
- sync = true를 통해 캐시 쇄도 해결
- 하지만 단일 서버에서만 적용이 가능하기 때문에, 다중 서버를 지향하는 서비스에서는 적합하지 않다고 판단.
만약 Cache 확인을 먼저한다면?(1. @DistributedLock 프록시 -> 2. @Cacheable 프록시)
@DistibutedLock 프록시를 통해 락을 얻고 나서 @Cacheable 프록시를 통해 Redis를 확인하면 DB에 접속을 하지 않게 된다.
평상시에 유저가 캐시된 데이터를 읽으러 올 때마다, 캐시를 읽기도 전에 무조건 분산 락부터 획득하려고 줄을 서야 하기 때문이다.
Redis는 0.001초 만에 읽을 수 있는데, 락 대기열 때문에 응답 속도가 10초가 걸리게 된다. 캐시를 쓰는 의미가 완전하게 파괴된다.
그래서 캐시가 비어 있다면, 단 1명의 스레드만 분산 락을 획득한다.
- 2차 검사(In Lock) : 락을 획득하고 들어왔더니, 간발의 차이로 앞선 스레드가 캐시를 채워놨을 수 있었다. 여기서 한 번 더 캐시를 확인한다.
- DB 쿼리 : 2차 검사에서도 비어있다면, 그때 진짜 DB를 찌르고 캐시에 담는다.
스프링의 트랜잭션과 AOP 프록시 특성(자기 호출 문제)을 피하기 위해, 이 로직을 2개의 클래스로 분리
@DistributedCachable 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedCacheable {
/**
* 캐시 공간 이름 (예: "weeklyBestSellers")
*/
String cacheName();
/**
* 캐시 키 (spEL 지원)
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간 - default 15s(15초)
* 락 획득을 위해 waitTime만큼 대기한다.
*/
long timeWait() default 15L;
/**
* 락 임대 시간 - default 3s(3초)
*. 락을 획득한 이후 설정한 leaseTime 이후 락이 해제된다.
*/
long leaseTime() default 1L;
}
- 락을 기다리는 시간을 15초로 설정한 이유
- 너무 짧다면(예: 1초): 1번 스레드가 DB에서 베스트셀러 데이터를 가져와 캐시에 넣기도 전에 전부(보통 수백 ms 소요), 밖에서 기다리던 99명이 못기다리고 전부 RuntimeException("락 획득 실패")을 던지며 에러를 뿜어냅니다. 즉, 정상적인 유저들에게 무더기로 장애 화면이 보이게 됩니다.
- 너무 길거나 무한대라면(-1 등) : 만약 DB 서버에서 일시적인 락이 걸려서 1번 스레드가 응답을 받지 못하고 멈춰버렸다고 가정할 경우, 99명의 스레드가 문 밖에서 영원히 기다립니다. 새로 접속하는 유저들도 계속 대기열에 쌓입니다. 결국 스프링 부트의 톰캣 스레드 풀(기본 200개)이 꽉 차버려서, 베스트셀러뿐만 아니라 '로그인', '쿠폰 발급'등 서버의 모든 API가 먹통이 되는 대참사가 발생합니다.
- 결국 15초는 앞선 스레드가 DB를 다녀올 때까지 충분하게 기다려주되, 만약 DB가 완전히 죽었더라도 내 서버의 스레드풀이 다 고갈되기 전에는 대기열을 끊어내겠다(Fail-Fast)는 일종의 서킷 브레이커 역할을 한다.
- 락 임대 시간을 1초로 설정한 이유
- 이것은 분산 시스템에서 서버가 갑자기 죽어버리는 상황(OOM, 배포로 인한 강제 종료 등)을 대비한 안전장치입니다.
- 만약 leaseTime이 없다면, 1번 스레드가 락을 잡고 DB를 조회하던 중, 해당 서버가 메모리 부족(OOM)으로 죽어버립니다. finally 블록의 lock.unlock()이 실행되지 못했다. 결과적으로 Redis에는 영원히 풀리지 않는 자물쇠가 남게 되고, 15초씩 기다리던 99명의 유저들은 영원히 베스트셀러를 보지 못하게 됩니다.
- 왜 하필 1초인가요? 단순한 읽기 트랜잭션인 주간 베스트셀러 DB 조회는 인덱스만 잘 타면 10ms ~ 100ms 안에 끝나는 아주 가벼운 작업입니다. 따라서 1초(100ms)는 DB를 조회하고 캐시에 넣기까지 엄청나게 넉넉한 시간입니다. 동시에, 서버가 죽더라도 딱 1초 뒤면 Redis가 알아서 락을 강제로 풀어주기 때문에 대기하던 2번 스레드가 재빨리 들어가서 시스템을 정상화할 수 있는 가장 빠른 복구 시간입니다.
DistributedLockAOP
사용자 한명씩 들어올 수 있도록 하는 문지기의 역할
준비 작업
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedCacheable distributedCacheable = method.getAnnotation(DistributedCacheable.class);
- joinPoint : 원래 실행되려던 타겟 메서드(BookService의 getBestSellersResponse)의 모든 정보를 담고 있는 객체입니다.
- 의미 : 지금 이 AOP를 부른 메서드가 누구지? 이 메서드 위에 달린 @DistributedCacheable 어노테이션을 가져와바. 옵션(캐시 이름, 대기 시간 등)을 읽는다.
동적 키 생성(SpEL 파싱)
String dynamicKey = CustomSpringELParser.getDynamicValue(
signature.getParameterNames(),
joinPoint.getArgs(),
distributedCacheable.key()
).toString();
String cacheName = distributedCacheable.cacheName();
Cache cache = cacheManager.getCache(cacheName);
- 의미 : 어노테이션에 #pageable.pageNumber라고 적어둔 문자열을, 실제 유저가 넘긴 파라미터 값(예: 0페이지)으로 치환해서 진짜 캐시 키(예: page: 0)로 변환하는 마법입니다.
- 그리고 cacheManager에게 "weeklyBestSellers"라는 이름의 서랍(Cache 공간)을 가져오라고 지시합니다.
락 없는 초고속 패스
// 🟢 [1차 검사] 락 없이 엄청 빠르게 캐시 찔러보기
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get(dynamicKey);
if (wrapper != null) {
return wrapper.get();
}
}
- 의미 : 성능 최적화의 핵심입니다. 100만 명의 트래픽이 몰려도, 이미 누군가 캐시를 채워놨다면 무거운 락(Lock)근처에도 가지 않고 여기서 즉시 데이터를 꺼내서 클라이언트에게 던져줍니다.
- ValueWrapper : DB에 데이터가 진짜 없어서 null을 캐싱해둔 것인지, 아니면 아예 캐시가 비어있는 상태인지를 구분해 주는 스프링의 안전 장치입니다.
분산 락 획득(Redisson)
String lockKey = REDISSON_LOCK_PREFIX + cacheName + ":" + dynamicKey;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean available = lock.tryLock(distributedCacheable.timeWait(), distributedCacheable.leaseTime(), distributedCacheable.timeUnit());
if (!available) {
throw new RuntimeException("캐시 갱신을 위한 락 획득에 실패하였습니다.");
}
- 의미 : 1차 검사에서 캐시가 비어있음을 확인한 100명의 스레드가 여기까지 내려옵니다. 그리고 tryLock을 통해 서로 자물쇠를 차지하려고 싸웁니다.
- 승리한 단 1명의 스레드만 avliable = true를 받고 아래로 통과합니다. 나머지 99명은 자물쇠가 풀릴 때까지(timeWait 동안) 여기서 숨죽이고 대기합니다.
[2차 검사]와 진짜 비즈니스 로직 실행(Double-Checked Locking)
// 🟡 [2차 검사]
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get(dynamicKey);
if (wrapper != null) {
return wrapper.get();
}
}
// 🔵 진짜 비즈니스 로직(DB 조회) 실행!
Object dbResult = joinPoint.proceed();
// 🟣 결과를 캐시에 저장
if (cache != null && dbResult != null) {
cache.put(dynamicKey, dbResult);
}
return dbResult;
- [2차 검사] : 락을 뚫고 들어온 스레드가 "혹시 내가 문 밖에서 기다리는 동안, 내 앞사람이 캐시를 채워놓고 가진 않았나?" 한 번 더 확인합니다. 뒷순서로 들어온 99명은 여기서 캐시를 발견하고 DB를 찌르지 않은 채 돌아갑니다.
- jointPoint.proceed() : BookService에 있는 진짜 DB 조회를 실행시키는 트리거입니다. 즉, 100명 중 최초 1명만 이 줄을 실행한다.
- cache.put(...) : DB에서 고생해서 가져온 데이터를 서랍(Redis)에 넣어둔다. 이제부터 들어오는 모든 요청은 1차 검사에서 다 걸러진다.
안전한 퇴장
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (IllegalMonitorStateException e) {
log.info("레디슨 락을 해제해주세요...");
}
}
- finally : DB 조회를 성공했든, 중간에 에러가 터졌든 무조건 자물쇠를 풀어줘야 다음 사람이 들어올 수 있으므로 finally 블록에 넣습니다.
- isHeldByCurrentThread() : 아주 디테일하고 중요한 방어 코드입니다. 이 자물쇠를 잠근 게 지금 나 인지 확인합니다. 만약 락 만료 시간이 지나서 시스템이 강제로 락을 풀고 다음 사람을 들여 보냈는데, 뒤늦게 작업이 끝난 내 스레드가 남의 자물쇠를 풀어버리는 대참사를 막기 위한 조건문입니다.