[설계 패턴 + 금융 시스템] 이벤트 소싱과 CQRS로 구현하는 주문 관리 시스템
CRUD 기반 주문 관리 시스템의 감사 추적 한계를 이벤트 소싱과 CQRS 패턴으로 극복하는 설계 방법을 Java 코드와 함께 설명합니다.
주문 관리 시스템에서 주문의 현재 상태만 저장하는 CRUD 구조로는 부분 체결 이후 취소된 주문의 정확한 이력을 재구성할 수 없다는 것을 장애 분석 도중 깨달았습니다. 브로커사 연동 감사 로그와 시스템 내부 상태가 불일치했고, 어느 시점에 무엇이 잘못됐는지 파악하는 데 한나절이 걸렸습니다. 그 이후 이벤트 소싱 방식으로 재설계하면서 주문의 전체 생애 주기를 불변 이벤트 스트림으로 기록하게 됐고, 이전에 불가능했던 특정 시점(point-in-time) 복원이 가능해졌습니다. 이 글은 그 재설계 경험을 바탕으로 이벤트 소싱과 CQRS의 핵심 개념을 정리한 것입니다.
배경
CRUD 기반 주문 관리 시스템의 한계
전통적인 CRUD 설계에서 주문 테이블은 현재 상태만 저장합니다.
CREATE TABLE orders (
order_id VARCHAR(36) PRIMARY KEY,
symbol VARCHAR(10),
side VARCHAR(4), -- BUY / SELL
quantity DECIMAL(18,4),
filled_qty DECIMAL(18,4),
status VARCHAR(20), -- PENDING, PARTIAL, FILLED, CANCELLED
updated_at TIMESTAMP
);
이 구조의 문제는 명확합니다. updated_at은 마지막 변경 시각만 기록할 뿐, 어떤 이유로 상태가 바뀌었는지, 중간 단계에서 얼마가 체결됐는지는 모두 소실됩니다. 주문이 PARTIAL에서 CANCELLED로 바뀐 사실은 알 수 있지만, 부분 체결이 몇 회 발생했고 각각 얼마에 체결됐는지는 별도 로그 테이블 없이는 불가능합니다.
금융 규제의 감사 추적 요구사항
MiFID II(유럽)와 Dodd-Frank(미국)는 모든 주문의 생애 주기를 타임스탬프 정밀도 마이크로초 수준으로 기록하고 최소 5년간 보관하도록 규정합니다. 구체적으로는 다음을 요구합니다.
- 주문 접수 시각과 전송 시각의 차이(지연) 기록
- 주문 수정 또는 취소의 전후 상태 비교
- 체결 이력 전체의 재현 가능성(reproducibility)
CRUD 구조에서 이 요건을 충족하려면 별도 감사 테이블과 트리거를 추가해야 하고, 결국 시스템이 복잡해지면서 일관성을 유지하기 어렵습니다. 이벤트 소싱은 이 문제를 구조적으로 해결합니다.
이벤트 소싱 패턴
이벤트 소싱의 핵심 원칙은 상태(state) 대신 상태 변화(event)를 저장하는 것입니다. 주문 관리 시스템에서는 다음 이벤트 종류를 정의합니다.
| 이벤트 | 의미 |
|---|---|
OrderPlaced | 주문 접수 |
PartiallyFilled | 일부 체결 |
FullyFilled | 전체 체결 |
OrderAmended | 수량 또는 가격 수정 |
OrderCancelled | 주문 취소 |
현재 상태는 이벤트 스트림을 처음부터 재생(replay)하여 도출합니다. 이를 이벤트 리플레이라고 합니다.
코드 예시: Java OrderAggregate
아래는 핵심 도메인 객체인 OrderAggregate와 이벤트 클래스 구현입니다.
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
// 이벤트 마커 인터페이스
public interface OrderEvent {
String orderId();
Instant occurredAt();
}
// 주문 접수 이벤트
public record OrderPlacedEvent(
String orderId,
String symbol,
String side, // "BUY" 또는 "SELL"
BigDecimal quantity,
BigDecimal limitPrice,
Instant occurredAt
) implements OrderEvent {}
// 부분 체결 이벤트
public record PartiallyFilledEvent(
String orderId,
BigDecimal filledQty,
BigDecimal executionPrice,
String executionId,
Instant occurredAt
) implements OrderEvent {}
// 전체 체결 이벤트
public record FullyFilledEvent(
String orderId,
BigDecimal filledQty,
BigDecimal executionPrice,
String executionId,
Instant occurredAt
) implements OrderEvent {}
// 주문 취소 이벤트
public record OrderCancelledEvent(
String orderId,
String reason,
Instant occurredAt
) implements OrderEvent {}
// 주문 애그리게이트: 이벤트를 순서대로 적용하여 현재 상태를 재구성
public class OrderAggregate {
public enum OrderStatus { PENDING, PARTIAL, FILLED, CANCELLED }
private String orderId;
private String symbol;
private String side;
private BigDecimal quantity;
private BigDecimal limitPrice;
private BigDecimal filledQuantity = BigDecimal.ZERO;
private BigDecimal averageExecutionPrice = BigDecimal.ZERO;
private OrderStatus status;
private int version = 0;
private final List<OrderEvent> uncommittedEvents = new ArrayList<>();
// 이벤트 스토어에서 로드할 때 사용 (이벤트 리플레이)
public static OrderAggregate reconstitute(List<OrderEvent> history) {
OrderAggregate aggregate = new OrderAggregate();
for (OrderEvent event : history) {
aggregate.apply(event);
aggregate.version++;
}
return aggregate;
}
// 새 주문 생성 (커맨드 핸들러에서 호출)
public static OrderAggregate place(
String orderId,
String symbol,
String side,
BigDecimal quantity,
BigDecimal limitPrice
) {
OrderAggregate aggregate = new OrderAggregate();
OrderPlacedEvent event = new OrderPlacedEvent(
orderId, symbol, side, quantity, limitPrice, Instant.now()
);
aggregate.applyAndRecord(event);
return aggregate;
}
// 부분 체결 처리
public void partiallyFill(BigDecimal qty, BigDecimal price, String executionId) {
if (status == OrderStatus.CANCELLED || status == OrderStatus.FILLED) {
throw new IllegalStateException("이미 종료된 주문에 체결을 적용할 수 없습니다.");
}
applyAndRecord(new PartiallyFilledEvent(orderId, qty, price, executionId, Instant.now()));
}
// 취소 처리
public void cancel(String reason) {
if (status == OrderStatus.FILLED) {
throw new IllegalStateException("완전 체결된 주문은 취소할 수 없습니다.");
}
applyAndRecord(new OrderCancelledEvent(orderId, reason, Instant.now()));
}
// 이벤트 적용 (상태 변이만 담당 — 사이드 이펙트 없음)
private void apply(OrderEvent event) {
switch (event) {
case OrderPlacedEvent e -> {
this.orderId = e.orderId();
this.symbol = e.symbol();
this.side = e.side();
this.quantity = e.quantity();
this.limitPrice = e.limitPrice();
this.status = OrderStatus.PENDING;
}
case PartiallyFilledEvent e -> {
this.filledQuantity = this.filledQuantity.add(e.filledQty());
updateAveragePrice(e.filledQty(), e.executionPrice());
this.status = OrderStatus.PARTIAL;
}
case FullyFilledEvent e -> {
this.filledQuantity = this.filledQuantity.add(e.filledQty());
updateAveragePrice(e.filledQty(), e.executionPrice());
this.status = OrderStatus.FILLED;
}
case OrderCancelledEvent e -> this.status = OrderStatus.CANCELLED;
default -> throw new IllegalArgumentException("알 수 없는 이벤트 타입: " + event.getClass());
}
}
private void applyAndRecord(OrderEvent event) {
apply(event);
uncommittedEvents.add(event);
version++;
}
private void updateAveragePrice(BigDecimal qty, BigDecimal price) {
if (filledQuantity.compareTo(BigDecimal.ZERO) == 0) return;
BigDecimal totalValue = averageExecutionPrice
.multiply(filledQuantity.subtract(qty))
.add(price.multiply(qty));
this.averageExecutionPrice = totalValue.divide(filledQuantity, 4, java.math.RoundingMode.HALF_UP);
}
public List<OrderEvent> getUncommittedEvents() {
return List.copyOf(uncommittedEvents);
}
public void markEventsAsCommitted() {
uncommittedEvents.clear();
}
// Getters
public String getOrderId() { return orderId; }
public OrderStatus getStatus() { return status; }
public BigDecimal getFilledQuantity() { return filledQuantity; }
public BigDecimal getAverageExecutionPrice() { return averageExecutionPrice; }
public int getVersion() { return version; }
}
CQRS 통합
이벤트 소싱만으로는 조회 성능 문제가 남습니다. 주문 10만 건의 이력을 매번 리플레이하면 응답이 느려집니다. CQRS(Command Query Responsibility Segregation)는 쓰기 모델과 읽기 모델을 분리하여 이 문제를 해결합니다.
Write 모델: 이벤트 스토어
커맨드(주문 접수, 체결, 취소)는 이벤트 스토어에만 기록됩니다. 이벤트 스토어는 append-only이며, 기존 이벤트를 수정하거나 삭제하지 않습니다.
이벤트 스토어 스키마 (개념적)
--------------------------------
order_id | version | event_type | payload (JSON) | occurred_at
---------|---------|------------------|------------------|------------------
ORD-001 | 0 | OrderPlaced | {symbol:"AAPL"} | 2026-06-25T09:00:00Z
ORD-001 | 1 | PartiallyFilled | {qty:50, px:150} | 2026-06-25T09:01:00Z
ORD-001 | 2 | OrderCancelled | {reason:"user"} | 2026-06-25T09:02:00Z
Read 모델: 투영(Projection)
Kafka를 이벤트 버스로 사용하여 이벤트 스토어의 이벤트를 읽기 모델에 투영합니다. 읽기 모델은 UI 또는 리포팅에 최적화된 비정규화 테이블입니다.
[커맨드 핸들러]
|
▼
[이벤트 스토어] ──(이벤트 발행)──▶ [Kafka Topic: order-events]
|
┌────────────────┼────────────────┐
▼ ▼ ▼
[주문 현황 뷰] [체결 내역 뷰] [감사 로그 뷰]
(Redis) (PostgreSQL) (Elasticsearch)
금융공학 관점
시점 복원 (Time-Travel Query)
이벤트 소싱의 가장 강력한 특성은 특정 시점의 상태를 정확히 재현할 수 있다는 것입니다. 감사 요청이 들어왔을 때 “2026-06-25 09:01:30 시점의 ORD-001 주문 상태는?”이라는 질문에 이벤트를 해당 시각까지만 리플레이하여 답할 수 있습니다.
public OrderAggregate reconstitutAt(String orderId, Instant pointInTime) {
List<OrderEvent> filteredHistory = eventStore.load(orderId)
.stream()
.filter(e -> !e.occurredAt().isAfter(pointInTime))
.toList();
return OrderAggregate.reconstitute(filteredHistory);
}
규제 감사 대응
MiFID II 규제 감사관이 특정 주문의 전체 생애 주기를 요청하면, 이벤트 스트림을 JSON 형태로 그대로 제출할 수 있습니다. CRUD 시스템에서 감사 로그 테이블을 별도로 운영하고 동기화하는 복잡성이 사라집니다.
체결 이력 재계산
알고리즘 전략 성과 분석 시 특정 기간 동안의 체결 이력을 재계산해야 할 경우, 이벤트 스트림에서 PartiallyFilledEvent와 FullyFilledEvent만 필터링하면 됩니다. CRUD 구조에서 이 정보가 현재 상태에 집계되어 있다면 역산이 불가능합니다.
트레이드오프
| 항목 | CRUD | 이벤트 소싱 + CQRS |
|---|---|---|
| 구현 복잡도 | 낮음 | 높음 (이벤트 스키마, 투영, 이벤트 버스 필요) |
| 감사 추적 | 별도 로그 테이블 필요 | 구조적으로 내장 |
| 특정 시점 복원 | 불가능 (별도 스냅샷 없이) | 가능 (이벤트 리플레이) |
| 읽기 성능 | 단순 쿼리에서 빠름 | 투영을 별도 최적화 가능 |
| 쓰기 성능 | UPDATE 단건 | Append-only (로그 I/O 유리) |
| 데이터 수정 | 직접 UPDATE 가능 | 보정 이벤트(compensating event)를 추가해야 함 |
| 규제 준수 | 추가 작업 필요 | 기본 설계가 요건과 일치 |
| 학습 곡선 | 낮음 | 높음 (DDD, 이벤트 모델링 이해 필요) |
결론
금융 주문 관리 시스템에서 이벤트 소싱이 CRUD보다 적합한 핵심 이유는 감사 추적이 부가 기능이 아닌 핵심 기능이기 때문입니다. CRUD에서 감사 추적을 구현하면 항상 도메인 로직과 로깅 로직이 얽히는 복잡성이 생깁니다. 이벤트 소싱은 이 둘을 구조적으로 분리합니다.
물론 이벤트 소싱이 모든 상황에 적합한 것은 아닙니다. 단순한 설정 관리나 유저 프로필처럼 이력이 중요하지 않은 도메인에서는 오히려 과설계입니다. 하지만 주문 생애 주기 추적, 체결 이력 재계산, 규제 감사 대응이 모두 요구되는 금융 주문 관리 시스템은 이벤트 소싱과 CQRS가 가장 자연스럽게 맞아떨어지는 사용 사례입니다.
재설계 이후 장애 분석에 걸리는 시간이 한나절에서 수십 분으로 줄었습니다. 이벤트 스트림이 있으면 “무엇이, 언제, 왜 바뀌었는가”를 코드가 아닌 데이터로 답할 수 있기 때문입니다.