본문 바로가기
Backend/Spring

[Spring] Transaction - @TransactionalEventListener를 이용한 이벤트 처리

by 제이동 개발자 2025. 8. 17.
728x90

[Spring] Transaction - @TransactionalEventListener를 이용한 이벤트 처리

 일반 @EventListener는 트랜잭션의 성공/실패와 독립적으로 동작합니다. 반면 @TransactionalEventListener는 트랜잭션의 상태(커밋/롤백/완료 직전) 에 맞춰 리스너 실행 시점을 제어합니다. 

 트랜잭션 안팎의 경계를 정확히 지키면서 후속 작업(이메일 발송, 캐시 무효화, 메시지 발행, 인덱싱 등) 을 안전하게 실행하고 싶다면 @TransactionalEventListener를 사용할 수 있습니다. 

 

1. 핵심 옵션

  • phase = AFTER_COMMIT(기본): 커밋 성공 후 실행
  • phase = AFTER_ROLLBACK: 롤백 시에만 실행
  • phase = AFTER_COMPLETION: 커밋/롤백 관계없이 완료 후 실행
  • phase = BEFORE_COMMIT: 커밋 직전에 실행(예외 발생 시 커밋 중단 가능)

참고로 현재 트랜잭션이 없으면 이벤트를 버리고, fallbackExecution=true 를 주면 트랜잭션이 없어도 실행됩니다.(단, 정말 필요한 경우에만 사용하세요)

 

2. 예제

Async에서 사용할 ThreadPoolTaskExecutor 등록

@Configuration
@EnableAsync
public class AsyncConfig {
  @Bean(name = "eventExecutor")
  public ThreadPoolTaskExecutor eventExecutor() {
    ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(4);
    ex.setMaxPoolSize(8);
    ex.setQueueCapacity(1000);
    ex.setThreadNamePrefix("event-");
    ex.initialize();
    return ex;
  }
}

 

💡 참고
@Async 규칙
- 유일한 TaskExecutor 타입 빈이 있으면 해당 빈을 쓴다.
- 여러 개의 TaskExecutor 타입 빈이 있을 경우 taskExecutor 이름의 빈을 찾아 사용한다.
- 둘 다 해당되지 않을 경우 SimpleAsyncTaskExecutor 로 동작한다.

❗️ 참고
스프링 부트(3.5 기준) : 부트가 자동으로 applicationTaskExecutor 빈을 만들어 @Async 의 기본 실행기로 쓴다.
예전(<=3.4)엔 taskExecutor 이름도 함께 제공했지만 3.5부터는 제공하지 않는다.
→ 기존에 이름으로 taskExecutor 를 참조하던 코드는 applicationTaskExecutor 로 바꾸거나 직접 빈을 정의해야 한다.

 

 

이벤트 정의

// 조회 이력 적재에 필요한 최소 식별자만 담는다.
// (리스너에서 불필요한 재조회 방지 → 성능/정합성에 유리)
public record ArticleViewedEvent(
    Long userId,         // 누가
    Long articleId,      // 무엇을
    Instant occurredAt   // 언제
) { }

 

 

 

서비스: Article 조회 → 이벤트 발행

@Service
@RequiredArgsConstructor
public class ArticleReadService {

  private final ArticleRepository articleRepository;
  private final ApplicationEventPublisher eventPublisher;

  // readOnly 있어도 트랜잭션 경계가 열리므로 AFTER_COMMIT 리스너 동작 가능
  @Transactional(readOnly = true)
  public ArticleDetailDto readAndRecord(Long userId, Long articleId) {
    // 1) Article 조회 (필요하면 조회수 증가 등 쓰기는 별도 서비스에서 처리 권장)
    Article article = articleRepository.findById(articleId)
        .orElseThrow(() -> new IllegalArgumentException("Article not found: " + articleId));

    // 2) 조회 이력 이벤트 발행 (리스너는 커밋 성공 후 실행)
    eventPublisher.publishEvent(new ArticleViewedEvent(
        userId, article.getId(), Instant.now()
    ));

    // 3) 조회 결과 반환 (이력 적재는 비동기로 진행)
    return ArticleDetailDto.from(article);
  }
}
  • 이벤트 페이로드에 충분한 정보(userId, articleId, occurredAt)를 담아 리스너에서 DB 재조회를 최소화합니다.
  • read-only 라우팅을 쓰는 경우라도, 리스너 메서드에 @Transactional만 붙이면 기본적으로 마스터로 쓰기가 라우팅되도록 구성하기 쉽습니다(프로젝트 라우팅 정책에 따름).

 

리스너: 커밋 후(AFTER_COMMIT) 조회 이력 저장

@Component
@EnableAsync // @Async 사용을 위한 설정
@RequiredArgsConstructor
public class ArticleViewHistoryListener {

  private final ArticleViewHistoryRepository historyRepository;

  @Async("eventExecutor") // 요청 스레드와 분리 → 응답 지연 최소화
  @Transactional // 리스너 스레드에서 별도 트랜잭션을 열어 INSERT 수행
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void onViewed(ArticleViewedEvent event) {
    // 커밋 성공 후에만 호출되므로 정합성 확보
    ArticleViewHistory history = ArticleViewHistory.of(
        event.userId(), event.articleId(), event.occurredAt()
    );
    historyRepository.save(history);
  }
}
  • 이력 저장과 같이 성공 여부가 서비스 로직과 무관하다면 Async를 이용하여 응답 속도를 향상 시키는 것이 좋습니다.

 

3. 장점

 

  • 커밋 인지(Commit-aware) 실행
    기본 AFTER_COMMIT으로 DB 커밋 성공 후에만 리스너가 실행되어 부수효과(이력 적재, 캐시 무효화 등)가 트랜잭션 정합성을 따른다.
  • 요청 지연 최소화
    리스너에 @Async를 더하면 커밋 후 별도 스레드에서 실행되어 API 응답 시간을 줄인다(특히 외부 I/O 분리 → Spring Boot 성능 최적화).
  • 관심사 분리·확장 용이
    핵심 트랜잭션(쓰기/조회)과 후속 작업(로그/이력/알림)을 분리해 코드 가독성과 테스트 용이성↑.
  • 유연한 실행 시점
    BEFORE_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION 등 상황 맞춤 실행이 가능(감사/검증/보상 로직에 유리).
  • JPA 도메인 이벤트와 궁합
    @DomainEvents/@AfterDomainEventPublication과 결합해 풍부한 모델링내부 이벤트 흐름을 구성하기 좋다(JPA 활용법).
  • 간단한 도입 비용
    메시지 브로커/Outbox 없이 프로세스 내부에서 시작할 수 있어 초기 적용 장벽이 낮다.

 

 

 

 

 

 

728x90