CQRS란?
CQRS(Command Query Responsibility Segregation)는 데이터의 읽기(Query)와 쓰기(Command)를 별도의 모델로 분리하는 아키텍처 패턴입니다. 읽기와 쓰기의 요구사항이 크게 다른 시스템에서, 각각을 독립적으로 최적화할 수 있습니다.
왜 CQRS가 필요한가?
| 측면 | 쓰기(Command) | 읽기(Query) |
|---|---|---|
| 모델 | 도메인 로직, 검증 중심 | 표시 최적화, 비정규화 |
| 확장 | 단일 DB, 트랜잭션 | 읽기 전용 복제본, 캐시 |
| 빈도 | 상대적으로 적음 | 매우 많음 (10:1~100:1) |
| 일관성 | 강한 일관성 필요 | 최종적 일관성 허용 가능 |
Command 측 구현
// commands/createOrder.js
class CreateOrderCommand {
constructor({ userId, items, shippingAddress }) {
this.userId = userId;
this.items = items;
this.shippingAddress = shippingAddress;
}
}
class CreateOrderHandler {
constructor(orderRepo, eventBus) {
this.orderRepo = orderRepo;
this.eventBus = eventBus;
}
async execute(command) {
const user = await this.orderRepo.findUser(command.userId);
if (!user.isActive) throw new Error('비활성 사용자');
for (const item of command.items) {
const product = await this.orderRepo.findProduct(item.productId);
if (product.stock < item.quantity) {
throw new Error('재고 부족: ' + product.name);
}
}
const order = await this.orderRepo.create({
userId: command.userId,
items: command.items,
status: 'PENDING',
total: this.calculateTotal(command.items),
});
await this.eventBus.publish('OrderCreated', {
orderId: order.id,
userId: command.userId,
items: command.items,
total: order.total,
createdAt: new Date(),
});
return order.id;
}
calculateTotal(items) {
return items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
}
Query 측 구현
class OrderQueryService {
constructor(readDb) {
this.readDb = readDb;
}
async getOrderSummary(orderId) {
return this.readDb.get('order:' + orderId);
}
async getUserOrders(userId, { page = 1, limit = 20 }) {
return this.readDb.zrange('user_orders:' + userId,
(page - 1) * limit, page * limit - 1);
}
async getOrderStats(period) {
return this.readDb.hgetall('order_stats:' + period);
}
}
class OrderProjection {
constructor(readDb) {
this.readDb = readDb;
}
async onOrderCreated(event) {
const summary = {
id: event.orderId,
userId: event.userId,
itemCount: event.items.length,
total: event.total,
status: 'PENDING',
createdAt: event.createdAt,
};
await this.readDb.set('order:' + event.orderId, JSON.stringify(summary));
await this.readDb.zadd('user_orders:' + event.userId,
Date.now(), JSON.stringify(summary));
}
async onOrderShipped(event) {
const order = JSON.parse(await this.readDb.get('order:' + event.orderId));
order.status = 'SHIPPED';
order.shippedAt = event.shippedAt;
await this.readDb.set('order:' + event.orderId, JSON.stringify(order));
}
}
이벤트 버스 구현
class EventBus {
constructor() {
this.handlers = new Map();
}
subscribe(eventType, handler) {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType).push(handler);
}
async publish(eventType, payload) {
const handlers = this.handlers.get(eventType) || [];
await Promise.all(handlers.map(h => h(payload)));
}
}
const eventBus = new EventBus();
const projection = new OrderProjection(redisClient);
eventBus.subscribe('OrderCreated', (e) => projection.onOrderCreated(e));
eventBus.subscribe('OrderShipped', (e) => projection.onOrderShipped(e));
- CQRS는 모든 시스템에 필요한 것은 아니며, 읽기/쓰기 비율이 10:1 이상일 때 효과적입니다
- 최종적 일관성(Eventual Consistency)을 수용해야 하므로, UI에서 이를 고려한 UX가 필요합니다
- 이벤트 소싱과 결합하면 모든 상태 변화를 추적할 수 있어 감사 로그가 자연스럽게 구현됩니다
- 읽기 모델은 Redis, Elasticsearch, 물질화된 뷰(Materialized View) 등 목적에 맞게 선택합니다
- 처음에는 단일 DB에서 논리적 분리로 시작하고, 필요 시 물리적 분리로 발전시킵니다
댓글 0