Thundering herd problem은 많은 클라이언트가 동시에 같은 리소스에 접근을 시도할 때 발생하는 문제이다.
프로젝트를 진행하면서 마주했던 문제에 대해서 얘기하고 어떻게 해결했는지 말하고자 한다.
캐시락을 사용하기로 했다.
동시에 메뉴를 조회하는 여러 요청 중 최초의 요청만이 데이터베이스에 접근하고 결과를 캐시에 저장한다. 이후의 요청들은 캐시에서 바로 데이터를 읽어오게 되므로, 데이터베이스에 대한 부하를 크게 줄일 수 있다. 또한, 분산 락을 사용함으로써 여러 서버 환경에서도 동일한 방식으로 Thundering Herd 문제를 방지할 수 있다.
public MenuDto.Response findMenuFromCache(String menuTitle) {
MenuDto.Response cachedMenu = null;
RLock lock = redissonClient.getLock(menuTitle);
// 먼저 캐시에서 메뉴를 조회
cachedMenu = Objects.requireNonNull(cacheManager.getCache("Menu")).get(menuTitle, MenuDto.Response.class);
// 캐시에 메뉴가 없을 경우에만 RDB에서 메뉴를 조회하고 캐시에 저장
if (cachedMenu == null) {
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("메뉴 접근 LOCK 획득 실패");
return null;
}
Menu findMenu = menuRepository.findByTitle(menuTitle);
cachedMenu = MenuDto.Response.fromMenu(findMenu);
Objects.requireNonNull(cacheManager.getCache("Menu")).put(menuTitle, cachedMenu);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
return cachedMenu;
}
- 클라이언트가 메뉴 조회 요청을 한다.
- 먼저 Redis 캐시에서 메뉴 데이터를 조회한다. 이때
cacheManager.getCache("Menu")
를 통해 캐시를 확인한다.- 캐시에서 해당 메뉴 데이터를 찾지 못했다면(Cache Miss), Redisson의 RLock을 사용하여 분산 락을 활용한다. 여기서 getLock(menuTitle)은 메뉴 타이틀을 기반으로 락을 생성한다.
tryLock(10, 1, TimeUnit.SECONDS)
를 사용하여 락을 시도한다. 이 메소드는 최대 10초 동안 락을 시도하고, 락을 획득하면 1초 동안 유지한다.
만약 락을 획득하지 못하면, 즉시 false를 반환하고 메뉴 접근 실패 메시지를 출력 후 null을 반환한다.- 락을 성공적으로 획득했다면, 데이터베이스에서 메뉴를 조회한다.
- 조회한 메뉴 데이터를 캐시에 저장한다.
Objects.requireNonNull(cacheManager.getCache("Menu")).put(menuTitle, cachedMenu)
- 현재 스레드가 락을 보유하고 있다면 락을 해제한다.
lock.unlock()
Q. 락이 해제되면 나머지 락 대기 중이었던 스레드들은 어떻게 자동으로 레디스로 캐싱 데이터를 찾으러 가는거지?
락 획득 시도: 스레드가 락을 획득하기 위해 시도한다. 만약 락이 이미 다른 스레드에 의해 보유 중이라면, 해당 스레드는 락이 해제될 때까지 대기 상태에 들어간다.
Pub/Sub 메커니즘: 락을 보유하고 있는 스레드가 작업을 완료하고 락을 해제하면, Redis를 통해 락 해제 이벤트가 발행된다. 이 이벤트는 다른 대기 중인 스레드들에게 전달되며, 이를 통해 다른 스레드들은 락을 다시 획득하려 시도할 수 있다.
락 재시도: 락 해제 이벤트를 받은 스레드들은 다시 락을 획득하기 위해 시도한다. 이때, 락을 획득하기 전에 캐시에서 데이터를 조회하는 과정을 거친다. 락을 획득하는 동안 다른 스레드가 이미 데이터를 캐시에 저장했을 가능성이 있다.
cachedMenu = Objects.requireNonNull(cacheManager.getCache("Menu")).get(menuTitle, MenuDto.Response.class);
캐시 재조회: 락을 획득하기 위해 대기 중이던 스레드들은 락 획득 전에 위 코드를 통해 캐시에서 메뉴 데이터를 재조회한다. 첫 번째 스레드가 캐시에 데이터를 저장했기 때문에, 이 시점에는 캐시에서 메뉴 데이터를 찾을 수 있다.
데이터 반환: 캐시에서 메뉴 데이터를 찾은 스레드는 데이터를 반환하고 작업을 마친다. 만약 락을 획득하게 된 스레드가 캐시에서도 데이터를 찾지 못하는 상황이라면, 이 스레드는 데이터베이스에서 데이터를 조회하고 캐시에 저장하는 과정을 진행한다. 첫 번째 스레드가 캐시에 데이터를 저장하므로, 이후 스레드들은 캐시에서 데이터를 바로 찾을 수 있다.
여러가지가 있지만 내가 생각했을 때 아래의 방법이 캐시락의 대체방안으로 적합하다고 판단했다.
Preloading and warming up the cache (캐시 사전 로딩 및 예열): 예상되는 트래픽 증가나 특정 이벤트 전에 미리 캐시를 로딩하고 업데이트함으로써, 실제 요청이 들어왔을 때 캐시 미스를 최소화한다.
특정 캐시의 TTL(Time-To-Live)이 24시간일 경우, 캐시 만료 30분 전에 미리 캐시를 다시 로딩함으로써 캐시 미스를 최소화하고, 사용자에게 더 빠른 응답 시간을 제공할 수 있다.
@Component
public class CachePreloader {
private final CacheManager cacheManager;
private final MenuService menuService;
public CachePreloader(CacheManager cacheManager, MenuService menuService) {
this.cacheManager = cacheManager;
this.menuService = menuService;
}
// 캐시를 사전 로딩하는 메소드
@Scheduled(fixedRate = 86400000 - 1800000) // 24시간 - 30분 = 23시간 30분마다 실행
public void preloadMenuCache() {
// 메뉴 데이터를 가져오는 로직
List<MenuDto.Response> menus = menuService.getAllMenus();
// 캐시 이름과 키 값을 기준으로 캐시 업데이트
Cache menuCache = cacheManager.getCache("Menu");
for (MenuDto.Response menu : menus) {
menuCache.put(menu.getTitle(), menu);
}
System.out.println("Cache preloaded successfully.");
}
}
특정 이벤트가 발생하기 전이나 트래픽이 급증하기 예상되는 시점에, 관련 데이터를 캐시에 미리 로드해 두어 시스템이 더 빠르고 효율적으로 응답할 수 있도록 준비하여 시스템의 부하를 분산시킬 수 있다.
앞으로도 사용자 경험을 최적화하는 데 고민을 해보자.
궁금한게 있어서 질문드립니다.
if (!available) {
System.out.println("메뉴 접근 LOCK 획득 실패");
return null;
}
위의 코드부분에서 '만약 락을 획득하지 못하면, 즉시 false를 반환하고 메뉴 접근 실패 메시지를 출력 후 null을 반환한다.' 라고 말씀해주셨는데,
'Q. 락이 해제되면 나머지 락 대기 중이었던 스레드들은 어떻게 자동으로 레디스로 캐싱 데이터를 찾으러 가는거지?' 부분에서는 락 대기중이라고 표현하셨기에 락을 흭득하지 못한 스레드들이 null 반환 후 종료되는건지, 아니면 락을 잡기위해 대기중인건지 궁금합니다.