Thundering Herd Problem을 마주쳤다

Hayoon·2024년 5월 6일
0

Thundering herd problem은 많은 클라이언트가 동시에 같은 리소스에 접근을 시도할 때 발생하는 문제이다.

프로젝트를 진행하면서 마주했던 문제에 대해서 얘기하고 어떻게 해결했는지 말하고자 한다.

문제 발생

  1. 클라이언트는 Redis에서 캐싱데이터를 통해 메뉴를 조회한다. (TTL은 24시간으로 설정하였다.)
  2. 우연히 동시에 많은 사용자들이 캐시가 비워져있을 때, 메뉴를 조회를 했다.
  3. Cache Miss가 발생을 했고 동시에 RDB에 메뉴 조회를 요청한다.
  4. 동시에 데이터베이스에 높은 부하를 발생시키게 된다.

선택한 해결방안

캐시락을 사용하기로 했다.

동시에 메뉴를 조회하는 여러 요청 중 최초의 요청만이 데이터베이스에 접근하고 결과를 캐시에 저장한다. 이후의 요청들은 캐시에서 바로 데이터를 읽어오게 되므로, 데이터베이스에 대한 부하를 크게 줄일 수 있다. 또한, 분산 락을 사용함으로써 여러 서버 환경에서도 동일한 방식으로 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;
}
  1. 클라이언트가 메뉴 조회 요청을 한다.
  2. 먼저 Redis 캐시에서 메뉴 데이터를 조회한다. 이때 cacheManager.getCache("Menu")를 통해 캐시를 확인한다.
  3. 캐시에서 해당 메뉴 데이터를 찾지 못했다면(Cache Miss), Redisson의 RLock을 사용하여 분산 락을 활용한다. 여기서 getLock(menuTitle)은 메뉴 타이틀을 기반으로 락을 생성한다.
  4. tryLock(10, 1, TimeUnit.SECONDS)를 사용하여 락을 시도한다. 이 메소드는 최대 10초 동안 락을 시도하고, 락을 획득하면 1초 동안 유지한다.
    만약 락을 획득하지 못하면, 즉시 false를 반환하고 메뉴 접근 실패 메시지를 출력 후 null을 반환한다.
  5. 락을 성공적으로 획득했다면, 데이터베이스에서 메뉴를 조회한다.
  6. 조회한 메뉴 데이터를 캐시에 저장한다. Objects.requireNonNull(cacheManager.getCache("Menu")).put(menuTitle, cachedMenu)
  7. 현재 스레드가 락을 보유하고 있다면 락을 해제한다. lock.unlock()

궁금증

Q. 락이 해제되면 나머지 락 대기 중이었던 스레드들은 어떻게 자동으로 레디스로 캐싱 데이터를 찾으러 가는거지?

  1. 락 획득 시도: 스레드가 락을 획득하기 위해 시도한다. 만약 락이 이미 다른 스레드에 의해 보유 중이라면, 해당 스레드는 락이 해제될 때까지 대기 상태에 들어간다.

  2. Pub/Sub 메커니즘: 락을 보유하고 있는 스레드가 작업을 완료하고 락을 해제하면, Redis를 통해 락 해제 이벤트가 발행된다. 이 이벤트는 다른 대기 중인 스레드들에게 전달되며, 이를 통해 다른 스레드들은 락을 다시 획득하려 시도할 수 있다.

  3. 락 재시도: 락 해제 이벤트를 받은 스레드들은 다시 락을 획득하기 위해 시도한다. 이때, 락을 획득하기 전에 캐시에서 데이터를 조회하는 과정을 거친다. 락을 획득하는 동안 다른 스레드가 이미 데이터를 캐시에 저장했을 가능성이 있다.

cachedMenu = Objects.requireNonNull(cacheManager.getCache("Menu")).get(menuTitle, MenuDto.Response.class);
  1. 캐시 재조회: 락을 획득하기 위해 대기 중이던 스레드들은 락 획득 전에 위 코드를 통해 캐시에서 메뉴 데이터를 재조회한다. 첫 번째 스레드가 캐시에 데이터를 저장했기 때문에, 이 시점에는 캐시에서 메뉴 데이터를 찾을 수 있다.

  2. 데이터 반환: 캐시에서 메뉴 데이터를 찾은 스레드는 데이터를 반환하고 작업을 마친다. 만약 락을 획득하게 된 스레드가 캐시에서도 데이터를 찾지 못하는 상황이라면, 이 스레드는 데이터베이스에서 데이터를 조회하고 캐시에 저장하는 과정을 진행한다. 첫 번째 스레드가 캐시에 데이터를 저장하므로, 이후 스레드들은 캐시에서 데이터를 바로 찾을 수 있다.

다른 해결방안?

여러가지가 있지만 내가 생각했을 때 아래의 방법이 캐시락의 대체방안으로 적합하다고 판단했다.

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.");
    }
}

특정 이벤트가 발생하기 전이나 트래픽이 급증하기 예상되는 시점에, 관련 데이터를 캐시에 미리 로드해 두어 시스템이 더 빠르고 효율적으로 응답할 수 있도록 준비하여 시스템의 부하를 분산시킬 수 있다.

앞으로도 사용자 경험을 최적화하는 데 고민을 해보자.

profile
Junior Developer

1개의 댓글

comment-user-thumbnail
2024년 8월 28일

궁금한게 있어서 질문드립니다.
if (!available) {
System.out.println("메뉴 접근 LOCK 획득 실패");
return null;
}
위의 코드부분에서 '만약 락을 획득하지 못하면, 즉시 false를 반환하고 메뉴 접근 실패 메시지를 출력 후 null을 반환한다.' 라고 말씀해주셨는데,
'Q. 락이 해제되면 나머지 락 대기 중이었던 스레드들은 어떻게 자동으로 레디스로 캐싱 데이터를 찾으러 가는거지?' 부분에서는 락 대기중이라고 표현하셨기에 락을 흭득하지 못한 스레드들이 null 반환 후 종료되는건지, 아니면 락을 잡기위해 대기중인건지 궁금합니다.

답글 달기

관련 채용 정보