CQRS 깊은 이야기: 같은 DB부터 Event Sourcing까지 단계별 진화
CQRS = '바꾸는 길과 보는 길을 갈라놓자'. read/write 분리의 본질부터 Stage 1(같은 DB) → Stage 2(Primary/Replica) → Stage 3(별도 DB + Kafka 동기화)까지 단계별 진화를 ticket-command/query 실제 구현으로 풀어 정리했다.
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:
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이 아니라 스펙트럼이다. 우리 프로젝트의 진화 경로는 이렇다.
[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가지
- 읽기 후 쓰기 (Read-Your-Writes) — 같은 사용자의 직후 read는 primary로
- Sticky Session — 같은 사용자 N분간 primary read
- Write-through cache — write 직후 응답을 클라이언트가 보유 + Redis에도 캐싱
- 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는 정규화
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 테이블 갱신.
새로 등장한 문제들
- Eventual consistency — write와 read 사이 지연이 더 커짐 (Kafka publish + consume + DB write)
- Out-of-order 처리 — Kafka는 partition 내 순서 보장. 다른 partition 이벤트 순서는 어플리케이션이 책임
- 중복 처리 — at-least-once. Worker가 같은 메시지 두 번 받아도 안전한 코드(idempotent) 필요
- 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,
)작은 차이도 의도적
- command:
status가var(변경 가능) - query:
status가val(불변)
표면적 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 한 군데로 집중. query는 마음대로 못 만듦
- 부팅 순서 강제: command 먼저 → query 나중. query가 먼저 뜨면 테이블이 없어서 validate 실패
- "공짜 CI": command와 query 엔티티 정의가 어긋나면 query 부팅이 거부. 사람이 한쪽만 고치는 실수 즉시 발견
검증된 동작
두 서비스를 띄울 때:
[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로 마킹의미
- HikariCP가 모든 connection을
READ ONLY트랜잭션으로 시작 - write SQL이 와도 DB가 거부 (
ERROR: cannot execute INSERT in a read-only transaction) - 누군가 실수로 query에 INSERT 코드를 넣어도 런타임에 차단
Stage 2 갈 때
datasource URL만 replica host로 바꾸면 끝:
- url: jdbc:postgresql://primary-host:5432/ticket_db
+ url: jdbc:postgresql://replica-host:5432/ticket_dbread-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 = ...
}효과
- JPA가 dirty checking 생략 — flush 안 함. 약간의 CPU 절약
- 트랜잭션 커밋 비용 감소 — write 없는 트랜잭션은 commit 사실상 no-op
- 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 조립
}같은 데이터를 다루지만:
- command: 트랜잭션, 락, 상태 머신
- query: stateless, projection, view 조립
코드 모양이 완전히 다르다. 이게 분리의 본질이다.
3.8 DTO도 책임이 다름
command DTO:
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:
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):
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 우리는 안 쓴다 (지금)
- 학습 단계가 너무 큼 (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 테이블.
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로.
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 코드 읽을 때 권장 순서.
[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 두 벌의 차이가 단 한 글자(
varvsval). "이 한 글자가 무엇을 의미하는가?" - command의
@Transactionalvs query의@Transactional(readOnly=true). "readOnly가 정확히 무엇을 약속하나?" - query repository는
findAllByIdIn의@Lock이 없다. "왜 query에는 락 메서드 자체를 노출 안 했나?" - command DTO
ReservationResponse와 query DTOReservationView가 매우 비슷한데 클래스가 다름. "미래에 어느 쪽이 먼저 다른 모양으로 갈라질까?" - mock-data.sql 한 파일이 두 서비스의 진실 출처. "이게 깨질 때 (Stage 2/3) 어떻게 진실을 옮길까?"
마치며
CQRS를 짧게 말하면 읽기와 쓰기는 다른 곡선을 그리고, 그래서 모델도 분리할 수 있다는 것이다.
처음 CQRS를 만나면 가장 자주 빠지는 함정은 두 가지다.
- "무조건 Stage 3까지 가야 한다" — Stage 1만으로도 CQRS이고, 작은 시스템엔 Stage 1이 정답일 때가 많다
- "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의 다리가 되는지가 더 선명해진다.