본문 바로가기
Backend2025년 1월 5일7분 읽기

CQRS 패턴 실전 — 읽기와 쓰기 모델 분리하기

YS
김영삼
조회 381

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

아직 댓글이 없습니다.
Ctrl+Enter로 등록