[Spring] Spring Event 스레드의 동작원리 (동기/비동기)
Spring Event 스레드의 동작원리를 이해해 보자
📌 서론
스프링 이벤트(Application Events)는 기본적으로 발행자(publisher)와 리스너(listener)가 같은 스레드에서 동작한다. 즉, 이벤트를 발행하는 메서드를 호출하면 그 호출을 처리하는 스레드가 이벤트 리스너도 처리하는 것이 기본 동작이다. 이번 포스트에서는 스프링 이벤트와 스레드의 동작 방식을 자세히 알아보자
1. 스프링 이벤트와 스레드 동작 방식 (1개의 이벤트 리스너)
스프링 이벤트의 스레드 동작(동기)
기본적으로, 스프링에서 이벤트를 발행하면, 그 이벤트는 기존 로직을 처리하고 있던 같은 스레드에서 처리된다.
예를 들어 어떤 컨트롤러 메서드에서 이벤트를 발행했다면, 그 이벤트에 대한 처리도 요청을 처리하고 있던 그 컨트롤러 메서드와 동일한 스레드에서 실행된다는 것이다.
이 방식은 이벤트 처리가 요청 처리 흐름을 방해하지 않고, 간단한 상황에서 잘 작동하지만, 이벤트 처리에 시간이 많이 소요되는 경우 요청 처리 시간도 함께 증가하게 된다.
이벤트 발행과 스레드 동작 예시(동기)
- 사용자가 로그인 요청을 보냄.
- 서버의 컨트롤러 메서드에서 로그인 처리 후, 로그인 성공 이벤트를 발행함.
- 동일 스레드에서 이벤트 리스너가 활성화되어, 사용자에게 환영 이메일을 보내는 처리를 함.
- 이메일 발송 처리가 완료된 후에야, 사용자에게 로그인 성공 응답을 반환함.
스프링 이벤트의 스레드 동작(비동기)
스프링은 @Async 어노테이션을 사용해 비동기 이벤트 처리도 지원한다.
이를 활용하면, 이벤트 처리를 별도의 스레드에서 실행할 수 있다. 즉, 이벤트를 발행한 후, 실제 이벤트 처리는 스프링이 관리하는 스레드 풀에서 가져온 다른 스레드에서 비동기적으로 수행하게 된다.
이 방식은 이벤트 처리 로직이 메인 로직의 처리를 방해하지 않으므로, 애플리케이션의 응답성을 향상시킬 수 있다. 그러나, 비동기 처리 시에는 실행 순서가 보장되지 않고, 동시성 문제에 대한 고려가 필요하다.
이벤트 발행과 스레드 동작 예시(비동기)
- 사용자가 로그인 요청을 보냄.
- 서버의 컨트롤러 메서드에서 로그인 처리 후, 로그인 성공 이벤트를 발행함.
- 다른 스레드에서 이벤트 리스너가 활성화되어, 사용자에게 환영 이메일을 비동기적으로 보내는 처리를 시작함.
- 메인 로직은 이메일 발송 처리와 동시에 진행되므로, 사용자에게 바로 로그인 성공 응답을 반환할 수 있음. 이메일 발송은 백그라운드에서 계속 진행됨.
2. 스프링 이벤트와 스레드 동작 방식 (N개의 이벤트 리스너)
이벤트 리스너가 여러 개인 경우(동기)
만약 이벤트에 대해 여러 리스너가 등록되어 있고, 그 이벤트가 발행되면, 스프링은 등록된 리스너들을 순차적으로 호출한다.
동기 방식인 경우, 모든 리스너는 발행한 이벤트를 처리하고 있던 원본 스레드에서 순서대로 실행된다.
즉, 첫 번째 리스너가 완료되기 전까진 두 번째 리스너가 시작되지 않고, 두 번째가 완료되기 전까진 세 번째 리스너가 시작되지 않는 식으로 순차적으로 처리된다는 것이다.
이벤트 리스너가 여러 개인 경우(비동기)
반면에, 리스너들이 비동기 방식(@Async 사용)으로 처리되도록 설정된 경우, 각 리스너는 독립적인 스레드에서 동시에 실행된다.
이 경우, 리스너들 간의 실행 순서는 보장되지 않으며, 모든 리스너가 독립적으로 실행되어 처리 시간을 단축시킬 수 있다. 하지만, 이 때는 리스너들 사이에서 공유하는 데이터에 대한 동시성 관리가 중요하다.
이벤트 리스너가 여러 개인 경우 코드 예시
동기 방식일 때
- 컨트롤러를 만들고 service에서 checkThread() 메서드를 호출한다.
@RequiredArgsConstructor
@RestController
public class ThreadCheckController {
private final ThreadCheckService threadCheckService;
@GetMapping("/checkThread")
public void checkThread() throws InterruptedException {
threadCheckService.checkThread();
}
}
- 서비스에서는 Thread.sleep으로 1초간 대기시킨 후 Spring 이벤트를 발행한다.
@Slf4j
@RequiredArgsConstructor
@Service
public class ThreadCheckService {
private final ApplicationEventPublisher eventPublisher;
// 이벤트를 발행하는 메서드
public void checkThread() throws InterruptedException {
log.info("========== 스프링 이벤트 발행 메서드 호출완료 ==========");
// 1초간 대기한다.
Thread.sleep(1000);
// 이벤트를 발행한다.
eventPublisher.publishEvent(new ThreadCheckEvent("Thread Check"));
log.info("========== 스프링 이벤트 발행 메서드 종료 ==========");
}
}
- 여기서 발행하는 이벤트 코드는 다음과 같다. (단순히 message만 가지고 있다.)
@Getter
@AllArgsConstructor
public class ThreadCheckEvent {
private final String message;
}
- 그럼 이벤트 객체에 알맞은 리스너가 동작하게 된다. (매게 변수 타입이 발행한 이벤트와 같은 리스너가 동작한다.)
@Slf4j
@Component
public class ThreadCheckEventListener {
@EventListener
public void checkThread(ThreadCheckEvent event) {
log.info("첫번째: " + event.getMessage());
}
@EventListener
public void checkThread2(ThreadCheckEvent event) {
log.info("두번째: " + event.getMessage());
}
@EventListener
public void checkThread3(ThreadCheckEvent event) {
log.info("세번째: " + event.getMessage());
}
}
- url에 요청을 보내고 로그를 확인했다. (아래와 같이 동일한 스레드에서 이벤트 리스너가 동작하는 것을 알 수 있다.)
비동기 방식일 때(@Async 사용)
- 동일한 컨트롤러, 서비스 코드를 사용하고 이벤트 리스너 코드만 수정한다. (리스너 메서드에 @Async를 적어준다.)
- 참고로 @EnableAsync를 설정해 줘야 @Async 비동기 호출이 동작한다. (스프링 부트 설정 클래스를 만들고 등록하면 된다.)
@Slf4j
@Component
public class ThreadCheckEventListener {
@Async
@EventListener
public void checkThread(ThreadCheckEvent event) {
log.info("첫번째: " + event.getMessage());
}
@Async
@EventListener
public void checkThread2(ThreadCheckEvent event) {
log.info("두번째: " + event.getMessage());
}
@Async
@EventListener
public void checkThread3(ThreadCheckEvent event) {
log.info("세번째: " + event.getMessage());
}
}
- 로그 출력 결과는 다음과 같다. (모두 다른 스레드에서 처리된다.)
3. 이벤트 리스너의 처리 순서를 보장할 수는 없을까?
동기 방식에서의 이벤트 처리 순서 지정
동기 방식에서 스프링 이벤트 처리 순서를 지정할 때는 @Order 어노테이션을 사용한다. 간단히 이 어노테이션을 이벤트 리스너에 붙여주면 된다. @Order는 숫자 값을 받아서, 숫자가 낮을수록 높은 우선순위를 가지게 되고 스프링은 이 우선순위에 따라 리스너를 순서대로 호출한다.
예를 들면, 로그인 성공 이벤트 처리를 로깅 처리보다 먼저 실행하고 싶다면 로그인 처리 리스너에 @Order(1), 로깅 처리 리스너에 @Order(2) 이런 식으로 설정하면 된다.
코드로 이해하기
- 위에서 작성한 동기식 이벤트 리스너에 @Order를 통해 역순으로 순서를 정해줬다.
@Slf4j
@Component
public class ThreadCheckEventListener {
@Order(3)
@EventListener
public void checkThread(ThreadCheckEvent event) {
log.info("첫번째: " + event.getMessage());
}
@Order(2)
@EventListener
public void checkThread2(ThreadCheckEvent event) {
log.info("두번째: " + event.getMessage());
}
@Order(1)
@EventListener
public void checkThread3(ThreadCheckEvent event) {
log.info("세번째: " + event.getMessage());
}
}
- 로그를 확인해 보니 @Order가 잘 적용되어 세 번째 -> 두 번째 -> 첫 번째 순으로 동작하는 것을 확인할 수 있었다.
비동기 방식에서의 이벤트 처리 순서 지정
비동기 방식에서는 @Order 어노테이션이 직접적으로 적용되지 않는다. 비동기 이벤트 처리 순서를 관리하려면 좀 더 창의적인 방법이 필요하다.
1. 자바의 동시성 유틸리티 사용
- CompletableFuture, CountDownLatch 같은 자바의 동시성 유틸리티를 활용해 특정 이벤트 처리가 완료될 때까지 다른 이벤트 처리를 지연시킬 수 있다.
2. 이벤트 순서 관리 컴포넌트 구현
- 이벤트를 관리하는 별도의 컴포넌트를 만들어서 이벤트 처리 전에 순서를 체크하고 조정할 수 있다. 예를 들어, 이벤트 큐를 만들어서 순서대로 처리하는 방식이 있을 것이다.
3. 동시성 문제 대비
- 여러 리스너가 동시에 같은 데이터에 접근할 수 있는 경우, 데이터 일관성을 유지하기 위해 동기화 메커니즘을 사용해야 한다. synchronized, lock, ConcurrentHashMap 같은 Thread-safe 컬렉션을 활용하면 동시성 문제를 해결할 수 있다.
이러한 접근 방식은 비동기 이벤트 처리의 복잡성을 관리하는 데 중요하며, 이벤트 처리 로직이 예측 가능하고 안정적으로 동작하도록 해준다. 따라서, 이벤트 처리 순서가 중요하거나 여러 리스너가 같은 데이터에 동시에 액세스 하는 상황에서는 이러한 추가적인 고려사항과 구현 전략을 반드시 염두에 두어야 한다.
4. 정리: 스프링 이벤트 동기/비동기 처리 전략의 중요성
동기 방식
동기 방식은 이벤트가 발생하면 바로 그 순간 처리되는 방식이다.
이 방식의 장점은 코드의 흐름을 쉽게 이해할 수 있다는 것이다. 처리 순서가 보장되므로, 특정 이벤트 처리가 다른 이벤트 처리의 전제 조건일 때 유용하다. 하지만, 만약 이벤트 처리가 많은 시간을 요구한다면, 이는 전체 시스템의 응답 시간을 늘리고, 결국 사용자 경험을 저하시킬 수 있다. 따라서, 동기 방식은 처리 시간이 짧고, 순서가 중요한 경우에 적합하다.
비동기 방식
비동기 방식은 @Async 어노테이션을 사용해서 이벤트 처리를 메인 로직과는 별개의 스레드에서 실행하게 하는 것이다.
이 방식의 가장 큰 장점은 시스템의 응답성을 크게 향상시킬 수 있다는 점이다. 메인 스레드가 이벤트 처리 로직의 부하에 영향을 받지 않기 때문에, 사용자는 더 빠른 응답을 경험할 수 있다. 하지만, 비동기 방식에서는 이벤트 처리 순서가 자동으로 보장되지 않고, 동시에 여러 작업이 진행될 때는 데이터 일관성을 유지하기 위한 추가적인 조치가 필요하다. 그래서, 비동기 처리를 사용할 때는 순서 보장이나 동시성 문제에 대한 해결 방안을 고민해야 한다.
여러 개의 이벤트 리스너
이벤트 리스너가 여러 개일 때, 동기 방식은 각 이벤트를 순차적으로 처리해서 처리 순서를 자연스럽게 보장하지만, 비동기 방식에서는 순서가 뒤바뀔 수 있다. 이벤트 처리 순서가 중요한 경우, 동기 방식에서는 @Order 어노테이션으로 순서를 지정할 수 있고, 비동기 방식에서는 동시성 유틸리티를 활용하거나 별도의 순서 관리 메커니즘을 개발해야 한다. 또한, 여러 스레드가 같은 데이터에 동시에 액세스 할 위험이 있을 때는 적절한 동기화 기법을 적용해야 데이터 일관성을 보장할 수 있다.