CQRS는 Command Query Responsibility Segregation의 줄임말로, 명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴입니다.

cqrs_svg.svg

핵심 개념

**명령(Command)**과 **조회(Query)**를 서로 다른 모델로 분리합니다:

  • Command: 데이터를 변경하는 작업 (생성, 수정, 삭제)
  • Query: 데이터를 읽기만 하는 작업

기본 구조

전통적인 방식에서는 하나의 모델로 읽기와 쓰기를 모두 처리하지만, CQRS에서는:

  • Write Model: 비즈니스 로직과 데이터 변경을 담당
  • Read Model: 조회 최적화된 별도의 모델

장점

  • 성능 최적화: 읽기와 쓰기를 각각 최적화 가능
  • 확장성: 읽기와 쓰기 부하를 독립적으로 처리
  • 복잡성 분리: 비즈니스 로직과 조회 로직을 명확히 구분
  • 유연성: 각각 다른 데이터베이스나 저장소 사용 가능

단점

  • 복잡성 증가: 시스템 구조가 복잡해짐
  • 데이터 일관성: 읽기/쓰기 모델 간 동기화 필요
  • 개발 비용: 초기 구현 비용이 높음

CQRS는 특히 복잡한 도메인 로직이 있거나 읽기와 쓰기 패턴이 크게 다른 시스템에서 유용합니다. Event Sourcing과 함께 사용되는 경우가 많습니다.


CQRS를 전자상거래 시스템 예시로 설명해드리겠습니다.

전통적인 방식 vs CQRS

전통적인 방식

javascript

// 하나의 Product 모델로 모든 작업 처리
class Product {
  constructor(id, name, price, stock, description, reviews) {
    this.id = id;
    this.name = name;
    this.price = price;
    this.stock = stock;
    this.description = description;
    this.reviews = reviews;
  }
  
  // 읽기와 쓰기가 같은 모델 사용
  updatePrice(newPrice) { /* 가격 업데이트 */ }
  getProductDetails() { /* 상품 상세 조회 */ }
}

CQRS 방식

1. Command Side (쓰기 모델)

javascript

// 비즈니스 로직에 집중한 간단한 모델
class ProductWriteModel {
  constructor(id, name, price, stock) {
    this.id = id;
    this.name = name;
    this.price = price;
    this.stock = stock;
  }
}
 
// 명령 처리
class UpdateProductPriceCommand {
  constructor(productId, newPrice) {
    this.productId = productId;
    this.newPrice = newPrice;
  }
}
 
class ProductCommandHandler {
  async handle(command) {
    // 비즈니스 규칙 검증
    if (command.newPrice <= 0) {
      throw new Error("가격은 0보다 커야 합니다");
    }
    
    // 데이터베이스 업데이트
    await this.productRepository.updatePrice(command.productId, command.newPrice);
    
    // 이벤트 발행 (읽기 모델 업데이트를 위해)
    await this.eventBus.publish(new ProductPriceUpdatedEvent(command.productId, command.newPrice));
  }
}

2. Query Side (읽기 모델)

javascript

// 조회에 최적화된 플랫한 구조
class ProductReadModel {
  constructor(data) {
    this.id = data.id;
    this.name = data.name;
    this.price = data.price;
    this.stock = data.stock;
    this.description = data.description;
    this.averageRating = data.averageRating;
    this.reviewCount = data.reviewCount;
    this.categoryName = data.categoryName;
    this.brandName = data.brandName;
    // 조회에 필요한 모든 정보를 미리 조인해서 저장
  }
}
 
class ProductQueryHandler {
  async getProductDetails(productId) {
    // 단일 쿼리로 모든 필요한 정보 조회
    return await this.readDatabase.findProductWithAllDetails(productId);
  }
  
  async searchProducts(criteria) {
    // 검색에 최적화된 인덱스 활용
    return await this.readDatabase.searchWithFilters(criteria);
  }
}

실제 시나리오

상품 가격 변경 시나리오

1. Command 처리

javascript

// 1. 사용자가 가격 변경 요청
const command = new UpdateProductPriceCommand("product-123", 29900);
 
// 2. Command Handler가 처리
await productCommandHandler.handle(command);
// - 비즈니스 규칙 검증
// - Write DB 업데이트 (정규화된 테이블)
// - 이벤트 발행

3. 읽기 모델 업데이트

javascript

// 이벤트 핸들러가 읽기 모델 업데이트
class ProductPriceUpdatedEventHandler {
  async handle(event) {
    // Read DB 업데이트 (비정규화된 뷰 테이블)
    await this.readDatabase.updateProductView({
      productId: event.productId,
      price: event.newPrice,
      lastUpdated: new Date()
    });
  }
}

4. 조회 처리

javascript

// 사용자가 상품 페이지 조회
const productDetails = await productQueryHandler.getProductDetails("product-123");
// 단일 쿼리로 모든 정보 즉시 반환 (빠른 응답)

데이터베이스 구조 예시

Write DB (정규화)

sql

-- 명령용 테이블 (정규화)
Products: id, name, price, stock
Categories: id, name
Reviews: id, product_id, rating, comment

Read DB (비정규화)

sql

-- 조회용 뷰 테이블 (비정규화)
ProductView: 
  id, name, price, stock, description, 
  category_name, average_rating, review_count,
  brand_name, last_updated

주요 이점 실현

  1. 성능: 읽기는 비정규화된 테이블에서 단일 쿼리로 처리
  2. 확장성: 읽기 전용 복제본을 여러 개 둘 수 있음
  3. 유연성: 쓰기는 MySQL, 읽기는 Elasticsearch 같은 조합 가능
  4. 복잡성 분리: 비즈니스 로직과 조회 로직이 완전히 분리

이런 식으로 CQRS는 복잡한 비즈니스 도메인에서 읽기와 쓰기의 서로 다른 요구사항을 효과적으로 분리하여 처리할 수 있게 해줍니다.