- Authors
- Name

- 개요
- CQRS 핵심 개념
- 이벤트 소싱 원리
- Axon Framework 구현
- EventStoreDB 활용
- 프로젝션과 읽기 모델
- 스냅샷 전략
- 이벤트 버전 관리 (업캐스팅)
- 트러블슈팅
- 실패 사례
- 운영 체크리스트
- 마무리
- 참고자료
개요
CQRS(Command Query Responsibility Segregation)와 이벤트 소싱(Event Sourcing)은 복잡한 도메인을 다루는 시스템에서 쓰기와 읽기를 분리하고, 상태 변경의 전체 이력을 보존하는 아키텍처 패턴이다. 개념 자체는 단순하지만, 실제 운영 환경에 적용하면 이벤트 스키마 진화, 프로젝션 재구축, 스냅샷 전략, 장애 복구 등 예상치 못한 문제들이 쏟아진다.
이 글에서는 JVM 생태계의 대표적 CQRS/ES 프레임워크인 Axon Framework 4.x와 전용 이벤트 저장소인 EventStoreDB(현재 KurrentDB로 리브랜딩)를 중심으로, 개념 설명부터 실전 코드, 운영 레벨의 트러블슈팅까지 다룬다. 이벤트 기반 거버넌스나 Saga 패턴의 트레이드오프는 별도 글에서 깊이 있게 다루므로, 여기서는 CQRS와 이벤트 소싱 구현 자체에 집중한다.
CQRS 핵심 개념
전통적 CRUD와 CQRS의 근본적 차이
전통적 CRUD 아키텍처에서는 하나의 도메인 모델이 쓰기와 읽기를 모두 담당한다. 주문 서비스를 예로 들면, Order 엔티티가 주문 생성, 상태 변경, 취소 같은 쓰기 로직과, 주문 목록 조회, 상세 조회 같은 읽기 로직을 동시에 수행한다. 데이터가 적고 도메인이 단순할 때는 문제가 없지만, 시스템이 복잡해지면 한 모델에 읽기 최적화와 쓰기 무결성 보장을 동시에 넣는 것이 점점 어려워진다.
CQRS는 이 문제를 구조적으로 해결한다. 커맨드 측(Write Side)은 도메인 불변식(invariant)을 검증하고 이벤트를 발행하는 데 집중하고, 쿼리 측(Read Side)은 사용자에게 최적화된 읽기 모델을 제공한다.
| 항목 | 전통적 CRUD | CQRS |
|---|---|---|
| 모델 | 단일 모델(읽기/쓰기 공유) | 커맨드 모델 + 쿼리 모델 분리 |
| 데이터 저장소 | 단일 DB | 쓰기 DB + 읽기 DB (분리 가능) |
| 확장성 | 읽기/쓰기 동일 비율로 스케일 | 읽기/쓰기 독립적 스케일 |
| 복잡도 | 낮음 (시작 단계) | 높음 (초기 학습 곡선) |
| 일관성 | 강한 일관성 | 최종 일관성(Eventual Consistency) |
| 감사 로그 | 별도 구현 필요 | 이벤트 소싱과 결합 시 자동 확보 |
| 적합한 시나리오 | 단순 CRUD 앱 | 읽기/쓰기 비율 차이가 큰 복잡 도메인 |
CQRS를 적용해야 하는 시점
CQRS는 모든 시스템에 적합하지 않다. 다음 조건 중 3개 이상 해당되면 도입을 검토할 가치가 있다.
- 읽기 트래픽이 쓰기 트래픽의 10배 이상이다
- 읽기와 쓰기에 서로 다른 데이터 모델이 필요하다 (예: 쓰기는 정규화, 읽기는 비정규화)
- 도메인 로직이 복잡하여 DDD Aggregate 패턴을 사용하고 있다
- 상태 변경의 전체 이력이 비즈니스 요구사항이다 (금융, 의료, 물류)
- 다수의 팀이 같은 데이터를 다른 관점에서 소비한다
반대로, 단순 CRUD 앱이나 트래픽이 적은 내부 관리 도구에 CQRS를 적용하면 오버엔지니어링이 된다.
이벤트 소싱 원리
상태 저장 vs 이벤트 저장
전통적 방식은 현재 상태를 DB에 저장한다. 주문 상태가 "배송중"이면, orders 테이블의 status 컬럼이 SHIPPING으로 업데이트된다. 이전 상태는 사라진다.
이벤트 소싱은 상태 변경을 일으킨 이벤트 자체를 저장한다. 주문의 현재 상태는 OrderCreated, PaymentCompleted, ShippingStarted 같은 이벤트를 순서대로 재생(replay)하여 도출한다. 이벤트는 불변(immutable)이므로 절대 삭제하거나 수정하지 않는다.
# 전통적 상태 저장
orders 테이블: id=123, status=SHIPPING, total=50000
# 이벤트 소싱
이벤트 스트림 [order-123]:
1. OrderCreated (total=50000, items=[...])
2. PaymentCompleted(method=CARD, amount=50000)
3. ShippingStarted (trackingNo=KR123456)
현재 상태 = fold(events) => Order(status=SHIPPING, total=50000)
이벤트 소싱의 장점과 대가
장점은 명확하다. 완전한 감사 로그를 자동으로 얻고, 시간 여행(Time Travel) 쿼리가 가능하며, 디버깅 시 정확히 어떤 이벤트가 문제를 일으켰는지 추적할 수 있다. 특히 금융 도메인에서는 규제 준수를 위해 이벤트 소싱이 사실상 필수다.
대가도 존재한다. 이벤트 스트림이 길어지면 Aggregate 로드 시간이 증가하고(스냅샷으로 완화), 이벤트 스키마 변경이 까다로우며(업캐스팅으로 해결), 최종 일관성 모델을 팀 전체가 이해해야 한다. 이 세 가지 문제를 해결하는 구체적 방법은 뒤에서 다룬다.
Axon Framework 구현
Axon Framework 아키텍처 개요
Axon Framework는 DDD, CQRS, 이벤트 소싱을 JVM 위에서 구현하기 위한 프레임워크다. 핵심 구성 요소는 다음과 같다.
- Command Bus: 커맨드를 적절한 핸들러로 라우팅한다. 고성능이 필요하면 DisruptorCommandBus를 사용한다.
- Event Bus / Event Store: 이벤트를 발행하고 저장한다. Axon Server를 쓰면 분산 이벤트 버스와 이벤트 저장소를 한 번에 해결한다.
- Query Bus: 쿼리를 적절한 핸들러로 라우팅한다.
- Aggregate: 도메인 불변식을 보호하는 커맨드 모델의 핵심 단위다.
- Saga: 장기 실행 트랜잭션을 관리한다.
Aggregate와 Command Handler 구현
주문 도메인을 Kotlin으로 구현해보자. Aggregate 클래스 안에 커맨드 핸들러와 이벤트 소싱 핸들러를 함께 배치한다.
// Command 정의
data class CreateOrderCommand(
@TargetAggregateIdentifier
val orderId: String,
val customerId: String,
val items: List<OrderItem>,
val totalAmount: Long
)
data class ConfirmPaymentCommand(
@TargetAggregateIdentifier
val orderId: String,
val paymentMethod: String,
val transactionId: String
)
data class CancelOrderCommand(
@TargetAggregateIdentifier
val orderId: String,
val reason: String
)
// Event 정의
data class OrderCreatedEvent(
val orderId: String,
val customerId: String,
val items: List<OrderItem>,
val totalAmount: Long,
val createdAt: Instant = Instant.now()
)
data class PaymentConfirmedEvent(
val orderId: String,
val paymentMethod: String,
val transactionId: String,
val confirmedAt: Instant = Instant.now()
)
data class OrderCancelledEvent(
val orderId: String,
val reason: String,
val cancelledAt: Instant = Instant.now()
)
// Aggregate 구현
@Aggregate
class OrderAggregate {
@AggregateIdentifier
private lateinit var orderId: String
private lateinit var customerId: String
private var totalAmount: Long = 0
private var status: OrderStatus = OrderStatus.DRAFT
private var items: List<OrderItem> = emptyList()
// 기본 생성자 (Axon 프레임워크가 리플렉션으로 사용)
constructor()
// 생성 커맨드 핸들러 — 새로운 Aggregate 인스턴스를 만든다
@CommandHandler
constructor(cmd: CreateOrderCommand) {
require(cmd.items.isNotEmpty()) { "주문 항목이 비어있을 수 없다" }
require(cmd.totalAmount > 0) { "주문 금액은 0보다 커야 한다" }
AggregateLifecycle.apply(
OrderCreatedEvent(
orderId = cmd.orderId,
customerId = cmd.customerId,
items = cmd.items,
totalAmount = cmd.totalAmount
)
)
}
// 결제 확인 커맨드 핸들러
@CommandHandler
fun handle(cmd: ConfirmPaymentCommand) {
check(status == OrderStatus.CREATED) {
"결제 확인은 CREATED 상태에서만 가능하다. 현재 상태: $status"
}
AggregateLifecycle.apply(
PaymentConfirmedEvent(
orderId = cmd.orderId,
paymentMethod = cmd.paymentMethod,
transactionId = cmd.transactionId
)
)
}
// 주문 취소 커맨드 핸들러
@CommandHandler
fun handle(cmd: CancelOrderCommand) {
check(status != OrderStatus.CANCELLED) {
"이미 취소된 주문은 다시 취소할 수 없다"
}
check(status != OrderStatus.SHIPPED) {
"배송 시작된 주문은 취소할 수 없다"
}
AggregateLifecycle.apply(
OrderCancelledEvent(
orderId = cmd.orderId,
reason = cmd.reason
)
)
}
// 이벤트 소싱 핸들러 — 상태를 변경한다 (부수효과 없이 순수하게)
@EventSourcingHandler
fun on(event: OrderCreatedEvent) {
orderId = event.orderId
customerId = event.customerId
items = event.items
totalAmount = event.totalAmount
status = OrderStatus.CREATED
}
@EventSourcingHandler
fun on(event: PaymentConfirmedEvent) {
status = OrderStatus.PAID
}
@EventSourcingHandler
fun on(event: OrderCancelledEvent) {
status = OrderStatus.CANCELLED
}
}
enum class OrderStatus {
DRAFT, CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
}
위 코드에서 핵심 원칙 두 가지를 주목해야 한다. 첫째, @CommandHandler에서는 비즈니스 규칙을 검증하고 이벤트를 발행만 한다. 상태를 직접 변경하지 않는다. 둘째, @EventSourcingHandler에서는 이벤트를 기반으로 상태를 업데이트만 한다. 검증 로직을 넣지 않는다. 이 분리가 깨지면 이벤트 리플레이 시 예기치 않은 오류가 발생한다.
Axon Framework와 직접 구현의 비교
| 항목 | Axon Framework | 직접 구현(Hand-rolled) |
|---|---|---|
| 학습 곡선 | 어노테이션 기반으로 비교적 완만 | 모든 인프라를 직접 이해해야 함 |
| 커맨드 라우팅 | 자동 (Command Bus) | 직접 라우팅 구현 필요 |
| 이벤트 저장소 | Axon Server 또는 JPA 기반 내장 | 직접 테이블 설계 + 직렬화 구현 |
| 스냅샷 | 설정 한 줄로 활성화 | 직접 구현 + 복원 로직 필요 |
| 업캐스팅 | 내장 Upcaster 체인 | 직접 변환 파이프라인 구현 |
| 분산 환경 | Axon Server가 클러스터링 지원 | 별도 인프라(Kafka 등) 필요 |
| 테스트 | FixtureConfiguration 제공 | 직접 테스트 인프라 구성 |
| 유연성 | 프레임워크 규약에 종속 | 완전한 자유도 |
| 팀 채용 | Axon 경험자 제한적 | 일반 Java/Kotlin 개발자 활용 가능 |
Axon Framework는 초기 구현 속도를 크게 높여주지만, 내부 동작을 이해하지 못한 채 사용하면 운영 단계에서 디버깅이 매우 어려워진다. 프레임워크를 선택하기 전에 팀이 이벤트 소싱의 원리를 충분히 이해하고 있는지 확인해야 한다.
EventStoreDB 활용
EventStoreDB란
EventStoreDB(2024년 KurrentDB로 리브랜딩)는 이벤트 소싱을 위해 설계된 전용 데이터베이스다. PostgreSQL이나 MongoDB 같은 범용 DB 위에 이벤트 저장소를 구현할 수도 있지만, EventStoreDB는 이벤트 스트림 읽기/쓰기, 프로젝션, 구독을 네이티브로 지원한다.
gRPC 기반 클라이언트를 제공하며, .NET, Java, Node.js, Python, Rust 등 다양한 언어를 지원한다. 낙관적 동시성 제어(Optimistic Concurrency Control)가 내장되어 있어, 같은 스트림에 동시 쓰기가 발생했을 때 충돌을 감지할 수 있다.
Event Store 구현체 비교
| 항목 | EventStoreDB (KurrentDB) | Axon Server | Marten (.NET) | PostgreSQL 직접 구현 |
|---|---|---|---|---|
| 타입 | 전용 이벤트 DB | 전용 이벤트 DB + 메시지 라우터 | .NET 라이브러리 (PostgreSQL 기반) | 범용 RDBMS 위 자체 구현 |
| 언어 지원 | 다국어 (gRPC 클라이언트) | JVM 중심 | .NET 전용 | 제한 없음 |
| 프로젝션 | 서버 사이드 JS 프로젝션 내장 | 클라이언트 사이드 프로젝션 | 인라인 + 비동기 프로젝션 | 직접 구현 |
| 구독 | Catch-up, Persistent 구독 | Tracking Event Processor | 데몬 기반 비동기 프로젝션 | Polling 또는 CDC |
| 클러스터링 | 리더-팔로워 클러스터 | Enterprise 에디션 | PostgreSQL 클러스터링 활용 | PostgreSQL HA 활용 |
| 라이선스 | Server-Side Public License | 커뮤니티 / 상용 | MIT | 해당 없음 |
| 운영 난이도 | 중간 | 낮음 (관리형 서비스 가능) | 낮음 (PostgreSQL 운영) | 높음 (모든 것 직접) |
Python에서 EventStoreDB 사용하기
EventStoreDB의 Python gRPC 클라이언트(esdbclient)를 사용한 이벤트 쓰기/읽기 예제다.
import json
import uuid
from datetime import datetime, timezone
from esdbclient import EventStoreDBClient, NewEvent, StreamState
# EventStoreDB 연결
client = EventStoreDBClient(uri="esdb://localhost:2113?tls=false")
# 이벤트 생성 및 스트림에 추가
stream_name = f"order-{uuid.uuid4()}"
events = [
NewEvent(
type="OrderCreated",
data=json.dumps({
"orderId": stream_name.split("-", 1)[1],
"customerId": "customer-001",
"items": [
{"productId": "prod-a", "quantity": 2, "price": 15000},
{"productId": "prod-b", "quantity": 1, "price": 20000},
],
"totalAmount": 50000,
"createdAt": datetime.now(timezone.utc).isoformat(),
}).encode("utf-8"),
metadata=json.dumps({"correlationId": str(uuid.uuid4())}).encode("utf-8"),
content_type="application/json",
),
NewEvent(
type="PaymentConfirmed",
data=json.dumps({
"paymentMethod": "CARD",
"transactionId": f"txn-{uuid.uuid4()}",
"confirmedAt": datetime.now(timezone.utc).isoformat(),
}).encode("utf-8"),
content_type="application/json",
),
]
# 스트림이 아직 존재하지 않을 때만 쓰기 (낙관적 동시성 제어)
commit_position = client.append_to_stream(
stream_name=stream_name,
current_version=StreamState.NO_STREAM,
events=events,
)
print(f"이벤트 기록 완료. Commit position: {commit_position}")
# 스트림에서 이벤트 읽기
recorded_events = client.get_stream(stream_name)
for event in recorded_events:
data = json.loads(event.data.decode("utf-8"))
print(f"[{event.type}] stream_position={event.stream_position}, data={data}")
위 예제에서 StreamState.NO_STREAM은 해당 스트림이 존재하지 않을 때만 쓰기를 허용한다. 이미 스트림이 존재하면 WrongExpectedVersionError가 발생한다. 이것이 EventStoreDB의 낙관적 동시성 제어다. 기존 스트림에 이벤트를 추가할 때는 마지막으로 읽은 이벤트의 stream_position을 current_version으로 전달해야 한다.
TypeScript에서 EventStoreDB 사용하기
Node.js/TypeScript 환경에서는 @eventstore/db-client 패키지를 사용한다.
import { EventStoreDBClient, jsonEvent, NO_STREAM, JSONEventType } from '@eventstore/db-client'
// 이벤트 타입 정의
interface OrderCreated extends JSONEventType {
type: 'OrderCreated'
data: {
orderId: string
customerId: string
items: Array<{ productId: string; quantity: number; price: number }>
totalAmount: number
}
}
interface PaymentConfirmed extends JSONEventType {
type: 'PaymentConfirmed'
data: {
paymentMethod: string
transactionId: string
}
}
// 클라이언트 생성 (싱글턴으로 사용)
const client = EventStoreDBClient.connectionString('esdb://localhost:2113?tls=false')
async function appendAndReadEvents(): Promise<void> {
const streamName = `order-${crypto.randomUUID()}`
// 이벤트 추가
const orderCreated = jsonEvent<OrderCreated>({
type: 'OrderCreated',
data: {
orderId: streamName.replace('order-', ''),
customerId: 'customer-001',
items: [{ productId: 'prod-a', quantity: 2, price: 15000 }],
totalAmount: 30000,
},
})
const paymentConfirmed = jsonEvent<PaymentConfirmed>({
type: 'PaymentConfirmed',
data: {
paymentMethod: 'CARD',
transactionId: `txn-${crypto.randomUUID()}`,
},
})
// 낙관적 동시성 제어: 스트림이 없을 때만 쓰기
await client.appendToStream(streamName, [orderCreated, paymentConfirmed], {
expectedRevision: NO_STREAM,
})
// 스트림에서 이벤트 읽기
const events = client.readStream(streamName)
for await (const resolvedEvent of events) {
console.log(`[${resolvedEvent.event?.type}]`, JSON.stringify(resolvedEvent.event?.data))
}
}
appendAndReadEvents().catch(console.error)
프로젝션과 읽기 모델
프로젝션이란
이벤트 소싱 시스템에서 이벤트 스트림은 쓰기에 최적화되어 있다. 사용자가 "내 주문 목록을 최신순으로 보여달라"고 요청하면, 모든 이벤트를 재생해서 현재 상태를 만들 수는 없다. 프로젝션(Projection)은 이벤트를 소비하여 읽기에 최적화된 별도의 뷰(Read Model)를 구축하는 프로세스다.
Axon Framework에서는 Event Handler가 이 역할을 담당한다. @EventHandler 어노테이션이 붙은 메서드가 이벤트를 받아서 읽기 모델(보통 RDBMS의 테이블)을 업데이트한다.
// 읽기 모델 엔티티
@Entity
@Table(name = "order_summary")
data class OrderSummaryEntity(
@Id
val orderId: String,
val customerId: String,
val totalAmount: Long,
val status: String,
val itemCount: Int,
val createdAt: Instant,
var updatedAt: Instant = Instant.now()
)
// 프로젝션 (Event Handler)
@Component
@ProcessingGroup("order-summary")
class OrderSummaryProjection(
private val repository: OrderSummaryRepository
) {
@EventHandler
fun on(event: OrderCreatedEvent) {
val summary = OrderSummaryEntity(
orderId = event.orderId,
customerId = event.customerId,
totalAmount = event.totalAmount,
status = "CREATED",
itemCount = event.items.size,
createdAt = event.createdAt
)
repository.save(summary)
}
@EventHandler
fun on(event: PaymentConfirmedEvent) {
repository.findById(event.orderId).ifPresent { summary ->
repository.save(summary.copy(
status = "PAID",
updatedAt = Instant.now()
))
}
}
@EventHandler
fun on(event: OrderCancelledEvent) {
repository.findById(event.orderId).ifPresent { summary ->
repository.save(summary.copy(
status = "CANCELLED",
updatedAt = Instant.now()
))
}
}
// 리플레이 시 기존 데이터를 정리하는 리셋 핸들러
@ResetHandler
fun onReset() {
repository.deleteAll()
}
}
// 쿼리 핸들러
@Component
class OrderQueryHandler(
private val repository: OrderSummaryRepository
) {
@QueryHandler
fun handle(query: FindOrderByIdQuery): OrderSummaryEntity? {
return repository.findById(query.orderId).orElse(null)
}
@QueryHandler
fun handle(query: FindOrdersByCustomerQuery): List<OrderSummaryEntity> {
return repository.findByCustomerIdOrderByCreatedAtDesc(query.customerId)
}
}
프로젝션 재구축 (Replay)
프로젝션의 가장 큰 장점은 언제든 처음부터 다시 구축할 수 있다는 것이다. 읽기 모델의 스키마를 변경하거나, 버그로 인해 데이터가 오염되었을 때, 이벤트를 처음부터 다시 재생하면 된다.
Axon Framework에서는 TrackingEventProcessor를 리셋하면 프로젝션이 재구축된다. @ResetHandler가 먼저 실행되어 기존 데이터를 정리하고, 이후 모든 이벤트가 순서대로 다시 재생된다.
운영 환경에서 프로젝션을 재구축할 때는 반드시 다음을 고려해야 한다.
- 재구축 시간: 이벤트가 수백만 건이면 수 시간이 걸릴 수 있다. 별도의 인스턴스에서 재구축하고, 완료 후 트래픽을 전환하는 Blue-Green 방식을 권장한다.
- 동시 읽기: 재구축 중에도 기존 읽기 모델로 서비스해야 한다. 새로운 프로세싱 그룹 이름으로 병렬 재구축을 진행하면 된다.
- 리소스 사용: 대량 재생 시 DB 부하가 급증한다. 배치 크기 조절과 속도 제한이 필요하다.
스냅샷 전략
왜 스냅샷이 필요한가
이벤트 소싱에서 Aggregate를 로드하려면 해당 스트림의 모든 이벤트를 처음부터 재생해야 한다. 이벤트가 10건이면 문제없지만, 10,000건이 넘으면 로드 시간이 눈에 띄게 느려진다. 스냅샷은 특정 시점의 Aggregate 상태를 캡처해 두고, 이후 이벤트만 재생하는 최적화 기법이다.
Axon Framework에서 스냅샷 설정
Axon Framework에서는 설정 파일로 스냅샷 트리거를 간단히 구성할 수 있다.
# application.yml — Axon 스냅샷 설정
axon:
aggregate:
order:
snapshot-trigger:
type: event-count
threshold: 100 # 100개 이벤트마다 스냅샷 생성
Java/Kotlin 설정으로도 가능하다.
@Configuration
class AxonSnapshotConfig {
@Bean
fun snapshotTriggerDefinition(snapshotter: Snapshotter): SnapshotTriggerDefinition {
// 50개 이벤트마다 스냅샷 생성
return EventCountSnapshotTriggerDefinition(snapshotter, 50)
}
}
스냅샷 운영 시 주의사항
- Aggregate 구조 변경 시: Aggregate 클래스의 필드를 추가/삭제하면 기존 스냅샷이 역직렬화에 실패할 수 있다. 이 경우 기존 스냅샷을 삭제하고 이벤트만으로 재구축해야 한다.
- 스냅샷 주기 튜닝: 너무 자주 생성하면 저장소 비용이 증가하고, 너무 드물면 로드 시간이 길어진다. 일반적으로 50~500 이벤트 간격이 적절하다.
- 비즈니스 경계 기반 스냅샷: 이벤트 수 기반 대신 비즈니스 이벤트 기반으로 스냅샷을 생성하는 것도 좋은 전략이다. 예를 들어 월말 정산 완료 시, 또는 특정 상태 전환 시 스냅샷을 찍는다.
이벤트 버전 관리 (업캐스팅)
이벤트 스키마 진화의 필연성
시스템이 살아 있는 한 이벤트 스키마는 반드시 변한다. OrderCreatedEvent에 couponCode 필드를 추가해야 할 수도 있고, totalAmount의 타입을 Long에서 BigDecimal로 바꿔야 할 수도 있다. 전통적 RDBMS라면 ALTER TABLE로 끝나지만, 이벤트 소싱에서는 이미 저장된 이벤트를 수정할 수 없다.
업캐스팅(Upcasting)은 이전 버전의 이벤트를 읽을 때, 현재 코드가 이해할 수 있는 형태로 변환하는 기법이다.
Axon Framework에서 업캐스팅 구현
먼저 이벤트 클래스에 @Revision 어노테이션으로 버전을 표시한다.
// V1: 최초 버전
@Revision("1.0")
data class OrderCreatedEvent(
val orderId: String,
val customerId: String,
val items: List<OrderItem>,
val totalAmount: Long,
val createdAt: Instant = Instant.now()
)
// V2: couponCode 필드 추가, currency 필드 추가
@Revision("2.0")
data class OrderCreatedEvent(
val orderId: String,
val customerId: String,
val items: List<OrderItem>,
val totalAmount: Long,
val currency: String = "KRW",
val couponCode: String? = null,
val createdAt: Instant = Instant.now()
)
// Upcaster 구현: V1 -> V2
class OrderCreatedEventUpcaster : SingleEventUpcaster() {
override fun canUpcast(intermediateRepresentation: IntermediateEventRepresentation): Boolean {
return intermediateRepresentation.type.name == OrderCreatedEvent::class.qualifiedName
&& intermediateRepresentation.type.revision == "1.0"
}
override fun doUpcast(intermediateRepresentation: IntermediateEventRepresentation): IntermediateEventRepresentation {
return intermediateRepresentation.upcast(
SimpleSerializedType(
OrderCreatedEvent::class.qualifiedName,
"2.0" // 목표 리비전
),
JsonNode::class.java
) { jsonNode ->
// V1 이벤트에 없는 필드에 기본값 추가
val objectNode = jsonNode as ObjectNode
objectNode.put("currency", "KRW")
objectNode.putNull("couponCode")
objectNode
}
}
}
// Upcaster 등록
@Configuration
class UpcasterConfig {
@Bean
fun orderUpcasterChain(): JpaEventStorageEngine {
// Upcaster는 체인으로 구성 — V1->V2->V3 순서로 실행
return JpaEventStorageEngine.builder()
.upcasterChain(DefaultUpcasterChain(listOf(
OrderCreatedEventUpcaster(),
// 추후 V2->V3 Upcaster 추가
)))
.build()
}
}
이벤트 버전 관리 원칙
- 항상 하위 호환성 우선: 가능하면 새 필드에 기본값을 주고, 기존 필드는 삭제하지 않는다.
- 의미 변경 금지: 같은 필드명의 의미를 바꾸지 않는다.
amount가 원래 세전 금액이었다면, 세후 금액으로 의미를 바꾸지 말고amountAfterTax필드를 새로 추가한다. - 업캐스터 체인 테스트: 업캐스터가 누적될수록 V1에서 최신 버전까지의 변환이 정상 동작하는지 통합 테스트로 반드시 검증한다.
- 리비전 관리 문서화: 각 리비전에서 무엇이 바뀌었는지, 왜 바뀌었는지를 코드 주석이나 ADR(Architecture Decision Record)에 남긴다.
트러블슈팅
자주 발생하는 문제와 해결책
1. 이벤트 핸들러 순서 문제
여러 @EventHandler가 같은 이벤트를 구독할 때, 실행 순서가 보장되지 않아 프로젝션 데이터가 불일치할 수 있다. Axon Framework에서는 @ProcessingGroup으로 프로세싱 그룹을 분리하고, 각 그룹 내에서는 @Order 어노테이션으로 순서를 지정한다.
2. 프로젝션 재구축 중 OOM(Out of Memory)
수백만 건의 이벤트를 한꺼번에 재생하면 메모리가 부족해진다. TrackingEventProcessor의 배치 크기(batchSize)를 줄이고, 재생 속도를 조절해야 한다.
axon:
eventhandling:
processors:
order-summary:
mode: tracking
batch-size: 256 # 한 번에 처리할 이벤트 수
thread-count: 2 # 병렬 처리 스레드 수
token-claim-interval: 5000 # 토큰 갱신 간격(ms)
3. 시리얼라이저 불일치
Axon은 기본적으로 XStream XML 시리얼라이저를 사용하지만, Jackson JSON으로 전환하는 팀이 많다. 기존 이벤트가 XML로 저장되어 있는데 시리얼라이저를 JSON으로 바꾸면 역직렬화에 실패한다. 전환 시에는 멀티 시리얼라이저 설정이 필요하다.
4. Aggregate 로드 타임아웃
이벤트가 극단적으로 많은 Aggregate(수만 건 이상)는 로드에 수 초가 걸릴 수 있다. 스냅샷 주기를 짧게 가져가거나, Aggregate의 책임을 분리하여 이벤트 수를 줄이는 것이 근본적 해결책이다.
5. 이벤트 중복 처리
네트워크 장애로 인해 이벤트가 중복 전달될 수 있다. 이벤트 핸들러는 반드시 멱등(idempotent)하게 구현해야 한다. 프로젝션에서는 이벤트 ID나 시퀀스 번호로 중복을 감지하는 로직을 넣는다.
실패 사례
사례 1: 모든 것을 하나의 Aggregate에 넣은 경우
한 이커머스 팀이 주문-결제-배송-리뷰를 모두 하나의 OrderAggregate에 넣었다. 초기에는 구현이 간단했지만, 리뷰 이벤트까지 포함되면서 주문당 이벤트가 수백 건에 달했다. Aggregate 로드 시간이 3초를 넘기기 시작했고, 스냅샷을 적용해도 Aggregate 직렬화 크기가 비대해져서 근본적 해결이 되지 않았다.
해결: 주문, 결제, 배송, 리뷰를 각각 별도의 Aggregate로 분리했다. Aggregate 간 연결은 Saga로 처리했다. 분리 후 각 Aggregate의 이벤트 수는 10건 이내로 유지되었다.
사례 2: 프로젝션 없이 이벤트만으로 쿼리한 경우
이벤트 소싱을 처음 도입한 팀이 "모든 데이터가 이벤트에 있으니 프로젝션은 불필요하다"고 판단하고, 매 쿼리마다 이벤트를 재생하여 현재 상태를 계산했다. 데이터가 적을 때는 동작했지만, 목록 조회 API의 응답 시간이 수 초로 늘어나면서 사용자 불만이 폭주했다.
해결: 읽기 전용 프로젝션 테이블을 만들고, 이벤트 핸들러가 비동기로 업데이트하도록 변경했다. 목록 조회 API의 응답 시간이 200ms에서 5ms로 단축되었다.
사례 3: 업캐스팅 없이 이벤트 스키마를 변경한 경우
필드명을 amount에서 totalAmount로 변경하면서 업캐스터를 구현하지 않았다. 기존 이벤트의 재생이 실패하면서 특정 Aggregate가 로드 불가 상태에 빠졌다. 운영 DB의 이벤트를 직접 수정하는 위험한 패치를 적용해야 했다.
해결: 업캐스터를 구현하고, 이벤트 스키마 변경 시 반드시 이전 버전과의 호환성 테스트를 CI에 포함시켰다.
운영 체크리스트
운영 환경에 CQRS + 이벤트 소싱을 배포하기 전에 다음 항목을 점검한다.
설계 단계
- Aggregate 경계가 올바르게 정의되었는가 (하나의 Aggregate에 너무 많은 책임이 없는가)
- 이벤트 이름이 과거형 동사(Created, Updated, Cancelled)로 명확하게 정의되었는가
- 커맨드 핸들러에 비즈니스 검증 로직만 있고, 이벤트 소싱 핸들러에 상태 업데이트만 있는가
- 프로젝션이 최소 1개 이상 구현되어 있고, 읽기 API가 프로젝션 테이블을 사용하는가
이벤트 관리
- 이벤트 클래스에
@Revision어노테이션이 붙어 있는가 - 이벤트 스키마 변경 시 업캐스터가 구현되어 있는가
- 이벤트 직렬화 포맷이 통일되어 있는가 (JSON 권장)
- 이벤트 크기 제한이 설정되어 있는가 (1MB 이하 권장)
성능
- 스냅샷 전략이 수립되어 있는가 (이벤트 수 기반 또는 비즈니스 이벤트 기반)
- Aggregate당 평균 이벤트 수를 모니터링하고 있는가
- 프로젝션 재구축 시간을 측정하고, 허용 범위 내인가
- 프로젝션 재구축 시 Blue-Green 전환 절차가 있는가
장애 대응
- 이벤트 핸들러가 멱등하게 구현되어 있는가
- Dead Letter Queue가 설정되어 있고, 모니터링되고 있는가
- 프로젝션 지연(Lag)을 실시간으로 모니터링하고 있는가
- Aggregate 로드 실패 시 알림이 발생하는가
- 이벤트 저장소 백업과 복원 절차가 검증되었는가
팀 역량
- 팀 전체가 최종 일관성(Eventual Consistency) 개념을 이해하고 있는가
- 이벤트 스토밍(Event Storming) 워크숍을 통해 도메인 이벤트를 도출했는가
- 이벤트 소싱의 디버깅 방법(이벤트 스트림 조회, 리플레이)을 알고 있는가
마무리
CQRS와 이벤트 소싱은 복잡한 도메인을 다루는 시스템에서 강력한 도구지만, 도입 비용이 작지 않다. 팀의 역량, 도메인의 복잡성, 감사 요구사항 등을 종합적으로 고려하여 도입 여부를 결정해야 한다.
핵심 원칙을 다시 정리하면 다음과 같다.
- Aggregate를 작게 유지한다: 하나의 Aggregate가 하나의 불변식을 보호하도록 설계한다.
- 이벤트는 불변이다: 저장된 이벤트는 절대 수정하지 않는다. 스키마 변경은 반드시 업캐스터로 처리한다.
- 프로젝션은 일회용이다: 언제든 처음부터 재구축할 수 있어야 한다. 프로젝션에 원본 데이터를 넣지 않는다.
- 멱등성은 필수다: 이벤트 핸들러는 같은 이벤트를 여러 번 처리해도 같은 결과를 보장해야 한다.
- 점진적으로 도입한다: 전체 시스템이 아닌, 가장 복잡한 바운디드 컨텍스트 하나에서 시작한다.
Axon Framework는 이 모든 것을 구조적으로 지원하는 성숙한 프레임워크이고, EventStoreDB는 이벤트 소싱에 특화된 저장소로서 강력한 선택지다. 두 가지를 함께 사용할 수도, 각각 독립적으로 사용할 수도 있다. 중요한 것은 도구가 아니라, 이벤트 소싱의 원리를 팀 전체가 이해하고 있느냐는 것이다.
참고자료
- Axon Framework 공식 문서 - Command Handlers
- Axon Framework 공식 문서 - Event Versioning & Upcasting
- Axon Framework 공식 문서 - Event Snapshots
- EventStoreDB (KurrentDB) 공식 문서
- esdbclient - Python gRPC Client for EventStoreDB (PyPI)
- @eventstore/db-client - Node.js Client (npm)
- Microsoft Azure Architecture Center - CQRS Pattern
- Microsoft Azure Architecture Center - Event Sourcing Pattern
- Baeldung - A Guide to the Axon Framework