승이의 기술블로그
article thumbnail

📝 들어가며

교내의 스포츠 경기 상활을 실시간으로 확인할 수 있는 서비스 '훕치치'에서는 사용자들이 응원하는 팀에 대해 응원 댓글을 남길 수 있는 기능을 제공하고 있다.

1차 릴리즈 이후 서비스에 대한 피드백을 받았을 당시, 새로고침을 해야만 새로운 댓글이 반영되는 것에 대한 지적이 있었다.

이전까지는 '댓글'에 가깝게 해당 기능을 정의했지만, 해당 피드백과 이벤트 스토밍을 통해 도메인 용어를 정리한 뒤에는 해당 기능이 '채팅'에 가깝다고 정의를 내리고 '응원톡'이라고 명명하기로 했다.

따라서 실시간으로 새로 등록된 댓글이 반영되는 것으로 기획이 바뀌어 새로 구현을 하게 됐다.

그 과정 중에서도 이번에는 이벤트 기반 아키텍처란 무엇이며, 왜 이 기능의 구현 과정에서 이벤트 기반 아키텍처를 사용했는지 그리고 스프링부트의 이벤트는 어떻게 구현되어야 하는지에 대해 작성해보겠다.

🎱 이벤트 기반 아키텍처 (EDA)

어플리케이션 간 설계를 위한 소프트웨어 아키텍처 및 모델이다.

이는 분리된 서비스들 간의 이벤트를 전달하기 위해서 설계 되었다.

이벤트란?

모든 중요한 발생 혹은 변경 사항에 대한 기록이다.

이벤트 기반 아키텍처의 구성 요소

1. event producers (publishers)

- 이벤트를 인지하거나 찾는다.

- 이벤트를 메시지로 표현한다.

- decoupling 하게 구성되기 때문에 이벤트의 consumer 와 이벤트의 결과를 알지 못한다.

- 이벤트의 channel 을 통해서 consumer 에게 전달된다.

 

2. event consumers (subscribers)

- 이벤트가 발행되면 알게 된다.

- 이벤트를 처리하거나 영향을 받는다.

 

3. event processing platform

- 이벤트에 대한 올바른 응답을 실행한다.

 

이벤트 기반 아키텍처의 모델

1. pub/sub model

- 특정 부분을 구독하고 있다가 이벤트가 발생하거나 발행되면 구독자들은 이를 알게 되고 이벤트를 수신하게 되는 방식

 

2. event streaming model

- 이벤트가 로그로 기록된다.

- event consumer 은 구독하지 않는다.

- consumer 들은 stream 의 어떤 부분이든 읽을 수 있고, 언제든지 stream 에 조인할 수 있다.

- apache kafka 와 같은 streaming platform 을 이용한다.

Event 를 사용하는 이유

event 를 기반으로 하여 아키텍처를 고려하게 되면, 서비스 간의 의존성이 분리된다.

우리 서비스를 예로 들어 설명하자면, 응원톡을 등록하고 나서 웹소켓을 이용해 해당 게임(경기) 페이지에 머무르고 있는 사용자들에게 새로 등록된 응원톡을 전송해줘야 했다.

그러나, 응원톡을 등록하는 로직과 소켓으로 데이터를 전송하는 로직은 완전히 다른 로직이다. 따라서 이는 서로 의존하지 않는 것이 적절하다.

따라서 응원톡이 등록되면, 이를 이벤트로 발행하고 이벤트를 핸들링하는 로직에서는 이벤트가 발행됐음을 알게 된 뒤 이를 웹소켓을 이용해 클라이언트 측에 전송해 새로 등록된 응원톡이 화면에 적절히 나타나도록 했다.

즉, 이벤트를 이용하게 되면 서로 직접적으로 연관이 없는 로직이 서로 의존하지 않도록 할 수 있는 것이다.

 

그렇다면, 서로 연관이 없는 로직이 의존한다는 건 어떤 상황일까?

예를 들어서, 이커머스 서비스에서

1. 주문을 하면

2. 포인트를 지급하고 

3. 추천 상품에 대한 알고리즘의 로직을 변경해야 한다고 가정해보자.

 

위에서 언급한 3가지의 로직은 모두 서로 연관이 없으므로 변경 사항을 전파해서는 안된다는 것이 느껴질 것이다.

만약 이벤트를 이용해 서로 분리하지 않는다면 다음과 같이 코드가 작성된다.

public class OrderService{
	public void order(OrderRequestDto orderRequestDto){
		주문(orderRequestDto);
		포인트_지급(포인트 지급에 필요한 파라미터);
		추천_상품_알고리즘_변경(알고리즘 변경에 필요한 파라미터);
	}
}

만약, 포인트 지급에 필요했던 정보가 바뀐다고 가정해보자. 그렇게 되면 OrderService 즉 주문에 대한 로직까지 변경이 전파된다.

서로 연관이 없는 로직끼리의 의존이 생기게 되는 것이다. 이런 경우에 이벤트의 발행이 필요해지는 것이다.

 

🎱 스프링부트에서의 이벤트 발행과 구독 구현해보기

1. Event

public record ExampleEvent(ExampleDomain exampleDomain) {
}

기존에는 이벤트에 해당하는 클래스가 반드시 ApplicationEvent 를 상속받아야 했다.

그러나, 더 이상은 상속받지 않아도 된다.

 

2. EventListener

@Component
public class ExampleEventListener {
    @EventListener
    public void listen(ExampleEvent event) {
        // .. do sth with event
    }
}

Event Listener 은 @EventListener 어노테이션을 붙여주면 된다.

 

3. EventPublisher

@Service
@RequiredArgsConstructor
public class ExampleEventPublisher {

    private final ApplicationEventPublisher eventPublisher;

    public void publish() {
        eventPublisher.publishEvent(new ExampleEvent(new ExampleDomain()));
    }
}

EventPublisher 는 ApplicationEventPublisher 와 같은 인터페이스를 활용하여 구현한다.

이에 대한 실제 구현은 ApplicationContext 에서 이루어진다.

해당 인터페이스의 publishEvent 메서드를 호출하여 Event 를 발행하면 된다.

🎱 이벤트 발행과 구독에서의 트랜잭션

트랜잭션과 핸들러의 동작 시점을 상세하게 조정할 수 있다.

 

TransactionPhase.BEFORE_COMMIT

- 트랜잭션이 커밋되기 전에 이벤트를 핸들링한다.

 

TransactionPhase.AFTER_COMPLETION

- 트랜잭션이 완료된 이후에 이벤트를 핸들링한다.

 

TransactionPhase.AFTER_COMMIT (default)

- 트랜잭션이 커밋된 이후에 이벤트를 핸들링한다.

 

TransactionPhase.AFTER_ROLLBACK

- 롤백된 이후에 핸들링이 이루어진다.

 

🎱 비동기로 처리하기

만약, 위의 과정을 비동기로 처리하고 싶다면 어떻게 해야 할까?

비동기 처리가 필요한 이유

@Service
@RequiredArgsConstructor
public class ExampleEventPublisher {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void register() throws InterruptedException {
        registerNewMember();
        eventPublisher.publishEvent(new ExampleEvent(new ExampleDomain("example")));
        sendEmail();
    }

    public void registerNewMember() throws InterruptedException {
        System.out.println("회원가입 완료!");
    }

    public void sendEmail() throws InterruptedException {
        System.out.println("이메일 전송 완료!");
    }
}

@Component
@RequiredArgsConstructor
@Slf4j
public class ExampleEventListener {

    private final ExampleEventPublisher publisher;

    @EventListener
    public void listen(ExampleEvent event) throws InterruptedException {
        System.out.println("가입 축하금 지급 완료!");
    }
}

위와 같은 상황을 예시로 들 수 있다.

새로운 사용자가 회원가입을 할 때

 

1. 새로운 회원의 정보 저장

2. 가입 축하 이메일 전송

3. 가입 축하 포인트 부여 가 이루어져야 한다고 가정해보자.

 

만약 이가 동기로 처리된다면, 가입 축하금이 부여될 때까지 이메일 전송 로직이 대기를 해야 한다.

그러나, 비동기로 처리된다면 이메일 전송 로직이 대기를 할 필요가 없어진다.

비동기 처리 방법

@Component
public class ExampleEventListener {
    @EventListener
    @Async
    public void listen(ExampleEvent event) {
        // .. do sth with event
    }
}

비동기로 처리하고자 하는 경우에는, 위처럼 @Async 어노테이션을 추가해주면 된다.

 

🎱 @Async, @EventListener, @TransactionalEventListener

처음 위의 어노테이션들을 접했을 때 너무 헷갈렸다.

그래서 다양한 조합을 고려해서 예시들을 정리해보고자 한다.

 

1. 동기 + @EventListener

@Service
@RequiredArgsConstructor
public class ExampleEventPublisher {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void register() throws InterruptedException {
        registerNewMember();
        eventPublisher.publishEvent(new ExampleEvent(new ExampleDomain("example")));
        sendEmail();
    }

    public void registerNewMember() throws InterruptedException {
        System.out.println("회원가입 완료!");
    }

    public void sendEmail() throws InterruptedException {
        System.out.println("이메일 전송 완료!");
    }
}

@Component
@RequiredArgsConstructor
@Slf4j
public class ExampleEventListener {

    private final ExampleEventPublisher publisher;

    @EventListener
    public void listen(ExampleEvent event) throws InterruptedException {
        Thread.sleep(10000);
        System.out.println("가입 축하금 지급 완료!");
    }
}

- 트랜잭션 : 하나의 트랜잭션

- 쓰레드 : 하나의 쓰레드

- 실행 순서 : 회원의 정보 저장 로직이 실행된 이후에 가입 축하금 부여 로직이 도는 동안 이메일 전송 로직은 대기하고 있다가 가입 축하금 부여 로직이 완료된 이후에 실행된다.

 

2. 비동기(@Async) + @EventListener

@EventListener
@Async
public void listen(ExampleEvent event) throws InterruptedException {
    Thread.sleep(10000);
    System.out.println("가입 축하금 지급 완료!");
}

listen 메서드에서만 차이가 발생한다. @Async 어노테이션을 붙여주면 된다.

 

- 트랜잭션 : 분리된 트랜잭션

- 쓰레드 : 분리된 쓰레드

- 실행 순서 새로운 회원의 정보 저장 로직이 실행되고, 이벤트 발행 이후에 가입 축하금 부여 로직이 실행된다. 이 때, register 메서드는 listen 메서드의 실행 완료를 기다리지 않고 listen 메서드와는 별개로 이메일 전송 로직이 바로 실행 된다.

3. 동기 + @TransactionalEventListener

@TransactionalEventListener
    public void listen(ExampleEvent event) throws InterruptedException {
        Thread.sleep(10000);
        System.out.println("가입 축하금 지급 완료!");
    }

- 트랜잭션 : 하나의 트랜잭션

- 쓰레드 : 하나의 쓰레드

- 실행 순서 : 이벤트 발행 쪽에서의 커밋을 기다렸다가 listen 메서드가 실행된다.

동기적으로 실행이 되었기 때문에 모두 같은 쓰레드에서 실행된다.

만약, 커밋이 이루어지지 않는다면?

 public void register() throws InterruptedException {
        registerNewMember();
        eventPublisher.publishEvent(new ExampleEvent(new ExampleDomain("example")));
        sendEmail();
    }

위와 같이 @Transactional 어노테이션이 누락되었고, 명시적으로 커밋도 하지 않았다고 가정해보자.

이러한 경우에는 어떠한 커밋도 일어나지 않기 때문에 listen 메서드는 무한정으로 대기하게 된다.

4. 비동기 + @TransactionalEventListener

@TransactionalEventListener
    @Async
    public void listen(ExampleEvent event) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
        System.out.println("listen 쓰레드: " + threadId);
        System.out.println("가입 축하금 지급 완료!");
    }

- 트랜잭션 : 별개의 트랜잭션

- 쓰레드 : 별개의 쓰레드

- 실행 순서 : 새로운 회원의 정보를 저장하고, 커밋이 될 때까지 listen 메서드는 대기하다가 커밋이 된 이후에 listen 메서드가 실행된다.

 

 

다음 글에서 이어집니다..

https://dev-seunghee.tistory.com/14

 

[SpringBoot] 도메인 이벤트를 알아보고 AbstractAggregateRoot 를 이용해 채팅 서비스를 구현해보자

https://dev-seunghee.tistory.com/12 [SpringBoot] 이벤트 기반 아키텍처를 알아보고 스프링부트의 이벤트를 구현해보자 📝 들어가며 교내의 스포츠 경기 상활을 실시간으로 확인할 수 있는 서비스 '훕치치'

dev-seunghee.tistory.com

 

출처

https://mangkyu.tistory.com/292

 

[Spring] 스프링에서 이벤트의 발행과 구독 방법과 주의사항, 이벤트 사용의 장/단점과 사용 예시

이벤트(Event)는 매우 유용하지만 상당히 간과되는 기능 중 하나입니다. 작년에 아마존 CTO는 이벤트 드리븐 아키텍처로 가야 한다고 기조 연설을 하기도 했는데, 이번에는 스프링 프레임워크에서

mangkyu.tistory.com

 

검색 태그