독서/도메인 주도 개발 시작하기

10~11장 이벤트, CQRS ( 完 )

Thinking 2024. 12. 8. 00:00

10.1 시스템 간 강결합 문제

쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이때 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있다. 보통 결제 시스템은 외부에 존재하므로 RefundService는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출한다. 2가지 문제가 발생할 수 있다.

 

1) 외부 서비스가 정상이 아닌 경우 트랜잭션 처리를 어떻게 해야 할지 애매하다. 외부의 환불 서비스를 실행하는 과정에서 익셉션이 발생하면 환불에 실패했으므로 주문 취소 트랜잭션을 롤백하는 것이 맞아 보인다. 하지만 반드시 트랜잭션을 롤백 해야 하는 것은 아니다. 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수도 있다.

 

2) 두 번째 문제는 성능에 관한 것이다. 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다. 환불 처리 기능이 30초가 걸리면 주문 취소 기능은 30초만큼 대기 시간이 증가한다. 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.

 

두 가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수 있다. 도메인 객체에 서로 다른 도메인 로직이 섞이는 문제가 예로 들 수 있다.

 

이러한 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합 때문이다. 이를 없애는 방법 중 하나는 이벤트를 사용하는 것이다. 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

 

 

10.2 이벤트 개요

이벤트라는 용어는 '과거에 벌어진 어떤 것'을 의미한다. 보통 '~할 때', '~가 발생하면', '만약 ~하면'과 같은 요구사항은 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해서 구현할 수 있다.

 

도메인 모델에 이벤트를 도입하려면 네 개의 구성요소이벤트, 이벤트 생성 주체, 이벤트 디스패처(퍼블리셔), 이벤트 핸들러(구독자)를 구현해야 한다. 도메인 모델에서 이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스 같은 도메인 객체이다. 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.

 

이벤트 핸들러이벤트 생성 주체가 발생한 이벤트에 반응한다. 이벤트 핸들러는 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.

 

이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이벤트 디스패처다. 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다. 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다. 

 

 

10.2.1 이벤트의 구성

1) 이벤트 종류 - 클래스 이름으로 이벤트 종류를 표현

2) 이벤트 발생 시간

3) 추가 데이터 : 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보

 

이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다. 이벤트는 크게 2가지 용도로 사용된다. 첫 번째 용도는 트리거다. 도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다. 주문에서는 주문 취소 이벤트를 트리거로 사용할 수 있다. 두 번째 용도는 서로 다른 시스템 간의 데이터 동기화다. 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정볼르 전송해야 한다.

 

이벤트의 장점으로는 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다. 또한 이벤트 핸들러를 사용하면 기능 확장도 용이하다.

 

 

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

이벤트 클래스 : 이벤트를 표현한다.

디스패처 : 스프링이 제공하는 ApplicationEventPublisher를 이용한다.

Events : 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher

이벤트 핸들러 : 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.

 

이벤트 클래스

이벤트 자체를 위한 상위 타입은 존재하지 않는다. 이벤트 클래스의 이름을 결정할 때에는 과거 시제를 사용해야 한다는 점, 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 한다. 또한 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 상위 클래스를 만들 수도 있다. 

 

 

10.3.2 Events 클래스와 ApplicationEventPublisher

이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용한다. 스프링 컨테이너는 ApplicationEventPublisher도 된다. Events 클래스는 ApplicationEventPublisher를 사용해서 이벤트를 발생시키도록 구현할 것이다.

 

package com.myshop.common.event;

import org.springframework.context.ApplicationEventPublisher;

public class Events{
	private static ApplicationEventPublisher publisher;
    
    static void setPublisher(ApplicationEventPublisher publisher){
    	Events.publisher = publisher;
    }
	
    public static void raise(Object event) {
    	if(publisher != null) }
        	publisher.publishEvent(event);
        }
    }
}

 

raise() 메서드는 ApplicationEventPublisher가 제공하는 publishEvent() 메서드를 이용해서 이벤트를 발생시킨다. Events 클래스가 사용할 ApplicationEventPublisher 객체는 setPublisher() 메서드를 통해서 전달받는다.

 

 

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

이벤트를 발생시킬 코드는 Events.raise() 메서드를 사용한다. 예로 Order#cancel() 메서드는 구매 취소 로직을 수행한 뒤 Events.raise()를 이용해서 관련 이벤트를 발생시킨다.

public class Order{

	public void cancel() {
    	verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
}

 

이벤트를 처리할 핸들러는 스프링이 제공하는 @EventListener 애너테이션을 사용해서 구현한다. 다음은 OrderCanceledEvent를 처리하기 위한 핸들러를 구현한 코드의 예다.

 

import org.springframework.context.event.EventListener;

@Service
public class OrderCanceledEventHandler{
	private RefundService refundService;
    
    public OrderCanceledEventHandler(RefundService refundService){
    	this.refundService = refundService;
    }
    
    @EventListener(OrderCanceledEvent.class)
    public void handle(OrderCanceledEvent event){
    	refundService.refund(event.getOrderNumber());
    }
}

 

ApplicationEventPublisher#publishEvent() 메서드를 실행할 때 OrderCanceledEvent 타입 객체를 전달하면, OrderCanceledEvent.class 값을 갖는 @EventListener 애너테이션을 붙인 메서드를 찾아 실행한다. 위 코드는 OrderCanceledEventHandler의 handle() 메서드를 실행한다.

 

이벤트 처리 흐름을 간단하게 요약하면 아래와 같다.

1) 도메인 기능을 실행한다.

2) 도메인 기능은 Events.raise()를 이용해서 이벤트를 발생시킨다.

3) Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해서 이벤트를 출판한다.

4) ApplicationEventPublisher는 @EventListener(이벤트타입.class) 애너테이션이 붙은 메서드를 찾아 실행한다.

 

코드 흐름을 보면 응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러를 실행한다. 즉, 도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.

 

 

10.4 동기 이벤트 처리 문제

위를 통해 강결합 문제는  해소했지만 아직 문제가 남아있다. 바로 외부 서비스에 영향을 받는 문제이다. 외부 서비스의 성능 저하가 바로 내 시스템의 성능 저하로 연결된다는 것을 의미한다. 성능 저하뿐만 아니라 트랜잭션도 문제가 된다. 외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 1) 이벤트를 비동기로 처리하거나 2) 이벤트와 트랜잭션을 연계하는 것이다.

 

 

10.5 비동기 이벤트 처리

우리가 구현해야 할 것 중 'A 하면 이어서 B 하라'는 내용을 담고 있는 요구사항은 실제로 'A 하면 최대 언제까지 B 하라'인 경우가 많다. 게다가 B를 하는 데 실패하면 일정 간격으로 재시도를 하거나 수동 처리를 해도 상관없는 경우가 있다. 'A 하면 일정 시간 안에 B 하라'는 요구 사항에서 'A 하면'은 이벤트로 볼 수 있다. 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다. 다시 말해 A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.

 

크게 4가지 비동기 이벤트 처리를 구현할 수 있는 방법이 있다.

 

1) 로컬 핸들러 비동기 실행

이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다. 스프링이 제공하는 @Async 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러를 실행할 수 있다. 이를 위해 2가지가 필요하다.

 

[1] @EnableAsync 애너테이션을 사용해서 비동기 기능을 활성화한다. -> @EnableAsync 애너테이션은 스프링의 비동기 실행 기능을 활성화한다. 

[2] 이벤트 핸들러 메서드에 @Async 애너테이션을 붙인다. -> 이제 비동기로 실행할 이벤트 핸들러에 @Async 애너테이션만 붙이면 된다. 스프링은 OrderCanceledEvent가 발생하면 handle() 메서드를 별도 스레드를 이용해서 비동기로 실행한다. 

 

 

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

비동기로 이벤트를 처리해야 할 때 사용하는 방법은 Kafka, RabbitMQ 같은 메시징 시스템을 사용하는 것이다. (1) 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다. (2) 메시지 큐는 이벤트를 메시지 리스너에 전달하고, (3) 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다. 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정을 별도 스레드나 프로세스로 처리한다.

 

 

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

이벤트를 일단 DB에 저장한 뒤 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 것이다. (1) 이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다. (2) 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행한다. 포워드는 별도 스레드를 사용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다.

 

이는 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용한다. 즉, 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다. 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패하는 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다. 포워더의 역할은 이벤트를 주기적으로 읽어와 전달 어디까지 전달했는지 추적한다.

 

이벤트 저장소를 이용한 두 번째 방법은 이벤트를 외부에 제공하는 API를 사용하는 것이다. API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식에 있다. 포워더 방식이 포워더를 이용해서 이벤트를 외부에 전달한다면, API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다. 포워더 방식이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다면, API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.

 

포워더, API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.

1) EventEntry : 이벤트 데이터를 정의한다.

2) EventStore : 이벤트 객체를 직렬화해서 payload에 저장한다. 이때 JSON으로 직렬화했다면 contenType : 'application/json' 이벤트는 과거에 벌어진 사건이므로, 새로운 이벤트를 추가하는 기능 및 조회하는 기능만 제공하고 수정 기능은 제공하지 않는다.

3) JdbcEventStore : EventStore 인터페이스를 구현한 JdbcEventStore 클래스

4) EventAPI : REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러이다.

 

자동 증가 칼럼 주의 사항 -> 주요키로 자동 증가 칼럼을 사용할 때 주의점이 있다. insert 쿼리를 실행하는 시점에 값이 증가하지만, 실제 데이터는 트랜잭션을 커밋하는 시점에 DB에 반영된다. 또한 커밋 시점에 따라 DB에 반영되는 시점이 달라질 수 있다. 예로 마지막 자동 증가 컬럼이 10인 상태에서 A 트랜잭션이 insert 쿼리를 실행한 뒤 B 트랜잭션이 insert 쿼리를 실행했다면 A는 11을, B는 12를 자동 증가 칼럼 값으로 사용하게 된다.

그런데 B 트랜잭션이 먼저 커밋되고 다음에 A 트랜잭션이 커밋되면 12가 DB에 먼저 반영되고, 그다음 11이 반영된다. 만약 B 트랜잭션 커밋과 A 트랜잭션 커밋 사이에 데이터를 조회한다면 11은 조회되지 않고  12만 조회되는 상황이 발생한다. 이런 문제가 발생하지 않도록 하려면 ID를 기준으로 데이터를 지연 조회하는 방식을 사용해야 한다.

 

 

10. 6 이벤트 적용 시 추가 고려 사항

이벤트를 구현할 때 고려할 점이 있다.

첫 번째는 이벤트 소스를 EventEntry에 추가할지 여부이다 : 특정 주체가 발생시킨 이벤트만 조회하는 기능을 구현할 수 없기 때문

두 번째는 포워더에서 전송 실패를 얼마나 허용할 것이냐에 대한 것이다 : 해당 이벤트 때문에 나머지 이벤트를 전송할 수 없기 때문

세 번째 고려할 점은 이벤트 손실에 대한 것이다 : 이벤트 저장소 방식에 따라 이벤트를 유실할 수 있기 때문

네 번째 고려할 점은 이벤트 순서에 대한 것이다. : 메시징 시스템은 사용 기술에 따라 순서가 다를 수 있기 때문

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

 

이벤트 처리와 DB 트랜잭션 관점에서 고려할 점을 살펴보자. 예로 주문 취소와 환분 기능을 아래와 같이 이벤트를 이용해서 구현했다고 하자. 주문 취소 기능은 주문 취소 이벤트를 발생시킨다. 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청한다. 환불 서비스는 외부 API를 호출해서 결제를 취소한다. 이벤트를 비동기로 처리할 때도 DB 트랜잭션을 고려해야 한다.

 

이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다. 경우의 수를 줄이면 덜 복잡해지는데, 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이 방법이다. 

@TransactionalEventListener (
	classes = OrderCanceledEvent.class,
	phase = TransactionPhase.AFTER_COMMIT
)

public void handle(OrderCanceledEvent event) {
	refundService.refund(event.getOrderNumber());
}

 

스프링은 @TransactionalEventListener 애너테이션을 지원한다. 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있고, phase 속성 값을 위처럼 사용하면, 트랜잭션 커밋에 성공한 뒤 핸들러 메서드를 실행한다.

 

 

11.1 단일 모델의 단점

주문 내역 조회 기능을 구현하려면 여러 애그리거트에서 데이터를 가져와야 한다. 식별자를 이용해서 애그리거트를 참조하는 방식을 사용하면 즉시 로딩 방식과 같이 한번의 SELECT 쿼리로 조회 화면에 필요한 데이터를 읽어올 수 없어 조회 성능에 문제가 생길 수 있다. 또한 직접 참조하는 방식으로 연결해도, 즉시나 지연 로딩으로 처리해야 하기 때문에 JPA의 네이트브 쿼리를 사용해야 할 수 있다.

 

이런 고민이 발생하는 이유는 시스템 상태를 변경할 때, 조회할 때 단일 도메인 모델을 사용하기 때문이다. 이때 좋은 방법으로 상태 변경을 위한 모델과 조회를 위한 모델을 분리하는 것이다.

 

 

11.2 CQRS

시스템이 제공하는 기능은 2가지로 나눌 수 있다. 하나는 상태를 변경하는 기능, 다른 하나는 사용자 입장에서 상태 정보를 조회하는 기능이다. 도메인 모델 관점에서 상태 변경 기능은 주로 한 애그리거트의 상태를 변경한다. 반면 조회 기능에 필요한 데이터를 표시하려면 두 개 이상의 애그리거트가 필요할 때가 많다.

 

단일 모델을 사용할 때 발생하는 복잡도를 해결하기 위해 사용하는 방법이 바로 CQRS다. 복잡한 도메인에 적합하며, 이를 사용하면 명령 모델은 객체지향에 기반해서 도메인 모델을 구현하기에 적당한 JPA를 사용해서 구현하고, 조회 모델은 DB 테이블에서 SQL로 데이터를 조회할 때 좋은 마이바티스를 사용해서 구현하면 된다.

 

CQRS 패턴을 적용할 때 얻을 수 있는 장점은 명령 모델을 구현할 때 도메인 자체에 집중할 수 있다는 점이다. 또한 조회 성능을 향상시키는 데 유리하다. 단점으로는 구현해야 할 코드가 더 많아진다. 두 번째는 더 많은 구현 기술이 필요하다. 도메인이 복잡하지 않은데 CQRS를 도입하면 유지 비용만 높아진다. 반면 트래픽이 높은 서비스인데 단일 모델을 고집하면 유지 보수 비용이 높아질 수 있기에 고려해보아도 좋다.