CQRS 실습 – 주문/결제 도메인 만들기 (1편)

핵심 요약

  • 이번 실습의 목표는 주문/결제/주문내역 도메인을 “대규모 트래픽을 견디는 구조”로 설계하는 감각을 익히는 것입니다.
  • CQRS의 핵심은 쓰기(Command)읽기(Query)를 분리하고, 각각을 최적화하는 것입니다. (쓰기: MySQL, 읽기: Redis)
  • 서비스 간 연결은 이벤트 기반(예: Kafka)으로 비동기 파이프라인을 만들며, 로컬에서는 Docker로 비용 없이 실습합니다.
  • 1편에서는 “완벽한 구현”보다 스코프를 줄인 최소 단위로 시작합니다. (1단계: 주문 생성/조회 중심)

목차

이 시리즈는 “CQRS를 책으로만 이해하는 수준”에서 멈추지 않고, 손으로 직접 주문/결제/주문내역 시스템을 쪼개고 붙이며 감각을 익히는 실습형 강의입니다. 중요한 전제는 하나예요. 우리는 처음부터 모든 걸 완벽하게 만들지 않습니다. 대신 서비스 분리 → 로컬 실행 기반 → 데이터 흐름(이벤트/프로젝션) → 운영 이슈 순서로 난이도를 올립니다.

1편의 역할

  • “CQRS를 붙일 수 있는 구조”를 먼저 만든다
  • 서비스 경계를 코드/폴더/실행 단위로 고정한다
  • 다음 편에서 Docker + 3개 앱 동시 실행으로 토대를 검증한다

1) 목표와 기본 가정

1-1. 실습 목표: “대규모 트래픽을 견디는 구조”를 경험한다

이번 프로젝트로 얻어가야 하는 핵심은 기능 그 자체가 아니라, “왜 이렇게 나누는지”를 설명할 수 있는 구조적 이해입니다. 주문 도메인은 시간이 지날수록 조회(주문내역/주문상세) 트래픽이 폭발하기 쉬워서, CQRS를 연습하기에 가장 현실적인 주제입니다.

  1. 서비스 경계를 먼저 잡습니다. “주문 생성/변경”, “결제 처리”, “주문내역 조회”가 한 덩어리로 뭉치지 않도록 분리합니다.
  2. CQRS의 전형적인 분리인 쓰기(MySQL) / 읽기(Redis)를 “이론”이 아니라 “손의 감각”으로 익힙니다. 여기서 핵심은 Redis가 빠르다는 사실이 아니라, 읽기 모델을 읽기에 맞게 따로 설계한다는 관점입니다.
  3. 이벤트 기반으로 시스템을 연결하는 사고방식을 연습합니다. 다음 편부터 Kafka를 붙이면서, “왜 동기 호출을 줄이려 하는지”가 체감될 겁니다.
  4. 실무에서 흔한 형태인 한 레포 안에서 여러 Spring Boot 앱을 운영하는 방식을 멀티모듈로 훈련합니다.

1-2. 기본 가정: “이 정도는 인정하고” 시작하자

CQRS는 강력한 대신 복잡도가 올라갑니다. 따라서 무엇을 감수하고 무엇을 얻는지부터 합의해야 합니다. 이 실습은 아래 가정 위에서 진행합니다.

  • 비용 0원: 로컬 Docker 컨테이너로 인프라(MySQL/Redis, 이후 Kafka)를 띄웁니다. 유료 매니지드 서비스에 의존하지 않습니다.
  • 개발 환경: IntelliJ + Gradle 멀티모듈로 진행합니다. “하나의 프로젝트로 여러 서비스를 동시에 운영”하는 형태를 목표로 합니다.
  • 최종적 일관성(Eventual Consistency) 허용: 주문을 생성한 직후 주문내역 화면 반영이 0.5초 ~ 몇 초 늦을 수 있다고 가정합니다. 대신 읽기 시스템을 강하게 확장하는 구조를 얻습니다.
  • 스코프 관리(가장 중요): 첫 단계에서 “재고/배송/복잡한 결제 상태 머신”까지 완성하려 하지 않습니다. 1단계는 주문 생성/조회에 집중하고, 결제는 껍데기부터 점진적으로 채웁니다.

여기서 핵심은 “최종적 일관성”을 두려워하지 않는 것입니다. 실무에서도 주문 생성 직후 주문내역이 약간 늦게 보이는 경험은 흔합니다. 중요한 건 그 지연을 숨기기보다 UX/운영 정책으로 흡수하고, 대신 트래픽 급증에도 안정적으로 버티는 구조를 만드는 것입니다.

2) 서비스 분리: 경계(Responsibility)부터 정하자

“서비스 분리”는 폴더를 나누는 작업이 아닙니다. 무엇을 누가 책임질지(경계)를 먼저 정하고, 그 다음에 코드/모듈/배포 단위로 고정해야 합니다. 이번 실습의 기본 서비스는 아래 3개입니다.

2-1. order-command (쓰기 전용)

  • 역할: 주문 생성/취소처럼 “주문 상태를 바꾸는 요청”을 처리합니다.
  • 데이터 기준점: 정합성의 기준은 MySQL 트랜잭션(쓰기 모델)입니다.
  • 예시 API: POST /commands/orders

2-2. payment-command (쓰기 전용)

  • 역할: 결제 승인/실패/환불 등 결제 상태 변경 요청을 처리합니다.
  • 실습 전략: 초반에는 Mock(가짜 결제)로 시작해도 충분합니다. 핵심은 결제를 “주문과 분리된 책임”으로 고정하는 것입니다.
  • 예시 API: POST /commands/payments

2-3. order-query (읽기 전용)

  • 역할: 주문내역/주문상세 같은 조회 요청만 처리합니다.
  • 원칙: 최종 형태에서는 Redis 읽기 모델을 주로 조회하도록 설계합니다.
  • 예시 API: GET /queries/users/{userId}/orders, GET /queries/orders/{orderId}

“왜 결제 서비스까지 지금부터 분리하나?”라는 질문이 자연스럽게 나옵니다. 답은 간단합니다. 1편에서 결제를 완성하려는 게 아니라, 서비스 경계를 먼저 확정해야 이후 단계(이벤트/읽기 모델)를 얹을 때 흔들리지 않습니다. 경계가 흐리면 CQRS는 금방 “한 서비스가 모든 걸 하면서 DB만 두 개 쓰는” 이상한 형태가 되기 쉽습니다.

3) 멀티모듈 구조 설계: “한 레포, 여러 Spring Boot 앱”

이제 경계를 “코드 구조”로 고정해봅시다. 멀티모듈의 장점은 서비스 경계가 폴더 구조로 강제된다는 점입니다. 아래는 최소 형태의 추천 구조입니다.


cqrs/
  infra/
    docker-compose.yml

  libs/
    event-contracts/ (선택) 이벤트 DTO/스키마 같은 “계약”만 공유

  services/
    order-command/ (Spring Boot App)
    payment-command/ (Spring Boot App)
    order-query/ (Spring Boot App)

  settings.gradle
  build.gradle
  gradle.properties

여기서 중요한 운영 감각이 하나 있습니다. 공통 라이브러리를 너무 많이 만들기 시작하면 서비스 분리의 의미가 금방 흐려집니다. 그래서 공유는 최소화합니다. event-contracts처럼 “경계가 명확한 계약”만 공유하고, 그 외 비즈니스 로직은 서비스 안에 두는 방식이 장기적으로 안전합니다.

3-1. 포트 분리 원칙(운영 감각 미리 심기)

멀티모듈로 서비스를 나누면, 실행 단위도 분리됩니다. 1편에서는 “실행은 다음 편에서 검증”하지만, 포트 분리 원칙은 미리 정해두는 게 좋습니다. 예를 들어 아래처럼 역할별로 포트를 고정해두면, 호출 흐름이 명확해집니다.

  • order-command: 8081
  • payment-command: 8082
  • order-query: 8083

4) 1편 체크포인트

1편의 도착점은 “기능 구현”이 아니라 “구조의 고정”입니다. 아래 항목이 만족되면, 다음 편에서 Docker와 실행 검증으로 자연스럽게 넘어갈 수 있습니다.

  • 주문/결제/조회가 서로 다른 Spring Boot 앱으로 분리될 준비가 되어 있다
  • 멀티모듈 폴더 구조가 잡혀 있고, “공유는 최소화” 원칙이 적용되어 있다
  • 포트 분리 원칙이 합의되어 있다(8081/8082/8083)

다음 편 예고

2편에서는 1편에서 만든 구조를 실제로 로컬에서 띄워 봅니다. Docker로 MySQL/Redis를 올리고, 3개 서비스를 동시에 실행한 뒤, curl로 “연결이 되는 상태”를 확인합니다. 이 토대가 있어야 3편부터 MySQL 저장, 이벤트(Kafka), Redis 읽기 모델(Projection)을 안정적으로 붙일 수 있습니다.


이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬