본문 바로가기
Tech

Event가 동작할 것만 같지? - 잔여 이벤트 처리하기

by Garam Kim 2023. 5. 1.
SMALL
 

Event가 동작할 것만 같지? - Spring Event와 Domain Event

최근 팀에서 기술공유 목적의 세미나(?)를 진행했다. 사실 간단히 내용 공유 목적이었지만, 준비하다보니 할 이야기들이 많아서 발표자료까지 만들게 되버렸다! 그때 준비하면서 고민했던 내용

ramka-devstory.tistory.com

 

지난 포스트에서 Domain Event를 구현한 AbstractAggregateRoot의 동작과정에 대해서 알아보았다.

Domain Event를 발행하는 것 역시 Spring Event를 사용하고 있었고, 여전히 부자연스러운 save 로직을 사용하여 이벤트를 발행하는 문제점이 해소되지 않았다.

 

이번엔 이를 해소하고자 시도했던 것들을 다뤄보고자 한다.

 

부자연스러운 행위를 없애자

팀원들과의 코드리뷰에서는 여러가지 의견이 나왔다. 그중에서 처음에 생각했던 것은 부자연스러운 행위를 없애자 였다.

registerEvent() 를 사용해서 이벤트발행이 되지 않고, save와 같은 명시적 호출이 필요하다면, 이를 ApplicationEventPublisher를 사용하여 publishEvent() 를 사용하자는 것이다. 

 

SampleEntity
SampleDetail Entity
publish 비즈니스 로직과 BaseEntity

샘플 프로젝트를 만들어서 간단히 엔티티와 비즈니스 로직을 구현하였다. 선행 작업은 단순히 SampleEntity와 연관관계를 맺은 SampleDetailEntity 데이터 1개와 SampleEntity 데이터 1개가 전부이다. BaseEntity에는 AbstractAggregateRoot를 상속받아 내부의 registerEvent()를 사용할 수 있도록 구현했다. 비즈니스로직에서 sampleEntity.publish() 를 하게되면, 내부에는 SampleDetailEntitypublish 로직도 포함이 되며, 내부에서는 또다른 Domain Event가 발생되고 있는 모습을 볼 수 있다. 

 

이와 같은 구조에서 SampleEntitypublish 한번에 SampleDetailEntitypublish 이벤트도 동작을하게 되며, 현재 상태에서는 Entity의 수정이 없음에도 save 액션을 명시해준 모습을 볼 수 있다.

 

이를 없애기에 ApplicationEventPublisher를 사용하여 구현한다면 다음과 같은 모습을 볼 수 있다.

 

ApplicationEventPublisher를 통해 이벤트를 직접 발행

이 경우에는 Entity 안에 있는 registerEvent를 사용하지 않고, 직접 ApplicationEventPublisher를 통해 이벤트를 발행하게 된다. 이렇게 한다면, 부적절한 save 액션도 없기 때문에, 부자연스러운 모습 또한 해소가 된다. 

 

아름답게 마무리 될 수 있었지만 DDD의 목적을 생각했을 때, 무언가 걸리는 것들이 있었다.

sampleEntity.publish()를 통해서 SampleEntitypublished 상태를 true로 바꿔주고, 실제로 이벤트를 발행해야만 하는 것을 모르고있다면? 그렇다면 이벤트가 또 발행되지 않아 결국 같은 문제를 야기할 수 있다. 결국 비즈니스로직을 구현하는데 있어서 이런 것들까지 고려하면서 개발을 진행해야만 하며, AbstractAggregateRootregisterEvent는 비즈니스 로직을 구현하는데 더욱 더 집중할 수 있도록 방안을 마련해준 것이기에, 사용할만한 이유가 충분했다. 'sampleEntity.publish() 를 수행하면서 event 발행 로직을 추가해야한다!' 라는 것을 몰라도 된다는 것이다.

 

다시 원점으로(?)

어떻게든 AbstractAggregateRoot를 같이 사용해야만 할 것 같은 느낌으로, 다른 방법을 생각해보았다. 찾아보던중 우리와 비슷한 고민을 한 흔적을 확인한 블로그 포스트를 확인할 수 있었다.

 

 

🧑‍💻 Spring에서 DDD Domain Event 사용하기 | Iberis

서론 SpringFramework은 이벤트를 사용하기 위한 ApplicationEventPublisher를 제공하고 있다. 이러한 ApplicationEventPublisher를 활용해서 DDD의 도메인 이벤트를 쉽게 사용 할 수 있는 여러가지 방법을 시도해보

namjug-kim.github.io

명시적 save 하지 않았기에 이벤트가 누락되는 케이스가 발생하였기에 AOP를 통해서 이를 해결하고자 하는 모습을 볼 수 있었다. 위 경우에는 Entity가 영속성 컨텍스트에서 관리하고 있는지의 여부에 따라서 이벤트를 바로 발행하거나, 전역 ThreadLocal 에 넣어 놓는 모습을 볼 수 있었고, 그후에는 processor를 커스텀하여 영속성 컨텍스트에서 관리되고 있지 않은 Entitysave 되고 난 후에 이벤트를 발행하도록 하고 있었다. 결국 AbstractAggregateRoot Domain Event가 어차피 Spring Event를 사용하고 있었기에, 제공하는 registerEvent() 대신에 모두 전역으로 선언 해놓은 ApplicationEventPublisher를 사용하는 모습을 볼 수 있었다. 

 

AOP에서 잡고 있는 항목은 오직 @Transactional 항목 뿐이었는데, @Transactional의 범위를 벗어나게 되면, 이벤트 발행하는데 NPE를 발생시킬 수 도 있어서, @Transactional이 까다롭게(?) 잘 붙어있어야 했다.

 

AbstractAggregateRoot를 상황에 맞게 잘 사용할 수 있지 않을까?

위 방법은 Domain Event를 전역에 설정한 ApplicationEventPublisher를 사용하여 해결하고 있었다. 위 사례에서 생각할 수 있었던 것은 AOPThreadLocal의 활용 이었다. ThreadLocal에 이벤트들을 담아놓고 수행하는 행위와 AOP를 통해서 필요에 맞게 이벤트를 수행할 수 있게 하는 것이다. 여기에 약간의 생각 전환을 통해서 방안을 마련해보았다.

 

  1. AbstractAggregateRoot의 registerEvent() 의 동작을 방해하지 않는다 ( save와 같은 액션에 의해 이벤트를 발행하는 행위 ) 
  2. registerEvent로 등록된 이벤트들 중에 발행되지 않고 남은 잔여 이벤트를 트랜잭션이 commit한 이후에 발행한다.

하나씩 살펴보자.

 

1. AbstractAggregateRoot의 registerEvent() 의 동작을 방해하지 않는다

registerEvent()에 의해 등록된 이벤트 발행 동작은 save와 같은 정해진 액션을 통해서 실제로 발행되게 된다. 누군가는 이를 알고 의도적으로 수행된 코드가 있을 수 있기에, 무작정 모든 부분을 ApplicationEventPublisher를 통해서 곧바로 발행하도록 하는 것은 다른 사이드이펙트를 야기할 수 있었기에, 기존에 수행되는 로직은 영향을 받지 않아야 한다고 생각했다.

 

여기서 문제가 되었던, 명시적 save 이벤트 없이 발행되고 있지 않았던 이벤트들을 발행하기 위해서, registerEvent()를 통해 등록된 이벤트들을 모두 ThreadLocal에 등록하여 관리하는 방안을 생각했다. 말로 하는데는 한계가 있을 수 있기에 코드와 같이 보도록 하자.

 

AbstractAggregateRoot의 내부 로직을 옮긴 모습

왼쪽은 BaseEntity의 모습이고, 오른쪽은 AbstractAggregateRoot의 내부 모습이다. 각 어노테이션의 역활과 동작과정은 이전 포스트에서 확인할 수 있다.

다른점은 이제는 AbstractAggregateRoot를 상속받아서 사용하지 않으며, 내부 로직중에 필요한 부분을 BaseEntity에 옮겼을 뿐이다.

왼쪽 코드에서 밑줄친 로직이 추가된 모습도 확인할 수 있다. BaseEntity내의 registerEvent() 를 통해서 domainEvents 리스트에 추가됨과 동시에 어딘가에 또 registerEvent()를 호출하는 모습을 확인할 수 있으며, @AfterDomainEventPublicationclearDomainEvents() 를 통해서 발행된 이벤트를 초기화해줄 때, 어딘가에서 또 이벤트를 제거하는 모습을 볼 수 있다. 정의된 어노테이션 모두, Spring이 올라갈때 지정된 Processor에서 모두 가져가 메서드 정보를 등록해놓고 사용하기 때문에 AbstractAggregateRoot가 아니더라도 모두 동작하게 된다.

 

ThreadContext의 registerEvent
ThreadContext의 removeEvent

위에서 새로 정의한 registerEvent()removeEvent()의 내부 모습이다. 이벤트가 등록되고 수행되어 초기화 될때 마다, 위 동작들은 같이 수행된다. registerEvent()를 통해서 ThreadLocal에 이벤트를 저장하고 있으며, removeEvent()를 통해서 등록된 이벤트 리스트를 순회하여 ThreadLocal에 등록된 이벤들을 필터링하여 전역에 가지고 있던 이벤트를 제거해주는 역할을 수행한다. EventPublishStatusObject는 중첩 클래스로 선언하여, 이벤트가 실제로 발행됬는지의 여부와 이벤트 객체 자체를 저장하여 관리하는 Class로써 사용되어 진다.

 

여기까지는 명시적 save 호출로 인해 발행되는 이벤트들을 처리해주는 로직에 부가 로직이 더해진 모습만 볼 수 있다. 이로써 AbstractAggregateRootregisterEvent()의 동작은 이전과 같이 동일하게 동작을 한다.

 

다음에 해결할 것은 명시적 선언이 없이 남겨진 잔여 이벤트들을 처리하는 일이다.

 

2. registerEvent로 등록된 이벤트들 중에 발행되지 않고 남은 잔여 이벤트를 트랜잭션이 commit한 이후에 발행한다.

잔여 이벤트를 처리하는 시점은 트랜잭션이 commit한 이후로 생각했다. 이벤트로 넘겨받은 unique 값을 통해서 또다시 DB를 조회했을 떄, 반영된 내용을 이벤트로써 처리하기 위해서는 실제로 모두 데이터 변경사항이 반영된 이후에 동작해야 정확한 데이터로써의 역할을 수행할 수 있다고 생각했기 때문이다. 트랜잭션의 커밋된 이후 시점을 잡기 위해서는 Hibernate에서 제공하는 Interceptor를 활용하였다.

 

 

Hibernate ORM 6.2.2.Final User Guide

Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat

docs.jboss.org

14번 항목의 Interceptors and events 에서는 트랜잭션의 여러가지 시점에 따라서 핸들링 할 수 있는 Interceptor를 제공해주고 있다고 한다. 여러가지 항목이 있지만, 두가지 항목만을 사용하게 된다.

 

HibernateInterceptor

afterTransactionBegin()은 트랜잭션이 시작된 이후에 로직을 타게 된다. @Transactional의 선언 위치에 영향을 받게된다. initEventThread()는 Event를 저장할 ThreadLocal이 초기화 되지 않았을 경우에 초기화 해주도록 하는 기능을 수행한다.

 

afterTransactionCompletion()은 트랜잭션 Commit이 완료된 이후에 로직을 타게 된다. 이후에 publishRemainEvents()를 통해서 실제로 잔여 이벤트들을 모두 소모하게 된다. beforeTransactionCompletion()을 사용하게 되면 변경사항이 반영되기 전이기 때문에, 이벤트 내에서 변경사항이 있던 Entity를 조회하여 데이터를 사용하게 되면 데이터가 맞지 않아서 문제를 일으킬 수 있기 때문에 올바른 구현 메서드를 사용해야 한다.

 

publishRemainEvents

잔여 이벤트들을 모두 가져와서, 발행되지 않았던 이벤트들을 모두 필터링한 다음에 나머지 이벤트들을 ApplicationEventPublisher를 통해서 실제로 모두 발행하게 된다. SpringApplicationEventPublisher는 미리 전역에 선언해 놓았으며, 내부는 ApplicationEventPublisher가 동작하게 되어있다.

 

ThreadLocal의 초기화는 위에서 봤던 것 처럼 트랜잭션이 시작된 후와 AOP를 통해서 초기화를 진행하도록 하였다.

ThreadLocal 초기화

모든 APIController를 타고 오기 때문에, 위와 같이 PointCut을 정의했다. 사실 WebFilter를 통해서 웹 요청이 왔을 때, 초기화할 수있도록 하는 방안도 생각했었고 구현했었지만, 웹 요청이 아닌 다른 조건에 의해 비즈니스 로직이 수행되는 경우에는 Filter를 타지 않아서 ThreadLocal이 초기화되지 않을 수 있었다. 예를 들면, @Scheduled@Async와 같은 독립적으로 기능을 수행하는 경우에는 추가적으로 ThreadLocal을 초기화하는 작업이 필요할 수 있기 때문에, 위와 같이 정의해 놓은 AOP 로직에 @Scheduled@Async와 같은 PointCut을 정의해서 추가하기만 하면 된다.

 

완성된 publish 로직
이전 publish 로직

이 과정이 모두 끝나게 되면, 위와 같이 Domain Event를 수행하기 위한 publish() 한번만 선언하게 되면 등록된 잔여 이벤트들이 모두 발행될 수 있는 결과를 확인할 수 있다. publish()를 통해서 SampleEntitypublished 상태를 바꾼다음에 발행 되어야하는 두가지 이벤트들을 발행하도록 직접 선언하지 않아도, 비즈니스 로직에 선언해줘야 하는 것을 몰라도 이벤트가 모두 수행될 수 있다.

 

고민 끝에 이와 같은 결과물이 나오긴 했는데, 항상 정답은 없다고 생각한다. 사실 AbstractAggregateRootregisterEvent()의 동작과정을 처음부터 잘 알고 사용했다면, 발행되지 않은 이벤트들도 없었을 것이며, 더 좋은 설계를 가지고 구현되어 있지 않았을까 생각이 든다. 하지만, 여러가지 문제에 직면했을 때, 어떤 상황에서든 알맞은 해결방법을 찾는 과정과 이를 구현하는 것이 결국 개발자의 일(?)이라고 생각하기에 고민했던 시간들이 무척 보람차다고 생각이 들었다.

 

편하게 사용하기 위해서 제공되는 라이브러리의 내부 동작과정은 문제가 된 상황을 해결하기 위해서 무조건 파악해야 한다는 것을 느꼈고, 사실 트랜잭션 상태를 핸들링하기 위해서 EntityManager의 상태도 가져와보고 여러가지 삽질(?)하는데 꽤나 많은 시간을 보냈는데, Hibernate에서 트랜잭션의 여러가지 상태를 핸들링할 수 있는 Interceptor를 제공하는 것을 보고, 생각보다 라이브러리가 제공해주는 커스텀 구현체들이 개발하는데 있어서 여러모로 편리하게 많은 것을 제공해주고 있다는 것을 느꼈다.

LIST