에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/프레임워크 & 언어

[Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파

반응형
[Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파

[Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파

@Async 한 줄이면 비동기가 되니 가볍게 쓰기 쉬운데, 이 애너테이션은 함정 밀도가 유난히 높아요. 기본 스레드풀은 운영에 부적합하고, 예외는 조용히 사라져요. 트랜잭션·인증·로그 추적은 끊겨요. 셋 다 "장애 났는데 아무 데도 안 보이는" 종류라 미리 알아야 해요.

@Async의 동작(역시 프록시예요)에서 출발해, 기본 풀의 무한 큐 함정, void 예외 증발, ThreadLocal 미전파(트랜잭션·SecurityContext·MDC)를 하나씩 헤집어볼게요. 앞 편 말미의 "AFTER_COMMIT + @Async" 조합을 쓰려면 이 글이 선행이에요.

01. @Async도 프록시예요

먼저 익숙한 이야기부터 — @Async도 프록시 기반이에요. 자기호출·private에서 조용히 동기로 실행돼요. "비동기인 줄 알았는데 응답이 느려요"의 1번 점검 항목이에요. @EnableAsync를 빼먹어도 마찬가지로 조용히 동기예요.

02. 기본 스레드풀, 큐가 무한이라 8개에서 멈춰요

스프링 부트 기본 Async 풀의 함정. 큐가 무제한이라 스레드 풀의 규칙상 큐부터 채워져 스레드가 코어 8개에서 멈추고, 작업이 큐에 무한정 쌓인다. 풀을 직접 정의하고 큐를 유한하게 둬야 한다

스프링 부트는 @Async용으로 ThreadPoolTaskExecutor를 자동 구성해주는데, 기본값이 core 8, 큐 무제한이에요. 여기에 스레드풀의 동작 규칙이 겹치면 함정이 완성돼요 — 풀은 core가 다 차면 큐부터 채우고 큐가 가득 차야 max까지 스레드를 늘려요. 큐가 무한이면? 영원히 안 차니 스레드는 8개 고정이에요.

결과적으로 작업이 몰리면 수만 개가 큐에서 대기해요. "비동기로 보냈는데 한참 뒤에 실행돼요"와 큐 메모리 증가가 그 증상이에요. 그래서 @Async를 운영에 쓰려면 풀 정의가 사실상 필수예요.

@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(32);
    executor.setQueueCapacity(500);            // 유한해야 max가 의미 있다
    executor.setThreadNamePrefix("notify-");   // 스레드 덤프에서 식별
    executor.setRejectedExecutionHandler(
        new ThreadPoolExecutor.CallerRunsPolicy());  // 넘치면 호출자가 실행 (백프레셔)
    executor.initialize();
    return executor;
}

@Async("notificationExecutor")   // 풀 이름 명시
public void sendPush(Long orderId) { ... }

주의할 게, 사용자 정의 Executor 빈이 하나라도 생기면 Boot의 기본 applicationTaskExecutor 자동구성이 물러나요. 한정자 없는 @Async까지 전부 이 풀로 흘러가니, 기본 풀을 유지하려면 spring.task.execution.*도 같이 챙겨요.

두 가지를 챙겨요. 첫째, 큐는 유한하게 + 넘칠 때의 정책을 명시해요. CallerRunsPolicy는 "넘치면 호출자 스레드가 직접 실행"이라 자연스러운 속도 조절(백프레셔)이 되고, 유실은 없어요. 다만 executor가 shutdown 중이면 조용히 버려질 수 있고, 호출자가 요청 스레드면 그 요청의 응답이 느려지는 비용이 있어요. 둘째, 용도별로 풀을 분리해요. 알림용과 정산용이 한 풀을 쓰면, 느린 정산 작업이 풀을 점거해 알림까지 굶어요. @Async("풀이름")으로 격리하는 게 운영의 기본이에요.

03. 예외 증발, void 비동기의 침묵

Async void 예외 증발. 동기 호출은 예외가 콜스택을 타고 올라오지만, 비동기는 호출자가 이미 떠나 예외 받을 사람이 없어 기본 로그만 남고 사라진다. CompletableFuture 반환이나 AsyncUncaughtExceptionHandler로 대응한다

동기 호출의 예외는 콜스택을 타고 호출자에게 가요. 그런데 @Async void 메서드의 예외는 받을 사람이 없어요 — 호출자는 이미 자기 갈 길을 갔으니까요. 스프링이 기본 로그를 남기긴 하지만 에러 응답도 알림도 없이 조용해요. "푸시가 안 나가는데 에러도 없어요"의 정체예요.

대응은 반환 타입에 따라 둘이에요.

  • 결과가 필요하면 CompletableFuture 반환 — 호출자가 exceptionally()·join()으로 예외를 받아 처리해요.
  • fire-and-forget이면 전역 핸들러 등록AsyncConfigurergetAsyncUncaughtExceptionHandler()로 공통 처리(에러 로그 + 알림 + 메트릭)를 박아요. 최소한 모니터링에는 보이게요.

04. 컨텍스트 미전파, 새 스레드는 빈손이에요

Async 컨텍스트 미전파. 요청 스레드의 트랜잭션, SecurityContext, MDC는 전부 ThreadLocal이라 풀 스레드에 없다. 필요한 값은 파라미터로 전달하고 MDC는 TaskDecorator로 복사하며 트랜잭션은 비동기 작업 자체의 것으로 설계한다

스프링의 많은 것들이 ThreadLocal에 살아요 — 트랜잭션, SecurityContext(인증 정보), MDC(로그 traceId). @Async로 넘어간 작업은 다른 스레드라 이게 전부 없어요.

  • 트랜잭션 — 호출자의 트랜잭션과 완전히 별개예요. 호출자가 롤백해도 비동기 작업은 이미 실행됐고, 같이 묶을 방법도 없어요. 비동기 작업은 자체 트랜잭션(@Transactional을 비동기 메서드에)으로 설계하고, "커밋 확정 후 실행"이 필요하면 AFTER_COMMIT과 조합해요.
  • SecurityContext — 비동기 안에서 인증 정보가 null이에요. 사용자 ID가 필요하면 파라미터로 넘기는 게 명시적이고 안전해요. 자동 전파가 꼭 필요하면 DelegatingSecurityContextAsyncTaskExecutor로 풀을 감싸요.
  • MDC — traceId가 끊겨 비동기 로그가 추적에서 빠져요. TaskDecorator로 제출 시점의 MDC를 복사해주는 게 정석이에요.
executor.setTaskDecorator(runnable -> {
    Map<String, String> mdc = MDC.getCopyOfContextMap();   // 제출 시점 복사
    return () -> {
        if (mdc != null) MDC.setContextMap(mdc);
        try { runnable.run(); } finally { MDC.clear(); }
    };
});

원칙은 하나예요 — 암묵적 컨텍스트에 기대지 말고 필요한 값은 파라미터로. 자동 전파 장치들은 그게 번거로울 때의 보조예요.

05. 자주 만나는 문제

@Async가 동기로 실행돼요

자기호출이거나 @EnableAsync 누락이에요. 스레드 이름 로그로 확인하면 1분이면 갈려요(Thread.currentThread().getName()).

비동기 작업이 한참 뒤에 실행돼요

기본 풀의 무한 큐 적체예요. 풀을 정의하고 큐를 유한하게, 풀 지표(active·queue size)를 모니터링에 올려요.

비동기 안에서 LazyInitializationException이 나요

호출자의 영속성 컨텍스트가 새 스레드에 없어서예요(지연 로딩의 그 예외). 엔티티를 넘기지 말고 ID나 DTO를 넘긴 뒤, 비동기 쪽에서 자체 트랜잭션으로 다시 조회해요.

비동기 로그가 traceId 없이 찍혀요

MDC 미전파예요. TaskDecorator를 등록해요.

정리

@Async를 운영에 쓰려면 세 가지가 세트로 따라와야 해요. 이 셋 없이 붙인 @Async는 "평소엔 되는데 장애 때 아무것도 안 보이는" 코드가 돼요.

  • ① 풀 직접 정의 — 유한 큐 + 거부 정책 + 용도별 분리. 기본 풀의 무한 큐를 피해요.
  • ② 예외 처리 명시 — CompletableFuture 반환 또는 전역 핸들러. void 예외가 증발하지 않게요.
  • ③ 컨텍스트는 파라미터로 — 트랜잭션은 자체로, MDC는 TaskDecorator로. ThreadLocal은 스레드를 못 넘어요.

다음은 의존성 주입의 발밑이에요 — 순환 참조와 빈 초기화 함정을 이어지는 글에서 다뤄요.

출처: Spring Framework — @Async · Spring Boot — Task Execution

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."