개발을 하다보면 너무 많은 의존성이 엮어 있어 가독성도 떨어지고, 단위테스트를 작성하기 어려운 객체를 만나곤 합니다. 이런 상황에서 스프링 이벤트가 도움이 될 수 있습니다.
스프링 이벤트는 Observer Pattern으로 구현된 기술로 객체 간 강결합 의존성을 떼어내기 위해 사용합니다. 해당 객체의 주 관심사가 아닌 로직과의 결합을 느슨하게 만들어 주 관심사에 집중할 수 있게 해줍니다.
1. Spring Event를 사용하기 전 알아두면 좋은 것들!
1-1. Observer Pattern
객체의 상태 변화를 관찰하는 관찰자의 목록을 객체에 등록하여 피관찰되는 객체의 상태 변화가 있을 때마다 메시지 교환을 통해 객체가 직접 목록의 각 옵저버에게 알리도록 하는 디자인 패턴입니다.
옵저버 패턴의 구성 요소는 Observable, Observer로 나눌 수 있습니다.
- Observable은 의존 대상이 되는 피관찰자입니다.
- Observer는 의존하고 있는 관찰자입니다.
- Observable은 여러 Observer를 등록할 수 있습니다. (registry) (Observable:Observer = 1:N)
- Observable에서 이벤트가 발생하면, Observable은 자신에게 등록된 모든 Observer에게 이를 알립니다. (notify)
- 실제 비즈니스 로직의 예를 들어 설명해보도록 하겠습니다.
- 예를 들어, OrderEvent라는 Observable이 존재하고 그것을 관찰하는 Observer인 PointService, CouponService, SupporterService 등이 있다고 가정해봅시다.
- Observer들은 OrderEvent라는 이벤트를 @EventListener 등의 기능을 통해 등록해놓습니다. (registry)
- OrderService에서 주문을 진행하면 OrderEvent를 라는 이벤트가 발생합니다. (notify) 이제 Observer들은 이벤트를 수신해 각자의 로직을 수행합니다.
1-2. Modular Monolithic Architecture
스프링 이벤트가 속한 프로젝트인 Spring Modulith는 Modular Monolithic Architecture를 지원하는 라이브러리입니다. 해당 기능이 어떤 철학을 바탕으로 만들어졌는지 공부해보면 좋을 것 같습니다.
링크: Modular Monolithic Architecture를 설명한 블로그
2. Spring Event
2-1. Spring Event란?
Spring Event는 Spring Modulith 프로젝트의 일부분입니다.
Spring Modulith는 마이크로서비스 아키텍처의 장점을 활용하면서도 단일 모놀리식 애플리케이션 구조 내에서 모듈화된 개발을 하자는 Modular Monolithic Architecture에서 시작된 프로젝트입니다.
스프링 이벤트는 각 모듈 간의 의존성을 줄여 모듈 설계에 유연함을 부여하는 기술입니다.
2-2. 특징
1. 스프링 이벤트는 멀티캐스팅입니다. 이벤트 하나가 발생하면 다수의 사용자가 수신할 수 있습니다.
2. 기본적으로 동기 방식으로 동작합니다.
3. 트랜잭션을 결합할 수 있습니다. 트랜잭션을 하나의 범위로 묶어서 사용할 수 있습니다. 이벤트를 구독하는 곳과 동일한 트랜잭션을 공유할 수 있다는 얘깁니다. 위에서 설명한 TransactionPhase 설정을 통해 트랜잭션에 관여할 수 있습니다.
- 스프링 이벤트를 사용했을 때의 장단점은 아래와 같습니다.
장점 | 단점 |
의존성을 분리하여 느슨하게 결합할 수 있습니다. | 코드를 파악하기 어렵습니다. 이벤트를 파악하기 위해 이벤트를 구독하는 모든 메소드를 찾아다녀야할 수도 있습니다. |
서비스 로직을 분리하기 쉽습니다. | 로직의 순서를 고려해야할 경우 오히려 처리하기 어려워질 수 있습니다. |
단위 테스트가 쉬워집니다. | 전체적인 이벤트 발행/구독 과정을 정확히 파악하기 어렵기 때문에 통합 테스트가 어려워집니다 |
2-3.언제 사용해야 할까?
예를 들어 주문 도메인이 있다고 해봅시다. 주문은 회원, 포인트, 쿠폰, 결제, 알림, 서포터즈, 제품 등등 너무 많은 의존성이 엮어 있어 관리가 힘든 상태입니다.
@Server
@RequiredArgsConstructor
public class OrderService {
private final MemberService memberService;
private final ProductService productService;
private final PaymentService paymentService;
private final PointService pointService;
private final CouponService couponService;
private final NotifiactionService notifiactionService;
}
이 때 의존성을 떼어내고 주문이라는 관심사에 집중하기 위해 스프링 이벤트를 사용해볼 수 있습니다. 주문은 회원, 제품, 결제 정보 없이는 로직 진행이 안되기 때문에 의존성을 남겨두기로 하고 나머지 포인트 및 쿠폰 사용 및 적립, 알림 등을 이벤트로 떼어내기로 결정했습니다.
이제 수많은 의존성이 엮어있던 주문의 서비스 의존성이 줄어든 것을 확인할 수 있습니다. 굳이 쿠폰, 포인트, 알림 서비스의 메소드를 호출할 필요 없이 이벤트만 발행하면 로직이 자동으로 실행되는 마법같은 일이 벌어집니다.
@Server
@RequiredArgsConstructor
public class OrderService {
private final MemberService memberService;
private final ProductService productService;
private final PaymentService paymentService;
private final OrderEventPublisher orderEventPublisher;
}
물론 스프링 이벤트로 인해 코드 복잡성이 올라갈 수 있습니다. 이벤트 발행 시 사용되는 로직을 이해하기 위해 모든 이벤트 사용처를 일일이 찾아다니면서 확인해야 한다는 불편함이 있습니다.
3. 세부 기능
3-1. ApplicationEventPublisher
ApplicationEventPublisher는 Spring에서 ApplicationContext로 구현됩니다. ApplicationContext는 빈 탐색과 등록, 리소스 처리 등의 역할을 수행하는 구현체입니다.
public interface ApplicationContext extends
EnvironmentCapable,
ListableBeanFactory,
HierarchicalBeanFactory,
MessageSource,
ApplicationEventPublisher,
ResourcePatternResolver {...}
ApplicationEventPublisher는 인터페이스 분리 원칙(Interface Segregation Principle)에 따라 이벤트 발행 책임만 처리하기 때문에 이벤트 발행 기능을 사용할 객체는 의존성 주입을 받을 때 굳이 ApplicationContext 인터페이스를 받지 않고 ApplicationEventPublisher 인터페이스만 주입받아도 됩니다!
3-2. @EventListener
이벤트를 수신할 때 사용하는 어노테이션입니다. @EventListener가 달려 있는 메소드는 이벤트 객체 파라미터를 필수로 넣어야 합니다. 이벤트 객체 파라미터를 넣지 않으면 실행 과정에서 에러가 발생하기 때문입니다.
@EventListener는 호출 시점에 바로 실행된다는 특징이 있습니다.
@Service
@RequiredArgsConstructor
public class SupporterService {
@EventListener
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
3-3. @TransactionalEventListener
트랜잭션과 연관된 이벤트 리스너입니다. 트랜잭션을 사용하지 않으면 이벤트를 수신하지 않는 특징이 있습니다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)에 감싸인 메소드에서 이벤트를 발행하면 @TransactionalEventListner는 이벤트를 수신하지 않습니다. 왜 그럴까요?
@TransactionalEventListner는 트랜잭션 커밋, 롤백, 종료 시점 등이 트리거가 되는데, 트랜잭션이 실행되지 않은 상태면 트리거가 작동하지 않기 때문에 이벤트가 당연히 수신되지 않습니다.
@TransactionalEventListner에는 phase라는 속성이 있습니다. 트랙잭션 페이즈의 종류는 4가지가 있고, 사용자는 원하는 시점을 선택해 설정하면 됩니다. 기본값은 TransactionPhase.AFTER_COMMIT 입니다.
- TransactionPhase
TransactionPhase | 설명 |
BEFORE_COMMIT | 트랜잭션 커밋이 되기 전에 실행된다. |
AFTER_COMMIT | 트랜잭션 커밋이 되면 실행된다. |
AFTER_ROLLBACK | 트랜잭션 롤백이 되면 실행된다. |
AFTER_COMPLETION | 트랜잭션이 끝난 이후에 실행된다. |
4. 코드 예시
주문을 진행하는 코드입니다.
- 주문 서비스에서는 이벤트 발행 책임을 분리하기 위해 orderEventPublisher 객체를 따로 생성해 주입받고 있습니다.
- orderEventPublisher는 ApplicationEventPulisher를 주입받습니다. 이벤트 객체를 만들고 ApplicationEventPulisher의 publishEvent() 메소드를 사용해 이벤트를 발행합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
...
private final OrderEventPublisher orderEventPublisher;
@Transactional
public Order order(final OrderRequest request) {
...
orderEventPublisher.publishOrderEvent(order);
return order;
}
...
}
@Component
@RequiredArgsConstructor
public class OrderEventPublisher {
private final ApplicationEventPulisher eventPublisher;
public void publishOrderEvent(final Order order) {
final OrderEvent orderEvent = OrderEvent.from(order);
eventPublisher.publishEvent(orderEvent);
}
}
@Service
@RequiredArgsConstructor
public class SupporterService {
@EventListener
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
4-1. 트랜잭션 시점을 조정하고 싶다면
트랜잭션의 시점을 조절하여 이벤트를 수신하고 싶다면 위에서 설명했던 @TransactionalEventListener의 phase 속성을 사용하시면 됩니다!
@Service
@RequiredArgsConstructor
public class SupporterService {
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
4-2. 비동기 이벤트 수신
단순히 AsyncConfig를 만들고 아래와 같이 비동기로 처리하고 싶은 이벤트 리스너에 @Async만 붙이면 되겠지? 라고 생각하시면 안 됩니다.
@Service
@RequiredArgsConstructor
public class SupporterService {
@Async
@TransactionalEventListener
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
하지만 위의 방식은 위험합니다. 트랜잭션이 분리되어 있지 않기 때문에 외부에서 Exception이 발생할 시, 이벤트 수신 메소드의 작업이 롤백되어 버릴 수 있습니다. (@Transactional 로직 상, 같은 트랜잭션 안에서 Exception이 발생하면 해당 트랜잭션의 속한 모든 작업을 롤백시켜 버림)
아래와 같이 트랜잭션 전파 레벨을 REQUIRES_NEW로 하면 트랜잭션이 분리되어 안전하게 처리할 수 있습니다.
@Service
@RequiredArgsConstructor
public class SupporterService {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
5. 여기서 이러시면 안 됩니다
5-1. 트랜잭션을 사용하지 않는데 @TransactionalEventListener로 이벤트를 수신하려는 경우
모종의 이유로 주문 로직에서 트랜잭션을 사용하지 않는다고 가정해봅시다.
@Service
@RequiredArgsConstructor
public class OrderService {
...
private final OrderEventPublisher orderEventPublisher;
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Order order(final OrderRequest request) {
...
orderEventPublisher.publishOrderEvent(order);
return order;
}
...
}
트랜잭션을 사용하지 않는 메소드로부터 @TransactionalEventListener을 사용해 이벤트를 수신하려고 하면 아무런 일도 발생하지 않습니다. @TransactionalEventListener는 트랜잭션이 롤백되거나 커밋 혹은 종료되는 시점을 기준으로 이벤트가 실행되는데 트랜잭션 자체가 없으면 이벤트 트리거가 발동되지 않기 때문입니다.
@Service
@RequiredArgsConstructor
public class SupporterService {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
따라서 트랜잭션을 사용하지 않는다면 아래와 같이 @EventListener를 사용합시다.
@Service
@RequiredArgsConstructor
public class SupporterService {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@EventListener
public void onOrderEvent(final OrderEvent orderEvent) {
...
}
}
5-2. 이벤트 발행이 소비자 종속적인 경우
만약 아래 그림과 같이 주문, 회원이 서포터즈 이벤트 발행자에게 의존하고 있는 모양이라면 어떻게 보이시나요?
이벤트 소비자가 주체가 되어 각각의 서비스에게 이벤트를 뜯어내고 있는 형국입니다.
나중에 이벤트가 늘어나면 늘어날 수록 그만큼 이벤트 발행자를 의존해야 하기 때문에 사실상 Service를 의존하는 것과 다를 바 없습니다. 이렇게 되면 도메인 간 강결합 의존성을 떼어낸다는 취지에 완전히 어긋나버리게 됩니다.
- 잘못된 예시
@Service
@RequiredArgsConstructor
public class OrderService {
private final SupporterEventPublisher supporterEventPublisher;
public void order() {
...
supporterEventPublisher.publishOrderEvent(order);
}
}
@Service
@RequiredArgsConstructor
public class MemberService {
private final SupporterEventPublisher supporterEventPublisher;
public void signUp() {
...
supporterEventPublisher.publishSignUpEvent(member);
}
}
@Service
@RequiredArgsConstructor
public class SupporterService {
@EventListener
public void onSignUp(final SignUpEvent signUpEvent) {...}
@EventListener
public void onOrderEvent(final OrderEvent orderEvent) {...}
}
이벤트 발행은 해당 도메인이 주체가 되어 발행하는 것이 자연스럽습니다.
이벤트 소비자는 이벤트를 발행시켜 가져오는 역할을 하기보다는 그저 이벤트가 발행되기만을 기다리는 것이 좋습니다. 소비자는 이벤트를 수신받을 뿐인 불특정 다수라는 사실을 인지합시다.