contents

CQRS(Command Query Responsibility Segregation)에 대해 알아보겠습니다.

CQRS는 시스템에서 상태를 변경하는 부분(쓰기)과 데이터를 조회하는 부분(읽기)을 분리하는 아키텍처 패턴입니다. 간단히 말해, 정보를 업데이트할 때 사용하는 모델과 정보를 읽을 때 사용하는 모델을 다르게 가져가는 것입니다.

처음에는 불필요하게 복잡하게 들릴 수 있지만, 정교한 애플리케이션에서 발생하는 중요한 문제들을 해결해 줍니다.


CQRS가 해결하려는 핵심 문제

대부분의 애플리케이션은 생성(Create), 읽기(Read), 업데이트(Update), 삭제(Delete) 즉, CRUD 작업을 위해 단일 통합 데이터 모델로 시작합니다.

전통적인 CRUD 모델:

전자상거래 시스템의 Product(상품) 객체를 상상해 보세요.

문제점:

애플리케이션이 성장함에 따라 이 단일 모델은 두 가지 상반된 방향으로 압력을 받게 됩니다.

  1. 쓰기 측면 (업데이트): 데이터 무결성을 보장하기 위해 모델에는 복잡한 비즈니스 규칙, 유효성 검사, 로직이 필요합니다. (예: "상품 가격은 음수가 될 수 없다", "고객의 장바구니에 담긴 상품은 단종시킬 수 없다.") 이 측면은 일관성에 최적화됩니다.

  2. 읽기 측면 (화면 표시): 빠르고 효율적인 조회를 위해 모델이 최적화되어야 합니다. 종종 여러 테이블의 데이터를 조인해서 보여줘야 합니다. (예: 상품의 카테고리명, 공급업체 정보, 최근 리뷰를 함께 표시) 이 측면은 성능과 편의성에 최적화됩니다.

하나의 모델로 이 두 가지 역할을 완벽하게 수행하려고 하면, 결국 어느 쪽도 제대로 해내지 못하는 복잡하고 지저분한 모델이 만들어집니다.


CQRS 솔루션: 깔끔한 책임 분리 ⚖️

CQRS는 간단하지만 강력한 해결책을 제안합니다: 읽기와 쓰기에 같은 모델을 사용하는 것을 멈추는 것입니다. 대신 두 개의 분리된 모델을 만듭니다.

  1. 명령 모델 (Command Model / 쓰기 모델): 애플리케이션의 상태를 변경하는 모든 요청을 처리합니다.

  2. 조회 모델 (Query Model / 읽기 모델): 상태 변경 없이 데이터를 가져오는 모든 요청을 처리합니다.

각 핵심 구성 요소를 자세히 살펴보겠습니다.


CQRS의 핵심 구성 요소

1. 명령 (Commands)

명령(Command) 은 시스템의 상태 변경 의도를 나타내는 메시지입니다. '무엇을 하라'는 지시죠.

예시 (의사 코드):

// 명령 객체
class RateHotelCommand {
  constructor(hotelId, customerId, rating) {
    this.hotelId = hotelId;
    this.customerId = customerId;
    this.rating = rating;
  }
}

// 명령 핸들러
class RateHotelHandler {
  handle(command) {
    // 1. 호텔의 '쓰기 모델'을 로드
    let hotel = hotelWriteRepository.findById(command.hotelId);
    
    // 2. 비즈니스 로직 실행
    hotel.addRating(command.customerId, command.rating);
    
    // 3. 변경 사항 저장
    hotelWriteRepository.save(hotel);
  }
}

2. 쓰기 모델 (도메인/명령 모델)

이 모델은 명령을 처리하는 책임을 집니다. 비즈니스 규칙의 수호자 역할을 하죠.

3. 조회 (Queries)

조회(Query) 는 데이터 요청을 나타내는 메시지입니다. 질문을 던지는 것과 같습니다.

예시 (의사 코드):

// 조회 객체
class GetHotelReviewsQuery {
  constructor(hotelId) {
    this.hotelId = hotelId;
  }
}

// 조회 핸들러
class GetHotelReviewsHandler {
  handle(query) {
    // 1. 특화된 '읽기 모델'을 직접 조회
    let reviews = hotelReadRepository.findReviewsByHotelId(query.hotelId);
    
    // 2. 데이터 전송 객체(DTO)를 반환
    return reviews;
  }
}

4. 읽기 모델 (조회/프로젝션 모델)

이 모델은 조회를 효율적으로 처리하기 위해 특별히 만들어집니다.


두 모델은 어떻게 동기화될까요?

이것이 가장 중요한 부분입니다. 쓰기용 DB와 읽기용 DB가 다르다면, 읽기용 DB는 어떻게 업데이트될까요?

쓰기 모델은 명령을 성공적으로 처리한 후, 이벤트(event) 를 발행합니다 (예: ProductPriceChanged, NewProductAdded). 시스템의 다른 부분들은 이 이벤트를 구독하여 처리할 수 있습니다.

일반적인 흐름은 다음과 같습니다:

  1. 사용자 행동이 **명령(Command)**을 발생시킵니다.

  2. 명령 핸들러쓰기 모델을 사용하여 변경 사항을 검증하고 실행합니다.

  3. 쓰기 모델이 "쓰기 DB"에 저장됩니다.

  4. 변경 사항을 설명하는 이벤트(Event) 가 발행됩니다.

  5. 이벤트 핸들러가 이 이벤트를 수신하여 "읽기 DB"에 있는 관련 읽기 모델들을 업데이트합니다.

이 과정은 중요한 트레이드오프를 낳습니다: 최종적 일관성 (Eventual Consistency). 쓰기 작업이 완료된 후 읽기 모델이 업데이트되기까지 아주 짧은 지연(보통 밀리초 단위)이 발생합니다. 대부분의 애플리케이션에서는 이 정도 지연은 전혀 문제가 되지 않습니다.


CQRS의 장점 📈


단점 및 주의할 점 ⚠️

CQRS는 만병통치약이 아닙니다. 그 자체로 복잡성을 야기합니다.


CQRS는 언제 사용해야 할까?

애플리케이션이 다음 특징 중 하나 이상을 가질 때 CQRS 사용을 고려해 보세요.

이벤트 소싱(Event Sourcing)과의 관계

CQRS는 이벤트 소싱(ES) 과 자주 함께 언급되지만, 둘은 별개의 패턴입니다. 다만, 서로 아주 잘 어울릴 뿐입니다.

서로를 보완하는 방식: 이벤트 소싱의 이벤트 로그는 CQRS 아키텍처에서 읽기 모델을 업데이트하는 완벽한 메커니즘이 됩니다. 명령이 처리되면 새로운 이벤트가 생성되고, 이 이벤트들은 이벤트 저장소(쓰기 측)에 저장되는 동시에 읽기 모델(프로젝션)을 업데이트하기 위해 발행됩니다.

요약하자면, CQRS는 확장성 있고, 고성능이며, 유지보수하기 좋은 애플리케이션을 구축하기 위한 강력한 패턴입니다. 하지만 그 복잡성 때문에 정말로 필요한 문제에 신중하게 적용해야 합니다.

references