레시피 조회 부하 테스트 중 발생한 RejectedExecutionException 문제 해결

오형상·2025년 7월 17일
0

TodayTable

목록 보기
12/12
post-thumbnail

문제 상황

부하 테스트 중 아래와 같은 에러가 발생했습니다.

Executor [java.util.concurrent.ThreadPoolExecutor@6a12629[Running, pool size = 20, active threads = 20, queued tasks = 800, completed tasks = 11]] did not accept task: org.springframework.aop.interceptor.AsyncExecutionInterceptor$$Lambda$3350/0x0000000801c1d000@317bc3a1

이 에러는 Spring의 @Async 기능이 내부적으로 사용하는 ThreadPoolTaskExecutor의 스레드 수 또는 큐 용량을 초과했을 때 발생합니다.

결과적으로, 조회수 증가를 위한 비동기 재시도 및 실패 시 DB 기록 로직이 실행되지 못했습니다. 해당 예외는 @ControllerAdvice@ExceptionHandlerRuntimeException 핸들러에 걸려 사용자에게 실패 응답이 전달되었습니다.

또한 초기 기획 의도에 따르면,

조회수 증가 등 부가적인 비동기 작업이 실패해도, 사용자는 성공적인 레시피 조회 결과를 받아야 한다.

하지만, 이번 부하 테스트에서 비동기 작업 실패가 예외로 처리되면서 조회 결과도 실패로 응답되어 초기 의도와 어긋났습니다.


문제 원인

  • ThreadPoolTaskExecutor풀 크기(20)큐 용량(최대 800 대기)이 포화 상태에 이르러 더 이상 작업을 수용하지 못했습니다.
  • 이 경우 기본 거부 정책인 AbortPolicy에 따라 작업이 거부되고 RejectedExecutionException이 발생합니다.
  • 비동기 작업이 거부되면서, 재시도 및 실패 처리 로직 자체가 실행되지 못하고 예외가 상위로 전파되었습니다.
  • 결과적으로, 비동기 작업 실패가 메인 스레드의 정상 응답 처리에 영향을 미쳤습니다.

해결 방안

1. 거부 정책 변경 검토

Spring의 ThreadPoolTaskExecutor는 내부적으로 java.util.concurrent.ThreadPoolExecutor를 사용하며, 작업이 거부될 경우 다음과 같은 거부 정책(RejectedExecutionHandler) 중 하나를 따르게 됩니다.

주요 거부 정책 종류
정책 이름설명
AbortPolicy (기본값)작업이 거부되면 RejectedExecutionException을 던지고 즉시 실패 처리
CallerRunsPolicy현재 요청한 호출 스레드가 직접 작업을 처리함. 큐가 찬 경우에도 작업이 유실되지 않음
DiscardPolicy작업을 조용히 버림. 예외도 발생시키지 않음
DiscardOldestPolicy큐에서 가장 오래된 작업을 버리고 새 작업을 큐에 삽입함
  • CallerRunsPolicy:
    비동기 작업이 거부되었을 때 호출 스레드가 직접 해당 작업(예: 조회수 증가)을 처리하게 됩니다.
    이는 레시피 조회 응답을 담당하는 메인 스레드의 응답 시간을 지연시킬 수 있기 때문에 사용하지 않기로 했습니다.

  • DiscardPolicy, DiscardOldestPolicy:
    두 정책 모두 작업 유실 가능성이 있으며, 실패 여부도 확인되지 않기 때문에 안정성이 부족하다고 판단해 사용하지 않았습니다.

따라서 기본 정책을 유지하고 다른 해결책을 찾기로 했습니다.

2. Try-Catch를 통한 예외 처리

비동기 작업 실행 시 ThreadPoolTaskExecutor가 작업을 거부하면서 RejectedExecutionException이 발생하는데, 이 예외는 실제 비동기 작업 내에서가 아니라 호출 시점에 발생합니다. 따라서 비동기 메서드 호출 부분을 try-catch로 감싸서 예외를 잡고, 메인 흐름에는 영향이 없도록 처리해야 합니다.

public void increaseRecipeViewCount(UUID recipeUuid) {
    Long recipeMetaId = recipeRepository.findRecipeMetaIdByRecipeUuid(recipeUuid);
    try {
        recipeMetaService.asyncIncreaseViewCnt(recipeMetaId);
    } catch (RejectedExecutionException e) {
        saveAsyncFailureLog(e, recipeMetaId, RECIPE_VIEW_COUNT_INCREMENT);
    }
}

결론

이번 문제를 통해 단순히 @Async와 @Retryable만으로는 모든 예외 상황을 안전하게 처리할 수 없다는 사실을 확인했습니다.
특히, 비동기 작업 자체가 스레드풀 포화로 실행조차 되지 못하는 경우, 예외는 비동기 메서드 내부가 아닌 호출 시점에서 발생하며,
이로 인해 메인 흐름(예: 레시피 조회)까지 영향을 받을 수 있었습니다.

기획 의도대로 사용자에게는 항상 레시피 조회 결과가 반환되도록 하기 위해,
비동기 호출부에서 RejectedExecutionException을 catch하고 실패 로깅만 처리하는 전략을 선택했습니다.

0개의 댓글