티스토리 뷰

업무 중 특정 도메인 객체가 생성되거나 변경되었을 때 이메일 또는 푸시를 전송해야 한다는 요구사항이 생겼고 각 도메인 간 결합을 낮추고 여러 도메인에서 사용할 수 있도록 하기 위해 이벤트 방식을 적용하기로 하였습니다.

 

이벤트 방식을 적용하면서 알게된 내용들을 간단히 정리하고자 합니다. 🙂

1. 스프링 이벤트


[스프링에서 이벤트 발행과 구독]

스프링은 이벤트를 발행하고 구독하는 기능을 제공하고 있는데, 각 로직들을 느슨하게 결합하여 변경 및 추가를 용이하게 하고 재사용성을 높이기 위해 사용합니다.

 

스프링에서 이벤트를 사용하는 방식은 이벤트를 ApplicationContext로 넘기고 Listener가 이를 구독하는 방식입니다. 따라서 애플리케이션 및 컨텍스트의 수명 주기에 연결되는 사용자 지정 작업을 수행할 수 있도록 ApplicationContextEvnet를 확장한 다양한 기본 제공 이벤트가 있습니다.

 

이벤트 발행은 ApplicationEventPublisher 인터페이스를 사용하게 되는데 이는 Spring의 ApplicationContext가 구현한 인터페이스 중 하나이고 디자인 패턴 중 하나인 옵저버(Observer) 패턴의 구현체입니다.

 

[이벤트 장단점]

  • 장점
    • 클래스 간 의존성을 분리하여 느슨한 결합이 가능
    • 클래스가 독립적이므로 재사용성 증가 (추후 별도 서비스 분리 가능)
    • 이벤트 구독 모듈이 추가, 수정되어도 다른 모듈에 영향 적음
    • 단위 테스트 용이
  • 단점
    • 이벤트 클래스, 리스너 생성 등 작업량 증가
    • 코드가 분리되어 있어 흐름 파악이 어려움
    • 이벤트 구독 순서를 고려해야 하는 경우 복잡도 증가
    • 전체 이벤트 발행, 구독 테스트 어려움
    • 특정 프레임워크 API 의존성 증가

이벤트를 사용했을 때 장점은 도메인 간의 의존성이 분리된다는 것입니다. A라는 클래스는 B라는 클래스를 몰라도 되기 때문에 느슨하게 결합되고 재사용성이 증가하며 각각의 클래스 변경에도 용이합니다.

 

하지만 코드가 분리되어 있기 때문에 전체적인 흐름을 파악하기 어렵다는 단점이 있습니다. 또한, 작업량이 증가할 수 있고 복잡도가 증가하기도 합니다.

 

따라서 상황에 맞게 유연한 이벤트 적용이 필요합니다. 예를 들어 결재 승인이 된 경우 메일을 전송해야하는 비즈니스 로직이 있다고 했을 때 결재 도메인에서는 메일 발송에 대한 내용을 몰라도 된다고 생각했고 이벤트를 적용하여 느슨한 의존성을 가지도록 할 수 있습니다. 이렇게 하면 결재 도메인 외 다른 도메인에서도 메일 발송을 추가할 수 있고 메일 발송 대신 푸시 발송을 추가하는 것에도 용이합니다.

 

정리하면

  • 하나의 도메인에 속하며 명시적인 처리 흐름이 있는 경우는 직접 메서드를 호출하는 것이 좋고
  • 여러 도메인에서 공통으로 사용되며 다른 도메인과 연관 없이 분리되어 사용할 수 있고 순서 보장 없이 처리되어도 되는 경우 이벤트를 주체적으로 인지하고 처리하는 것이 더 좋다고 생각합니다.

 

[구성 요소 및 구현 방법]

스프링 이벤트는 크게 Event Class와 이벤트를 발생시키는 Event Publisher 그리고 이벤트를 받아들이는 Event Listener 3가지 요소로 구성되어 있다고 볼 수 있습니다.

 

Event Class

Event Class 는 이벤트를 처리하는데 필요한 데이터를 가지고 있으며 기존에는 ApplicationEvent 클래스를 상속 받아 사용하였지만 스프링 프레임워크 4.2 버전부터 ApplicationEvent를 확장할 필요가 없어졌습니다.

// 스프링 프레임워크 4.2 버전 이전
public class DomainEvent extends ApplicationEvent {

    private String key;

    public DomainEvent(Object source, String key) {
        super(source);
        this.key = key;
    }

    public String getKey() {
        return key;
    }
}

// 스프링 프레임워크 4.2 버전부터
public class DomainEvent {

    private String key;

    public DomainEvent(String key) {
        this.key = key;
    }

    public String getKey() {
        return key;
    }
}

 

EventPublisher

EventPublisher는 ApplicationPublisher 빈을 주입하여 publishEvent() 메서드를 통해 생성된 이벤트 객체를 넣어주어 발행할 수 있습니다.

@Slf4j
@Service
public class DomainEventPublisher {

    ApplicationEventPublisher publisher;

    public DomainEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.publisher = applicationEventPublisher;
    }

    public void publish(String key) {
        // 로직 처리
        log.info("로직 처리 [key : {}]", key));
        publisher.publishEvent(new DomainEvent(key));
        
        // 4.2 버전 이전에서 event class가 ApplicationEvent를 구현하는 경우
        // publisher.publishEvent(new DomainEvent(this, key));
    }
}

 

EventListener

EventListener는 발생한 이벤트를 캐치하여 이후 로직을 수행할 수 있는데 이 또한 스프링 프레임워크 4.2 버전을 전후로 구현 방법이 달라졌습니다.

// 스프링 프레임워크 4.2 버전 이전
@Slf4j
@Component
public class DomainEventListener implements ApplicationListener<DomainEvent> {

    @Override
    public void onApplicationEvent(DomainEvent event) {
        ...
    }
}

// 스프링 프레임워크 4.2 버전부터
@Slf4j
@Component
public class DomainEventListener {

    @EventListener
    public void sendPush(DomainEvent event) throws InterruptedException {
        log.info("푸시 메세지 발송 [key : {}]", event.getKey());
    }

    @EventListener
    public void sendMail(DomainEvent event) throws InterruptedException {
        log.info("메일 발송 [key : {}]", event.getKey());
    }
    
    @EventListener({DomainEvent.class})
    public void sample() throws InterruptedException {
        log.info("이벤트 객체 내부에 접근하지 않을 때는 이렇게 사용하는 것도 가능");
    }
}

 

[특징]

  • 멀티태스킹 (Multi-Tasking)
  • 동기방식 (Sync)

스프링 이벤트 리스너는 멀티태스킹 관계를 가집니다. 멀티태스킹이란 다수의 수신자가 존재할 수 있는 통신 형태로 동일한 타입의 여러 리스너가 등록되어 있다면 모든 리스너가 이벤트를 받게 됩니다.

 

또한, 스프링 이벤트는 기본적으로 동기 방식으로 동작합니다. 동기 방식으로 동작한다는 것은 이벤트를 발행한 쪽에서 이벤트 구독 쪽의 처리가 완료될 때까지 기다린다는 것을 의미하며 또한 트랜잭션이 하나의 범위로 묶일 수 있다는 것을 의미합니다.

 

비동기 방식으로 구현하거나 트랜잭션을 분리하는 방법은 아래에서 조금 더 살펴보겠습니다.

 

2. 실전 적용


[비동기 및 트랜잭션]

처음 이벤트 리스너 적용은 다음과 같이 되어 있었습니다. 이렇게 적용을 하고 사용하다보니 추후에 API 응답이 느리다는 이슈가 있었고 확인해보니 그냥 @EventListener를 사용하는 경우는 동기 방식으로 하나의 트랜잭션에서 처리가 되고 있다는 것을 알 수 있었습니다.

@Component
@Slf4j
public class OutboxEventListener {
    ...

    @Transactional(rollbackFor = Exception.class)
    @EventListener({ApplicationReadyEvent.class, OutboxCreated.class})
    public void deliveryOutboxEvents() {
        // 작업 내용
    }
}

 

또한 이 상태에서 리스너의 처리가 실패하는 경우 예외가 전파되면서 이전 트랜잭션의 처리도 실패하는 문제가 있을 수 있었습니다. (현재 코드 상 이전 트랜잭션에서 이후 트랜잭션에 대한 try-catch를 하지 않았기 때문이고 일반 @Transactional을 사용하는 경우 새로운 트랜잭션이 아닌 이전 트랜잭션이 있다면 해당 트랜잭션에 참여하기 때문입니다...)

 

따라서 이벤트 리스너 부분을 비동기로 처리하여 API 응답 속도를 개선하고 트랜잭션 이슈도 해결하도록 수정하였습니다. 비동기를 구현하는 방식은 크게 @Async 애노테이션을 사용하거나 ApplicationEventMulticaster를 사용하여 구현할 수 있는데 저는 @Async 애노테이션을 붙여서 비동기를 구현하였습니다.

@Component
@Slf4j
public class OutboxEventListener {
    ...

    @Async
    @Transactional(rollbackFor = Exception.class)
    @EventListener({ApplicationReadyEvent.class, OutboxCreated.class})
    public void deliveryOutboxEvents() {
        // 작업 내용
    }
}

 

위와 같이 처리하는 경우 이벤트 리스너 부분이 비동기로 동작하기 때문에 이벤트 발행 주최는 발행한 이후 리스너 처리를 기다리지 않고 바로 종료되어 API 응답 속도가 개선되었습니다. 또한, 별도 트랜잭션으로 분리가 되었기 때문에 리스너에서 처리 중 발생한 예외는 발행한 쪽으로 전파되지 않게 되었습니다.

 

@Async 처리와 별개로 트랜잭션을 분리하고 싶거나 리스너 쪽 예외를 전파하고 싶지 않다면 @Transactional(REQUIRED_NEW)를 사용할 수 있습니다. (같은 트랜잭션을 사용하면서 예외 전파를 원하지 않는 경우 발행 쪽에서 try-catch를 사용해도 됩니다.)

 

하지만 단순 @EventListner를 사용하는 경우 발행 쪽에서 예외가 발생한다고 해도 예외 발생 전 이벤트가 발행되었다면 이벤트 리스너가 실행됩니다. 트랜잭션을 조금 더 세부적으로 다루려면 @EventListener를 확장한 @TransactionalEventListener를 사용할 수 있습니다.

 

기본적으로 @TransactionalEventListener를 사용하게 되면 phase가 AFTER_COMMIT 이기 때문에 이벤트 발행 쪽 트랜잭션이 커밋된 이후에 해당 이벤트 리스너가 이벤트를 받아 실행되므로 예외가 발생하는 경우는 실행되지 않습니다. 또한, 이렇게 되는 경우 이미 커밋이 된 다음 수행하는 것이므로 기존 트랜잭션과 분리가 되어 별도로 @Transactional(REQUIRED_NEW)를 하지 않아도 됩니다.

 

이런 경우에  트랜잭션이 분리되는 경우 리스너 쪽 예외가 발행 쪽으로 전파되는 경우는 없기 때문에 다음과 같은 이슈가 있을 수 있습니다. 하지만 이 경우는 추후에 배치를 통해서 삭제 처리를 해주는 등의 대안이 있으므로 큰 문제는 아니라고 생각합니다.

 

이슈

  • 게시글-댓글 예시가 있을 때 게시글을 삭제하고 댓글 삭제를 이벤트로 처리한 경우 게시글 삭제는 성공했는데 댓글 삭제가 실패하는 경우 없는 게시글에 대한 댓글이 남아있을 수 있는 문제
  • 하지만 댓글의 경우 게시글이 없을 때 배치 등을 통해 참조하는 게시글이 없는 댓글은 따로 삭제할 수 있는 방법이 존재하여 큰 이슈는 아니라고 생각

참고

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함