CS/Spring

ApplicationEvent와 @Async: 컨테이너 안의 비동기

dding-shark 2026. 4. 13. 10:24
728x90

ApplicationEvent와 @Async: 컨테이너 안의 비동기


들어가며

서비스 A가 주문을 저장한 뒤 B에게 알림을, C에게 재고 차감을, D에게 정산 로그를 넘겨야 한다고 해보자. 가장 쉬운 코드는 A가 B, C, D를 전부 직접 주입받아 순서대로 호출하는 모양이다. 그런데 이 순간 A는 단순히 "주문을 저장하는 책임"을 넘어, B·C·D의 트랜잭션 경계, 예외 전파 규칙, 실행 순서와 타이밍, 재시도 정책까지 전부 떠안게 된다. A가 풀어야 할 문제가 아니었는데 A의 코드에 전부 들어와 있는 것이다.

스프링은 이 의존 방향을 끊기 위한 두 개의 장치를 내장한다. 하나는 ApplicationEvent다. 호출 대신 발행/구독으로 A가 "주문이 저장됐다"는 사실만 방송하고 B·C·D는 각자 구독한다. 다른 하나는 @Async + TaskExecutor다. 호출자 스레드를 잡아두지 않고 풀에 작업을 떠넘긴다. 두 장치는 자주 같이 쓰이고, 같이 쓰일 때 가장 많이 망가진다. 특히 다음 다섯 개는 실무에서 거의 매주 밟는다.

  • @TransactionalEventListener를 붙였는데 리스너가 실행되지 않는다 — 트랜잭션 없는 경로에서 발행하면 조용히 drop된다
  • @Async 메서드가 예외를 던졌는데 로그가 어디에도 없다void 반환은 예외가 증발한다
  • ThreadPoolTaskExecutormaxPoolSize를 올렸는데 스레드가 그 숫자까지 안 늘어난다queueCapacity 기본값이 범인이다
  • 같은 클래스 안에서 @EventListener 메서드를 직접 호출했더니 동기로만 실행된다 — 프록시 우회
  • 부트 기동 초반에 발행된 이벤트가 @EventListener에 잡히지 않는다 — 컨텍스트 생성 전에는 리스너가 없다

이 글은 왜 → 발행 → 수신 → 트랜잭션 계약 → @Async 모델 → 풀 설정 → 실무 순으로, Spring Framework 6.x / Spring Boot 3.x / Java 17+ 기준으로 정리한다.


목차


1) ApplicationEvent 모델: 빈 사이의 느슨한 결합

스프링의 ApplicationContext는 사실 ApplicationEventPublisher를 구현한다. 즉 컨테이너 자체가 이벤트 버스다. 별도의 메시지 브로커도, 큐도 없다. 이벤트는 단지 "호출 대신 발행/구독으로 의존 방향을 끊기 위한 도구"다. 이 한 줄이 10편 전체를 관통한다. 이벤트는 비동기의 동의어도, 메시지 큐의 축약도 아니다. 기본적으로는 같은 JVM, 같은 스레드, 같은 트랜잭션 안에서 돌아가는 인프로세스 디스패처다.

Spring 4.2부터는 이벤트 클래스가 ApplicationEvent를 상속할 필요도 없다. 임의의 POJO를 발행하면 프레임워크가 내부적으로 PayloadApplicationEvent<T>로 자동 래핑한다. 덕분에 이벤트 타입은 도메인 언어 그대로 쓸 수 있다. OrderPlaced, PaymentApproved, UserDeactivated — 이런 이름이면 된다.

1-1) 직접 호출 vs 이벤트 발행

항목 직접 호출 이벤트 발행
의존 방향 A → B, C, D (A가 전부 안다) A → 이벤트 타입 (A는 B, C, D 모른다)
트랜잭션 경계 한 트랜잭션에 묶이기 쉬움 phase 선택 가능 (BEFORE/AFTER COMMIT)
테스트 B, C, D 전부 mock 필요 발행 여부만 검증
실패 전파 B가 터지면 A가 본다 리스너별로 격리 가능
추가 리스너 A 코드 수정 새 리스너 빈 하나 추가

호출자의 트랜잭션이 리스너 전체를 삼켜도 괜찮은 경우는 드물다. "주문 저장은 성공했는데 알림 전송이 실패해서 주문이 롤백됐다" 같은 상황은 대부분 이벤트로 분리해서 잘라야 할 선이다. 이 선을 어디에 그을지의 설계 언어가 바로 이벤트 모델이다.


2) 이벤트 발행: publishEvent의 기본 계약

발행 API는 단순하다. ApplicationEventPublisher를 주입받아 publishEvent(Object) 한 번 호출하면 끝이다. 스프링 부트에서는 거의 모든 서비스 빈이 이 인터페이스를 추가 주입만으로 쓸 수 있다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher publisher;

    @Transactional
    public Order place(OrderCommand cmd) {
        Order order = orderRepository.save(Order.from(cmd));
        // POJO를 그대로 발행. ApplicationEvent 상속 불필요.
        publisher.publishEvent(new OrderPlaced(order.getId(), order.getTotal()));
        return order;
    }
}

여기서 가장 먼저 잡아야 할 사실은 publishEvent는 기본적으로 동기라는 점이다. 이 "동기"는 세 가지를 동시에 의미한다. 첫째, 호출 스레드가 블로킹된다. 리스너가 전부 끝나야 publishEvent가 반환한다. 둘째, 리스너가 던진 예외는 호출자에게 전파된다. try/catch 없이는 발행 지점까지 스택이 올라온다. 셋째, 같은 트랜잭션에 참여한다. 리스너가 @Transactional(REQUIRED)이면 새 트랜잭션이 아니라 발행자의 트랜잭션에 합류한다.

이 세 가지는 장점이자 함정이다. 장점은 "리스너 실패 시 발행자도 같이 롤백"이라는 일관성을 얻을 수 있다는 것이고, 함정은 "알림 하나 보내려다 전체 주문이 롤백"되는 상황을 쉽게 만들 수 있다는 것이다. 경계는 §5 @TransactionalEventListener에서 phase로 분리한다.


3) 이벤트 수신: @EventListener

리스너는 한 줄짜리 선언이다. 파라미터 타입이 곧 관심 이벤트 타입이다. 스프링이 발행 시점에 리플렉션으로 모든 리스너의 파라미터 타입과 이벤트 타입을 매칭해 디스패치한다.

@Component
@RequiredArgsConstructor
public class OrderNotificationListener {

    private final NotificationClient notificationClient;

    @EventListener
    public void onOrderPlaced(OrderPlaced event) {
        notificationClient.send(event.orderId(), "주문이 접수됐습니다");
    }

    // 여러 타입을 한 메서드로 받을 수도 있다
    @EventListener({OrderPlaced.class, OrderCancelled.class})
    public void onAnyOrderChange(Object event) {
        // payload 이벤트도 이 시그니처로 받을 수 있다
    }
}

실행 순서를 강제해야 하면 @Order를 붙인다. @Order(1)이 가장 먼저, 숫자가 클수록 나중이다. 더 재미있는 기능은 반환값 체이닝이다. 리스너가 다른 이벤트를 반환하면 스프링이 그 객체를 다시 publishEvent에 태운다. Collection이나 Stream을 반환하면 각 원소를 개별 이벤트로 재발행한다. "한 이벤트를 받아 여러 후속 이벤트로 쪼개는" 패턴이 한 줄로 된다.

3-1) 같은 클래스 안에서 호출하면 어떻게 되는가

@EventListener는 스프링이 빈 생성 시점에 후처리해 디스패처에 등록한다. 리스너 자체가 프록시로 감싸지는 게 아니라, 컨테이너가 "이 빈의 이 메서드는 이 이벤트 타입에 반응한다"는 매핑을 들고 있을 뿐이다. 따라서 같은 클래스 안에서 그 메서드를 this.onOrderPlaced(...)로 직접 호출해봐야 그냥 평범한 메서드 호출일 뿐, 이벤트 디스패치가 아니다. 7편의 self-invocation 이야기와 원리는 동일하다. 프록시(혹은 프레임워크 훅)를 거치지 않는 호출은 프레임워크 기능 전부를 우회한다. 리스너를 쓰고 싶으면 반드시 publisher.publishEvent(...)로 보내야 한다.


4) 조건부 이벤트: SpEL condition

리스너가 같은 타입의 이벤트 중 일부에만 반응해야 할 때가 있다. 가장 쉬운 방법은 메서드 안에서 if 한 줄 넣는 것이고, 가장 깔끔한 방법은 @EventListenercondition 속성에 SpEL을 쓰는 것이다.

@EventListener(condition = "#event.amount > 1000")
public void onBigOrder(OrderPlaced event) {
    fraudCheck.enqueue(event.orderId());
}

SpEL 컨텍스트에는 이벤트 객체가 #root.event 혹은 #event 변수로 노출된다. 조건이 false리스너 메서드 자체가 호출되지 않는다. if와의 차이는 이것이다.

항목 if 분기 condition 게이트
메서드 진입 항상 조건 true일 때만
트랜잭션 열림 메서드의 @Transactional 기준 항상 열림 조건 불충족 시 아예 미진입 → 트랜잭션 미개설
로깅/메트릭 진입 후 찍힘 조건 불충족이면 흔적 없음
테스트 메서드 단위 condition 표현식 단위

"조건 불충족 시 트랜잭션도 안 열린다"는 성격 때문에, @TransactionalEventListener + condition 조합은 불필요한 트랜잭션 개설을 줄이는 실용적 패턴이다.

조건부 필터링까지 봤으니, 이제 이벤트와 트랜잭션의 관계를 본격적으로 파고들 차례다.


5) @TransactionalEventListener: 트랜잭션 Saga의 최소 단위

여기가 이벤트 이야기의 정수다. @TransactionalEventListener는 발행자 트랜잭션의 특정 phase에 리스너 실행을 걸어두는 어노테이션이다. "주문이 저장됐다"는 이벤트가 있어도, 그 이벤트는 트랜잭션이 실제로 커밋된 다음에야 알림으로 나가야 한다. 커밋 전에 알림이 나가면, 그 뒤에 트랜잭션이 롤백될 경우 존재하지 않는 주문에 대한 알림이 발송된다. 이런 실수가 Saga의 시작점이다.

5-1) 4개의 phase

Phase 실행 시점 실무 용법
BEFORE_COMMIT 커밋 직전 부가 검증, 파생 엔티티 저장처럼 발행자 트랜잭션에 묶고 싶은 작업
AFTER_COMMIT (기본) 커밋 직후 외부 알림, 다른 시스템 호출, 캐시 갱신 — 가장 자주 쓰임
AFTER_ROLLBACK 롤백 직후 실패 메트릭, 보상 로직 트리거
AFTER_COMPLETION 커밋/롤백 공통 직후 리소스 정리, 감사 로그

phase를 따로 지정하지 않으면 AFTER_COMMIT이 기본이다. 대부분의 "외부 연동"은 여기에 걸면 된다.

10_spring-application-event-async-01

이 그림의 핵심은 "발행 시점"과 "실행 시점"이 다르다는 사실이다. publishEvent를 호출하는 순간에는 스프링이 TransactionSynchronizationManager에 등록만 해두고, 지정된 phase에 도달했을 때 실제 호출한다. 이 분리가 있기 때문에 "커밋 후 알림"이라는 요구가 한 줄로 표현된다.

5-2) 함정 1: fallbackExecution=false가 기본이다

가장 자주 밟는 실수가 이것이다. @TransactionalEventListener활성 트랜잭션이 없을 때 리스너를 실행하지 않는다. 경고도, 예외도 없다. 조용히 drop된다. 재현은 쉽다.

// 테스트 코드에서 @Transactional 없이 호출
@Test
void publishes() {
    orderService.place(cmd); // OrderService.place에 @Transactional이 없다면
    // → OrderPlaced 발행은 되지만 TransactionalEventListener는 실행 X
}

해결은 두 가지다. 첫째, 발행 경로에 실제로 트랜잭션이 있는지 확인한다. 둘째, 트랜잭션 없는 경로에서도 반드시 실행돼야 한다면 fallbackExecution = true를 명시한다.

@TransactionalEventListener(
    phase = TransactionPhase.AFTER_COMMIT,
    fallbackExecution = true
)
public void onOrderPlaced(OrderPlaced event) { ... }

여기서 틀리기 쉬운 건 "테스트는 되는데 프로덕션에서만 drop"되는 반대 상황도 있다는 점이다. 어느 쪽이든 리스너가 실행될 조건을 명시적으로 선언하는 게 답이다.

5-3) 함정 2: AFTER_COMMIT 안에서 REQUIRED 트랜잭션

AFTER_COMMIT 리스너는 이름 그대로 커밋이 끝난 뒤에 실행된다. 이 시점에는 바깥 트랜잭션이 이미 닫혀 있다. 여기서 두 시나리오를 구분해야 한다.

첫째, 리스너에 @Transactional 없이 EntityManager를 쓰는 경우 — 활성 트랜잭션이 없으므로 TransactionRequiredException이 터진다. 이게 가장 흔한 실수다.

둘째, 리스너에 @Transactional(REQUIRED)를 붙인 경우 — 기본 전파 수준은 "있는 트랜잭션을 재사용하되 없으면 새로 만든다"이므로 새 트랜잭션이 열린다. TransactionRequiredException은 안 나지만, 이 새 트랜잭션의 영속성 컨텍스트는 이전 세션과 공유되지 않는다. 발행자 트랜잭션에서 로드한 엔티티의 lazy 프록시를 여기서 건드리면 LazyInitializationException이 튀어나온다.

해결은 REQUIRES_NEW를 명시적으로 쓰는 것이다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlaced event) {
    // 독립 트랜잭션. 실패해도 발행자 트랜잭션은 이미 커밋 완료.
    auditRepository.save(AuditLog.of(event));
}

이 조합이 "커밋 후 독립 트랜잭션으로 후처리"라는 실무 관용구다.

5-4) 실무 패턴: 주문 저장 → 커밋 후 알림

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher publisher;

    @Transactional
    public Order place(OrderCommand cmd) {
        Order order = orderRepository.save(Order.from(cmd));
        publisher.publishEvent(new OrderPlaced(order.getId(), order.getTotal()));
        return order;
    }
}

@Component
@RequiredArgsConstructor
public class OrderAlertListener {

    private final NotificationClient client;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced event) {
        // 1. 발행자 트랜잭션 커밋 이후에만 실행
        // 2. @Async로 호출자 스레드 블로킹 없이 풀에 위임
        // 3. 여기서 예외가 나도 주문 트랜잭션은 이미 커밋 상태
        client.send(event.orderId(), "주문이 접수됐습니다");
    }
}

세 줄 주석이 이 패턴의 전부다. phase로 타이밍을, @Async로 스레드를, REQUIRES_NEW로 트랜잭션 경계를 각각 따로 제어한다. 이 세 축이 분리돼 있다는 사실을 알면 이벤트는 복잡한 게 아니라 오히려 단순해진다.

사용자 이벤트의 발행-수신-트랜잭션 계약을 다 봤으니, 스프링이 자체적으로 쏘는 내장 이벤트를 정리해보자.


6) Spring 내장 이벤트: Framework + Boot

ApplicationEvent는 사용자 이벤트만을 위한 게 아니다. 스프링 자체가 생명주기 이벤트를 같은 버스에 쏜다. Framework 레벨 4종과 Boot 레벨 9종이 있다.

6-1) Framework 내장 이벤트

이벤트 발생 시점
ContextRefreshedEvent 컨텍스트 초기화/갱신 완료 — 모든 빈 주입·초기화가 끝났을 때
ContextStartedEvent context.start() 호출 직후 — Lifecycle 빈이 start
ContextStoppedEvent context.stop() 호출 직후 — Lifecycle 빈이 stop
ContextClosedEvent context.close() — 싱글턴 파괴 직전

"모든 빈이 준비된 뒤 한 번만 실행하고 싶은 초기 로직"은 ContextRefreshedEvent가 답이다. @PostConstruct는 해당 빈 하나의 주입만 보장하지만, ContextRefreshedEvent컨텍스트 전체 완성을 보장한다.

6-2) Boot 기동 이벤트 순서

스프링 부트는 기동 단계를 더 잘게 쪼갠다. 순서대로는 다음과 같다.

  1. ApplicationStartingEvent — SpringApplication.run 진입 직후
  2. ApplicationEnvironmentPreparedEventEnvironment 준비 완료, 컨텍스트 생성 전
  3. ApplicationContextInitializedEvent — 컨텍스트 생성, 빈 정의 로드 전
  4. ApplicationPreparedEvent — 빈 정의 로드, 리프레시 전
  5. ContextRefreshedEvent — 리프레시 완료
  6. ApplicationStartedEventrun() 호출 완료, Runner 실행 전
  7. AvailabilityChangeEvent(LivenessState.CORRECT) — 헬스 신호
  8. ApplicationReadyEvent — Runner까지 전부 끝, 트래픽 수신 준비
  9. ApplicationFailedEvent — 기동 실패 시

6-3) 경계: @EventListener로는 못 받는 이벤트

여기서 함정이 하나 있다. 1~3번 이벤트(Starting, EnvironmentPrepared, ContextInitialized)는 컨텍스트 자체가 아직 준비되지 않은 시점에 발생한다. 즉 @Component, @EventListener로 등록한 빈 리스너는 아직 존재하지도 않는다. 이들을 받으려면 SpringApplication.addListeners()로 직접 등록하거나, META-INF/spring.factoriesApplicationListener 키에 정적으로 리스너 클래스를 등록해야 한다. Boot 3에서도 spring.factoriesApplicationListener 키는 여전히 동작한다. 이 경계가 3편의 라이프사이클, 6편의 초기화 순서 이야기와 그대로 이어진다. 그 "아직 빈이 아닌 시점"에 대응하는 이벤트가 이 위쪽 세 개다.

ApplicationReadyEvent 이후부터는 걱정 없이 @EventListener로 받으면 된다. 실무에서 "부트 기동 끝나면 한 번 실행"이라는 요구는 대부분 이 이벤트에 거는 게 맞다.


7) @Async와 TaskExecutor: 동작 모델

이벤트가 "누구에게"를 분리하는 장치라면, @Async는 "어느 스레드에서"를 분리하는 장치다. 스프링의 @Async새 스레드를 직접 만들지 않는다. 그 대신 프록시를 통해 가로챈 호출을 TaskExecutorsubmit으로 위임한다. 이 한 문장이 @Async의 전부다. 7편에서 "프록시 기반 AOP는 메서드만 가로챈다"고 했던 그 엔진이 여기서도 동일하게 돈다.

7-1) 설정과 기본 사용

@Configuration
@EnableAsync
public class AsyncConfig {
    // 별도 Executor를 지정하지 않으면 Boot가 ThreadPoolTaskExecutor를 기본 제공
}

@Service
public class ReportService {

    @Async
    public CompletableFuture<Report> generate(long userId) {
        Report r = heavyComputation(userId);
        return CompletableFuture.completedFuture(r);
    }
}

// 호출자
CompletableFuture<Report> future = reportService.generate(42L);
future.thenAccept(r -> log.info("done: {}", r));

호출자는 generate를 평범한 메서드처럼 부른다. 프록시가 그 호출을 가로채 TaskExecutor.submit(() -> target.generate(42L))로 바꾸고, 즉시 CompletableFuture를 반환한다. 실제 실행은 풀의 워커 스레드에서 벌어진다.

7-2) 반환 타입 3종

반환 타입 용도 예외 처리
void 호출자가 결과/완료 여부를 몰라도 되는 경우 AsyncUncaughtExceptionHandler 필요
Future<T> 호출자가 합류해야 하는 경우 (레거시) future.get() 시점에 예외 전파
CompletableFuture<T> 합류, 체이닝, 조합 future.get() 또는 exceptionally()

실무 기본값은 CompletableFuture<T>다. 합류 타이밍을 미룰 수 있고, 에러 핸들링도 함수 체이닝으로 자연스럽다.

7-3) 함정 3: @Async void는 예외가 증발한다

void를 반환하는 @Async 메서드가 예외를 던지면, 호출자에게 알려줄 통로가 없다. Future라면 future.get()에서 터지지만 void는 반환값이 없다. 스프링은 기본적으로 이 예외를 로그에만 남기고 버린다. 그마저도 Executor 설정에 따라 안 남을 수 있다. 해결은 AsyncUncaughtExceptionHandler를 명시적으로 등록하는 것이다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        return taskExecutor();
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("async error in {}: {}", method.getName(), ex.getMessage(), ex);
    }

    @Bean
    public ThreadPoolTaskExecutor taskExecutor() { ... }
}

이 핸들러는 void 반환 메서드에만 적용된다. Future 계열은 여전히 get() 시점에 예외를 받아야 한다. 두 경로가 다르다는 사실이 중요하다.

7-4) 함정 4: self-invocation 재확인

같은 클래스 안에서 this.generate(...)를 호출하면 프록시를 거치지 않으므로 동기로, 호출자 스레드에서 그대로 실행된다. 이 역시 7편의 self-invocation과 같은 원리다. 해결책도 같다. 자기 자신을 빈으로 주입받거나, 호출 경계를 다른 빈으로 분리하거나, AopContext.currentProxy()를 쓴다. 첫 번째가 가장 흔하고 읽기 쉽다.


8) ThreadPoolTaskExecutor 설정의 함정

@Async가 돌아가는 풀은 대부분 ThreadPoolTaskExecutor다. 이건 사실 JDK의 java.util.concurrent.ThreadPoolExecutor를 감싼 스프링 래퍼다. 그리고 JDK의 확장 로직에는 거의 모든 사람이 한 번은 틀리는 규칙 하나가 숨어 있다.

8-1) core → queue → max 순서

JDK ThreadPoolExecutor는 새 작업이 들어오면 다음 순서로 판단한다.

  1. 현재 활성 스레드 수가 corePoolSize보다 작다 → 새 스레드를 만든다
  2. corePoolSize만큼 차 있으면 → 큐에 넣는다
  3. 큐가 꽉 찼으면 그제서야 → maxPoolSize까지 추가 스레드를 만든다
  4. maxPoolSize도 다 차고 큐도 꽉 찼으면 → RejectedExecutionHandler가 발동

여기서 결정적인 포인트는 **"큐가 꽉 차야만 max까지 간다"**는 것이다. 그리고 ThreadPoolTaskExecutorqueueCapacity 기본값은 Integer.MAX_VALUE다. 즉 큐는 사실상 절대 안 찬다. 결과는 명료하다. corePoolSize까지만 스레드가 만들어지고, 그 뒤로는 작업이 전부 큐에 쌓인다. maxPoolSize는 아무리 올려도 무의미하다.

파라미터 의미 실무 기본 감각
corePoolSize 항상 유지되는 최소 스레드 수 CPU 코어 수 근처 (웹 I/O는 더 많이)
maxPoolSize 버스트 때 늘어나는 최대 스레드 수 core의 2~4배
queueCapacity 큐 최대 크기 — 유한값 필수 100~1000, 작업 성격에 따라
keepAliveSeconds core 초과 스레드 유휴 시간 60
rejectedExecutionHandler 거절 정책 기본 AbortPolicy (예외)가 안전

8-2) 설정 예제

@Bean
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(8);
    ex.setMaxPoolSize(32);
    ex.setQueueCapacity(500);     // 유한값 필수
    ex.setKeepAliveSeconds(60);
    ex.setThreadNamePrefix("async-");
    ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    ex.initialize();
    return ex;
}

CallerRunsPolicy는 거절 상황에서 호출자 스레드가 직접 그 작업을 실행하게 만드는 정책이다. 압력이 올라가면 자연스럽게 호출자가 백프레셔를 느끼고 발행 속도가 떨어진다. "작업을 잃지 않는 가장 싼 브레이크"다.

8-3) @Async 프록시 호출 시퀀스

10_spring-application-event-async-02

이 시퀀스를 머리에 그려두면 "왜 maxPoolSize가 안 늘어나는가"를 바로 설명할 수 있다. E -> W: dispatch에서 큐가 꽉 차지 않으면 풀은 영원히 max로 가지 않는다. 병목은 스레드가 아니라 큐의 용량 기본값이라는 사실이 이 그림에서 보인다.


9) Virtual Thread 지원: Java 21 + Framework 6.1 / Boot 3.2

Java 21과 Spring Framework 6.1 / Spring Boot 3.2부터 Virtual Thread(가상 스레드) 지원이 들어왔다. 설정 한 줄로 켤 수 있다.

spring.threads.virtual.enabled=true

이걸 켜면 스프링 부트는 몇 가지 기본 Executor를 VirtualThreadTaskExecutor로 교체한다. 가상 스레드는 OS 스레드가 아니라 JVM이 관리하는 경량 스레드이므로, "최대 몇 개까지 만들 수 있는가"를 걱정할 필요가 거의 없다. I/O에서 블로킹될 때 OS 스레드를 놓아주고 다른 가상 스레드가 올라탄다.

이 모델이 잘 맞는 곳은 I/O 바운드 경로다. HTTP 콜, DB 쿼리, 파일 I/O처럼 대기 시간이 대부분인 작업이면 가상 스레드가 유리하다. 반대로 CPU 바운드 경로에서는 여전히 고정 크기 풀이 낫다. 가상 스레드를 수십만 개 띄워도 실제 CPU 코어 수만큼만 병렬로 돌아가기 때문이다. CPU를 태우는 작업은 "몇 개까지 병렬로 돌릴까"를 명시적으로 제어하는 편이 예측 가능성이 높다.

가상 스레드를 쓰면 §8 ThreadPoolTaskExecutor 설정의 함정queueCapacity 고민 자체가 상당 부분 사라진다. 큐 대신 그냥 새 가상 스레드를 만들면 되기 때문이다. 하지만 상한이 없다는 건 동시에 상한이 필요한 경로에 대한 별도 장치가 필요하다는 뜻이기도 하다. 외부 API 호출처럼 동시 호출 수를 제한해야 하는 경우, 세마포어나 Semaphore 기반 게이트를 따로 둬야 한다.


10) 실무에서 이렇게 읽고 쓴다

  • 이벤트 도입 전 질문 한 줄: "이 호출은 발행자 트랜잭션과 분리되어도 OK인가?" OK면 이벤트, 아니면 직접 호출이다. 둘의 선택은 "느슨한 결합이 멋져서"가 아니라 트랜잭션 경계가 기준이다.
  • @TransactionalEventListener는 phase와 fallbackExecution을 항상 같이 결정한다. 기본값 조합(AFTER_COMMIT + fallbackExecution=false)은 "트랜잭션 없는 경로에서는 무조건 drop"을 의미한다는 사실을 팀 전체가 공유해야 한다.
  • AFTER_COMMIT 리스너가 DB에 뭔가 써야 하면 @Transactional(REQUIRES_NEW)를 명시한다. 생략하면 LazyInitializationException이나 TransactionRequiredException이 기다린다.
  • @Async의 반환 타입은 CompletableFuture를 기본으로 한다. void는 "실패해도 아무도 모른다"의 다른 이름이다. 굳이 void를 써야 하면 AsyncUncaughtExceptionHandler를 반드시 등록한다.
  • ThreadPoolTaskExecutorqueueCapacity는 반드시 유한값으로. 500이든 1000이든 숫자를 써라. 기본값 Integer.MAX_VALUEmaxPoolSize를 죽은 파라미터로 만든다.
  • I/O 바운드는 가상 스레드, CPU 바운드는 고정 풀. 한 애플리케이션 안에 두 종류 작업이 섞여 있으면 Executor를 분리한다. "웹 요청용 풀"과 "무거운 연산용 풀"은 다른 빈이어야 한다.
  • 디버깅할 때 체크 순서: ① 리스너 메서드가 실제로 빈인가 ② 프록시를 거쳐 호출되고 있는가(self-invocation 아닌가) ③ 발행 경로에 트랜잭션이 있는가 ④ phase가 기대한 시점과 맞는가 ⑤ 풀이 포화 상태인가(queueCapacity 확인). 이 다섯 개면 90%는 잡힌다.

11) 한 줄 정리

이벤트는 호출 대신 발행/구독으로 의존 방향을 끊는 인프로세스 디스패처다. @Async는 별도 스레드가 아니라 프록시가 TaskExecutor에게 작업을 위임하는 장치다. 이 둘 사이에 phase 계약과 core → queue → max 순서를 모르면 이벤트는 drop되고 스레드는 멈춘다.


태그: Spring Framework, ApplicationEvent, @TransactionalEventListener, @Async, ThreadPoolTaskExecutor, Virtual Thread, Spring Boot 3, Java 21

728x90