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) 위에서 동작.
문제:
- 좌석맵 한 번 그리려면 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_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 = ...
}
효과
- 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 (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 = 모델 분리. "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)
texual search가 필요한 도메인. "콘서트 제목으로 검색".
- 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
자세한 건 discussion §2 참조.
- 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) 어떻게 진실을 옮길까?"