AbstractAggregateRoot의 동작 원리(with @PostUpdate로 맞이한 버그)
AbstractAggregationRoot 동작 원리
AbstractAggregateRoot는 DDD(Domain Driven Design)를 구현하기 편리하게 해 주는, 정확히는 도메인 이벤트를 등록하고 가져오기 편리하게 해주는 클래스 정도로 이해하고 있다. (클래스 이름과 다르게 추상 클래스가 아니다.)
AbstractAggregateRoot와 같이 무언가에서 편의 기능을 제공해줄 때는 항상 트레이드 오프(trade-off)가 있다.
즉, 편의 기능을 사용하는 만큼 개발 생산성이 올라가지만, 동작 원리를 모른 채 사용하다보면 훗날에 대가를 치르게 되어있다.
이 포스트는 그 대가를 치르고 남기는 포스트다.
AbstractAggregateRoot 코드 분석
public class AbstractAggregateRoot<A extends AbstractAggregateRoot<A>> {
private transient final @Transient List<Object> domainEvents = new ArrayList<>(); // (1)
protected <T> T registerEvent(T event) { // (2)
Assert.notNull(event, "Domain event must not be null!");
this.domainEvents.add(event);
return event;
}
@AfterDomainEventPublication
protected void clearDomainEvents() { // (3)
this.domainEvents.clear();
}
@DomainEvents
protected Collection<Object> domainEvents() { // (4)
return Collections.unmodifiableList(domainEvents);
}
// 이하 생략 ...
}
먼저 코드로 하나하나 살펴보겠다. (주석으로 번호를 표시했다)
- (1)로 표신된 부분을 보면, 단순한 리스트 객체(domainEvents)를 만들었다. AbstractAggregateRoot를 상속한 도메인에서 발생한 이벤트를 순서대로 잠시 저장해놓는 용도로 보인다.
- (2)로 표시된 메서드(registerEvent)를 보면, (1)에서 봤던 List 객체에 이벤트를 add하는 기능을 갖는다.
- (3)으로 표시된 메서드(clearDomainEvents)를 보면, (1)에서 봤던 List 객체에 있는 이벤트를 clear하는 기능을 갖는다.
- (4)로 표시된 메서드(domainEvents)를 보면, (1)의 List객체를 불변 컬렉션으로 변환해서 리턴해주는 기능을 갖는다.
분석하기 어렵지 않은 클래스인 건 좋았으나 이벤트를 저장하고 조회하는 메서드는 찾을 수 있지만, 정작 ApplicationContext를 통해 이벤트를 발행(publish)하는 부분은 볼 수 없었다.
그렇다는 것은 다른 객체가 이벤트 발행을 담당하고 있지 않을까란 추측을 할 수 있다. (AbstractAggregateRoot 클래스를 Spring Data에서 제공하고 있고 위 코드에서는 주석을 지웠지만 설명이 잘 나와있다.)
@AfterDomainEventPublication, @DomainEvents
위에서 코드를 살펴볼 때 빼놓은 것이 있다. 바로 아래의 두가지 애노테이션이다.
- @AfterDomainEventPublication
- 메서드에 적용하는 애노테이션이고, aggregate root의 이벤트가 발행된 후에 실행될 메서드에 사용된다.
- @DomainEvents
- 메서드에 적용하는 애노테이션이고,
@DomainEvents
애노테이션이 적용된 메소드가 리턴하는 이벤트를 Spring Data repository에서 aggregate root의 이벤트를 발행하기 용도로 사용된다.
- 메서드에 적용하는 애노테이션이고,
애노테이션 설명에 나온 것을 내 마음대로 해석한(?) 것이다.
Spring Data가 어떻게 해준다는 건지 알기 위한 방법으로 디버깅을 통해 알아보려고 한다.
Spring Data의 동작
AbstractAggregateRoot클래스의 domainEvents() 메서드에 브레이크 포인트를 걸어서 확인한 결과는 아래와 같다.
우선 EventPublishingRepositoryProxyPostProcessor
라는 클래스가 이벤트 발행을 담당하고 있다.
위 그림에서 알 수 있듯 RepositoryProxyPostProcessor를 구현한 구현체로 우리가 흔히 쓰는 JpaRepository(CrudRepository)에 메서드 인터셉터를 등록해주는 역할을 하고 있고 이벤트 발행에 필요한 ApplicationEventPublisher 객체로 보인다.
addAdvice로 등록한 메서드 인터셉터(EventPublishingMethodInterceptor)를 만들 때, EventPublishingMethod를 생성하는 로직이 보이는데 여기서 아까 우리가 살펴보면 어노테이션을 찾아서 등록하는 부분을 볼 수 있다. (아래 그림 참고)
publisingMethod에 AbstractAggregateRoot의 domainEvents() 메서드가 할당되어 있고 clearingMethod에 AbstractAggregateRoot의 clearDomainEvents() 메서드가 할당되어있다.
이제 저 두 메서드가 어떻게 사용되는지 보려고 했는데 무려 호출 조건이 있다.
if(!isEventPublisingMethod(invocation.getMethod())
이 조건에 해당하면 이벤트가 발생되지 않는 데 따라가다 보면 조건은 다음과 같다.
- MethodInvocation(=SimpleJpaRepository)의 메서드 이름이 save~로 시작하는 메서드(ex. save, saveAll, saveAndFlush, ...) 이거나 delete, deleteAll 이면서 메서드 파라미터 수가 1개여야만 이벤트가 발행된다.
이 조건에 대한 이슈는 아래에서 더 알아보고 우선은 로직을 따라가 보면 결국엔 아래와 같이 반복하며 이벤트가 발행된다.
메서드 파라미터로 전달된 것은 aggregate root고 for문 내부를 보면 알 수 있듯, publishingMethod(@DomainEvents가 있는 메서드)를 호출하여 도메인에 등록되어 있던 이벤트들을 가져오고 하나하나 발행하고 이후에 바로 clearingMethod(@AfterDomainEventPublication가 있는 메서드)를 호출하여 이벤트를 가지고 있던 리스트를 비우는 과정을 반복한다.
동작 원리는 이렇게 알아보았다.
그러면 나는 무엇이 문제였을까?
문제점
필자가 맞이한 문제점은 크게 두 가지의 문제점 있었다.
- 조건
- 시점
(이 이후로는 경험과 관련된 이야기니 동작 원리에만 관심이 있었다면 그만 읽어도 좋습니다!)
조건
아까 위에서 봤듯이 조건에 알려진 문제가 있다.
바로 saveXXXX로 시작하는 메서드 또는 delete, deleteAll이 아니면 아무리 도메인에 이벤트를 등록해놓아도 발행되지 않는 것이다.
만약 어떤 도메인이 변경되었을 때, 이벤트를 발행하고 싶다면 어떻게 해야 할까?
도메인을 수정해야 한다면 JPA에서는 더티 체크로 하는 것을 권장하는데도 불구하고 굳이 그때마다 save를 호출해서 처리해야 하는 게 맞을까? (물론 DDD에서는 aggregate root가 UPDATE 하는 대신 관련된 모두를 DELETE 하고 다시 다 CREATE 하는 식으로도 해결하기에 예시가 적절하지 않을 수는 있다.)
필자 회사 프로젝트에는 DDD는 아닌데 AbstractAggregateRoot를 사용하여 이벤트를 발생시키고 있었고 무언가 DDD를 하려다가만 흔적만 있었다.
그래서 저런 식으로 의도한 것과 다르게 이벤트가 발생하지 않는 경우도 있었던 것이다.
시점
시점에도 문제가 있다.
이벤트가 발행되는 시점은 언제였나?
정확하게는 이벤트를 발행하기 위해서 이벤트가 있는지 찾아보는 그 시점 말이다.
바로 Repository의 메서드가 호출되고 난 “후”다. (EventPublishingRepositoryProxyPost
Processor)
단독으로는 문제가 없지만 하이버네이트의 JPA 콜백인 @PostUpdate
를 같이 쓰고 있었기에 문제가 발생했다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class XXXX {
// ... 생략
@PostUpdate
private void registerUpdateEvent() {
registerEvent(new BookUpdateEvent(this));
}
}
대충 위와 같이 되어있었는데 시점에 대해 이해를 못 한 상태였던 것 같다.
하이버네이트 공식 문서에 보면 @PostUpdate
는 실제 업데이트 쿼리 DB로 수행된 후다.
그렇기 때문에 위와 같이 프로그래밍하면 업데이트 쿼리가 나간 후에 이벤트를 등록한다.
근데 문제는 repository의 메서드가 수행되면 바로 쿼리가 나가는 것이냐는 거다.
보통 서비스에 원자적으로 처리되어야 할 필요가 있을 때, @Transactional
애노테이션을 사용하고 해당 트랜잭션이 commit 되어야 그제야 쿼리가 나간다.
이 말은 곧 이벤트를 등록하기도 전에 repository의 후처리기가 돌아서 이벤트를 발행하려고 시도하는 것이다.
그러면 아직 등록된 이벤트가 없기 때문에 이벤트가 발행되지 않는 상태가 된다.
이것이 문제였다.
끝으로..
결국 원인은 파악했고 처리는 어떻게 했을까는 아직이다.
먼저 드는 생각은 “조건”을 피하기 위해서 AOP를 이용하여 서비스에 인터셉터를 등록해야 하지 않을까 싶다. (트랜잭션에 걸 수 있으면 더 좋겠지만...?)
인터셉터에서 EventPublishingRepositoryProxyPostProcessor와 거의 동일한 역할을 갖는 객체를 만들어서 남아있는 도메인의 이벤트를 발행하는 방법?을 생각해봤다.
다음으로 시점에 대한 것은 역시나 잘 파악하는 게 우선이지 않을까 싶다.
대가를 치렀으니 일단락 지었다지만 다른 기술이 사용될 때 같이 고려될 수 있도록 할 수 있는 방법을 찾아봐야 하지 않을까 한다.
= 아직 근본적인 문제를 해결하진 못했음