이벤트 구조는 3가지 구성 요소(이벤트 , 발행자 , 구독자)가 필요하고, 이는 스프링에서 쉽게 구현할 수 있다.
1. 이벤트
발행자와 구독자 간에 사용할 이벤트를 아래와 같이 정의할 수 있다. 여기서는 이메일 전송을 위한 이벤트를 예시로 들겠다.
public record SendEmailEvent(String email) {
}
2. 구독자
@EventListener 어노테이션을 통해 이벤트 발생 시, 실행할 작업을 쉽게 정의할 수 있다.
아래 예시에서는 메일 전송 이벤트가 발행될 때, listen 함수 내부에 정의된 작업들이 실행된다.
@Component
@RequiredArgsConstructor
public class SendEmailEventListener {
private final EmailService emailService;
@EventListener
public void listen(SendEmailEvent sendEmailEvent) {
emailService.send(sendEmailEvent.email());
}
}
3. 발행자
스프링의 ApplicationEventPublisher를 사용하여 이벤트를 발행할 수 있다. 아래와 같이 ApplicationEventPublisher를 주입을 받고, publishEvent 함수를 통해서 이벤트를 발행한다.
이벤트 발행 시, 해당 이벤트를 구독 중인 리스너들이 반응하여 정의된 작업을 수행한다.
@Service
@RequiredArgsConstructor
public class CreateUserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher publisher;
@Transactional
public void save() {
userRepository.save(new User("KWY"));
publisher.publishEvent(new SendEmailEvent("KWY@gmail.com"));
}
}
이렇게 발행자와 구독자 간의 이벤트 기반 구조를 스프링에서 쉽게 구현할 수 있다.
또한, 스프링 이벤트는 멀티캐스팅 방식이기 때문에, 같은 이벤트를 구독하는 리스너가 여러 개 있을 수 있으며, 이벤트가 발행되면 해당 이벤트를 구독 중인 모든 리스너가 동작한다.
도메인 간 의존성을 덜 수 있다는 장점 외에도 API 응답 시간 단축을 기대했다. 하지만 스프링 이벤트 방식은 동기 방식으로 동작하기 때문에 리스너들의 작업이 끝난 후에 API가 종료된다. 이를 해결하기 위해, @Async 어노테이션을 통해 이벤트를 비동기 방식으로 동작하도록 만들 수 있다.
Application에는 @EnableAsync 어노테이션을, 리스너 함수에는 @Async 어노테이션을 정의하면 쉽게 이벤트를 비동기 방식으로 동작하게 만들 수 있다.
@EnableAsync
public class ServerApplication {
public static void main(String[] args) { SpringApplication.run(ServerApplication.class, args); }
}
@Component
@RequiredArgsConstructor
public class SendEmailEventListener {
private final EmailService emailService;
@EventListener
@Async
public void listen(SendEmailEvent sendEmailEvent) {
emailService.send(sendEmailEvent.email());
}
}
하지만 @Async 어노테이션은 호출될 때마다 스레드를 생성하기 때문에 이벤트가 발생할 때마다 스레드를 생성, 제거하는 비용이 든다. 이를 해결하기 위해 스레드 풀을 미리 정의하는 방식을 사용한다.
@Configuration
@EnableAsync
public class SpringAsyncConfig {
/*
평소 스레드 최대 3개의 스레드 동작
3개 이상의 스레드 요청이 동시에 들어올 때, 크기가 50인 큐에서 대기
대기 큐가 가득 찼을 때, 최대 10개의 스레드 생성
*/
@Bean
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(3);
taskExecutor.setQueueCapacity(50);
taskExecutor.setMaxPoolSize(10);
return taskExecutor;
}
}
위와 같이 Application이 아닌 Config에 @EnableAsync 어노테이션을 붙이고, 스레드 풀을 정의한다. 이후 이벤트가 발생할 때마다 매번 스레드를 생성하지 않고, Config에 정의했던 스레드 풀에서 스레드를 가져다 쓰고 반납한다.
각 이벤트마다 트랜잭션에 포함이 될 수도, 안 될 수도 있기 때문에, 이를 위해 스프링은 @TransactionEventListener를 제공하고 있다.
@EventListener 대신 사용하면 되며, 옵션은 다음과 같다.
BEFOR_COMMIT : : 이벤트 발행 주체가 커밋이 되기 전에 이벤트 실행
AFTER_COMPLETION : : 이벤트 발행 주체가 커밋 또는 롤백이 된 후에 이벤트 실행
AFTER_COMMIT (default) : 이벤트 발행 주체가 커밋이 되면 이벤트 실행
AFTER_ROLLBACK : 이벤트 발행 주체가 롤백이 되면 이벤트 실행
주의할 점 1
AFTER_ 관련 옵션은 이벤트가 실행될 때, 트랜잭션이 이미 커밋 또는 롤백되어 종료된 상태이다. 따라서 @TransactionEventListener 내부에서 데이터를 저장 또는 수정하는 작업은 DB에 반영되지 않는다.
이를 해결하기 위해서 이벤트 함수에 @Transactional(propagation = Propagation.REQUIRES_NEW) 어노테이션을 정의해서 이벤트가 발생할 때마다 트랜잭션을 새롭게 만들어 줘야 한다.
주의할 점 2
@Transactional(propagation = Propagation.REQUIRES_NEW) 어노테이션이 붙은 이벤트가 N번 발생한다면, N개의 트랜잭션이 생성되어 DB 커넥션을 점유한다.
해당 이벤트가 동기 방식이라면 스레드가 종료(API가 종료)될 때까지 DB 커넥션을 계속 점유한다. 이를 해결하기 위해서 비동기 방식으로 이벤트를 실행함으로써, 각 이벤트가 종료될 때마다 DB 커넥션이 반납되도록 할 수 있다.