Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Week 9] 이벤트 - 김재연 #49

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 315 additions & 0 deletions chap10/김재연.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
# 이벤트

## 시스템 간 강결합 문제

환불 기능을 실행한다고 했을 때, 도메인 서비스를 입력받을 수 있다.
다음과 같이 말이다.

![](https://i.imgur.com/fFaw2Eo.png)

최근들어서 내가 되게 많이 쓰고 있는 방식과 굉장히 비슷하다.

또한 다음과 같이 응용서비스에서 실행 해줄 수도 있다.

![](https://i.imgur.com/wzPaC0N.png)

쨌든, 위의 과정 중에서 환불 시스템은 외부의 시스템이니 언제나 터질 수 있다. 이렇게 외부 시스템과 연결된 경우, 항상 하게 되는 고민이 동일한 트랜잭션으로 묶을 것이냐는 것인데, 둘 다 나름의 이유가 존재한다.

트랜잭션을 롤백하는 경우는, 당연히 환불에 실패했으니, 주문 취소 행위자체도 롤백해야 하는 것이 아니냐. 라는 의견으로 좁혀질 수도 있고, 그와 반대로, 주문 취소만 일단 진행해놓고 환불은 나중에 진행할 수도 있는 것이다.

또 고민할 수 있는 부분은 성능에 대한 부분이다.
외부 시스템을 사용하는 경우, 성능도 외부 시스템에 의존적으로 변하게 된다.

이 두가지 문제 이외에 이와 같이 도메인 객체에 서비스를 전달하면, 서로 다른 로직인, 주문 로직과 결제 로직이 섞인다는 문제점도 존재한다. (설계상 문제)

![](https://i.imgur.com/zG8Ffyy.png)

이렇게 되면, 환불 기능이 바뀌게 되면 Order도 영향을 받게 된다는 것이고, 객체 지향의 장점 중 하나인, 다른 객체로 변경사항이 최소로 퍼져나가는 것을 어기게 된다.

만일 여기서 환불에 대한 결과를 통지해야한다면, 더 많은 Service가 필요할 것이고, 이로써 더 복잡해지고 설계상의 허점은 많아질 것이다.

![](https://i.imgur.com/7TryVWJ.png)

이러한 문제가 발생하는 이유가 무엇일까? 이는, 바운디드 컨텍스트 간의 강결합 때문에 발생하는 것이다.

이벤트를 사용해 이 강결합을 없애보자.

## 이벤트 개요

이벤트는 발생하고 끝나지 않는다. 이벤트가 발생하게 되면, 그 이후 동작들도 이어지게 된다.

### 이벤트 관련 구성요소

사실 엄청 중요한 내용인지는 모르겠지만, 이벤트 관련 구성요소는 다음과 같다.

![](https://i.imgur.com/ehass6b.png)

당연히, 퍼블리셔가 있고, 구독자가 있는 형태이다. (퍼블리싱 하면 받아서 처리하는 형식)

이벤트를 생성하면 이벤트 디스패처가 이를 받아, 알맞은 이벤트 핸들러를 찾아 처리하게 된다.
이 때, 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로도 처리할 수 있다.

### 이벤트의 구성

이벤트는 발생한 이벤트에 대한 다음과 같은 정보를 담는다.

![](https://i.imgur.com/7cfXT0M.png)

그리고 처음 알았는데, 아무래도 이벤트는 바로 직전이라고 하더라도, 현재 일어난 것이 아니니, 과거시제를 사용해준다고 한다.

![](https://i.imgur.com/mZNsA5b.png)

나왔다. 도메인 이벤트..

진짜 매력적이다. 도메인에서 ShippingInfo가 변경되었다고 이벤트를 발행하면 어디에선가 이를 받아서 처리하겠지? (Events.raise라는 이벤트 발행을 도와주는 Static 메서드도 제공하나보다, 진짜 엘레강스하다.)

이벤트는 꼭 필요한 정보들을 다 담고 있어야 한다고 한다. 당연하지만, 그렇지 않다면 비효율적인 처리를 해야하니까. (DB 접근 등등)

최근 들어, 이벤트에 너무 많은 정보를 담아, 효율적이긴 하지만, 이벤트에 이렇게 많은 정보를 담아도 되는 것일까? 라고 생각했던 나의 의구심을 확신으로 만들어주는 챕터이다. (물론, 너무 필요없는 정보를 담으면 좋지 않다.)

### 이벤트 용도

이벤트가 트리거로 사용될 때는 다음과 같이 사용된다.

![](https://i.imgur.com/8hvBuCk.png)

또한, 시스템 간의 데이터 동기화를 이루어낼 때도 사용할 수 있다.
다만, 이렇게 좋은 이벤트도, 뭔가 이 이벤트를 발생시켰을 때, 어디로 가서 로직을 처리하는지 등을 트래킹 하는 것은 조금 힘들었던 부분이 있었던 것 같다. 조금 단점?

### 이벤트 장점

이벤트의 장점으로는, 다음과 같다. 강결합을 끊어냈다.

![](https://i.imgur.com/jaRL3cc.png)

그렇기에, 기능에 변경이 생기더라도, Order는 계속해서 이벤트를 발행하기만 하면 되기 때문에, 변화되는 사항들이 없다.

다음과 같이 이루어지기 때문이다.

![](https://i.imgur.com/XOM8LxT.png)

## 이벤트, 핸들러, 디스패처 구현

이번장은 실제 이벤트와 관련된 코드를 구현해본다고 한다. 뭔가 트랜잭션처럼 프록시를 앞에다가 두고, 처리할 것 같다라는 생각을 하긴 했는데, 막상 실제 구현을 해보려 하니 살짝 설레이기도한다.

![](https://i.imgur.com/W67djUl.png)

### 이벤트 클래스

이벤트에 사용될 클래스는 딱히 상위 클래스가 존재하지 않는다. 그냥 어떤 객체를 생성하고, 그거를 Publisher가 캐치하기만 하면 된다.

다만, 네이밍만 신경쓰면된다.

![](https://i.imgur.com/Dlrla1U.png)

상위 클래스는 없지만, 공통된 데이터를 하나로 묶기 위해서 상속구조를 사용할 수 있다.

### Events 클래스와 ApplicationEventPublisher

![](https://i.imgur.com/vX0nR6e.png)

뭐야, Events가 Spring혹은 외부에서 제공하는 라이브러리인줄 알았는데, 임의로 구현하신건가.. 조금 짜치는데

다만, 주변에서 이벤트를 자주 사용하는 사람들이 많아서, 본 결과 AbstractAggregateRoot를 상속받아, register() 메서드를 활용해 이벤트를 퍼블리싱하는 것 같다.

![](https://i.imgur.com/qZzyovU.png)

쨌든, 위와 같이 Events를 선언해놓고, Bean을 초기화해보자

### 이벤트 발생과 이벤트 핸들러

또 Event를 Listen 하기 위해서는 다음과 같이 어노테이션을 붙여주어야한다.

![](https://i.imgur.com/hJF7q3W.png)

### 흐름 정리

지금까지의 흐름을 보면 다음과 같다.

![](https://i.imgur.com/udjpYRx.png)

코드 흐름을 보면 응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러를 실행하고 있다.

즉, Event 를 Publishing하면서 되게 동떨어져있다는 느낌을 받기는 하지만, 하나의 트랜잭션으로 이어진다는 말이다. (물론 Propagation 설정에 따라 달라지겠지)

## 동기 이벤트 처리 문제

강결합 문제는 해결했다. 하지만, 트랜잭션과 관련한 것, 그리고 성능과 관련된 문제를 아직 해결하지 못했다.

## 비동기 이벤트 처리

비동기 처리는 이제 `A 하면 B 하라`를 `A 하면 최대 언제까지 B하라`로 치환이 되는 문제에서 활용할 수 있다.

그러면 이제 비동기로 구현할 수 있는 방법을 알아봐야하지 않겠는가, 비동기로 처리할 수 있는 방법은 다음과 같이 4가지가 있다고 한다.

![](https://i.imgur.com/IEPfW8e.png)

당연히 방식에 따라 각자 구현하는 방식도 다르고 그에 따른 장단점이 존재한다. 차례대로 알아보자.

### 로컬 핸들러 비동기 실행

굉장히 쉬운 방법이다. 이벤트 핸들러를 별도 스레도 실행하게 하는 것인데, 이 때 스프링이 제공하는 @Async 어노테이션을 활용하면 해당 방법으로 진행할 수 있다.

@EnableAsync 어노테이션을 활용해서, 스프링의 비동기 실행 기능을 활성화하도록 하자.

![](https://i.imgur.com/5QPNqrG.png)

그러고서 이제 EventListener에다가 Async를 붙여주면 된다.

### 메시징 시스템을 이용한 비동기 구현

비동기로 이벤트를 처리할 때, 가장 많이 사용하는 카프카(Kafka), 래빗MQ(RabbitMQ)와 같은 메시징 시스템을 사용하는 것이다.

이벤트가 발생하고, 메시징을 전송하게 되었을 때, 메시징 시스템은 이를 받아 알맞은 이벤트 핸들러를 찾아주는 형식이다.

![](https://i.imgur.com/tXOvLcf.png)

굉장히 간단히 나타낸 것 같긴하다.

서로 다른 트랜잭션으로 이루어지는 것을 볼 수 있고, 이를 글로벌 트랜잭션을 활용하여 하나의 트랜잭션으로 처리할 수 있지만, 이는 전체적인 성능을 떨어뜨리기에, 이를 지원하지 않는 메시징 시스템도 존재한다고 한다. (카프카)

이런 메시징 시스템을 활용하면, 이벤트 발생, 이벤트 처리 프로세스가 별도의 프로세스가 동작하고, 이는 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미한다.

이를 동일 JVM으로 처리할 수 있지만, 시스템을 복잡하게 만들 뿐이다.

### 이벤트 저장소를 이용한 비동기 처리

이벤트를 비동기로 처리하는 방법은 DB를 활용해, 저장한 뒤, 이를 별도의 프로그램이 처리하는 것이다.

![](https://i.imgur.com/9cIusc8.png)

당연히 주기적으로 읽어와, 처리하겠지?

이렇게 되면, 당연히 포워더는 별도의 스레드로 운영되기에 비동기로 처리된다.

이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 활용하게 된다, 즉 도메인 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다는 것이다.

이벤트 핸들러가 처리를 실패하더라도 포워더가 다시 읽어오면 되기 때문에, 상관없다.

또한, 포워더가 하던 일을 API 방식으로 제공할 수 있다.

그냥 이벤트를 조회하고 가져가는 주체가 변경된 것이라고 간단하게 생각하면된다.

![](https://i.imgur.com/tsdmSmv.png)

둘 다, 이벤트 저장소가 필요하다. 코드 구조는 어떻게 될까?

![](https://i.imgur.com/ig5iiQn.png)

![](https://i.imgur.com/fxr6OTL.png)

구조는 굉장히 간단하다.

API에서 제공하는 형식이다. DB에 다음과 같이 저장되고, 제공도 이와 같이 진행된다.

- EventEntry

![](https://i.imgur.com/rLe2Vri.png)

![](https://i.imgur.com/fqALpKL.png)

여러 이벤트 형식이 저장될텐데, 어떻게 다양한 이벤트를 하나의 API로 제공할 수 있지라고 생각할 수 있지만, 이는 직렬화에 숨겨져있다. 객체를 json 형태로 직렬화하기만 하면 String 타입으로 모든 객체를 표현할 수 있다. 그리고 type을 application/json으로 명시해놓기만 하면 다 처리되는 것이다.

API 인터페이스이다. offset, limit이 있는 것을 보니, 최대한 필요한 이벤트 데이터만을 가져가는 형식을 볼 수 있다.

- EventStore

![](https://i.imgur.com/VTnsjx8.png)

정말 간단하게 JDBC Template을 활용하여, DB에 있는 데이터를 다루는 코드이다.
그냥, jdbc template 을 활용해 EventEntry를 저장하거나, EventEtnry를 반환받는 것을 볼 수 있다.

- JdbcEventStore

![](https://i.imgur.com/6hvBln3.png)

![](https://i.imgur.com/7gMwVTQ.png)

![](https://i.imgur.com/EKHdjXb.png)

DDL은 다음과 같다.

- EventEntry

![](https://i.imgur.com/Kk4uhPL.png)

이제 실제로, 발생한 이벤트를 저장하는 EventStoreHandler만 구현하면 준비는 끝이다.

- EventStoreHandler

![](https://i.imgur.com/g5rABqt.png)

API를 제공하기 위한 Controller다.

- EventApi

![](https://i.imgur.com/IvwxeN4.png)

![](https://i.imgur.com/AXIPfPV.png)

이렇게 구현을 완성했으면, 클라이언트는 일정 간격으로 다음 과정을 실행하면 된다. (수정은 x)

![](https://i.imgur.com/2e7sMII.png)

중복으로 이벤트를 처리하지 않기 위해 페이징을 야무지게 처리하고 있다.

![](https://i.imgur.com/FOQBbs2.png)

이런 식으로 주기적으로 서비스에서 발생한 이벤트를 전달받아 처리하는 것을 볼 수 있다. (위에서 보였던 lastOffset을 활용한 결과로 중복없이 이벤트를 처리하고 있다.)

포워더도 한번 봐볼까? (똑같이 lastOffset을 기억해뒀다가, 중복없이 이벤트를 처리할 수 있음)

- EventForwarder

![](https://i.imgur.com/xKCyOvY.png)

![](https://i.imgur.com/1E43vW9.png)

동일하게 주기적으로 lastOffset부터 이벤트를 읽어와, event를 sent해주고, 마지막에 lastOffset을 save 해준다. (처리한 이벤트가 1개 이상인 경우에만)

보면 알 수 있지만, offsetStore를 활용하는데, DB 테이블에 저장하거나, 로컬 파일에 보관하는 등, 물리적 저장소에 보관하면 된다.

EventSender는 send라는 인터페이스를 제공하는데, 이는 외부 메시징 시스템을 활용하여 진행하거나, 원하는 핸들러에 이벤트를 전달한다(이벤트를 전달한다는 의미는 동일)

또한, 이벤트 처리 중 익셉션이 발생하면, 그대로 전파하여 다음 주기에 getAndSend() 메서드를 실행할 때 재 처리할 수 있도록 한다.

## 이벤트 적용 시 추가 고려 사항

이 장에서 구현하지는 않았는데, 이벤트를 구현할 때 추가로 고려할 점이 있는데, 이벤트의 소스를 저장하는 것이다. (어떤 주체로 인해 이벤트가 발생했는지 알아야하기 때문에)

또한, 이벤트를 처리하는 최대 재시도 횟수를 지정해줘야 한다. 어떤 이벤트가 자꾸 오류를 일으킨다면, 당연히 이 다음 이벤트들을 처리하기 위해서, 넘어가주어야 한다.

세 번째는 이벤트 손실이 일어날 수도 있다는 것이다.

네 번째, 이벤트 처리의 순서가 굉장히 중요한 경우, 이벤트 저장소를 사용하는 것이 좋다. 아무래도 메시징 시스템은 사용하는 기술에 따라 이벤트의 순서가 달라질 수도 있기 때문이다.

다섯 번째, 이벤트 재처리를 고려해야한다. 동일한 이벤트를 다시 처리해야 할 때 이벤트를 어떻게 할지 결정해야 한다.

가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억해 두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지 않고 무시할 수 있다.

### 이벤트 처리와 DB 트랜잭션 고려

DB 트랜잭션도 고려해야 하는데, 예를 들어 주문 취소와 환불 기능을 다음과 같이 이벤트를 이용해서 구현했다고 가정하면

![](https://i.imgur.com/PTjN3ey.png)

![](https://i.imgur.com/5Sj3rA7.png)

동기로 처리하게 되면, 다음과 같은 플로우로 진행이 될 것이다. 취소를 진행하고, Event를 던지고, 이것을 처리하는 방향으로

근데 만일, DB업데이트 과정 중에 Exception이 나버리면 어떻게 될까? 결제 취소는 되었는데, 실제로 결제가 취소되지 않은 상태로 DB에 데이터가 남아있어, 고객은 꿩먹고 알먹기가 되지 않을까?

그렇기에, 트랜잭션을 더더욱이 신경써야하고, 이는 비동기로 이벤트를 처리할 때에도 고려해야한다.

![](https://i.imgur.com/tnsg7Xq.png)

이게 더 최악이다. DB에 업데이트 되고나서, 주문은 취소가 되었는데, 결제 환불이 되지 않았다? 이것은 고객이 노발대발할 사항이다.

트랜잭션 처리와, 이벤트 처리 모두를 고민하면 경우의 수가 늘어 머리가 아프다.
그렇기에, 이를 줄이는 방식은 다음과 같이 TransactionalEventListener를 추가해주는 것이다. 원래도 동일한 트랜잭션으로 실행이 되긴 하지만, 이처럼 설정하게 되면, 이벤트 핸들러를 실행시키기 이전의 트랜잭션이 커밋이 된 후에 실행이 된다.

![](https://i.imgur.com/hAZ59qs.png)

그렇기에, 외부 인프라 딴과 연결되어 있는 경우, 이를 효과적으로 처리할 수 있는 것이다.

이번 장은 많은 것들을 배울 수 있었던 것 같다.