개요

CQRS(Command and Query Responsibility Segregation)는 쓰기 명령과 읽기 쿼리의 책임을 분리하는 아키텍처 패턴 CQS를 제안한 Bertrand Meyer의 아이디어가 뿌리이고, 실무적 CQRS 패턴을 알린 인물로 Greg Young이 널리 알려짐 핵심은 CUD(Command: Create, Update, Delete)와 R(Query: Read)을 하나의 모델로 처리하지 않고 분리해 복잡도와 결합도를 낮추는 것

왜 분리하는가

전통적인 CRUD 중심 구조에서는 단일 도메인 모델이 쓰기와 읽기 모두를 떠안음 변화 무쌍한 도메인 규칙과 고도화된 UX 요구로 인해 모델이 비대해지고, 유지보수 비용과 리스크가 누적됨 실제 비즈니스 룰과 제약은 쓰기 경로에서 주로 발생하고, 읽기는 상대적으로 단순 조회나 집계 중심인 경우가 많음 두 책임을 하나의 모델로 끌어안으면 불필요한 속성과 검증 로직이 뒤섞여 모델이 설계 의도에서 이탈함 CQRS는 책임을 분리해 각 경로를 그 목적에 맞게 최적화할 수 있게 함

적용 레벨

다음 세 수준으로 접근하면 복잡도를 단계적으로 통제 가능

  • 일반: 단일 데이터 저장소 유지, 모델 계층만 명령 모델과 조회 모델로 분리

    • 장점: 가장 단순, 도메인 복잡도 완화, 코드 가독성 및 테스트 용이성 향상
    • 한계: 동일 DB 사용에 따른 읽기·쓰기 경합과 성능 병목은 해소 어려움
  • 프리미엄: 쓰기 DB와 읽기 DB 분리, 동기화는 브로커로 처리

    • 장점: 읽기와 쓰기를 별도 스케일링, 저장소를 목적에 맞게 선택하는 폴리글랏 구성이 가능 (예: 쓰기 RDBMS, 읽기 캐시/문서형)
    • 리스크: 동기화 브로커의 가용성과 신뢰도 확보 필요, 결국 일관성 모델과 지연 허용 범위에 대한 명확한 설계 요구
  • 디럭스: 이벤트 소싱 도입

    • 개념: 애플리케이션의 상태 변화를 이벤트 스트림으로 영속화, 스트림은 추가 전용, 조회 시점에 머터리얼라이즈드 뷰를 생성·갱신
    • CQRS와 상호관계: CQRS에 이벤트 소싱은 필수 아님, 반대로 이벤트 소싱을 선택하면 CQRS적 분리가 사실상 전제됨
    • 장점: 완전한 변경 이력, 강력한 감사 추적, 뷰 재구성 가능
    • 비용: 설계·운영 복잡도 상승, 스키마 진화와 재생 비용 관리 필요

동작 원리와 구조

  • 쓰기 경로

    • 명령 유효성 검증, 도메인 규칙 적용, 상태 변경 단위 관리 (DDD의 애그리게이트 단위로 일관성 유지)
    • 이벤트 소싱 채택 시 명령 처리 결과를 이벤트로 기록하고 프로젝션을 통해 읽기 모델 반영
  • 읽기 경로

    • 조회 전용 모델 또는 뷰 모델로 응답 구성
    • 복잡한 조인을 피하고 조회 패턴에 맞춘 구조로 최적화 (캐시, 읽기 전용 저장소, 별도 인덱스 등)
  • 일관성 모델

    • 강한 일관성 요구가 낮다면 결국적 일관성 선택 여지 확대
    • 읽기 모델이 최신이 아닐 수 있음을 시스템/UX 차원에서 수용하는 설계 필요

언제 유리한가

  • 다수 사용자가 동일 데이터에 동시 접근하는 협업 도메인, 도메인 수준에서 충돌을 최소화할 세분화된 명령 정의가 가능한 경우
  • 여러 단계를 거치는 복잡한 업무 흐름과 풍부한 도메인 모델이 필요한 쓰기 경로, 반면 읽기는 단순 뷰 모델 반환이면 충분한 경우
  • 읽기 트래픽이 쓰기보다 현저히 많아 별도 확장과 튜닝이 필요한 경우
  • 팀을 분리해 쓰기 모델과 읽기 모델/프론트에 병렬로 집중해야 하는 경우
  • 시간이 지나며 모델 버전이 늘고 규칙이 자주 바뀌는 도메인
  • 이벤트 소싱과 결합해 외부 시스템과 느슨하게 통합해야 하는 경우

피해야 할 경우

  • 도메인 규칙이 단순하고 CRUD 형태로 충분한 경우
  • 분리로 인한 구조 복잡도가 이점보다 큰 경우

간단 예시: 코드 레벨의 저비용 분리

가장 낮은 수준의 적용은 단일 DB를 유지한 채 인터페이스와 구현을 쓰기와 읽기로 나누는 것 도메인 유스케이스가 읽기만 수행하는 경로를 별도 인터페이스로 분리하면, 조회 최적화와 독립적 테스트가 쉬워짐 필요 시 읽기 저장소만 다른 기술 스택으로 교체해도 영향 범위를 최소화할 수 있음

  • 인터페이스 분리 예시
// 최소 스니펫, 개념 전달 목적
interface ReadRepository {
  findById(id: string): Promise<SomeView>
  searchBy(filter: ReadFilter): Promise<SomeView[]>
}

interface WriteRepository {
  save(entity: SomeAggregate): Promise<void>
  delete(id: string): Promise<void>
}
  • 유스케이스 의존성 분리

    • 읽기 유스케이스는 ReadRepository에만 의존
    • 쓰기 유스케이스는 WriteRepository와 도메인 규칙에만 집중
  • 점진적 확장 포인트

    • 읽기만 캐시를 두거나 Read Replica를 붙이는 선택 가능
    • 읽기 전용 인덱스를 별도로 구성하거나, 읽기 저장소를 문서형 DB로 대체하는 폴리글랏 전환 용이

이벤트 소싱과 CQRS의 결합 고려사항

  • 이벤트 스트림은 추가 전용이므로 재현성과 감사를 보장하기 쉬움
  • 읽기 모델은 프로젝션으로 재구성하므로 모델 진화에 유연
  • 반면 리플레이 시간, 이벤트 스키마 진화, 보정 이벤트 관리 등 운영 숙제가 생김
  • CQRS는 이벤트 소싱의 전제에 가깝지만, 이벤트 소싱은 CQRS의 필수 요소 아님

운영상의 체크리스트

  • 경계 정의: 무엇이 Command이고 무엇이 Query인지 명확히 구분
  • 일관성 전략: 읽기 지연 허용 범위와 사용자 경험 정책 합의
  • 데이터 동기화: 프리미엄 이상에서는 브로커의 가용성·내구성 확보
  • 관찰 가능성: 읽기 모델 갱신 지연, 동기화 실패, 프로젝션 오류를 계측 및 알림으로 가시화
  • 회귀 방지: 읽기·쓰기 모델을 독립 배포 가능하게 관리, 계약 기반 테스트 활용

실용 팁

  • 먼저 코드 레벨 분리부터 시작, 단일 DB 유지하되 인터페이스로 경계 고정
  • 읽기 경로에 캐시와 인덱스 최적화 적용, 필요한 경우에만 저장소 분리 검토
  • 저장소를 분리한다면 이벤트·메시지 브로커의 장애 전파 차단과 재처리 전략을 먼저 설계
  • 복잡한 도메인에만 이벤트 소싱을 제한 적용, 전면 도입은 비용 대비 효과를 신중히 검토

마무리

CQRS는 만능 해법이 아님 변경이 잦고 읽기 패턴이 뚜렷한 도메인에서는 책임 분리만으로도 코드 단순화와 성능 이득을 얻을 수 있음 DB 성능 튜닝과 캐시, 인덱스 최적화 같은 기본기를 먼저 적용하고, 필요 시 단계적으로 프리미엄과 디럭스 레벨로 확장하는 전략 권장 핵심은 CUD와 R의 분리, 경계를 명확히 하고 각 경로를 그 목적에 맞게 최적화할 것

참고자료