Spring Boot에서 비동기 스레드 풀을 분리하는 이유와 방법
실제로 터진 이야기#
LLM 기능을 서비스에 붙였을 때 일입니다. LLM API 응답이 보통 5~30초인데, 트래픽이 몰리니까 기본 Tomcat 스레드 풀이 LLM 요청으로 가득 찼습니다. 문제는 그 사이에 들어오는 일반 비즈니스 로직 — 조회, 저장 같은 평소엔 수십 ms면 끝나는 요청들이 같이 밀려버린 것입니다.
LLM은 부가 기능인데, 이게 핵심 기능까지 먹통으로 만들었습니다. 사용자 입장에서는 그냥 서비스 전체가 느려진 겁니다.
이때 깨달은 것은, 느린 작업과 빠른 작업은 같은 풀을 쓰면 안 된다는 겁니다.
풀 분리 구조#
핵심은 격리입니다. 느린 작업이 빠른 작업을 방해하지 않게, 부가 기능이 핵심 기능의 자원을 잡아먹지 않게.
1. 기본 Async 설정#
Spring Boot에서 @Async를 그냥 쓰면 SimpleAsyncTaskExecutor가 동작합니다. (Spring @Async) 요청마다 새 스레드를 만들고, 풀링도 하지 않습니다. 운영에서 쓰면 스레드가 끝없이 늘어납니다.
AsyncConfigurer를 구현해서 기본 Executor를 등록합니다:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("TaskExecutor-");
executor.setTaskDecorator(new ContextCopyingTaskDecorator());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
}
}2. 외부 API 전용 스레드 풀#
LLM 같은 외부 API는 응답이 5~60초까지 걸립니다. 이걸 기본 풀에 넣으면 큐가 차고, 일반 비동기 작업이 뒤에서 기다립니다.
별도 Bean으로 등록하고 @Async("이름")으로 지정합니다:
@Bean("externalApiExecutor")
public Executor externalApiExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(60);
executor.setAllowCoreThreadTimeOut(true);
executor.setThreadNamePrefix("ExtAPI-");
executor.setTaskDecorator(new ContextCopyingTaskDecorator());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
executor.getThreadPoolExecutor().prestartAllCoreThreads();
return executor;
}풀 사이즈 산정#
풀 사이즈를 대충 넣으면 나중에 고생하기 때문에 트래픽 기준으로 잡았습니다.
피크 시간대 동시 요청 수를 먼저 보고, 전체 max를 정한 다음에 용도별 비율로 나눴습니다. 외부 API가 전체 비동기 작업의 60% 정도를 차지하고 있어서:
- 전체 비동기 스레드 예산: ~80
- 외부 API 풀: 80 * 0.6 = ~50 (max)
- 기본 풀: 80 * 0.25 = ~20 (max)
- 로그 풀: 80 * 0.06 = ~5 (max)
core는 평상시 트래픽 기준으로 잡습니다. 피크가 아닌 시간대에 유지할 스레드 수인데, 보통 max의 20~30% 정도로 잡으면 무난합니다.
allowCoreThreadTimeOut(true)를 설정하면 트래픽 없는 시간대에는 코어 스레드도 정리되기 때문에 자원 낭비를 줄일 수 있습니다.
prestartAllCoreThreads#
prestartAllCoreThreads()는 앱 시작 시점에 코어 스레드를 미리 만들어 둡니다. 외부 API는 첫 요청부터 느린데, 여기에 스레드 생성 시간까지 더해지면 초기 응답이 꽤 느려집니다.
3. Provider Fallback#
외부 API를 하나만 쓰면 그게 죽었을 때 답이 없습니다. Claude API가 사용량 급증 이벤트 때 가끔 오류를 뱉은 적이 있었는데, 이럴 때 fallback provider로 넘어가게 해뒀습니다.
public String executeWithFallback(List<ApiProvider> providers, Request request) {
Map<ApiProvider, Exception> failures = new LinkedHashMap<>();
for (ApiProvider provider : providers) {
try {
String result = provider.execute(request);
return result;
} catch (Exception e) {
log.error("Provider {} failed: {}", provider.getName(), e.getMessage());
failures.put(provider, e);
}
}
throw new AllProvidersFailedException(failures);
}provider 목록을 우선순위 순으로 정렬해두고, 앞에서부터 시도해서 성공하면 바로 반환합니다. 다 실패하면 실패 맵과 함께 예외를 던집니다.
이 구조 덕분에 메인 provider가 불안정해도 사용자한테는 티가 나지 않았습니다. 모니터링에서 fallback 발동 횟수를 보면 provider 안정성 판단도 됩니다.
4. 컨텍스트 전파: TaskDecorator#
비동기 스레드에서 가장 자주 겪는 문제가 컨텍스트 유실입니다. @Async로 넘어가면 호출 스레드의 MDC(로깅 traceId), RequestContext(인증 정보), Locale이 사라집니다.
분산환경으로 전환한 초기에 로그 메트릭에서 비동기 작업의 traceId가 안 찍혀서 요청 추적이 끊긴 적이 있습니다. 비동기 메서드가 아직 적어서 큰 장애까진 아니었는데, 로그 추적이 끊기면 문제 터졌을 때 원인 잡기가 힘들어집니다.
TaskDecorator로 호출 스레드의 컨텍스트를 비동기 스레드에 복사합니다:
public class ContextCopyingTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
RequestAttributes requestAttributes = null;
try {
requestAttributes = RequestContextHolder.currentRequestAttributes();
} catch (IllegalStateException e) {
// 스케줄러 등 요청 컨텍스트 없는 경우
}
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
Locale locale = LocaleContextHolder.getLocale();
RequestAttributes finalRequestAttributes = requestAttributes;
return () -> {
try {
if (finalRequestAttributes != null) {
RequestContextHolder.setRequestAttributes(finalRequestAttributes);
}
if (mdcContext != null) {
MDC.setContextMap(mdcContext);
}
LocaleContextHolder.setLocale(locale);
runnable.run();
} finally {
MDC.clear();
LocaleContextHolder.resetLocaleContext();
RequestContextHolder.resetRequestAttributes();
}
};
}
}finally에서 정리를 안 하면 스레드 풀이 스레드를 재사용할 때 이전 요청의 컨텍스트가 남아서, 다음 작업에서 엉뚱한 사용자 정보가 보일 수 있습니다.
5. DB 커넥션 풀과의 관계#
스레드 풀을 늘리면 DB 커넥션도 같이 봐야 합니다. 비동기 작업에서 DB 쿼리를 날리면 HikariCP 커넥션을 하나 잡기 때문입니다.
기본 풀(5) + 외부 API 풀(10) + 로그 풀(2) = 17개 스레드가 동시에 DB에 접근할 수 있습니다. Tomcat 스레드까지 합치면 커넥션이 부족해질 수 있습니다.
실전에서 적용한 방법:
- 외부 API 풀에서는 DB 접근 최소화. API 호출 전에 필요한 데이터를 미리 가져오고, 결과만 저장.
- 로그 풀은 배치로 모아서 한번에 저장.
- HikariCP maximumPoolSize는 넉넉하게 설정하되, DB 서버의 max_connections도 확인.
Thread Starvation 장애 해결기에서 HikariCP 풀 사이즈를 조정한 경험이 있습니다.
6. @Async + 트랜잭션 분리#
비동기 메서드에서 DB 작업을 할 때 주의할 점이 있습니다. @Async와 @Transactional을 같이 쓰면 트랜잭션이 호출 스레드가 아닌 비동기 스레드에서 열립니다. 호출자의 트랜잭션과 완전히 분리되어야 하는 경우 Propagation.NOT_SUPPORTED를 명시해야 합니다.
@Async
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void analyzeAsync(Long targetId) {
// 호출자의 트랜잭션과 완전히 분리됨
// 여기서 실패해도 호출자의 트랜잭션에 영향 없음
List<DataDto> data = readService.findByTargetId(targetId);
try {
String result = externalApiClient.call(data);
createService.save(targetId, result);
} catch (Exception e) {
log.error("[ASYNC] failed. targetId={}", targetId, e);
createService.saveError(targetId, data);
}
}실패 시 에러 상태를 별도로 기록해두면 나중에 재시도하거나 관리자 화면에서 확인할 수 있습니다. 비동기 작업은 실패해도 호출자한테 바로 알려줄 수 없으니 이 처리가 중요합니다.
7. CompletableFuture 패턴#
동기 버전과 비동기 버전을 같이 만들어두면 상황에 따라 골라 쓸 수 있습니다.
// 동기 버전 — 결과를 바로 받아야 할 때
public ResultDto processSync(String input) {
try {
ResultDto result = externalApiClient.call(input);
return ResultDto.success(result.getValue());
} catch (Exception e) {
log.error("[SYNC] failed. input={}", input, e);
return ResultDto.fallback(input);
}
}
// 비동기 버전 — 결과를 나중에 받아도 될 때
public CompletableFuture<ResultDto> processAsync(String input) {
return externalApiClient.callAsync(input)
.thenApply(result -> ResultDto.success(result.getValue()))
.exceptionally(e -> {
log.error("[ASYNC] failed. input={}", input, e);
return ResultDto.fallback(input);
});
}thenApply로 결과를 변환하고, exceptionally로 에러를 잡습니다. 동기 버전의 try-catch 구조와 대응됩니다.
여러 비동기 작업을 동시에 실행하고 모두 완료될 때까지 기다려야 하면 CompletableFuture.allOf를 씁니다:
CompletableFuture<ResultDto> task1 = processAsync(input1);
CompletableFuture<ResultDto> task2 = processAsync(input2);
CompletableFuture.allOf(task1, task2)
.thenRun(() -> {
ResultDto r1 = task1.join();
ResultDto r2 = task2.join();
// 두 결과를 합쳐서 처리
});8. 분리 전후 변화#
스레드 풀을 분리한 이후 가장 체감된 변화는 비즈니스 API의 응답 딜레이가 줄어든 것입니다. 이전에는 외부 API 호출이 몰리면 일반 조회/저장 API까지 같이 느려졌는데, 분리 이후에는 외부 API 쪽이 바빠도 비즈니스 API는 영향을 받지 않았습니다.
모니터링에서 확인한 것:
- 기본 풀의 queue size가 이전 대비 크게 줄었습니다
- 외부 API 풀이 가끔 max까지 차지만, 기본 풀의 active 스레드에는 영향 없음
- 비즈니스 API의 p99 응답시간이 안정화됨
모니터링#
스레드 풀 분리하고 끝이 아닙니다. 각 풀의 상태를 실시간으로 봐야 합니다.
메트릭 수집 도구에서 스레드 풀 상태를 확인하고 있습니다. ThreadNamePrefix를 다르게 설정해놨기 때문에 ExtAPI-, TaskExecutor-, LogTask- 등으로 풀별 구분이 가능합니다.
봐야 할 것들:
- active 스레드 수: 실제로 작업 중인 스레드
- queue size: 큐에 대기 중인 작업 수. 이게 계속 차오르면 풀 사이즈를 키워야 합니다
- completed task count: 처리 완료된 작업 누적 수
- rejected count: 큐가 가득 차서 reject된 작업 수. 0이 아니면 문제
Bean 하나 더 등록하고 @Async에 이름 넣는 정도처럼 보여도, 이걸 안 해놓으면 외부 API 하나 때문에 서비스 전체가 밀리는 걸 실제로 겪게 됩니다.
직접 붙여보면서 같이 챙겨야 한다고 느낀 건 이 정도였습니다:
- 느린 작업은 반드시 별도 풀로 분리
- 풀 사이즈는 트래픽 비율 기준으로 산정하되, 운영하면서 조정
@Async+@Transactional조합 시 전파 설정 확인- TaskDecorator로 컨텍스트 전파해야 로그 추적이 끊기지 않습니다
- CompletableFuture로 동기/비동기 버전 같이 준비해두면 유연합니다
- 외부 API는 Provider Fallback으로 장애 전파 차단
- DB 커넥션 풀과의 균형도 같이 볼 것
- 배포 후에는 모니터링으로 각 풀의 active/queue 상태 확인