본문 바로가기
빌드노트 검색

빌드노트

publishEvent가 리스너 종료까지 기다리는 이유

Spring 이벤트를 발행했는데 호출이 늦고 리스너 예외까지 돌아온다면 실제 multicaster와 executor 설정을 확인한다. Spring 6.2.19에서 기본 동기 호출과 별도 스레드 실행을 비교하고, latch로 발행 반환과 작업 완료가 다른 시점임을 확인했다.

Spring의 일반 @EventListener로 작업을 옮겼는데 publishEvent() 호출이 늦게 끝나고, 리스너에서 던진 예외까지 발행자에게 돌아올 수 있다. 기본 SimpleApplicationEventMulticaster는 발행자의 스레드에서 리스너를 호출하기 때문이다. 리스너가 오래 걸리면 발행 호출도 그만큼 기다린다. 먼저 실제 multicaster에 어떤 executor가 연결됐는지 확인해야 한다. SimpleApplicationEventMulticaster API (새 탭에서 열림)

publishEvent라는 메서드 자체가 비동기 실행을 약속하는 것은 아니다. 공식 API가 정하는 역할은 이벤트를 multicaster에 전달하는 것이다. 실행 스레드와 반환 시점은 그 뒤의 구성에 달려 있다. ApplicationEventPublisher API (새 탭에서 열림)

실제 발행에 쓰는 컨텍스트부터 확인한다

multicaster는 이벤트를 받을 리스너를 찾아 호출하는 객체다. 문제가 생긴 이벤트를 발행하는 ApplicationContext를 ctx라고 할 때, 초기화가 끝난 컨텍스트에서 다음 한 줄로 실제 구현을 확인할 수 있다.

System.out.println(ctx.getBean("applicationEventMulticaster").getClass().getName());

이번 격리 실행에서는 org.springframework.context.event.SimpleApplicationEventMulticaster가 나왔다. 다만 이 클래스 이름만으로 동기·비동기를 확정할 수는 없다. 아래에서 비교할 기본 설정과 별도 executor 설정 모두 같은 클래스였다.

해당 이름의 빈을 만드는 설정으로 가서 setTaskExecutor(...)에 무엇을 넣는지 확인한다. executor 빈이 따로 존재하는지만 보지 말고 multicaster에 실제로 연결됐는지 본다. setErrorHandler(...)도 함께 확인한다. 이 설정은 리스너 예외가 발생했을 때의 동작을 바꾼다. Spring은 이름이 applicationEventMulticaster인 빈으로 기본 multicaster를 교체하거나 설정할 수 있게 한다. 이벤트 발행 설정 (새 탭에서 열림)

기본 설정에서는 발행 앞뒤 사이에 리스너가 들어왔다

OpenJDK 17.0.2와 Spring Framework 6.2.19에서 새 컨텍스트와 합성 이벤트로 비교했다. 실제 서비스의 지연을 측정한 것은 아니다. 각 조건에 일반 @EventListener 한 개를 등록했고, 기본 조건에는 custom multicaster, executor, ErrorHandler, @Async를 넣지 않았다.

합성 이벤트 Probe는 식별자와 실패 여부만 담았다. new Probe("probe-1", false)를 발행하면서 호출 직전·리스너 진입·정상 종료·호출 반환을 기록했다. 다음은 실제 출력이다.

default-ok 01 before-publish thread=main
default-ok 02 listener-enter thread=main sameThread=true
default-ok 03 listener-exit thread=main id=probe-1
default-ok 04 publish-return thread=main listenerFinished=true

sameThread=true는 이름이 같다는 추측이 아니라 발행 스레드와 리스너 스레드의 Thread 객체를 ==로 비교한 값이다. 리스너가 종료된 뒤에야 publishEvent 다음 줄로 넘어갔다. 이 발행을 HTTP 요청 처리 도중 직접 호출한다면 리스너 작업도 그 호출 경로의 대기 원인이 될 수 있다.

기존 코드에서도 같은 이벤트를 기준으로 발행 직전과 직후, 해당 리스너의 진입과 종료 위치에 스레드 이름을 기록해 본다. 같은 스레드에서 리스너 종료 뒤 발행 후 로그가 나온다면, 그 호출에서 작업이 발행 경로 안에 들어온 것이다. 응답 시간이 짧다는 이유로 비동기라고 판단하면 짧은 동기 작업을 잘못 분류할 수 있다.

같은 이벤트의 실패 여부만 true로 바꿔 리스너가 IllegalStateException("listener-boom")을 던지게 했다. 이번에는 발행 후 정상 로그 대신 발행자의 catch가 실행됐다.

default-fail 01 before-publish thread=main
default-fail 02 listener-enter thread=main sameThread=true
default-fail 03 listener-throw thread=main type=IllegalStateException message=listener-boom
default-fail 04 publisher-caught thread=main type=IllegalStateException message=listener-boom sameException=true

발행자가 잡은 객체는 리스너가 던진 원래 예외 객체와 같았다. 이 조건에서 publishEvent는 정상 반환하지 않았다. 기본 ErrorHandler는 지정돼 있지 않으며, 이때 리스너 예외가 발행자에게 전파되는 것은 문서에 명시된 동작이다. 애플리케이션에 예외를 처리하고 정상 반환하는 ErrorHandler가 있다면 이 관찰과 달라질 수 있다. 예외 처리 설정 (새 탭에서 열림)

별도 executor에서는 반환과 작업 완료가 갈라졌다

비동기 처리를 선택했다면 실행할 executor를 multicaster에 연결해야 한다. 다음은 이미 eventExecutor라는 이름의 java.util.concurrent.Executor 빈을 관리하는 프로젝트에서 사용할 빈 정의다. 기존 @Configuration 클래스 안에 두며, 같은 이름의 multicaster가 있다면 그 정의를 수정한다. Bean, Qualifier, SimpleApplicationEventMulticaster는 각각 Spring의 설정·주입 애너테이션과 위에서 확인한 클래스다.

@Bean(name = "applicationEventMulticaster")
SimpleApplicationEventMulticaster applicationEventMulticaster(
        @Qualifier("eventExecutor") Executor executor) {
    var multicaster = new SimpleApplicationEventMulticaster();
    multicaster.setTaskExecutor(executor);
    return multicaster;
}

이 정의는 executor를 연결하는 부분이다. 연결한 executor가 실제로 다른 스레드에서 작업을 실행하는지도 확인해야 한다. 실험에서는 이 빈 정의에 Executors.newSingleThreadExecutor(...)로 만든 전용 executor를 연결하고 작업 스레드 이름을 event-worker로 지정했다. 풀의 종료도 생성한 쪽에서 처리했다. 프로젝트에 적용할 때도 executor의 생성과 종료를 담당하는 구성이 있어야 한다.

비교를 위해 SyncTaskExecutor를 연결한 실행도 했다. 이 경우에는 executor를 명시했는데도 기본 조건과 똑같이 main에서 리스너가 끝난 뒤 발행이 반환됐다. 설정에 executor가 보인다는 사실만으로는 충분하지 않았다.

별도 스레드 조건에서는 리스너가 진입 신호를 보낸 다음 latch 앞에서 기다리게 했다. 발행자는 반환한 뒤 진입 신호를 확인하고, 리스너가 아직 끝나지 않았음을 검사한 다음 대기를 해제했다. 대기에는 모두 제한을 두고, 해제 후 종료 신호까지 확인했다. sleep으로 작업 시간을 늘려 추측한 결과가 아니다. CountDownLatch의 대기와 신호 (새 탭에서 열림)

async-ok 01 before-publish thread=main
async-ok 02 publish-return thread=main listenerFinished=false
async-ok 03 listener-enter thread=event-worker sameThread=false
async-ok 04 checkpoint thread=main returned=true listenerFinished=false differentThread=true
async-ok 05 release-listener thread=main
async-ok 06 listener-exit thread=event-worker id=probe-1
async-ok 07 verified thread=main listenerFinished=true publisherCaught=false

이 실행에서는 반환 로그가 리스너 진입 로그보다 먼저 나왔다. 매번 두 로그가 이 순서일 필요는 없다. 여기서 판별 근거는 서로 다른 Thread 객체와, 리스너를 끝낼 수 없는 상태로 유지했는데도 발행이 반환했다는 사실이다. 대기를 해제한 뒤 리스너 종료도 따로 확인했으므로 미실행을 비동기로 오인한 것도 아니다.

실패 조건에서도 리스너의 대기를 발행 반환 뒤에 해제했다. 이번에는 관찰용 ErrorHandler를 추가해 원래 예외와 처리 스레드를 기록하고 정상 반환하도록 했다. 발행자는 예외를 받지 않았고, event-worker에서 ErrorHandler가 같은 예외 객체를 받았다. 이 결과는 해당 executor가 작업을 받아 별도 스레드에서 실행하고, 추가한 ErrorHandler가 예외를 처리한 조건의 결과다. 위 빈 정의만으로 이 ErrorHandler까지 등록되지는 않는다.

기다릴 범위와 실패를 받을 위치를 함께 정한다

응답 전에 리스너의 작업 성공이 필요하다면, 일반 리스너를 동기로 호출하는 구성에서는 지연과 예외도 발행 경로에 포함된다는 점을 받아들여야 한다. 먼저 느린 리스너가 무엇을 기다리는지 확인한다. 비동기로 바꾸면 발행이 빨리 돌아오는 대신 반환 시점의 의미가 달라진다.

반환 후 작업을 계속해도 된다면 완료 여부와 실패를 어디에서 확인할지 별도로 정한다. 이번 별도 executor 조건에서 publishEvent의 정상 반환은 리스너 성공의 증거가 아니었다. ErrorHandler를 둘지, 그 처리에서 어떤 실패 정보를 남길지까지 선택해야 한다. ErrorHandler 없이 작업 스레드에서 발생한 예외는 executor 쪽으로 전달된다는 것이 공식 문서의 설명이다. 그 경로는 이번 실행에서 직접 비교하지 않았다.

executor를 설정해도 항상 별도 스레드가 보장되는 것은 아니다. 앞의 SyncTaskExecutor 관찰 외에도, supportsAsyncExecution()이 false인 리스너는 원래 발행 스레드에서 실행된다. 또한 6.2.19의 multicaster 구현은 executor가 RejectedExecutionException을 던지면 해당 리스너를 발행 스레드에서 직접 실행한다. 이 두 조건은 문서와 소스로 확인했으며 실행 대조에는 포함하지 않았다. 리스너의 비동기 실행 지원 조건 (새 탭에서 열림), 6.2.19의 실행 분기 (새 탭에서 열림)

리스너에 @Async가 붙어 있다면 해당 기능을 활성화한 설정과 실제 프록시·executor 경로도 확인해야 한다. 일반 @EventListener의 기본 호출과는 별도의 비동기 실행 경로다. 이 글의 실행에서는 @Async를 사용하지 않았다. 비동기 이벤트 리스너 설명 (새 탭에서 열림)

스레드를 바꿀 때는 발행자의 문맥도 다시 살펴야 한다. multicaster API는 executor가 명시적으로 지원하지 않는 한 발행 스레드의 트랜잭션 같은 문맥에 참여하지 않는다고 설명한다. 이번 실험은 호출 순서·스레드 동일성·예외 경계만 검증했다. 실제 HTTP 지연, 트랜잭션·보안 문맥 전파, 여러 리스너의 완료 순서는 검증하지 않았다. @TransactionalEventListener의 커밋 단계 호출 여부도 별도 문제이므로, 여기서 확인한 일반 리스너의 반환 시점을 그대로 적용하지 않는다.

← 전체 글 보기