부하 테스트 중 아래와 같은 에러가 발생했습니다.
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
와 @ExceptionHandler
의 RuntimeException
핸들러에 걸려 사용자에게 실패 응답이 전달되었습니다.
또한 초기 기획 의도에 따르면,
조회수 증가 등 부가적인 비동기 작업이 실패해도, 사용자는 성공적인 레시피 조회 결과를 받아야 한다.
하지만, 이번 부하 테스트에서 비동기 작업 실패가 예외로 처리되면서 조회 결과도 실패로 응답되어 초기 의도와 어긋났습니다.
ThreadPoolTaskExecutor
의 풀 크기(20)와 큐 용량(최대 800 대기)이 포화 상태에 이르러 더 이상 작업을 수용하지 못했습니다.AbortPolicy
에 따라 작업이 거부되고 RejectedExecutionException
이 발생합니다.Spring의 ThreadPoolTaskExecutor
는 내부적으로 java.util.concurrent.ThreadPoolExecutor
를 사용하며, 작업이 거부될 경우 다음과 같은 거부 정책(RejectedExecutionHandler) 중 하나를 따르게 됩니다.
정책 이름 | 설명 |
---|---|
AbortPolicy (기본값) | 작업이 거부되면 RejectedExecutionException 을 던지고 즉시 실패 처리 |
CallerRunsPolicy | 현재 요청한 호출 스레드가 직접 작업을 처리함. 큐가 찬 경우에도 작업이 유실되지 않음 |
DiscardPolicy | 작업을 조용히 버림. 예외도 발생시키지 않음 |
DiscardOldestPolicy | 큐에서 가장 오래된 작업을 버리고 새 작업을 큐에 삽입함 |
CallerRunsPolicy
:
비동기 작업이 거부되었을 때 호출 스레드가 직접 해당 작업(예: 조회수 증가)을 처리하게 됩니다.
이는 레시피 조회 응답을 담당하는 메인 스레드의 응답 시간을 지연시킬 수 있기 때문에 사용하지 않기로 했습니다.
DiscardPolicy
, DiscardOldestPolicy
:
두 정책 모두 작업 유실 가능성이 있으며, 실패 여부도 확인되지 않기 때문에 안정성이 부족하다고 판단해 사용하지 않았습니다.
따라서 기본 정책을 유지하고 다른 해결책을 찾기로 했습니다.
비동기 작업 실행 시 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하고 실패 로깅만 처리하는 전략을 선택했습니다.