
CQRS 패턴: 읽기와 쓰기를 분리하면 생기는 일
단일 모델이 복잡해질수록 읽기와 쓰기가 서로 발목을 잡는다. CQRS가 그 문제를 어떻게 해결하는지, 단순한 분리부터 이벤트 소싱까지 TypeScript 예제로 완벽 정리.

단일 모델이 복잡해질수록 읽기와 쓰기가 서로 발목을 잡는다. CQRS가 그 문제를 어떻게 해결하는지, 단순한 분리부터 이벤트 소싱까지 TypeScript 예제로 완벽 정리.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

직접 가기 껄끄러울 때 프록시가 대신 갔다 옵니다. 내 정체를 숨기려면 Forward Proxy, 서버를 보호하려면 Reverse Proxy. 같은 대리인인데 누구 편이냐가 다릅니다.

서비스가 성장하면서 한 번쯤은 이런 상황을 겪게 된다.
주문 목록 조회 API가 점점 느려진다. 인덱스도 걸었고, 쿼리도 최적화했는데 여전히 느리다. 알고 보니 이유가 있다. 주문 도메인 객체(Order)는 비즈니스 로직을 잔뜩 품고 있는 복잡한 녀석인데, 조회할 때도 그 모델을 그대로 쓰고 있었던 것이다.
id, status, created_at, total_price 4개 컬럼만 있으면 된다.근데 같은 Order 모델을 쓰니까, 조회할 때도 불필요한 조인이 발생하고, ORM이 필요 없는 관계까지 eager load하고, 결과적으로 DB가 과부하된다.
CQRS는 Command Query Responsibility Segregation의 약자다. 직역하면 "명령과 조회의 책임 분리."
Greg Young이 2010년에 정식으로 명명했고, Martin Fowler도 자신의 블로그에서 깊게 다룬 패턴이다.
핵심 아이디어는 단순하다.
시스템의 상태를 변경하는 작업(Command)과 상태를 읽는 작업(Query)은 서로 다른 모델을 사용해야 한다.
이게 끝이다. 나머지는 다 이 아이디어를 어디까지 밀어붙이느냐의 문제다.
| 구분 | Command | Query |
|---|---|---|
| 목적 | 상태 변경 | 상태 조회 |
| 반환값 | 없음 (또는 ID만) | 데이터 |
| 예시 | PlaceOrder, CancelOrder | GetOrderList, GetOrderDetail |
| 부작용 | 있음 (DB 변경, 이벤트 발행) | 없음 (side-effect free) |
이건 사실 버트런드 마이어(Bertrand Meyer)가 1988년에 제안한 CQS(Command Query Separation) 원칙에서 온 거다. CQRS는 CQS를 객체 수준이 아닌 시스템/아키텍처 수준으로 끌어올린 것이다.
전통적인 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 { /* ... */ }
}
문제가 뭔지 보이는가?
CQRS는 "all or nothing"이 아니다. 얼마나 분리할지에 따라 스펙트럼이 있다.
같은 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 "이 코드는 뭔가를 읽는 코드야"가 구조적으로 분리된다.
읽기 전용 데이터 모델(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 없음, 초고속.
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)]
이 수준이 되면 각각 독립적으로 스케일아웃이 가능하다. 읽기 트래픽이 폭발하면 Read DB 인스턴스만 늘리면 된다.
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) 로그가 자동으로 생긴다.
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를 만들어서 버스에 태울 뿐이다.
CQRS가 만능은 아니다. 아래 상황에서는 오히려 복잡도만 늘어난다.
| 상황 | CQRS 적합도 |
|---|---|
| CRUD가 전부인 간단한 앱 | 부적합 — 단순 Repository 패턴으로 충분 |
| 팀이 작고 빠른 프로토타이핑 필요 | 부적합 — 초기 오버헤드가 큼 |
| 읽기/쓰기 트래픽이 비슷한 경우 | 보통 — 분리 이득이 크지 않음 |
| 복잡한 비즈니스 도메인 (DDD) | 적합 — 도메인 로직과 조회 로직 명확 분리 |
| 읽기 트래픽이 압도적으로 많은 경우 | 매우 적합 — Read DB 독립 스케일아웃 |
| 이벤트 기반 마이크로서비스 | 매우 적합 — 서비스 간 느슨한 결합 |
특히 주의할 점: Eventual Consistency. 쓰기가 발생한 후 읽기 모델이 업데이트되기까지 약간의 지연이 있다. 사용자가 주문을 하고 바로 목록을 새로 고침했을 때 방금 주문이 안 보일 수 있다. 이게 비즈니스적으로 허용되는지 반드시 확인해야 한다.
CQRS는 복잡성을 없애주는 패턴이 아니라, 복잡성을 적재적소에 배치하는 패턴이다.
단순한 코드 분리(Level 1)부터 시작해서 필요할 때마다 점진적으로 분리 수준을 높여가는 게 현실적인 접근이다. 처음부터 풀 CQRS를 구축하려고 하면 팀이 지쳐서 중간에 포기하게 된다.
"이 도메인에서 읽기와 쓰기의 복잡도 차이가 충분히 크냐?" — 이 질문이 CQRS 도입 여부를 결정하는 핵심이다.