msa

CQRS — 깊은 이야기

msa / msa-design §2,3 / kafka

CQRS = Command Query Responsibility Segregation. 한 문장: "바꾸는 길과 보는 길을 갈라놓자".

흔히 "DB 두 개 쓰는 패턴" 으로 오해되지만, 본질은 모델·서비스·DB 어느 층에서 갈라놓을지 선택의 자유. 우리 ticket-command/query 분리가 그 첫 단계. 어디까지 가느냐는 부하 측정이 답함.


§1. 왜 read와 write를 가르나

1.1 본질적 비대칭

read와 write는 다음 측면에서 다르다:

측면 Write (Command) Read (Query)
트래픽 양 보통 적음 (사용자가 가끔 변경) 매우 많음 (한 사용자가 N번 봄)
응답 시간 요구 약간 느려도 OK (확실히 처리되는 게 중요) 빨라야 함 (UI 반응성)
모델 모양 정규화 (중복 없이 정확) 비정규화 (한 번에 다 보여주기)
일관성 강한 일관성 필수 eventual 도 가끔 OK
트랜잭션 멀티 row, ACID 단일 row, read-only
캐시 친화도 거의 X (방금 쓴 게 진실) 매우 좋음
스케일링 방향 vertical (단일 DB primary) horizontal (replica N개)
인덱스 정책 적게 (write 비용) 많이 (read 빠르게)
장애 영향 비즈니스 정지 UX 저하 (캐시로 완화)

같은 모델·같은 서비스·같은 DB로 두 측면을 동시에 최적화하는 건 어렵다.

1.2 단일 서비스의 함정

전형적 단일 ticket-service:

ticket-service
├── GET    /events/{id}/seats        ← 한 화면에 모든 정보 = JOIN 많음
├── GET    /events/{id}/reservations
├── POST   /reservations              ← 좌석 락 + INSERT
├── POST   /reservations/{id}/confirm
└── DELETE /reservations/{id}

모두 같은 정규화 스키마(Event/Section/Seat/Reservation/User) 위에서 동작.

문제:

1.3 CQRS의 답

read와 write를 코드 차원에서 분리한다.

각 단계에서 무엇이 풀리고 무엇이 새로 어려워지는지 체감하는 게 학습 핵심.


§2. 단계별 진화 (3 stages)

CQRS는 0/1이 아니라 스펙트럼. 우리 프로젝트의 진화 경로:

[Stage 1 · 현재] 두 서비스, 같은 DB
    ├── ticket-command-service (R/W)
    ├── ticket-query-service (Read-only)
    └── 같은 Postgres ticket_db
    └── 강한 일관성, 학습 변수 0

         ↓ 부하 측정으로 read 부담 확인

[Stage 2] 두 서비스, Primary/Replica
    ├── command → Postgres Primary
    ├── query   → Postgres Replica (streaming replication)
    └── replica lag 등장 (보통 ms~수십ms)

         ↓ "단순 복제로는 read model 모양을 못 바꾼다" 한계

[Stage 3] 두 서비스, 별도 DB + Kafka 동기화
    ├── command → command DB (정규화)
    ├── command → Kafka publish "ReservationCreated"
    ├── query Worker → consume → query DB (비정규화 projection)
    └── eventual consistency. outbox 패턴 필요.

2.1 Stage 1 — 같은 DB

무엇이 풀렸나

무엇이 안 풀렸나

트레이드오프

2.2 Stage 2 — Primary/Replica

무엇이 풀렸나

무엇이 새로 어려워졌나 — Replica Lag

Replica Lag 대응 4가지

  1. 읽기 후 쓰기 (Read-Your-Writes) — 같은 사용자의 직후 read는 primary로.
  2. Sticky Session — 같은 사용자 N분간 primary read.
  3. Write-through cache — write 직후 응답을 클라이언트가 보유 + Redis에도 캐싱.
  4. UI 낙관적 업데이트 — 응답 받기 전에 UI는 변경된 척, 실패 시 rollback.

→ 다 트레이드오프 있음. 우리 도메인은 (4)가 자연스러움 — "예약했어요" 즉시 UI 반영 + replica에서 천천히 보여도 OK.

다른 시점 문제

2.3 Stage 3 — 별도 DB + Kafka 동기화

여기까지 가면 진짜 CQRS.

무엇이 가능해지나 — Read Model 자유

query DB의 스키마를 query에 최적화된 모양으로 자유롭게 설계.

예시: command DB는 정규화

events(id, title, ...)
seat_sections(id, event_id, name, price, ...)
seats(id, section_id, row, number, status, ...)
reservations(id, user_id, seat_id, status, created_at, ...)

query DB는 비정규화 projection — "사용자별 예약 화면" 한 번에:

user_reservations_view(
    user_id, reservation_id,
    event_title, event_date, venue,
    section_name, seat_label,    -- "R-A-12"
    price,
    status, created_at
)  -- JOIN 0회로 한 사용자의 모든 예약 한 번에 SELECT

또는 좌석맵 전용:

event_seatmap_view(
    event_id, section_id, section_color, section_price,
    row_label, seat_number, status, last_updated_at
)  -- 화면 한 번 그리기 위한 모든 정보

어떻게 동기화? — Event 발행/소비

command가 변경할 때마다 이벤트 발행:

ReservationCreated   { reservationId, userId, seatId, createdAt }
ReservationConfirmed { reservationId, paidAt }
ReservationCancelled { reservationId, cancelledAt, reason }
SeatStatusChanged    { seatId, oldStatus, newStatus, by }

query side의 Worker가 consume → projection 테이블 갱신.

새로 등장한 문제들

  1. Eventual consistency — write와 read 사이 지연이 더 커짐 (Kafka publish + consume + DB write).
  2. Out-of-order 처리 — Kafka는 partition 내 순서 보장. 다른 partition 이벤트 순서는 어플리케이션이 책임.
  3. 중복 처리 — at-least-once. Worker가 같은 메시지 두 번 받아도 안전한 코드(idempotent) 필요.
  4. Outbox 패턴 — command가 "DB INSERT + Kafka publish" 둘 다 하는 게 원자적이지 않음. outbox 테이블로 해결.

Outbox 패턴 자세히

문제:

@Transactional
fun reserve() {
    db.insertReservation(...)         // 1. DB 커밋
    kafka.publish("ReservationCreated") // 2. publish 실패하면?
    // 1만 성공, 2 실패 → query side가 영원히 모름. inconsistency.
}

해결 — 같은 트랜잭션에 outbox 테이블 INSERT:

@Transactional
fun reserve() {
    db.insertReservation(...)
    db.insertOutbox(eventType="ReservationCreated", payload="{...}")
    // 둘 다 같은 트랜잭션. 원자적.
}

// 별도 outbox-relay 데몬:
loop {
    val rows = db.fetchUnpublished(limit=100)
    for (row in rows) {
        kafka.publish(row.eventType, row.payload)
        db.markPublished(row.id)
    }
}

이제 publish 실패해도 outbox에 남아 있어 재시도됨. at-least-once 자동 보장.

→ outbox 자체도 학습 거리. CDC(Debezium) 같은 도구가 outbox를 대신할 수도 있음.


§3. 우리 구현 자세히 (Stage 1)

실제 코드: ~/workspace/ticketing/ticket-command-service/ticket-query-service/

3.1 디렉토리 구조

두 서비스 거의 대칭:

ticket-command-service/src/main/kotlin/com/example/ticketcommand/
├── TicketCommandApplication.kt
├── entity/        EventEntity, SeatSectionEntity, SeatEntity, ReservationEntity
├── repository/    EventRepository, SeatSectionRepository, SeatRepository, ReservationRepository
├── dto/           CreateReservationRequest, ReservationResponse
├── service/       ReservationService           ← reserve / confirm / cancel
├── controller/    ReservationController         ← POST + DELETE
└── exception/     GlobalExceptionHandler

ticket-query-service/src/main/kotlin/com/example/ticketquery/
├── TicketQueryApplication.kt
├── entity/        (같은 4개, 별도 패키지)
├── repository/    (find* 만)
├── dto/           EventView, SeatMapView, SectionView, SeatView, ReservationView
├── service/       EventQueryService, ReservationQueryService  ← 모두 @Transactional(readOnly=true)
├── controller/    EventQueryController, ReservationQueryController
└── exception/     GlobalExceptionHandler

3.2 의도적 중복 — Entity 두 벌

같은 4개 엔티티가 두 패키지에 클래스 두 벌로 존재:

// command 측
package com.example.ticketcommand.entity
@Entity @Table(name = "reservations")
class ReservationEntity(
    @Id val id: String,
    @Column val userId: String,
    @Column val seatId: String,
    @Enumerated @Column var status: ReservationStatus,
    @Column val createdAt: Instant = Instant.now(),
)

// query 측 — 같은 테이블 매핑, 같은 컬럼
package com.example.ticketquery.entity
@Entity @Table(name = "reservations")
class ReservationEntity(
    @Id val id: String,
    @Column val userId: String,
    @Column val seatId: String,
    @Enumerated @Column val status: ReservationStatus,   // var 아닌 val
    @Column val createdAt: Instant,
)

작은 차이도 의도적

표면적 DRY 위반인데 왜?

1. 다음 단계에서 분기할 자리

Stage 3로 가면 query는 더 이상 정규화된 Reservation 이 아니라 ReservationView (이벤트 정보 join된 비정규화). 그때 클래스가 갈라져야 함. 처음부터 공유 모델로 묶으면 그 분기 비용이 큼.

2. 변경 권한의 격리

command가 엔티티에 새 필드 추가 → query는 자동으로 영향 안 받음. validate 단계에서 발견 → 의식적으로 query도 같이 갱신.

3. CQRS의 "Q는 다른 모양일 수 있다" 라는 감각 보존

같은 클래스 쓰면 그 감각을 못 얻는다. 약간의 불편을 짊어지는 비용으로 학습 가치 얻음.

비용 (의식하고 받아들임)

3.3 권한 분리 — ddl-auto

ticket-command-service/src/main/resources/application.yml:

spring:
  jpa:
    hibernate:
      ddl-auto: update    # 엔티티 추가/변경 시 스키마 자동 변경

ticket-query-service/src/main/resources/application.yml:

spring:
  jpa:
    hibernate:
      ddl-auto: validate  # 부팅 시 엔티티 vs 실제 스키마만 검증

이게 주는 것

검증된 동작

이번 세션에서 두 서비스를 띄울 때:

[command] Hibernate: create table events (...)        ← 스키마 생성
[command] Started TicketCommandApplication in 3.2s
[command] Tomcat started on port 8082

[query]   HHH000477: Schema 'public' validated
[query]   Started TicketQueryApplication in 2.8s
[query]   Tomcat started on port 8083

→ validate가 통과했다 = 두 엔티티 정의가 완벽 일치. 무료로 얻은 검증.

3.4 권한 분리 — HikariCP read-only

query의 datasource:

spring:
  datasource:
    hikari:
      read-only: true       # ← 풀 자체를 read-only로 마킹

의미

Stage 2 갈 때

datasource URL만 replica host로 바꾸면 끝:

- url: jdbc:postgresql://primary-host:5432/ticket_db
+ url: jdbc:postgresql://replica-host:5432/ticket_db

read-only: true 는 그대로 → replica는 어차피 read-only라 자연스러움.

3.5 권한 분리 — @Transactional(readOnly = true)

query의 모든 @Service 에:

@Service
@Transactional(readOnly = true)
class EventQueryService(...) {
    fun listEvents(): List<EventView> = ...
    fun getEvent(eventId: String): EventView = ...
    fun getSeatMap(eventId: String): SeatMapView = ...
}

효과

  1. JPA가 dirty checking 생략 — flush 안 함. 약간의 CPU 절약.
  2. 트랜잭션 커밋 비용 감소 — write 없는 트랜잭션은 commit 사실상 no-op.
  3. routing 힌트 — Spring 5+의 LazyConnectionDataSourceProxy + AbstractRoutingDataSource 패턴으로 "read-only 트랜잭션이면 replica connection 가져옴" 라우팅 가능.

Stage 2에 더 빛남

Stage 2에 router DataSource를 도입하면 @Transactional(readOnly=true) 만 보고 replica로 routing. 코드 변경 0.

3.6 Repository 차이

command의 SeatRepository:

interface SeatRepository : JpaRepository<SeatEntity, String> {
    fun findBySectionId(sectionId: String): List<SeatEntity>

    @Lock(LockModeType.PESSIMISTIC_WRITE)               // ← write 시 비관락
    fun findAllByIdIn(ids: Collection<String>): List<SeatEntity>
}

query의 SeatRepository:

interface SeatRepository : JpaRepository<SeatEntity, String> {
    fun findBySectionId(sectionId: String): List<SeatEntity>
    fun findBySectionIdIn(sectionIds: Collection<String>): List<SeatEntity>
    // 락 X. write 안 함.
}

같은 인터페이스 이름, 다른 책임. command는 락을 가짐. query는 안 가짐.

3.7 Service 책임 분리

command의 ReservationService.reserve():

@Transactional
fun reserve(userId: String, seatIds: List<String>): List<ReservationEntity> {
    val seats = seatRepository.findAllByIdIn(seatIds)   // 비관락 잡힘
    if (seats.size != seatIds.size) throw SeatNotAvailableException(...)
    val unavailable = seats.filter { it.status != AVAILABLE }
    if (unavailable.isNotEmpty()) throw SeatNotAvailableException(...)

    seats.forEach { it.status = RESERVED }              // ← 상태 변경
    seatRepository.saveAll(seats)

    val reservations = seats.map { ReservationEntity(...) }
    return reservationRepository.saveAll(reservations)
}

query의 EventQueryService.getSeatMap():

fun getSeatMap(eventId: String): SeatMapView {
    if (!eventRepository.existsById(eventId)) throw EventNotFoundException(...)
    val sections = seatSectionRepository.findByEventId(eventId)
    val seats = seatRepository.findBySectionIdIn(sections.map { it.id }).groupBy { it.sectionId }
    return SeatMapView.build(eventId, sections, seats)   // ← projection 조립
}

같은 데이터를 다루지만:

코드 모양이 완전히 다름. 이게 분리의 본질.

3.8 DTO도 책임이 다름

command DTO (dto/ReservationDto.kt):

data class CreateReservationRequest(
    @field:NotBlank val userId: String,
    @field:NotEmpty val seatIds: List<String>,
)
data class ReservationResponse(
    val id: String, val userId: String, val seatId: String,
    val status: ReservationStatus, val createdAt: Instant,
)

→ 1:1로 엔티티 닮음. 입력 검증 (@NotBlank 등).

query DTO (dto/QueryDto.kt):

data class EventView(...)              // Event 1:1
data class SeatView(...)               // Seat 일부
data class SectionView(                // Section + 그 안의 Seat 목록
    val id: String, val name: String, val korName: String, ...,
    val seats: List<SeatView>,         // ← 트리 구조
)
data class SeatMapView(                // Event + 모든 Section + 모든 Seat
    val eventId: String,
    val sections: List<SectionView>,
)

계층 구조 projection. UI 한 번에 그릴 수 있게.

이게 read model의 시작. Stage 3에 가면 DB 자체가 이 모양으로 저장됨.


§4. Event Sourcing — CQRS의 짝

CQRS 이야기에 거의 항상 따라오는 개념. 헷갈리기 쉬움.

4.1 둘은 다른 패턴

CQRS는 Event Sourcing 없어도 됨. Event Sourcing은 CQRS와 잘 어울림 (이벤트가 곧 read model 갱신 트리거).

4.2 Event Sourcing 핵심

전통 (state-based):

UPDATE reservations SET status='CANCELLED' WHERE id=?;
-- 이전 상태(PENDING)는 사라짐.

Event-sourced:

events 테이블:
  id | aggregate_id | event_type           | payload                  | created_at
  1  | r-001        | ReservationCreated   | {userId, seatId, ...}    | t1
  2  | r-001        | ReservationConfirmed | {paidAt, ...}            | t2
  3  | r-001        | ReservationCancelled | {reason, ...}            | t3

현재 상태는 events를 처음부터 fold:

fun rebuild(id: String): Reservation = events
    .filter { it.aggregateId == id }
    .fold(Reservation.initial()) { state, event -> state.apply(event) }

4.3 장점/단점

장점 단점
완전한 audit trail 쿼리 어려움 ("현재 PENDING인 예약" → 전체 fold 필요)
시간 여행 ("3일 전 상태는?") 이벤트 schema evolution 어려움
새 read model 추가 쉬움 (이벤트 다시 재생) 스토리지 양 ↑
비즈니스 의도 보존 ("취소" vs "삭제") 학습 곡선 ↑

→ snapshot 패턴(주기적으로 fold 결과 저장)으로 쿼리 비용 완화.

4.4 CQRS + Event Sourcing 콤보

Command side:
  ReservationCommand → 이벤트만 append → events 테이블

Event Bus (Kafka):
  events 테이블 → outbox-relay → Kafka

Query side:
  Worker → consume → 여러 projection 테이블 갱신
    - reservations_current_view
    - user_reservations_view
    - event_seatmap_view
    - revenue_by_event_view  ← 새 view 필요하면 이벤트 처음부터 재생

→ "Event Sourcing이 진짜 CQRS의 완성형" 이라는 말의 의미.

4.5 우리는 안 쓴다 (지금)

4.6 그래도 비슷한 게 우리에게 있다

MyKafka 자체가 commit log = 이벤트 시퀀스. __consumer_offsets 가 Event-Sourced. 우리 도메인엔 안 쓰지만 인프라는 Event-Sourced. → kafka §3.8.


§5. Read Model 의 다양한 모습

Stage 3에 가면 query side의 저장소가 자유로워짐. 후보들:

5.1 동일 DB, 별도 테이블 (projection)

가장 흔함. RDB의 정규화된 테이블 → 비정규화 view 테이블.

CREATE TABLE user_reservations_view (
    user_id VARCHAR PRIMARY KEY,
    reservations JSONB,        -- 사용자의 모든 예약 한 row에 JSON으로
    last_updated_at TIMESTAMP
);

5.2 별도 RDB

스키마 격리. write DB 마이그레이션이 read DB에 영향 없음.

5.3 검색 엔진 (Elasticsearch / OpenSearch)

texual search가 필요한 도메인. "콘서트 제목으로 검색".

5.4 in-memory cache (Redis)

초고속 조회. "남은 좌석 수" 같이 자주 조회되는 작은 데이터.

5.5 Materialized View (DB 내장)

Postgres MATERIALIZED VIEW — 미리 계산된 결과를 디스크에 저장.

5.6 컬럼형 DB (ClickHouse, BigQuery)

대시보드용 집계. "이번 달 매출 by 이벤트".

5.7 그래프 DB (Neo4j)

"이 사용자가 친구의 친구가 예약한 좌석과 같은 row인가?" 같은 관계 쿼리.

→ "Read model = DB" 가 아님. 데이터의 자연스러운 모양을 따라가는 게 핵심.


§6. Eventual Consistency 다루기

CQRS의 가장 어려운 부분. Stage 2부터 등장, Stage 3에서 본격적.

6.1 무엇이 보장 안 되는가

6.2 대응 패턴

Read-Your-Writes

같은 사용자의 직후 read는 primary 또는 cache로.

class SmartRouter {
    fun route(userId: String): DataSource = if (recentlyWrote(userId)) PRIMARY else REPLICA
}

Causal Consistency (벡터 클럭 / hybrid logical clock)

복잡. Cassandra/CockroachDB가 내부적으로 함. 우리 학습 범위 밖.

Optimistic UI

Compensating Action

6.3 우리 도메인에 무엇이 맞나

좌석 예약은 강한 일관성 요구. 하지만:

→ "한 도메인 안에서도 일관성 요구가 다르다." 이게 CQRS가 답하는 진짜 문제.


§7. 트레이드오프 정리

CQRS가 주는 것

CQRS가 가져오는 비용

안 해야 할 때

점진 도입의 가치

우리 진화 [Stage 1 → 2 → 3] 처럼 단계별 도입. 각 단계의 한계를 측정 후 다음 단계 결정. 처음부터 Stage 3 가지 말 것.


§8. 흔한 오해

오해 진실
"CQRS는 DB 두 개 쓰는 것" 코드/모델 분리부터 DB 분리까지 스펙트럼. 우리 Stage 1은 같은 DB.
"CQRS = Event Sourcing" 별개. CQRS는 모델 분리, ES는 저장 방식. 같이 자주 쓸 뿐.
"read는 무조건 stale 데이터" 같은 DB Stage 1은 강한 일관성. Stage 2/3에서만 lag.
"Replica 추가는 무료 read 처리량" 데이터 동기화 트래픽이 primary 부담. 적정 replica 수는 측정 필요.
"Outbox 쓰면 exactly-once" At-least-once. consumer 측에서 idempotency 필요.
"Query side는 항상 더 빠름" projection 갱신 비용 + JOIN 없앤 만큼 storage 폭증. 작은 도메인엔 손해.
"CQRS는 read 부하용" read 부하는 일부. 모델 모양의 자유가 본질.

§9. 토론 prompts

자세한 건 discussion §2 참조.


§10. 코드 reading map (토론 진행자용)

주니어와 함께 ticket-command/query 코드 읽을 때 권장 순서.

[1] 두 entity/ReservationEntity.kt 나란히 비교        "왜 거의 같은데 두 벌?"
    └─ command는 var status, query는 val status

[2] 두 application.yml의 jpa.ddl-auto 비교            "권한 분리"
    └─ update vs validate

[3] query application.yml의 hikari.read-only         "DB 차원의 잠금"
    └─ Stage 2 갈 때 URL만 바꾸면 되는 이유

[4] query의 모든 @Service @Transactional(readOnly)   "routing 힌트의 씨앗"

[5] command SeatRepository @Lock(PESSIMISTIC_WRITE)  "쓰기 책임의 표현"
    └─ query에는 없음

[6] command ReservationService.reserve()             "상태 머신 + 트랜잭션"

[7] query EventQueryService.getSeatMap()             "projection 조립"
    └─ DTO가 트리 구조

[8] query dto/QueryDto.kt SeatMapView                "read model의 시작"
    └─ Stage 3에서 DB가 이 모양으로 저장될 자리

[9] mock-data.sql                                    "단일 시드, 멱등"
    └─ 둘이 같은 DB를 본다는 사실의 명백한 증거

토론하면 좋은 코드 포인트