개발일기/Spring

“Spring Event”, 세부적으로 조작해보자.

ignuy 2024. 9. 25.

바로 직전 포스팅의 일부분을 복습해보자.

Spring Event를 활용하게 되면 SRP, OCP 등 객체 지향 원칙을 준수하며 확장성 있는 서비스를 구축할 수 있다. 이벤트를 손쉽게 추가하거나 수정하여 새로운 기능을 도입하기도 쉽고 코드가 더 명확해진 것을 볼 수 있다. 하지만 Spring Event의 도입이 무조건 장점만 있는 것은 아니다. Spring Event의 여러 특성을 잘 알고 활용해야 한다.

멀티 캐스팅 관계

Spring Event는 기본적으로 “멀티 캐스팅” 관계이다. 하나의 이벤트 발행자의 반대편에는 다수의 소비자가 존재할 수 있다는 뜻이다. 따라서 동일한 타입의 여러 리스너가 등록되었다면 모든 리스너가 이벤트를 받게 된다.

이벤트를 뿌린다고?

단순히 ‘오.. 훌륭한데?’라고 감탄만 할 문제가 아니다. Spring Event를 동기식으로 구축하느냐 비동기식으로 구축하느냐는 이 “멀티 캐스팅”관계이기 때문에 복잡해지기 시작한다.

 

Spring Event를 동기식으로 구축했을 때는 Event 선언 순서에 의해 로직의 동작 순서가 강력하게 통제된다. 늘, 동일한 처리 순서를 보장하므로 특정 이벤트 처리가 다른 이벤트 처리의 전제 조건일 때 유용하게 사용할 수 있다. 이렇듯 코드의 흐름을 쉽게 이해할 수 있다는 장점이 있지만 많은 이벤트를 동시에 처리해야 하는 상황이라면 하나의 프로세스가 너무 긴 응답시간을 가지게 되는 부작용이 생길 수 있다.

 

비동기식으로 구축했을 때는 반대로 시스템의 응답성을 향상할 수 있지만 데이터의 일관성을 유지해야 하고 처리 순서를 반드시 지켜야 하는 작업에서는 약점을 드러낸다.

 

따라서 이벤트 처리 순서가 중요한 경우, 동기 방식에서는 @Order 어노테이션으로 순서를 지정할 수 있고, 비동기 방식에서는 동시성 유틸리티를 활용하거나 별도의 순서 관리 메커니즘을 개발해야 한다. 또한, 여러 스레드가 같은 데이터에 동시에 액세스 할 위험이 있을 때는 적절한 동기화 기법을 적용해야 데이터 일관성을 보장할 수 있다.

Spring event의 동기 처리

Spring Event는 이름이 “Event”여서 그런지 기본적으로 비동기식일 거라고 오해할 수 있다. 하지만 이벤트를 발행하는 스레드와 소비하는 스레드의 로그를 확인하면 동일한 스레드 하에서 동기식으로 동작하는 것을 볼 수 있다.

동기식으로 동작하는 사실을 계속해서 강조하는 이유는 “트랜잭션” 때문이다. Event를 발행한 곳에서 시작된 트랜잭션이 다른 소비자로 넘어가도 하나의 트랜잭션으로 동작한다. 즉, 트랜잭션에 의해 이벤트 발행자와 이벤트 소비자가 하나의 트랜잭션 범위로 묶이게 되는 것이다.

 

이벤트 처리가 하나의 트랜잭션 범위로 묶인다면 개발자는 트랜잭션의 상태를 이용하여 Spring Event구현을 더 세부적으로 수행할 수 있다. 방법은 아래와 같이 @TransactionalEventListener 애노테이션을 이용한다.

// 1. default, 트랜잭션이 commit되면 수행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
// 2. rollback시 수행
@TransactionalEventListener(phase = TransactionPhase.ROLLBACK)
// 3. completion(commit 또는 rollback)시 수행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
// 4. commit 되기 "전" 수행
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

phase 옵션을 통해서 위와 같은 역할의 Event를 수행할 수 있다.

TransactionPhase.AFTER_COMMIT

이 경우, 이벤트 발행자가 트랜잭션을 사용하는 경우, 트랜잭션이 커밋되지 않으면 이벤트 리스너가 처리되지 않는다. 트랜잭션이 롤백될 경우에도, 이벤트 발행 역시 취소되므로 이벤트 리스너에서 정의된 로직이 실행되지 않는다.

phase를 지정하지 않으면 디폴트 설정으로 이 동작을 수행한다.

케이리버스에 어울리는 옵션

앞서 살펴보았단 케이리버스의 로그인 Event 설계에 따르면 AFTER_COMMIT 옵션이 가장 이상적 이어 보인다. 사용자가 Goal 100을 성공적으로 얻었다면 이벤트를 발행하여 알림 서비스를 간접적으로 부를 수 있도록 구현하는 것이 좋아 보인다.

public void signIn(User user) {
    user.setGoal(user.getGoal() + 100);
    
    // 이벤트 발행, 트랜잭션 내에서 동기적으로 실행
    eventPublisher.publishEvent(new UserSignedInEvent(user));
}
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {

    private final NotificationService notificationService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendSignedInNotification(UserSignedInEvent event) {
        // 이벤트에서 사용자 정보를 가져와서 알림 전송
        log.info("[NotificationListener - sendSignedInNotification]")
        User user = event.getUser();
        notificationService.sendNotification(user.getId());
    }
}

Spring Event의 비동기 처리

Event를 비동기 방식으로 처리한다는 뜻은 동기 방식과 다르게 더 이상 하나의 트랜잭션으로 묶일 수 없다는 것을 의미한다. Spring Event의 발행자와 소비자가 별도의 흐름을 가지고 각자의 로직을 처리하게 된다.

구현 방법은 간단하다. @Async 메소드를 붙이기만 하면 된다.

@EventListener
@Async
public void sendSignedInNotification(UserSignedInEvent event) {
    // 비동기적으로 실행
    log.info("[NotificationListener - sendSignedInNotification]")
    User user = event.getUser();
    notificationService.sendNotification(user.getId());
}

Event 처리 타이밍이 중요한 문제라면 동기 구현을 고민하거나 특별히 다른 작업(ex. 비동기 스레드를 잠시 멈춰두는 코드 삽입)을 고민하자.

※ 비동기 실행이 안된다면 @EnableAsync로 비동기 설정을 먼저 활성화해야 한다.

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
    // 비동기 처리 설정을 추가
}

Spring Event 기본 처리를 비동기로 바꾸고 싶다면

일부 Event만 비동기처리 해야 하는 상황이라면 일일이 @Async 애노테이션을 붙여주며 처리하면 되지만 모든 메시지가 기본적으로 비동기 처리되어야 하는 상황을 가정해 보자. 수많은 이벤트에 애노테이션을 붙여주는 것은 골치 아픈 일이다.

 

Spring 환경을 천천히 까고 들어가면 이런 번거로움을 해결할 방법을 발견할 수 있다. Spring Context 내에서 Event가 발행됐을 때 여러 리스너에게 이벤트를 전달하기 위해 내부적으로 ApplicationEventMulticaster라는 빈을 사용한다.

내가 직접 그렸다. 한땀한땀. 정성스럽게.

Spring Container에서는 ApplicationEventMulticaster을 만들 때, applicationEventMulticaster라는 “이름”의 빈을 찾는데, 만약 사용자가 이를 커스터마이징하지 않았다면, Spring Container는 기본적으로 SimpleApplicationEventMulticaster를 사용해 자동으로 빈을 등록한다.

공감 눌러야겠지?

이 생명 주기를 이해했다면 applicationEventMulticaster의 생성과정에 간섭하여 기본 설정을 비동기 처리로 바꿀 수 있음을 이해할 수 있을 것이다.

댓글 써야겠지?

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.SimpleApplicationEventMulticaster;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import static org.springframework.context.support.AbstractApplicationContext.APPLICATION_EVENT_MULTICASTER_BEAN_NAME;

@Configuration
public class EventConfig {

    @Bean(name = APPLICATION_EVENT_MULTICASTER_BEAN_NAME)
    public SimpleApplicationEventMulticaster simpleApplicationEventMulticaster(TaskExecutor taskExecutor) {
        SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();
        // 비동기 처리를 위한 TaskExecutor 설정
        eventMulticaster.setTaskExecutor(asyncExecutor);  
        return eventMulticaster;
    }

    @Bean
    public TaskExecutor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);   // 최소 스레드 수
        executor.setMaxPoolSize(10);   // 최대 스레드 수
        executor.setQueueCapacity(25); // 큐 용량
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(10);
        executor.initialize();
        return executor;
    }
}

 

댓글