CQRS2026년 5월 27일18분 읽기

CQRS 깊은 이야기: 같은 DB부터 Event Sourcing까지 단계별 진화

CQRS = '바꾸는 길과 보는 길을 갈라놓자'. read/write 분리의 본질부터 Stage 1(같은 DB) → Stage 2(Primary/Replica) → Stage 3(별도 DB + Kafka 동기화)까지 단계별 진화를 ticket-command/query 실제 구현으로 풀어 정리했다.

#CQRS#Architecture#EventSourcing#Spring#Kotlin#Database#Replication

CQRS 깊은 이야기

CQRS = Command Query Responsibility Segregation. 바꾸는 길과 보는 길을 갈라놓자는 것이다.

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


들어가며

CQRS는 선착순 티켓팅 MSA 시리즈의 핵심 결정 중 하나다. MSA 설계 결정 글의 §2와 §3에서 간략히 다뤘는데, 이 글에서는 그 결정의 깊은 이야기를 따로 푼다.

처음 CQRS를 만나면 가장 흔한 오해가 "DB 두 개 쓰는 패턴"이다. 그런데 그건 CQRS의 가장 무거운 형태일 뿐이다. 같은 DB를 read/write 권한만 나눠 쓰는 것도 CQRS이고, 한 서비스 안에서 패키지만 분리해도 CQRS의 시작이다. 0/1이 아니라 스펙트럼이라는 게 핵심이다.

이 글은 그 스펙트럼을 3단계로 나눠서 풀어본다. 각 단계에서 무엇이 풀리고 무엇이 새로 어려워지는지 직접 체감하는 게 학습의 본질이다.


목차

  • §1. 왜 read와 write를 가르나
  • §2. 단계별 진화 (3 stages)
  • §3. 우리 구현 자세히 (Stage 1)
  • §4. Event Sourcing — CQRS의 짝
  • §5. Read Model의 다양한 모습
  • §6. Eventual Consistency 다루기
  • §7. 트레이드오프 정리
  • §8. 흔한 오해
  • §9. 토론 prompts
  • §10. 코드 reading map

§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:

text
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) 위에서 동작한다.

문제는 세 가지다.

  • 좌석맵 한 번 그리려면 4-way JOIN. 인덱스 추가하면 write 느려짐
  • read 트래픽이 폭주하면 같은 connection pool을 쓰는 write도 느려짐
  • "조회용 read replica" 도입해도 같은 코드 안에서 어느 쿼리는 primary, 어느 쿼리는 replica인지 routing이 복잡

1.3 CQRS의 답

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

  • 작은 단계: 한 서비스 안에서 패키지 분리 (command/, query/)
  • 중간 단계: 서비스 자체를 분리 (우리)
  • 큰 단계: DB도 분리 + Kafka로 동기화

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


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

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

text
[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

무엇이 풀렸나

  • 코드 분리. command/query 책임 명확
  • 부팅 순서 강제 (command가 스키마 만들고 query는 validate). 엔티티 정의 불일치 즉시 발견
  • query는 read-only 마킹 — 미래 replica 분리할 기반

무엇이 안 풀렸나

  • read 트래픽이 여전히 같은 DB에 부담
  • read와 write가 같은 connection pool을 공유하지는 않음 (서비스가 분리되어 각자 HikariCP 풀). 하지만 DB CPU·디스크 IO는 공유

트레이드오프

  • 이득: 단순. 추가 인프라 0. 강한 일관성
  • 비용: 코드 중복 (entity 두 벌). read·write 양쪽 스케일은 같이 가야 함 (DB가 공통)

2.2 Stage 2: Primary/Replica

무엇이 풀렸나

  • query 부하가 replica로 분산. primary는 write 전용
  • replica는 여러 대 두면 read throughput 선형 증가

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

  • Postgres streaming replication은 보통 async. primary 커밋 후 replica 반영까지 ms~수십ms
  • 사용자가 방금 예약한 좌석이 새로고침에서 잠시 안 보임

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.

다른 시점 문제

  • replica 갯수 증가 시 어느 replica로 routing? round-robin / 가중치 / 지연 적은 곳?
  • replica 한 대 죽으면? load balancer health check + failover

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

여기까지 가면 CQRS의 본래 형태에 가깝다.

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

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

예시: command DB는 정규화

sql
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 — "사용자별 예약 화면" 한 번에:

sql
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

또는 좌석맵 전용:

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

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

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

text
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 패턴 자세히

문제:

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

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

kotlin
@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 디렉토리 구조

두 서비스 거의 대칭이다.

text
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개 엔티티가 두 패키지에 클래스 두 벌로 존재한다.

kotlin
// 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,
)

작은 차이도 의도적

  • command: statusvar (변경 가능)
  • query: statusval (불변)

표면적 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:

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

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

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

이게 주는 것

  • 스키마 권한은 command 한 군데로 집중. query는 마음대로 못 만듦
  • 부팅 순서 강제: command 먼저 → query 나중. query가 먼저 뜨면 테이블이 없어서 validate 실패
  • "공짜 CI": command와 query 엔티티 정의가 어긋나면 query 부팅이 거부. 사람이 한쪽만 고치는 실수 즉시 발견

검증된 동작

두 서비스를 띄울 때:

text
[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:

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

의미

  • HikariCP가 모든 connection을 READ ONLY 트랜잭션으로 시작
  • write SQL이 와도 DB가 거부 (ERROR: cannot execute INSERT in a read-only transaction)
  • 누군가 실수로 query에 INSERT 코드를 넣어도 런타임에 차단

Stage 2 갈 때

datasource URL만 replica host로 바꾸면 끝:

diff
- 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에:

kotlin
@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:

kotlin
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:

kotlin
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():

kotlin
@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():

kotlin
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 조립
}

같은 데이터를 다루지만:

  • command: 트랜잭션, 락, 상태 머신
  • query: stateless, projection, view 조립

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

3.8 DTO도 책임이 다름

command DTO:

kotlin
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:

kotlin
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 = 모델 분리. "read는 write와 다른 모양일 수 있다"
  • Event Sourcing = 저장 방식. "현재 상태가 아니라 이벤트의 합을 저장"

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

4.2 Event Sourcing 핵심

전통 (state-based):

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

Event-sourced:

text
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:

kotlin
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 콤보

text
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 우리는 안 쓴다 (지금)

  • 학습 단계가 너무 큼 (Stage 3보다 더 위)
  • 우리 도메인(예매)은 state-based로 충분
  • Event Sourcing은 audit·history가 본질적 요구일 때(금융, 보험, 의료) 더 합리적

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 테이블.

sql
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)

텍스트 검색이 필요한 도메인. "콘서트 제목으로 검색".

  • Worker가 ReservationCreated 받으면 Elasticsearch 문서도 갱신

5.4 in-memory cache (Redis)

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

  • 우리 다이어그램의 "잔여좌석 Redis Seat Counter"가 이 패턴

5.5 Materialized View (DB 내장)

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

  • 단점: 갱신 시점 제어 어려움 (REFRESH MATERIALIZED VIEW 수동)
  • 단순 case에 적합

5.6 컬럼형 DB (ClickHouse, BigQuery)

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

  • write 모델과 완전 분리. 분석 전용

5.7 그래프 DB (Neo4j)

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

  • 우리 도메인엔 부적합

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


§6. Eventual Consistency 다루기

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

6.1 무엇이 보장 안 되는가

  • write 직후 같은 데이터 read = 옛 값일 수 있음
  • 두 사용자가 거의 동시에 같은 좌석 조회 = 한 명은 AVAILABLE, 한 명은 RESERVED 볼 수 있음
  • 사용자가 새로고침하면 안 보이던 게 보임

6.2 대응 패턴

Read-Your-Writes

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

kotlin
class SmartRouter {
    fun route(userId: String): DataSource = if (recentlyWrote(userId)) PRIMARY else REPLICA
}
  • recentlyWrote()는 Redis나 session에 마지막 write timestamp 저장. N초 안이면 true

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

복잡하다. Cassandra/CockroachDB가 내부적으로 한다. 우리 학습 범위 밖이다.

Optimistic UI

  • UI가 응답 받기 전에 "예약 성공한 척" 표시
  • 실제 응답 오면 confirm. 실패면 rollback + 안내
  • 사용자 체감 latency = 0

Compensating Action

  • "예약 성공 → 결제 실패"가 발생하면 "예약 취소" 보상 액션 발행
  • Saga 패턴의 핵심

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

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

  • 좌석맵 조회: eventual OK. 잠시 옛 정보 보여줘도 다음 클릭 때 정확하면 됨
  • 내 예약 목록: read-your-writes 적용. 본인 거니까 즉시 보여야 함
  • 좌석 lock 단계: 강한 일관성 필수. → Redis SETNX 같은 단일 진실 출처가 필요

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


§7. 트레이드오프 정리

CQRS가 주는 것

  • read·write 독립 스케일
  • 모델·DB·기술 스택 분리
  • 장애 격리 (read가 망가져도 write 살아있음)
  • 코드 책임 명확
  • read 성능 자유 (projection 마음대로)

CQRS가 가져오는 비용

  • 코드 중복 (entity, dto, repository)
  • 운영 복잡도 (서비스 N개, DB N개)
  • eventual consistency
  • 동기화 메커니즘 학습 (Kafka, outbox)
  • 디버깅 어려움 ("왜 query에서 안 보이지?")

안 해야 할 때

  • 단순 CRUD 앱
  • 트래픽 작아 read·write 둘 다 한 DB로 충분
  • 팀이 작아 운영 부담 못 짊
  • **"우리도 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

  • Stage 1에서 우리 query 서비스에 hikari read-only: true 거는 게 효과가 있나?
  • "같은 DB 쓰는 CQRS는 CQRS가 아니다" 동의/반대?
  • entity를 두 패키지에 중복 정의하는 비용 vs 이득. 언제 결정 뒤집을까?
  • Event Sourcing을 우리 티켓팅에 도입한다면 첫 이벤트 5개를 무엇으로 정할까?
  • replica lag이 5초인데 사용자가 새로고침해서 자기 예약이 안 보임. 어떻게 대응?
  • query DB를 별도로 가져갈 때 첫 projection 테이블로 무엇을 만들면 학습 가치 클까?

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

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

text
[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를 본다는 사실의 명백한 증거

토론하면 좋은 코드 포인트

  • entity 두 벌의 차이가 단 한 글자(var vs val). "이 한 글자가 무엇을 의미하는가?"
  • command의 @Transactional vs query의 @Transactional(readOnly=true). "readOnly가 정확히 무엇을 약속하나?"
  • query repository는 findAllByIdIn@Lock이 없다. "왜 query에는 락 메서드 자체를 노출 안 했나?"
  • command DTO ReservationResponse와 query DTO ReservationView가 매우 비슷한데 클래스가 다름. "미래에 어느 쪽이 먼저 다른 모양으로 갈라질까?"
  • mock-data.sql 한 파일이 두 서비스의 진실 출처. "이게 깨질 때 (Stage 2/3) 어떻게 진실을 옮길까?"

마치며

CQRS를 짧게 말하면 읽기와 쓰기는 다른 곡선을 그리고, 그래서 모델도 분리할 수 있다는 것이다.

처음 CQRS를 만나면 가장 자주 빠지는 함정은 두 가지다.

  1. "무조건 Stage 3까지 가야 한다" — Stage 1만으로도 CQRS이고, 작은 시스템엔 Stage 1이 정답일 때가 많다
  2. "CQRS = Event Sourcing" — 다른 패턴이다. 같이 쓰면 강력하지만, 분리해서 도입할 수 있다

이 글은 Stage 1을 충실히 다루고, Stage 2/3에 가면 무엇이 풀리고 무엇이 새로 어려워지는지를 함께 짚었다. 우리 ticket-command/query 구현이 "가장 작은 CQRS"의 reference다. 한 글자(var vs val)의 차이가 무엇을 의미하는지, @Transactional(readOnly=true)가 나중에 어떤 routing을 위한 준비인지를 짚어보면, 지금 무심코 짠 코드 한 줄이 다음 단계의 선택지를 넓혀둔다는 걸 알 수 있다.

Kafka 글과 함께 읽으면, Stage 3에서 Kafka가 어떻게 command와 query의 다리가 되는지가 더 선명해진다.

#CQRS#Architecture#EventSourcing#Spring#Kotlin#Database#Replication

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js