Blog

도메인 주도 개발 시작하기(10)

도메인 주도 개발 시작하기(10)

10-1. 시스템 간 강결한 문제

바운디드 컨텍스트간의 강결합 (High Coupling) 일 경우 3가지 문제가 발생할 수 있다
1.
외부 시스템에 대한 요청이 실패했을 때 트랜잭션에 대한 처리가 애매해진다
2.
외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간이 길어지기 때문에 성능에 영향을 끼친다
a.
외부 시스템의 성능에 직접적인 영향을 받는다
3.
도매엔 객체에 서비스를 전달하면 설계상 문제가 발생할 수 있다
a.
서비스가 복잡해질수록 로직이 섞이는 문제가 더 커지고 트랜잭션 처리가 더 복잡해진다
강결합을 없애는 방법이 이벤트를 이용한 방식이며, 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다

10-2. 이벤트 개요

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

10-2-1. 이벤트 관련 구성 요소

이벤트 (Event)
이벤트 생성 주체
엔티티, 밸류, 도메인 서비스와 같은 도메인 객체
도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다
이벤트 디스패처 (퍼블리셔, Publisher)
이벤트 생성 주체와 이벤트 핸들러를 연결해주는 역할
이벤트 생성 주체는 이벤트를 생성해서 디스패처에 전달하고, 대스패처는 해당 이벤트를 처리할 수 있는 핸들러에 전파한다
디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다
이벤트 핸들러 (구독자, Subscriber)
이벤트 생성 주체가 발생한 이벤트에 반응한다
생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다

10-2-2. 이벤트의 구성

이벤트에 담기는 정보
이벤트 종류
클래스 이름으로 이벤트 종류를 표현
이벤트 발생 시간
추가 데이터
이벤트와 관련된 정보 (주문번호, 신규 배송지 정보 등)
이벤트는 현재 기준으로 과거 (바로 직전이라도) 에 벌어진 것을 표현하기 때문에 이름에 과거 시제를 사용한다
이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담아야 한다
이 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해 관련 API 를 호출하거나, DB 에서 데이터를 직접 읽어와야 한다
이벤트는 데이터를 담아야 하지만, 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다

10-2-3. 이벤트 용도

이벤트는 2가지 용도로 사용한다
트리거 (Trigger)
도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용한다
서로 다른 시스템 간의 데이터 동기화
예시
flowchart LR order(Order) dispatcher(EventDispatcher) handler(OrderCanceledEventHandler) service(RefundService) order -->|OrderCanceledEvent| dispatcher dispatcher -->|OrderCanceledEvent| handler handler --> service
Mermaid

10-2-4. 이벤트 장점

이벤트 핸들러를 사용하면 서로 다른 도메인간의 의존을 제거할 수 있다
이벤트 핸들러를 사용하면 기능 확장도 용이하다
예시
flowchart LR order(Order) dispatcher(EventDispatcher) handler(OrderCanceledEventHandler) handler1(OrderCanceledEventHandler) refund_service(환불처리) email_service(이메일통지) order -->|OrderCanceledEvent| dispatcher dispatcher -->|OrderCanceledEvent| handler dispatcher -->|OrderCanceledEvent| handler1 handler --> refund_service handler1 --> email_service
Mermaid

10-3. 이벤트, 핸들러, 디스패터 구현

이벤트 디스패처를 직접 구현할 수 있지만, 스프링이 제공하는 이벤트 관련 기능을 사용해서 이벤트 발생과 처리를 구현한다.
이벤트 클래스: 이벤트를 표현한다
디스패처: 스프링이 제공하는 ApplicationEventPublisher 를 이용한다
Events: 이벤트를 발행한다.
이벤트 발행을 위해 ApplicationEventPublisher 를 사용한다
이벤트 핸들러: 이벤트를 수신해서 처리한다

10-3-1. 이벤트 클래스

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

10-3-2. Events 클래스와 ApplicationEventPublisher

package com.myshop.common.event; import org.springframework.context.ApplicationEventPublisher; public class Event { private static ApplicationEventPublisher publisher; static void setPublisher(ApplicationEevntPublisher publisher) { Events.publisher = publisher; } public static void raise(Object event) { if (publisher != null) { publisher.publicEvent(event); } } }
Java
Events 클래스의 raise() 메서드는 ApplicatrionEventPublisher 가 제공하는 publishEvent() 메서드를 이용해서 이벤트를 발생시킨다

10-3-3. 이벤트 발생과 이벤트 핸들러

public class Order { public void cancel() { verifyNotYetShipped(); this.state = OrderState.CANCELED; Events.raise(new OrderCanceledEvent(number.getNumber())); } }
Java
이벤트를 발생시킬 코드는 Events.raise() 메서드를 사용한다

10-3-4. 흐름 정리

sequenceDiagram autonumber participant application as 응용 서비스 participant domain as 도메인 participant events as Events participant publisher as ApplicationEventPublisher participant handler as 이벤트 핸들러 application ->> domain: 도메인 기능 activate application activate domain domain ->> events: raise(evt) activate events events ->> publisher: publishEvent(evt) activate publisher publisher ->> handler: @EventListener<br>메서드 실행 publisher --) events: deactivate publisher events --) domain: deactivate events domain --) application: deactivate domain deactivate application
Mermaid

10-4. 동기 이벤트 처리 문제

이벤트를 사용해서 강결합 문제는 해소했지만, 외부 서비스에 영향을 받는 문제가 있을 수 있다
이 경우 외부 시스템 실행에 실패했다고 해서 반드시 트랜잭션을 롤백 해야 하는지에 대한 문제를 고민해봐야 한다
트랜잭션은 정상 처리해놓고, 외부 시스템 부분만 재처리하거나 수동으로 처리할 수도 있다
외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나, 이벤트와 트랜잭션을 연계하는 것이다

10-5. 비동기 이벤트 처리

A 하면 이어서 B 하라 라는 요구사항 중에서 A 하면 최대 언제까지 B 하라 로 변경할 수 있는 요구사항은 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다
A 이벤트가 발생하면 별도 쓰레드로 B 를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다

10-5-1. 로컬 핸들러 비동기 실행

스프링에서 제공하는 @Async 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러를 실행할 수 있다
예시 코드
@SpringBootApplication @EnableAsync public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }
JavaScript
import org.springframework.scheduling.annotation.Async; @Service public class OrderCanceledEventHandler { @Async @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundServie.refund(event.getOrderNumber()); } }
JavaScript

10-5-2. 메시징 시스템을 이용한 비동기 구현

카프카 (KAFKA) 나 래빗MQ(RabbitMQ) 와 같은 메시징 시스템을 사용한다
flowchart LR order[Order] event[Events] mq[메시징 시스템] ml[Message Listener] handler[이벤트 핸들러] subgraph 트랜잭션 범위 order -->|"Events.raise()"| event end event -->|메시징 전송| mq subgraph 트랜잭션 범위 mq -->|"onMessage()"| ml ml -->|"handle()"| handler end
Mermaid
글로벌 트랜잭션
도메인 기능을 실행한 결과를 DB에 반영하고, 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에 실행할 때 필요한 것
 장점
안전하게 이벤트를 메시지 큐에 전달할 수 있음
 단점
글로벌 트랜잭션으로 인해 전체 성능이 떨어질 수 있음
글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있음
메시지 큐를 사용하면 보통 이벤트를 발생시키는 주체와 이벤트 핸들러가 별도의 프로세스에서 동작한다
한 JVM에서 메시지 큐를 이용해서 이벤트를 주고 받을 수 있지만, 시스템을 복잡하게 만들뿐이기 때문에 권장하지 않는다
래빗MQ (RabbitMQ)
글로벌 트랜잭션 지원
클러스터와 고가용성을 지원
다양한 개발 언어와 통신 프로토콜을 지원
카프카 (KAFKA)
글로벌 트랜잭션을 지원하지 않음
다른 메시징 시스템에 비해 높은 처리량을 보여줌

10-5-3. 이벤트 저장소를 이용한 비동기 처리

이벤트 저장소와 포워더를 이용한 비동기 처리
이벤트를 DB 에 저장한 뒤, 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 방법이 있다
flowchart LR domain[도메인] dispatcher[이벤트 디스패처] local_handler[로컬 핸들러] db[저장소] forwarder[포워더] event_handler[이벤트 핸들러] domain --> dispatcher dispatcher --> local_handler local_handler -->|이벤트 저장| db forwarder -->|"이벤트를 주기적으로 읽어와 전달<br>어디까지 전달했는지 추적"| db forwarder --> event_handler
Mermaid
이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB 를 사용하기 때문에 로컬 트랜잭션으로 같이 처리된다
이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다
API 를 이용해서 이벤트를 외부에 제공하는 방식
API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져가고 실행하는 방식이다
flowchart LR domain[도메인] dispatcher[이벤트 디스패처] local_handler[로컬 핸들러] db[저장소] api[API] event_handler[이벤트 핸들러] domain --> dispatcher dispatcher --> local_handler local_handler -->|이벤트 저장| db event_handler -->|"API 를 통해 이벤트를 읽어와 처리"| api api -->|"REST와 같은 방식으로 이벤트를 외부에 제공"| db
Mermaid
API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리햇는지 기억해야 한다
이벤트 저장소 구현
API 방식이나 포워더 방식이나 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다
Entity
이벤트 객체를 직렬화해서 payload 에 저장한다
만약 JSON 으로 직렬했다면 contentType 에는 application/json 으로 지정한다
public class EventEntry { private Long id; private String type; private String contentType; private String payload; private long timestamp; // ... }
Java
이벤트 저장소
이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다
새로운 이벤트를 추가하는 기능과 조회하는 기능만 제공하고, 기존 이벤트 데이터를 수정하는 기능은 제공하지 않는다
public interface EventStore { void save(Object event); List<EventEntity> get(long offset, long limit); }
Java
EventEntity 테이블
create table evententry ( id int not null AUTO_INCREMENT PRIMARY KEY, `type` varchar(255), `content_type` varchar(255), payload MEDIUMTEXT, `timestamp` datetime ) character set utf8mb4;
SQL
이벤트 핸들러
@Component public class EventStoreHandler { private EventStore eventStore; public EventStoreHandler(EventStore eventStore) { this.eventStore = eventStore; } @EventListener(Event.class) public void handle(Event event) { eventStore.save(event); } }
Java
REST API
@RestController public class EventApi { private EventStore eventStore; public EventApi(EventStore eventStore) { this.eventStore = eventStore; } @GetMapping("/api/events") public List<EventEntry> list( @RequestParam("offset") Long offset, @RequestParam("limit") Long limit) { return eventStore.get(offset, limit); } }
Java
API 클라이언트 구현
일정한 간격으로 다음의 과정을 실행해야 한다
1.
가장 마지막에 처리한 데이터의 offset 인 lastOffset 을 구한다. ( 저장한 lastOffset 이 없으면 0을 사용한다 )
2.
마지막에 처리한 lastOffset 을 offset 으로 사용해서 API 를 실행한다
3.
API 결과로 받은 데이터를 처리한다
4.
offset + 데이터 개수를 lastOffset 으로 저장한다
클라이언트 API 를 이용해서 언제든지 원하는 이벤트를 가져올 수 있기 때문에 이벤트 처리에 실패하면 다시 실패한 이벤트로부터 읽어와 이벤트를 재처리할 수 있다
API 서버에 장애가 발생한 경우에도 주기적으로 재시도를 해서 API 서버가 살아나면 이벤트를 처리할 수 있다
포워더 구현
@Component public class EventForwarder { private static final int DEFAULT_LIMIT_SIZE = 100; private EventStore eventStore; private OffsetStore offsetStore; private EventSender eventSender; private int limitSize = DEFAULT_LIMIT_SIZE; public EventForwarder(EventStore eventStore, OffsetStore offsetStore, EventSender eventSender) { this.eventStore = eventStore; this.offsetStore = offsetStore; this.eventSender = eventSender; } @Scheduled(initialDelay = 1000L, fixedDelay = 1000L) public void getAndSend() { long nextOffset = getNextOffset(); List<EventEntry> events = eventStore.get(nextOffset, limitSize); if (!event.isEmpty()) { int processedCount = sendEvent(events); if (processedCount > 0) { saveNextOffset(nextOffset +} processedCount); } } } private long getNextOffset() { return offsetStore.get(); } private int sendEvent(List<EventEntry> events) { int processedCount = 0; try { for (EventEntry entry : events) { eventSender.send(entry); processedCount++; } } catch (Exception ex) { // 로깅 처리 } return processedCount; } private void saveNextOffset(long nextOffset) { offsetStore.update(nextOffset); } }
Java
public interface OffsetStore { long get(); void update(long nextOffset); } public interface EventSender { void send(EventEntry event); }
Java

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

이벤트를 구현할 때 고려해야 할 점
1.
이벤트 소스를 EventEntry 에 추가할 지 여부
이벤트 발생 주체에 대한 정보를 갖지 않기 때문에 특정 주체가 ㅂ라생시킨 이벤트만 조회하는 기능을 구현할 수 없다
이벤트에 발생 주체에 대한 정보를 추가할 지 고려해야 한다
2.
포워더에서 전송 실패를 얼마나 허용할 것인지
특정 이벤트에서 계속 전송에 실패하면 나머지 이벤트를 전송할 수 없게 되기 떄문에 재전송 횟수 제한을 두어야 한다
처리에 실패한 이벤트를 생략하지 않고, 별도의 실패용 DB 나 메시지 큐에 저장하기도 한다
처리에 실패한 이벤트를 물리적인 저장소에 남겨두면 이후 실패 이유 분석이나 후처리에 도움이 된다
3.
이벤트 손실에 대한 고려
이벤트 저장소를 사용하는 방식은 한 트랜잭션으로 처리되기 때문에 저장소에 보관된다는것을 보장할 수 있지만, 로컬 핸들러를 이용한 이벤트 처리는 이벤트 처리에 실패하면 유실하게 된다
4.
이벤투 순서에 대한 고려
이벤트 발생 순서대로 외부 시스템에 전달해야 할 경우, 이벤트 저장소를 사용하는 것이 좋다
메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있다\
5.
이벤트 재처리에 대한 고려
동일한 이벤트를 다시 처리해야 할 때 이벤트를 어떻게 해야 할 지 결정해야 한다
마지막으로 처리한 이벤트의 순번을 기억해뒀다가 해당 이벤트가 발생하면 처리하지 않고 무시하는 방법이 있다
이벤트를 멱등으로 처리하는 방법도 있다

10-6-1. 이벤트 처리와 DB 트랜잭션 고려

이벤트 처리를 동기로 하든, 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다
트랜잭션 실패와 처리 실패를 모두 고려하면 복잡해지므로 경우의 수를 줄이면 도움이 된다
경우의 수를 줄이는 방법은 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이다
이벤트 저장소로 DB 를 사용하면 동일한 효과를 얻을 수 있다
이벤트 발생 코드와 이벤트 저장 처리를 한 트랜잭션에서 처리하기 때문에 트랜잭션에 실패하면 이벤트 핸들러가 실행되지 않기 때문이다
트랜잭션이 성공할 때만 이벤트 핸들러를 실행하게 되면 트랜잭션 실패에 대한 경우의 수가 줄어들어 이제 이벤트 처리 실패만 고민하면 된다