본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #23] CQRS: 읽기 모델과 쓰기 모델을 분리해야 할 때

페이지 수정

DDD를 공부하다 보면 CQRS라는 단어를 자주 만나게 된다. 그래서 DDD를 제대로 하려면 CQRS를 해야 하고, CQRS를 하려면 이벤트 소싱까지 해야 한다고 생각하기 쉽다.

하지만 CQRS는 DDD의 필수 요소가 아니다. CQRS는 특정 문제를 해결하기 위한 선택지다. 그 문제란 읽기 요구와 쓰기 요구가 서로 다른 방향으로 모델을 압박할 때 발생한다.

Aggregate는 변경 규칙과 불변식을 지키기 위한 모델이다. 하지만 화면 조회는 종종 전혀 다른 형태의 데이터를 요구한다. 이 둘을 하나의 모델로 모두 해결하려고 하면 모델이 이상해진다.

CQRS는 이 긴장을 줄이기 위해 읽기와 쓰기의 책임을 분리한다.

쓰기 모델은 불변식을 지킨다

DDD에서 Aggregate는 주로 쓰기 모델에 가깝다. Aggregate의 핵심 역할은 상태 변경을 안전하게 수행하고 불변식을 지키는 것이다.

구독 Aggregate를 생각해보자.

subscription.changePlan(targetPlan, planChangePolicy, now);
subscription.cancelByMember(reason, now);
subscription.renew(paymentResult, now);

이 모델이 중요한 이유는 구독의 규칙을 지키기 위해서다.

만료된 구독은 요금제를 변경할 수 없다.
결제 실패 후 유예 기간은 한 번만 시작된다.
해지된 구독은 다시 해지할 수 없다.
무료 체험은 정해진 기간을 초과할 수 없다.

쓰기 모델은 이런 규칙을 중심으로 설계된다. 따라서 내부 구조도 규칙을 지키기 쉬운 방향이어야 한다.

읽기 요구는 다르게 생겼다

반면 읽기 요구는 화면이나 리포트 중심으로 생긴다. 관리자 구독 목록 화면을 생각해보자.

회원 이메일
회원 등급
현재 요금제 이름
구독 상태
다음 결제일
최근 결제 성공 여부
미납 여부
고객센터 문의 수
예상 월 매출

이 정보는 여러 컨텍스트나 테이블에서 온다. 이것을 보여주기 위해 Subscription Aggregate를 모두 로딩하고, 회원, 결제, 문의, 매출 정보를 객체 그래프로 따라가게 만들면 쓰기 모델이 조회 요구에 끌려간다.

쓰기 모델에 화면용 필드가 늘어나고, 읽기 성능을 위해 Aggregate 경계가 흐려지며, 불변식 보호와 상관없는 연관관계가 생긴다.

이때 CQRS를 고려할 수 있다.

CQRS의 기본 생각

CQRS는 Command Query Responsibility Segregation의 약자다. 명령과 조회의 책임을 분리하자는 뜻이다.

Command는 상태를 변경하는 요청이다.

구독을 해지한다.
요금제를 변경한다.
구독을 갱신한다.
무료 체험을 시작한다.

Query는 상태를 조회하는 요청이다.

구독 목록을 검색한다.
회원의 현재 이용 권한을 조회한다.
다음 결제 예정 목록을 조회한다.
월별 매출 리포트를 조회한다.

CQRS를 적용하면 쓰기 모델은 도메인 규칙과 불변식에 집중하고, 읽기 모델은 조회 요구와 성능에 맞게 별도로 설계할 수 있다.

// Command
changePlanUseCase.changePlan(command);

// Query
List<SubscriptionListItem> items = subscriptionQuery.search(condition);

둘은 같은 데이터를 바라볼 수 있지만 같은 모델일 필요는 없다.

단순한 CQRS도 가능하다

CQRS라고 해서 반드시 별도 데이터베이스, 메시지 브로커, 이벤트 소싱이 필요한 것은 아니다. 가장 단순한 형태는 같은 DB를 사용하되 읽기 전용 쿼리 모델을 따로 두는 것이다.

public class SubscriptionQueryRepository {
    public List<SubscriptionListItem> search(SubscriptionSearchCondition condition) {
        // 화면에 필요한 형태로 조인해서 조회
    }
}

쓰기에서는 Aggregate Repository를 사용한다.

Subscription subscription = subscriptionRepository.findById(id);
subscription.cancelByMember(reason, now);
subscriptionRepository.save(subscription);

읽기에서는 화면 전용 DTO를 조회한다.

List<SubscriptionListItem> items = subscriptionQueryRepository.search(condition);

이 정도도 충분히 CQRS의 효과를 얻을 수 있다. Aggregate를 조회 화면에 맞추느라 망가뜨리지 않아도 되기 때문이다.

복잡한 CQRS는 비용이 크다

더 나아가 읽기 모델을 별도 저장소로 분리할 수도 있다. 쓰기 모델에서 이벤트를 발행하고, 읽기 모델은 이벤트를 구독해 조회 전용 테이블이나 문서를 갱신한다.

SubscriptionRenewed 이벤트
→ subscription_read_model 갱신
→ admin_dashboard_view 갱신

이 방식은 대규모 조회, 복잡한 리포트, 고성능 검색에 유용할 수 있다. 하지만 비용도 크다.

읽기 모델 동기화 지연
이벤트 처리 실패와 재시도
중복 이벤트 처리
스키마 변경 관리
운영 모니터링
디버깅 어려움

따라서 CQRS를 적용할 때는 단계가 중요하다. 처음부터 복잡한 이벤트 기반 CQRS로 갈 필요는 없다. 같은 DB 안에서 Command 모델과 Query 모델을 분리하는 것만으로도 충분한 경우가 많다.

언제 CQRS를 고려할까

CQRS는 다음과 같은 상황에서 유용하다.

조회 요구가 Aggregate 구조와 크게 다르다.
조회 성능 때문에 쓰기 모델이 오염되고 있다.
하나의 화면이 여러 Aggregate나 컨텍스트의 데이터를 조합해야 한다.
쓰기 규칙은 복잡하지만 조회는 단순한 형태로 빨라야 한다.
조회 트래픽과 쓰기 트래픽의 규모가 크게 다르다.
리포트나 검색 요구가 도메인 모델을 압박한다.

반대로 단순한 CRUD 시스템에서는 CQRS가 과할 수 있다. 읽기와 쓰기를 분리하는 순간 코드와 운영 구조가 늘어난다. 분리할 만큼의 긴장이 없다면 단순한 구조가 낫다.

CQRS는 DDD를 돕는 선택지다

DDD에서 CQRS가 자주 등장하는 이유는 Aggregate의 목적과 조회 요구가 다르기 때문이다. Aggregate는 모든 정보를 보기 좋게 담는 객체가 아니다. 변경을 안전하게 수행하는 모델이다.

조회 화면 때문에 Aggregate에 불필요한 연관관계와 필드가 늘어나면, 쓰기 모델의 목적이 흐려진다. CQRS는 이 문제를 해결하는 선택지다.

하지만 CQRS 자체가 DDD는 아니다. CQRS를 했다고 도메인 모델이 좋아지는 것도 아니다. 읽기와 쓰기를 분리해도 쓰기 모델이 여전히 빈약하면 DDD는 실패한다.

중요한 것은 먼저 도메인 모델을 올바르게 세우고, 그 모델이 조회 요구와 충돌할 때 분리를 고려하는 것이다.

마치며

CQRS는 읽기와 쓰기를 분리하는 설계 방식이다. 쓰기 모델은 불변식과 도메인 규칙을 지키는 데 집중하고, 읽기 모델은 조회 요구와 성능에 맞게 설계한다.

CQRS는 DDD의 필수 요소가 아니다. 하지만 DDD를 적용하다 보면 자연스럽게 필요해지는 순간이 있다. Aggregate를 조회 화면에 맞추느라 망가뜨리고 있다면, 읽기 모델을 분리할 때일 수 있다.

처음부터 복잡한 CQRS를 도입할 필요는 없다. 같은 DB 안에서 Command 모델과 Query 모델을 나누는 작은 분리부터 시작할 수 있다.

좋은 설계는 모든 것을 분리하는 것이 아니다. 서로 다른 책임이 실제로 충돌할 때 분리하는 것이다. CQRS는 그 충돌을 다루는 도구다.


페이지 수정
공유하기:

이전 글
[DDD Intro #24] Event Sourcing은 DDD의 필수가 아니다
다음 글
[DDD Intro #22] 트랜잭션과 일관성: 무엇을 한 번에 지켜야 하는가