ApplicationEventPublisher의 @Async 사용 시 주의점

2025. 5. 13. 17:33·개발/SpringBoot

매번 글의 서두에 글을 쓰지만 이 글이 Spring Boot ApplicationEventPublisher 에 모든 정보를 담고 있지는 않습니다.
제가 실제 개발 경험을 바탕으로 주의해야할 점에 대해서 작성하였으니
이러한 문제점이 발생할 수 도 있구나 하는정도의정보를 공유하는 글로 읽어주시면 좋을것 같습니다.
앞의 글에서 소개한 바와 같이 Spring Boot에서 ApplicationEventPublisher를 @Async와 함께 사용하는 것은
비즈니스 로직을 깔끔하게 분리하고 애플리케이션 성능을 최적화하는데 좋은 방법입니다.

하지만 비동기 이벤트 처리의 특성상 간과하기 쉬운 몇 가지 주의사항이 존재하며, 잘못 사용하면 예상치 못한 문제에 직면할 수 있습니다. 지난 글에 이어서 이번 글에서는 실제 프로젝트에서 ApplicationEventPublisher와 @Async를 적용하면서 겪었던
OOM, 데이터 누락, 트랜잭션 관련 문제와 그 해결 과정을 상세히 공유하고자 합니다.

 

ApplicationEventPublisher와 @Async를 선택배경

 

프로젝트에서는 특정 핵심 서비스를 실행한 후, 그 결과를 여러 개의 로그 테이블에 기록해야 하는 요구사항이 있었습니다.
핵심 요구사항은 다음과 같았습니다.

  • 메인 서비스 실행 결과를 기반으로 여러 테이블에 데이터를 조회(SELECT)하고 삽입(INSERT)해야 한다.
  • 로그 기록을 위한 다중 테이블 작업으로 인해 메인 서비스의 응답 속도가 느려지는 병목 현상을 방지해야 한다.
  • 메인서비스와 로그 기록은 비지니스상 관계가 없는 내용이다.

이러한 요구사항을 충족하기 위해 ApplicationEventPublisher를 사용하여 서비스 실행 결과를 이벤트로 발행하고,
@Async 통해 해당 로그 삽입 로직을 비동기적으로 처리하기로 결정했는데 메인 스레드의 부담을 최소화하고
성능을 향상시킬 수 있을 것으로 판단했었으나 실제 적용 과정에서 예상치 못한 여러 문제에 직면하게 되었습니다.

 

ApplicationEventPublisher와 @Async의 동작 방식 이해
ApplicationEventPublisher.publishEvent(event)를 호출하면, Spring의 ApplicationEventMulticaster는 해당 이벤트 타입에 등록된
모든 리스너(@EventListener가 붙은 메서드)에 이벤트를 순차적으로 전달하는 옵저버 패턴의 구현 방식이고
만약 @EventListener가 적용된 메서드에 @Async와 함께 사용되었다면,
Spring은 해당 리스너의 실행을 프록시 객체를 통해 별도의 스레드에서 처리하며
이 비동기 스레드는 Spring이 관리하는 TaskExecutor 인터페이스의 구현체에 의해 관리됩니다.
따라서 @Async를 사용할 계획이라면, 이벤트 처리가 어떤 TaskExecutor의 설정에 따라 동작하는지 명확히 이해하는 것이 매우 중요합니다.

 

실제 프로젝트에서 겪었던 문제들..


1. OutOfMemoryError (OOM) 문제
가장 먼저 겪었던 문제는 어플리케이션이 부하 상황에서 OutOfMemoryError로 인해 다운되는 현상이었습니다.
처음 해당 개발을 진행 할 때 ThreadPoolTaskExecutor를 명시적으로 설정하지 않았는데,
개발 당시 Spring Boot 환경에 대한 깊이 있는 이해가 부족했었는데 Springboot 2.1 이전 버전에서는
사용자 정의 TaskExecutor가 없을 경우 @Async는 기본적으로 SimpleAsyncTaskExecutor를 사용합니다.

SimpleAsyncTaskExecutor는 스레드 풀을 사용하는게 아닌 각각의 비동기 태스크마다 새로운 스레드를 생성하는 방식으로 동작합니다.
부하 테스트 중 이벤트가 급증하면서 다음과 같은 현상이 발생했습니다.

  • 로그 데이터를 삽입하기 위한 스레드가 제한 없이 빠르게 증가
  • 계속된 새로운 스레드 생성으로 인해 시스템 리소스가 과도하게 소모되었고, Insert작업도 점점 오래 걸리기 시작 
  • 결국 스레드 수가 시스템이 감당할 수 있는 수준을 넘어서면서 OutOfMemoryError가 발생

스레드 수를 명시적으로 제어할 수 있는 ThreadPoolTaskExecutor를 Spring 빈으로 등록하여 @Async가 이 빈을 사용하도록 설정했습니다.

@Configuration
@EnableAsync
public class AsyncConfig{

    @Bean(name = "loggingTaskExecutor")
    public TaskExecutor loggingTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        int cores = Runtime.getRuntime().availableProcessors();
        executor.setCorePoolSize(cores * 2);
        executor.setMaxPoolSize(cores * 4);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("logging-async-");
        executor.initialize();
        return executor;
    }
}

그리고 @Async에 사용할 TaskExecutor 빈의 이름을 명시했습니다.

@Async("loggingTaskExecutor")
@EventListener
public void handleMyEvent(MyEvent event) {
    // ... 로그 삽입 로직 ...
}

SpringBoot 2.1 Version 부터는 org.springframework.boot.autoconfigure.task 의 TaskExecutionAutoConfiguration 에서 ThreadPoolTaskExecutor 를 기본적으로 사용하게 설정 되어 집니다.


단 여기에서도 문제가 있는데 기본 생성 corePoolSize 수가 8개로 지정 되어 있기 때문에 성능상에 문제가 생길 수 있습니다. 

 

2. 어플리케이션 재기동 시 데이터 누락: 대기 큐와 Graceful Shutdown
ThreadPoolTaskExecutor를 설정한 후 OOM 문제는 해결되었지만, 어플리케이션을 재기동할 때
일부 로그 데이터가 누락되는 새로운 문제가 발생했습니다.
처음에는 애플리케이션의 비정상 종료로 인해 미처 처리되지 못한 스레드가 중단되는 것이 원인이라고 생각하여
Spring Boot의 Graceful Shutdown 기능을 적용했지만, 문제는 여전히 발생했습니다.
문제의 근본 원인은 ThreadPoolTaskExecutor의 **대기 큐(queueCapacity)**에 였는데 corePoolSize만큼의 스레드가 모두 사용 중일 때,
새로운 작업은 이 큐에 쌓이게 되는데 해당 Queue에 쌓여 있는 작업은 Graceful Shutdown 과 관련이 없고
Graceful Shutdown  은현 재 수행 중인 Thread 까지 정상적으로 수행 후 종료하도록 되어있어 활성 스레드의 완료만 기다리고,
대기 큐에 남아있는 태스크는 별도의 처리 없이 소멸
시키기 때문입니다.
그래서 ThreadPoolTaskExecutor 빈 설정에 다음과 같은 설정을 추가하여
애플리케이션 종료 시 대기 큐의 태스크까지 처리하도록 보장했습니다.

@Bean(name = "loggingTaskExecutor")
public TaskExecutor loggingTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    // ... 기존 설정 ...
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(60); // 최대 60초 동안 종료 대기
    executor.initialize();
    return executor;
}

setWaitForTasksToCompleteOnShutdown(true) 설정은 애플리케이션 종료 시 TaskExecutor가 아직 실행 중인 모든 태스크가 완료될 때까지 대기하도록 지시합니다. setAwaitTerminationSeconds(60)은 최대 대기 시간을 설정하여, 너무 오래 걸리는 작업으로 인해 종료 프로세스가 무한정 지연되는 것을 방지합니다.
하지만 큐 크기가 매우 크거나 태스크 처리 시간이 오래 걸리는 경우에는 재기동 시간이 길어질 수 있다는 단점이 있습니다.
Pod가 많이 생성되는 Cloud 환경에서는 배포시간이 길어진다는 단점이 더욱 부각 될 수 있기에 이러한 상황에서는 대안으로
애플리케이션 종료 훅(Shutdown Hook)을 사용하여 대기 큐에 있는 이벤트를
Kafka나 RabbitMQ와 같은 메시지 큐로 안전하게 이동시키는 방법을 고려해볼 수 있습니다. 물론 Application 기동시 해당 Queue에 있는 데이터를 가져와 작업하도록 하는 기능의 추가도 필요합니다.

 

3. 트랜잭션 타이밍으로 인한 데이터 누락: @TransactionalEventListener 활용
또 다른 까다로운 문제는 비동기 이벤트가 메인 스레드의 트랜잭션 결과에 의존할 때 발생했습니다. 예를 들어, 메인 스레드에서 특정 데이터를 데이터베이스에 삽입한 직후에 비동기 이벤트가 발생하여 해당 데이터를 조회하려고 시도했지만,
메인 스레드의 트랜잭션이 아직 커밋되지 않아 비동기 리스너에서는 해당 데이터를 찾을 수 없는 상황이 발생했습니다.
앞 선 글의 코드로 본다면 회원가입의 트랜잭션이 완료되기 전에 Mail 서비스에서 회원의 메일 정보를 조회하는 현상이발생하여
메일을 찾을 수 없어 축하메일이 누락되는 현상이 발생하였습니다.
위와 같이 비동기로 사용하더라도 트랜잭션의 선후관계가 필요하다면 @EventListener 대신 @TransactionalEventListener를 사용하여 이벤트 처리 로직을 메인 스레드의 트랜잭션이 완료된 후 수행 되도록 변경하였습니다.

import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Async("loggingTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleMyEventAfterCommit(MyEvent event) {
    // 메인 트랜잭션이 성공적으로 커밋된 후에 이벤트 처리 로직 실행
    // 이제 데이터 조회가 안전하게 수행될 수 있습니다.
    // ... 로그 삽입 로직 ...
}

@TransactionalEventListener 애너테이션의 phase 속성을 TransactionPhase.AFTER_COMMIT으로 설정하면, 해당 이벤트 리스너는 메인 스레드의 트랜잭션이 성공적으로 커밋된 후에만 실행됩니다. 이를 통해 비동기 리스너에서 필요한 데이터를 안정적으로 조회할 수 있게 되어 데이터 누락 문제를 해결할 수 있었습니다. @TransactionalEventListener는 AFTER_ROLLBACK, AFTER_COMPLETION, BEFORE_COMMIT 등 다양한 트랜잭션 단계를 지원하므로, 상황에 맞춰 적절한 단계를 선택하여 사용할 수 있습니다.

 

결론: 신중한 설계와 깊이 있는 이해가 필수

 
ApplicationEventPublisher와 @Async를 함께 사용하여 비즈니스 로직을 분리하고 비동기 처리를 구현하는 것은 분명 강력한 도구이지만,
앞서 언급한 것처럼 스레드 풀 설정, 애플리케이션 종료 시 데이터 보존, 트랜잭션 격리 수준 및 타이밍 등
다양한 기술적 고려 사항에 대한 깊이 있는 이해와 신중한 설계가 필요합니다. ApplicationEventPublisher를 제대로 사용하기 위해
ThreadPoolTaskExecutor의 올바른 설정 및 이해도 그리고 
TransactionalEventListener와 같은 트랜잭션 바인딩 리스너의 활용등
관련이 없어보이지만 안정적인 시스템을 구축하기 위해 고민해야할 부분들이 많이 있습니다.
이러한 복잡성에도 불구하고, 핵심 비즈니스 로직과 부가적인 기능을 명확히 분리하고, 잠재적인 성능 병목 현상을 해결할 수 있다는 이점은 매우 매력적입니다. ApplicationEventPublisher 의 처리 메커니즘을 깊이 이해하고, 상황에 맞게 올바르게 활용한다면 ApplicationEventPublisher는 백엔드 개발의 생산성과 안정성을 크게 향상시킬 수 있는 강력한 도구가 될 것입니다. 

 
참고 :
https://github.com/spring-projects/spring-boot/blob/v2.1.0.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java 
 

'개발 > SpringBoot' 카테고리의 다른 글

SpringBoot의 ApplicationEventPublisher 를 활용한 비즈니스 분리  (0) 2025.04.25
SpringBoot AOP 의 아이러니  (0) 2025.03.11
Springboot Custom Annotation  (0) 2025.02.17
'개발/SpringBoot' 카테고리의 다른 글
  • SpringBoot의 ApplicationEventPublisher 를 활용한 비즈니스 분리
  • SpringBoot AOP 의 아이러니
  • Springboot Custom Annotation
개발자아저씨
개발자아저씨
그냥 오랫동안 개발하고 싶은 아저씨...
  • 개발자아저씨
    코딩하는 아저씨
    개발자아저씨
  • 전체
    오늘
    어제
    • 분류 전체보기 (17)
      • 개발자 (5)
      • 개발 (11)
        • SpringBoot (4)
        • AI (5)
        • MSA (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    MCP
    백엔드
    AI
    결재연동
    Session Cluster
    springai
    생각과 경험
    회고
    local llm 연동
    Claude.md
    로그인 발전
    비지니스 분리
    ai coding
    springboot
    이직
    aop 사용 기준
    글쓰기
    annotation 활용
    claude desktop
    은탄환은 없다
    MSA
    simpleasynctaskexecutor
    AI컨벤션
    claudecode
    경량llm
    backend
    AOP
    ai 서빙
    ai서빙
    ApplicationEventPublisher
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
개발자아저씨
ApplicationEventPublisher의 @Async 사용 시 주의점

티스토리툴바