CQRS 패턴: 읽기와 쓰기를 분리하면 생기는 일
1. 프롤로그 — "왜 조회 API가 이렇게 느려?"
서비스가 성장하면서 한 번쯤은 이런 상황을 겪게 된다.
주문 목록 조회 API가 점점 느려진다. 인덱스도 걸었고, 쿼리도 최적화했는데 여전히 느리다. 알고 보니 이유가 있다. 주문 도메인 객체(Order)는 비즈니스 로직을 잔뜩 품고 있는 복잡한 녀석인데, 조회할 때도 그 모델을 그대로 쓰고 있었던 것이다.
- 주문 생성 시에는 재고 확인, 결제 검증, 쿠폰 적용, 알림 발송 등 수십 가지 비즈니스 규칙이 필요하다.
- 주문 목록 조회 시에는 그냥
id,status,created_at,total_price4개 컬럼만 있으면 된다.
근데 같은 Order 모델을 쓰니까, 조회할 때도 불필요한 조인이 발생하고, ORM이 필요 없는 관계까지 eager load하고, 결과적으로 DB가 과부하된다.
이게 CQRS가 해결하려는 핵심 문제다.
2. CQRS가 뭔데?
CQRS는 Command Query Responsibility Segregation의 약자다. 직역하면 "명령과 조회의 책임 분리."
Greg Young이 2010년에 정식으로 명명했고, Martin Fowler도 자신의 블로그에서 깊게 다룬 패턴이다.
핵심 아이디어는 단순하다.
시스템의 상태를 변경하는 작업(Command)과 상태를 읽는 작업(Query)은 서로 다른 모델을 사용해야 한다.
이게 끝이다. 나머지는 다 이 아이디어를 어디까지 밀어붙이느냐의 문제다.
Command vs Query 구분
| 구분 | Command | Query |
|---|---|---|
| 목적 | 상태 변경 | 상태 조회 |
| 반환값 | 없음 (또는 ID만) | 데이터 |
| 예시 | PlaceOrder, CancelOrder | GetOrderList, GetOrderDetail |
| 부작용 | 있음 (DB 변경, 이벤트 발행) | 없음 (side-effect free) |
이건 사실 버트런드 마이어(Bertrand Meyer)가 1988년에 제안한 CQS(Command Query Separation) 원칙에서 온 거다. CQRS는 CQS를 객체 수준이 아닌 시스템/아키텍처 수준으로 끌어올린 것이다.
3. 단일 모델의 문제: 왜 분리해야 하나?
전통적인 CRUD 아키텍처에서는 하나의 도메인 모델이 모든 역할을 담당한다.
// 전통적인 단일 모델
class Order {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
shippingAddress: Address;
paymentInfo: PaymentInfo;
discountCoupons: Coupon[];
// 비즈니스 로직
place(): void { /* 재고 확인, 결제 처리, 알림 발송... */ }
cancel(): void { /* 환불 처리, 재고 복구... */ }
ship(): void { /* 배송 상태 업데이트... */ }
// 조회를 위한 메서드들도 같이 있음
getSummary(): OrderSummary { /* ... */ }
getDetailView(): OrderDetailView { /* ... */ }
}
문제가 뭔지 보이는가?
- 쓰기 최적화 vs 읽기 최적화가 충돌: 쓰기는 정규화된 테이블 구조가 좋다. 읽기는 비정규화된 뷰가 빠르다. 둘 다 잡으려면 둘 다 망한다.
- 캐싱이 어렵다: 데이터가 변경될 수 있는 모델을 그대로 캐싱하면 일관성 문제가 생긴다.
- 확장성이 다르다: 대부분의 시스템은 읽기 트래픽이 쓰기보다 10배~100배 많다. 근데 같은 모델을 쓰면 같은 DB 인스턴스에서 경쟁한다.
- 복잡한 조회 요구사항: 대시보드, 리포트, 통계 — 이런 건 여러 테이블을 복잡하게 조인해야 한다. 이걸 도메인 모델에 우겨넣으면 모델이 지저분해진다.
4. CQRS 구현의 3단계
CQRS는 "all or nothing"이 아니다. 얼마나 분리할지에 따라 스펙트럼이 있다.
Level 1: 코드 수준 분리 (가장 단순)
같은 DB를 쓰되, Command와 Query를 처리하는 코드를 물리적으로 분리한다.
// commands/place-order.command.ts
export interface PlaceOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddressId: string;
couponCode?: string;
}
// command-handlers/place-order.handler.ts
export class PlaceOrderHandler {
constructor(
private readonly orderRepository: OrderRepository,
private readonly inventoryService: InventoryService,
private readonly paymentService: PaymentService,
) {}
async handle(command: PlaceOrderCommand): Promise<string> {
// 비즈니스 로직 집중
await this.inventoryService.reserve(command.items);
const order = Order.create(command);
await this.paymentService.authorize(order);
await this.orderRepository.save(order);
return order.id;
}
}
// queries/get-order-list.query.ts
export interface GetOrderListQuery {
customerId: string;
page: number;
pageSize: number;
status?: OrderStatus;
}
export interface OrderListItem {
id: string;
status: OrderStatus;
totalPrice: number;
createdAt: Date;
itemCount: number;
}
// query-handlers/get-order-list.handler.ts
export class GetOrderListHandler {
constructor(private readonly db: DatabaseConnection) {}
async handle(query: GetOrderListQuery): Promise<OrderListItem[]> {
// 조회에 최적화된 날쿼리 — 도메인 객체 생성 없음
return this.db.query(
`SELECT
o.id,
o.status,
o.total_price,
o.created_at,
COUNT(oi.id) as item_count
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.customer_id = $1
AND ($3::text IS NULL OR o.status = $3)
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT $2 OFFSET ${(query.page - 1) * query.pageSize}`,
[query.customerId, query.pageSize, query.status ?? null]
);
}
}
이 레벨만 해도 코드베이스가 훨씬 명확해진다. "이 코드는 뭔가를 바꾸는 코드야" vs "이 코드는 뭔가를 읽는 코드야"가 구조적으로 분리된다.
Level 2: 읽기 모델 분리 (중간 수준)
읽기 전용 데이터 모델(Read Model, View Model)을 별도로 관리한다. 쓰기 이벤트가 발생할 때마다 읽기 모델을 업데이트한다.
// 읽기 모델 — 조회에 최적화된 비정규화 테이블
// CREATE TABLE order_read_models (
// id UUID PRIMARY KEY,
// customer_id UUID,
// customer_name TEXT, -- 비정규화: customers 테이블 조인 불필요
// status TEXT,
// total_price DECIMAL,
// item_count INTEGER,
// item_names TEXT[], -- 비정규화: 자주 쓰는 데이터 미리 조합
// created_at TIMESTAMP
// );
// 이벤트 핸들러가 읽기 모델을 동기화
export class OrderReadModelProjector {
constructor(private readonly db: DatabaseConnection) {}
async onOrderPlaced(event: OrderPlacedEvent): Promise<void> {
await this.db.query(
`INSERT INTO order_read_models
(id, customer_id, customer_name, status, total_price, item_count, item_names, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
event.orderId,
event.customerId,
event.customerName,
'PLACED',
event.totalPrice,
event.items.length,
event.items.map(i => i.name),
event.occurredAt,
]
);
}
async onOrderShipped(event: OrderShippedEvent): Promise<void> {
await this.db.query(
`UPDATE order_read_models SET status = 'SHIPPED' WHERE id = $1`,
[event.orderId]
);
}
}
이제 GetOrderListHandler는 order_read_models 테이블만 조회한다. 조인 없음, ORM 없음, 초고속.
Level 3: 저장소 분리 (풀 CQRS)
Command 저장소(Write DB)와 Query 저장소(Read DB)를 물리적으로 분리한다.
[Client]
│
├── Command ──▶ [Write API] ──▶ [Write DB (PostgreSQL)]
│ │
│ └── 이벤트 발행 ──▶ [Message Queue]
│ │
│ ▼
│ [Read Model Updater]
│ │
│ ▼
└── Query ───▶ [Read API] ──▶ [Read DB (Redis/Elasticsearch/Read Replica)]
- Write DB: 정규화, 트랜잭션 보장, 복잡한 비즈니스 로직
- Read DB: 비정규화, 빠른 조회, 검색 최적화 (Elasticsearch), 캐시 (Redis)
이 수준이 되면 각각 독립적으로 스케일아웃이 가능하다. 읽기 트래픽이 폭발하면 Read DB 인스턴스만 늘리면 된다.
5. CQRS와 이벤트 소싱의 관계
CQRS와 이벤트 소싱(Event Sourcing)은 자주 함께 언급되는데, 서로 독립적인 패턴이다. 그냥 궁합이 잘 맞을 뿐이다.
이벤트 소싱은 시스템의 현재 상태를 저장하는 게 아니라, 상태 변경 이력(이벤트)을 저장하는 패턴이다.
// 이벤트 소싱 없이 (현재 상태 저장)
// orders 테이블: { id, status: 'SHIPPED', total_price: 50000, ... }
// 이벤트 소싱 (이벤트 이력 저장)
// order_events 테이블:
// { order_id, type: 'OrderPlaced', payload: {...}, timestamp: t1 }
// { order_id, type: 'PaymentMade', payload: {...}, timestamp: t2 }
// { order_id, type: 'OrderShipped', payload: {...}, timestamp: t3 }
// 현재 상태는 이벤트를 replay해서 계산
class OrderAggregate {
private state: OrderState = { status: 'NEW', totalPrice: 0 };
apply(event: OrderEvent): void {
switch (event.type) {
case 'OrderPlaced':
this.state.status = 'PLACED';
this.state.totalPrice = event.payload.totalPrice;
break;
case 'PaymentMade':
this.state.status = 'PAID';
break;
case 'OrderShipped':
this.state.status = 'SHIPPED';
break;
}
}
static rehydrate(events: OrderEvent[]): OrderAggregate {
const order = new OrderAggregate();
events.forEach(e => order.apply(e));
return order;
}
}
이벤트 소싱을 쓰면 CQRS와 자연스럽게 연결된다. 이벤트가 발행될 때마다 Read Model을 업데이트하는 Projector를 붙이면 된다. 또한 이벤트 이력이 있으니 과거 어느 시점의 상태도 재현할 수 있고, 감사(Audit) 로그가 자동으로 생긴다.
6. 실전 아키텍처: NestJS + CQRS 모듈
NestJS에는 @nestjs/cqrs 패키지가 있어서 CQRS 패턴을 깔끔하게 구현할 수 있다.
// place-order.command.ts
import { ICommand } from '@nestjs/cqrs';
export class PlaceOrderCommand implements ICommand {
constructor(
public readonly customerId: string,
public readonly items: OrderItemDto[],
public readonly shippingAddressId: string,
) {}
}
// place-order.handler.ts
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: PlaceOrderCommand): Promise<string> {
const order = Order.create(command.customerId, command.items);
await this.orderRepo.save(order);
// 도메인 이벤트 발행
this.eventBus.publish(new OrderPlacedEvent(order.id, order.customerId));
return order.id;
}
}
// get-orders.query.ts
import { IQuery } from '@nestjs/cqrs';
export class GetOrdersQuery implements IQuery {
constructor(
public readonly customerId: string,
public readonly page: number,
) {}
}
// get-orders.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
@QueryHandler(GetOrdersQuery)
export class GetOrdersHandler implements IQueryHandler<GetOrdersQuery> {
constructor(private readonly readDb: ReadDatabaseService) {}
async execute(query: GetOrdersQuery): Promise<OrderListItem[]> {
return this.readDb.getOrdersByCustomer(query.customerId, query.page);
}
}
// orders.controller.ts
@Controller('orders')
export class OrdersController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async placeOrder(@Body() dto: PlaceOrderDto, @CurrentUser() user: User) {
const orderId = await this.commandBus.execute(
new PlaceOrderCommand(user.id, dto.items, dto.shippingAddressId)
);
return { orderId };
}
@Get()
async getOrders(@Query() query: GetOrdersDto, @CurrentUser() user: User) {
return this.queryBus.execute(
new GetOrdersQuery(user.id, query.page ?? 1)
);
}
}
컨트롤러가 얼마나 깔끔해졌는지 보이는가? 비즈니스 로직이 없다. 그냥 Command/Query를 만들어서 버스에 태울 뿐이다.
7. CQRS가 오버킬인 경우
CQRS가 만능은 아니다. 아래 상황에서는 오히려 복잡도만 늘어난다.
| 상황 | CQRS 적합도 |
|---|---|
| CRUD가 전부인 간단한 앱 | 부적합 — 단순 Repository 패턴으로 충분 |
| 팀이 작고 빠른 프로토타이핑 필요 | 부적합 — 초기 오버헤드가 큼 |
| 읽기/쓰기 트래픽이 비슷한 경우 | 보통 — 분리 이득이 크지 않음 |
| 복잡한 비즈니스 도메인 (DDD) | 적합 — 도메인 로직과 조회 로직 명확 분리 |
| 읽기 트래픽이 압도적으로 많은 경우 | 매우 적합 — Read DB 독립 스케일아웃 |
| 이벤트 기반 마이크로서비스 | 매우 적합 — 서비스 간 느슨한 결합 |
특히 주의할 점: Eventual Consistency. 쓰기가 발생한 후 읽기 모델이 업데이트되기까지 약간의 지연이 있다. 사용자가 주문을 하고 바로 목록을 새로 고침했을 때 방금 주문이 안 보일 수 있다. 이게 비즈니스적으로 허용되는지 반드시 확인해야 한다.
8. 결론
CQRS는 복잡성을 없애주는 패턴이 아니라, 복잡성을 적재적소에 배치하는 패턴이다.
- 쓰기 모델은 비즈니스 규칙과 일관성에 집중
- 읽기 모델은 성능과 사용자 경험에 집중
단순한 코드 분리(Level 1)부터 시작해서 필요할 때마다 점진적으로 분리 수준을 높여가는 게 현실적인 접근이다. 처음부터 풀 CQRS를 구축하려고 하면 팀이 지쳐서 중간에 포기하게 된다.
"이 도메인에서 읽기와 쓰기의 복잡도 차이가 충분히 크냐?" — 이 질문이 CQRS 도입 여부를 결정하는 핵심이다.