본문 바로가기
Spring/Spring Boot

@TransactionalEventListener 비동기 이벤트 처리 하기

by yoon_seon 2024. 6. 22.

회원가입 기능을 구현 중 회원가입 완료 메일 전송 기능을 스프링의 @TransactionalEventListener어노테이션을 사용해서 비동기로 처리했었습니다.

 

문제 상황

아래는 회원가입 후 메일을 전송하는 기능의 코드입니다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberSignupService {

    private final MemberProcessor memberProcessor;
    private final MailService mailService;

    @Transactional
    public MemberSignupDto.Info signup(final MemberSignupDto.Command request) {
        /** 회원 가입 **/
        MemberSignupDto.Info info = memberProcessor.register(request);

        /** 회원가입 완료 후 이메일 전송 **/
        String registeredEmail = info.getEmail();
        mailService.sendMail(toEmailArray(registeredEmail), "회원가입 완료 안내", "회원가입이 완료되었습니다.");

        return info;
    }

    private String[] toEmailArray(String... email) {
        return email;
    }
}

 

코드를 보면 memberProcessor.register메서드를 통해 회원가입을 진행하고,

회원가입한 이메일을 대상으로 sendMail메서드를 통해 메일을 전송하고 있습니다.

 

실제로 구현 후 회원가입을 테스트 해보았는데요. API 응답 시간이 무려 4초나 소요되는 문제를 발견했습니다.

 

문제 원인은?

문제의 원인을 파악해보기 위해 메서드 호출과 완료의 시간을 체크해봤습니다.

 

위 로그와 같이 메서드 총 소요 시간 중 96% 이상이 메일 전송 메서드에 할애되고 있었습니다.

 

이유는 동기 방식으로 처리하고 있었기 때문에 회원가입 자체는 빠르게 수행되어도 메일 전송 로직에서 시간이 지연되었고,

이에 따라 전체 프로세스가 끝나는 시간이 그만큼 길어지게 되었던 것 입니다.

 

따라서 메일전송 기능을 스프링의 이벤트 방식으로 변경하고 비동기로 처리해서 API 응답 시간을 개선하기로 결정했습니다.

 

스프링의 비동기 이벤트 방식으로 변경해보자.

스프링의 이벤트 방식은 기본적으로 이벤트를 발행하면, 그 이벤트를 발행했던 스레드와 동일한 스레드에서 처리되기 때문에 동기 방식으로 처리 됩니다.

 

위 문제 상황을 예로 들면, 메일 전송 기능이 이벤트 방식으로 구현되있고 회원가입 후 메일 발송 이벤트를 발행한다고 가정했을 때, 회원가입 로직을 수행하는 스레드(메일 전송 이벤트를 발행)와 발행한 이벤트를 처리하는 스레드가 동일하다는 것입니다.

 

이 문제는 다른 스레드에서 이벤트를 처리하는 비동기 방식으로 처리한다면 쉽게 개선할 수 있습니다.

 

스프링의 비동기 이벤트 방식을 사용하기위해 우선 기존 소스를 이벤트 방식으로 리팩토링 해보겠습니다.

 

리팩토링 1. 스프링 이벤트 방식으로 변경

스프링에서는 ApplicationEventPublisherpublishEvent메서드를 사용하여 이벤트를 쉽게 발행할 수 있는데, publishEvent메서드는 이벤트 객체를 인자로 받아 이를 발행합니다.

회원가입 완료 메일 발송 이벤트의 경우, 이벤트 객체에 회원가입한 회원의 이메일을 포함하여 발행하기 때문에 회원의 이메일을 필드로 갖는 이벤트 객체를 생성해주겠습니다.

 

전달할 데이터를 담은 이벤트 객체 생성

@Getter
@RequiredArgsConstructor
public class SignupEvent {

    private final String email;
    
    public static SignupEvent of(String email) {
        return new SignupEvent(email);
    }
}

 

이제 위 문제의 코드를 스프링 이벤트 방식으로 변경해보겠습니다.

 

메일전송 기능 응용로직 스프링 이벤트 발행 방식으로 변경

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberSignupService {

    private final MemberProcessor memberProcessor;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public MemberSignupDto.Info signup(final MemberSignupDto.Command request) {
        /** 회원 가입 **/
        MemberSignupDto.Info info = memberProcessor.register(request);

        /** 회원가입 완료 후 이메일 전송 **/
        String registeredEmail = info.getEmail();
        eventPublisher.publishEvent(SignupEvent.of(registeredEmail));

        return info;
    }
}
  • ApplicationEventPublisher를 필드로 선언하고
  • publishEvent메서드를 호출하여 이벤트를 발행하며, 이때 전달할 데이터를 담은 이벤트 객체를 생성합니다.
  • 이 이벤트 객체를 publishEvent메서드의 인자로 전달하여 발행합니다.

이어서, 발행된 이벤트를 처리할 리스너를 설정해 보겠습니다.

이벤트 리스너는 특정 이벤트가 발생했을 때 이를 감지하고, 지정된 동작을 수행하도록 합니다.

 

발행될 이벤트를 처리할 이벤트 리스너 구현

@Slf4j
@Component
@RequiredArgsConstructor
public class MemberEventListener {

    private final MailService mailService;

    @TransactionalEventListener
    public void signupEventListener(final SignupEvent event) {

        log.info("MemberEventListener.signupEventListener !!");

        String[] toEmail = toEmailArray(event.getEmail());
        mailService.sendMail(toEmail, "회원가입 완료 안내", "회원가입이 완료되었습니다.");
    }

    private String[] toEmailArray(String... email) {
        return email;
    }
}

 

스프링은 이벤트 리스너 메서드가 호출되는 기준으로 메서드의 매개변수 타입을 사용합니다.

메서드를 발행할 때 SignupEvent이벤트 객체로 담아서 실행했기에signupEventListener메서드가 발행한 이벤트를 처리하게 됩니다.

또한 크게 달라진 부분은@TransactionalEventListener메서드 인데 이 부분은 마지막 챕터에서 자세하게 살펴보겠습니다.

 

이제 이벤트 리스너가 구현됐으니 다시 API 테스트를 진행해보겠습니다.

스프링 이벤트 리스너 구현 후 API 응답 테스트

 

스프링 이벤트 방식으로 변경했지만 아직 4초 가량의 시간이 소요되고 있습니다.

이유는 메일 전송 로직을 스프링 이벤트 방식으로 처리되도록 변경한 것이지, 비동기 방식으로 변경하지 않았기 떄문입니다.

 

리팩토링 2. 스프링 이벤트 방식 비동기로 변경

스프링에서 제공하는 @Aysnc 어노테이션으로 스프링 이벤트가 비동기 방식으로 처리되도록 할 수 있습니다.

 

@EnableAsync 설정

@Aysnc어노테이션을 사용하려면 @EnableAsync어노테이션을 설정해주어야 하는데,

어플리케이션 실행파일에 직접 등록하거나, 설정 클래스를 만들어서 등록할 수 있습니다.

하나 선택

이벤트 리스너에 @Aysnc 사용

이벤트 리스너 메서드에 @Async어노테이션을 추가합니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class MemberEventListener {

    private final MailService mailService;

    @Async // <- 추가됨
    @TransactionalEventListener
    public void signupEventListener(final SignupEvent event) {
        log.info("MemberEventListener.signupEventListener !!");

        String[] toEmail = toEmailArray(event.getEmail());
        mailService.sendMail(toEmail, "회원가입 완료 안내", "회원가입이 완료되었습니다.");
    }

    private String[] toEmailArray(String... email) {
        return email;
    }
}

 

스프링 이벤트 리스너 비동기 API 응답 테스트

 

메일 전송 이벤트는 비동기로 처리되어 API 소요 시간이 0.3초로 단축된 것을 확인할 수 있습니다.

 

그럼 위에서 사용했던 @TransactionalEventListener에 대해서 이야기 해보겠습니다.

 

@TransactionalEventListener와 @EventListener

@TransactionalEventListener는 이벤트를 발행한 메서드의 트랜잭션의 상태에 따라 발생된 이벤트를 처리유무를 결정하는 어노테이션 입니다.

이벤트를 발행한 메서드의 트랜잭션이 성공하면 발생된 이벤트가 수행되고,

이벤트를 발행한 메서드의 트랜잭션이 실패하면 발생된 이벤트가 수행되지 않습니다.

 

반면, @EventListener는 이벤트를 발행한 메서드의 트랜잭션 상태에 관계 없이 무조건 발행된 이벤트를 처리합니다.

간단한 예시로 @TransactionalEventListener가 필요한 상황과 @EventListener가 필요한 상황을 살펴보겠습니다.

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberSignupService {

    private final MemberProcessor memberProcessor;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public MemberSignupDto.Info signup(final MemberSignupDto.Command request) {
        /** 1. 회원 가입 **/
        MemberSignupDto.Info info = memberProcessor.register(request);

        /** 2. 회원가입 완료 후 이메일 전송 **/
        String registeredEmail = info.getEmail();
        eventPublisher.publishEvent(SignupEvent.of(registeredEmail));
        
        /** 3. 비즈니스 로직을 수행하다가 예외 발생 **/
        if (....) {
            throw new RuntimeException("예외 발생")
        } 

        return info;
    }
}

 

signup메서드는 @Transactional어노테이션을 통해 하나의 트랜잭션으로 응용 로직이 수행되고 있습니다.

 

여기서 1. 회원 가입2. 회원가입 완료 후 이메일 전송 기능이 정상 수행된 이후 3. 비즈니스 로직을 수행하다가 예외 발생 에서 예외가 발생한다면, 하나의 트랜잭션으로 묶여있기 때문에 수행되었던 트랜잭션이 모두 롤백되게 됩니다.

 

이 때, 예외가 발생되더라도 이벤트를 발행하고 처리해야한다면 @EventListener을 사용하면 되고,

이벤트가 발행된 스레드의 트랜잭션과 상태에 따라 수행되어야 한다면 @TransactionalEventListener을 사용할 수 있습니다.

 

@TransactionalEventListener은 다양한 옵션 제공하지만 오늘은 트랜잭션 상태에 따라 수행할 수 있는 phase옵션을 살펴보겠습니다.

 

@TransactionalEventListener의 phase 옵션

@TransactionalEventListener에서 phase옵션은 아래와 같이 트랜잭션의 어느 시점에 이벤트를 실행할지를 정의합니다.

  • BEFORE_COMMIT:
    • 트랜잭션이 커밋되기 직전에 이벤트를 실행
    • 트랜잭션 내에서 변경된 데이터를 확인하거나 추가적인 작업을 할 때 사용
  • AFTER_COMMIT(default):
    • 트랜잭션이 성공적으로 커밋된 후에 이벤트를 실행
    • 트랜잭션이 완료되었고, 데이터베이스에 변경 사항이 반영된 상태
  • AFTER_ROLLBACK:
    • 트랜잭션이 롤백된 후에 이벤트를 실행
    • 롤백은 이 경우에 대한 처리를 수행
  • AFTER_COMPLETION:
    • 트랜잭션이 성공적으로 완료된 후에 이벤트를 실행
    • 트랜잭션이 성공적으로 커밋되었든 롤백되었든 간에 마지막 단계에서 수행할 수 있는 후처리 작업에 사용
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void handleBeforeCommit(MyEvent event) {
        // 트랜잭션이 커밋되기 직전에 실행될 로직
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleAfterCommit(MyEvent event) {
        // 트랜잭션이 성공적으로 커밋된 후에 실행될 로직
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleAfterRollback(MyEvent event) {
        // 트랜잭션이 롤백된 후에 실행될 로직
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void handleAfterCompletion(MyEvent event) {
        // 트랜잭션이 완료된 후에 실행될 로직
    }

 


 

추가로 궁금했던 것

이벤트 리스너 매개변수가 많다면 전부 호출될까?

스프링은 이벤트 리스너 메서드가 호출되는 기준으로 메서드의 매개변수 타입을 사용한다고 했는데,

그러면 매개변수 타입(이벤트 객체)가 동일한 이벤트 리스너가 2개 이상이라면 어떻게 처리될까요?

 

그래서 직접 만들어봤습니다.

 

 

실행 결과

 

실행 결과 스프링은 해당 매개변수 타입(이벤트 객체)과 일치하는 이벤트가 발생할 때 매개변수 타입이 일치하는 모든 리스너 메서드를 호출하는 것을 확인할 수 있습니다.

 

만약 여러 개의 이벤트 타입이 있고, 각 이벤트 타입에 대해 매개변수 타입이 동일한 메서드들이 존재한다면, 스프링은 각 이벤트가 발생할 때 해당 매개변수 타입과 일치하는 모든 메서드를 호출합니다.

 

이를 통해서 스프링의 이벤트 리스너 메커니즘이 매우 유연하고 다양한 이벤트 처리 방식을 지원한다는 것을 알 수 있었습니다.

 


 

Github 소스 코드는 아래에서 확인할 수 있습니다.

https://github.com/yoonseon12/spring-boot-layered/commit/f3eec6c3dff0d4dc38372f03becb08072eab61cd

 

댓글