NestJS CQRS란? Command와 Query를 분리하는 이유
CQRS(Command Query Responsibility Segregation)는 명령(쓰기)과 조회(읽기)의 책임을 분리하는 아키텍처 패턴이다. 전통적인 CRUD 아키텍처에서는 하나의 모델이 읽기와 쓰기를 모두 담당하지만, 도메인이 복잡해질수록 이 접근은 한계에 부딪힌다. NestJS는 @nestjs/cqrs 패키지를 통해 CQRS 패턴을 프레임워크 수준에서 지원한다.
이 글에서는 NestJS에서 CQRS를 실전 적용하는 방법을 Command, Query, Event, Saga 전체 흐름으로 다룬다. 단순 예제가 아닌 실무에서 마주치는 패턴과 주의점까지 포함한다.
패키지 설치와 모듈 설정
npm install @nestjs/cqrs
모듈에 CqrsModule을 import하면 CommandBus, QueryBus, EventBus가 DI 컨테이너에 등록된다.
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { OrderController } from './order.controller';
import { CreateOrderHandler } from './commands/create-order.handler';
import { GetOrderHandler } from './queries/get-order.handler';
import { OrderCreatedHandler } from './events/order-created.handler';
import { OrderSaga } from './sagas/order.saga';
@Module({
imports: [CqrsModule],
controllers: [OrderController],
providers: [
CreateOrderHandler,
GetOrderHandler,
OrderCreatedHandler,
OrderSaga,
],
})
export class OrderModule {}
Command 정의와 Handler 구현
Command는 시스템 상태를 변경하는 의도를 표현하는 객체다. 값 객체(Value Object)처럼 불변으로 만들고, Handler가 실제 비즈니스 로직을 수행한다.
// commands/create-order.command.ts
export class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: { productId: string; quantity: number }[],
public readonly shippingAddress: string,
) {}
}
// commands/create-order.handler.ts
import { CommandHandler, ICommandHandler, EventPublisher } from '@nestjs/cqrs';
import { CreateOrderCommand } from './create-order.command';
import { Order } from '../models/order.model';
import { OrderRepository } from '../repositories/order.repository';
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
private readonly repository: OrderRepository,
private readonly publisher: EventPublisher,
) {}
async execute(command: CreateOrderCommand): Promise<string> {
const { userId, items, shippingAddress } = command;
// Aggregate Root 생성
const order = this.publisher.mergeObjectContext(
Order.create(userId, items, shippingAddress),
);
await this.repository.save(order);
// 도메인 이벤트 발행
order.commit();
return order.getId();
}
}
Aggregate Root와 도메인 이벤트
CQRS에서 Aggregate Root는 AggregateRoot를 상속하며, 상태 변경 시 이벤트를 내부적으로 적재한다. commit() 호출 시점에 EventBus로 발행된다.
import { AggregateRoot } from '@nestjs/cqrs';
import { OrderCreatedEvent } from '../events/order-created.event';
import { OrderCancelledEvent } from '../events/order-cancelled.event';
import { v4 as uuid } from 'uuid';
export class Order extends AggregateRoot {
private id: string;
private userId: string;
private status: 'PENDING' | 'CONFIRMED' | 'CANCELLED';
private items: { productId: string; quantity: number }[];
private totalAmount: number;
static create(
userId: string,
items: { productId: string; quantity: number }[],
shippingAddress: string,
): Order {
const order = new Order();
order.id = uuid();
order.userId = userId;
order.status = 'PENDING';
order.items = items;
order.totalAmount = 0; // 실제로는 가격 계산 로직
// 이벤트 적재 (아직 발행되지 않음)
order.apply(new OrderCreatedEvent(order.id, userId, items, shippingAddress));
return order;
}
cancel(reason: string): void {
if (this.status === 'CANCELLED') {
throw new Error('이미 취소된 주문입니다');
}
this.status = 'CANCELLED';
this.apply(new OrderCancelledEvent(this.id, this.userId, reason));
}
getId(): string {
return this.id;
}
}
Query 분리: 읽기 전용 경로
Query는 상태를 변경하지 않는 조회 요청이다. Command와 완전히 다른 경로를 타므로, 읽기에 최적화된 별도 모델이나 뷰를 사용할 수 있다.
// queries/get-order.query.ts
export class GetOrderQuery {
constructor(public readonly orderId: string) {}
}
// queries/get-order.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetOrderQuery } from './get-order.query';
import { OrderReadRepository } from '../repositories/order-read.repository';
@QueryHandler(GetOrderQuery)
export class GetOrderHandler implements IQueryHandler<GetOrderQuery> {
constructor(private readonly readRepo: OrderReadRepository) {}
async execute(query: GetOrderQuery) {
const order = await this.readRepo.findById(query.orderId);
if (!order) {
throw new Error('주문을 찾을 수 없습니다');
}
return order;
}
}
핵심은 읽기 저장소를 별도로 두는 것이다. 쓰기는 정규화된 RDB에, 읽기는 비정규화된 뷰나 Elasticsearch 같은 검색 엔진에 위임할 수 있다. 이 분리가 CQRS의 진정한 가치다.
Event Handler: 부수 효과 처리
// events/order-created.event.ts
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: { productId: string; quantity: number }[],
public readonly shippingAddress: string,
) {}
}
// events/order-created.handler.ts
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { OrderCreatedEvent } from './order-created.event';
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedHandler implements IEventHandler<OrderCreatedEvent> {
handle(event: OrderCreatedEvent): void {
// 읽기 모델 업데이트
console.log(`[ReadModel] 주문 ${event.orderId} 동기화`);
// 알림 발송, 재고 차감 등 부수 효과
// 각각 별도 Handler로 분리 가능
}
}
하나의 이벤트에 여러 Handler를 등록할 수 있다. 알림 발송, 읽기 모델 갱신, 로깅 등을 독립적으로 처리하면 관심사 분리가 명확해진다.
Saga: 이벤트 간 오케스트레이션
Saga는 이벤트 스트림을 관찰하고, 특정 이벤트에 반응하여 새로운 Command를 발행하는 오케스트레이터다. 분산 트랜잭션이나 보상 로직(compensating transaction) 구현에 핵심적이다.
import { Injectable } from '@nestjs/common';
import { Saga, ICommand, ofType } from '@nestjs/cqrs';
import { Observable, map, delay, filter } from 'rxjs';
import { OrderCreatedEvent } from '../events/order-created.event';
import { OrderCancelledEvent } from '../events/order-cancelled.event';
import { ReserveStockCommand } from '../../inventory/commands/reserve-stock.command';
import { ReleaseStockCommand } from '../../inventory/commands/release-stock.command';
import { SendNotificationCommand } from '../../notification/commands/send-notification.command';
@Injectable()
export class OrderSaga {
@Saga()
orderCreated = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(OrderCreatedEvent),
map((event) => new ReserveStockCommand(event.orderId, event.items)),
);
};
@Saga()
orderCancelled = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(OrderCancelledEvent),
map((event) => new ReleaseStockCommand(event.orderId)),
);
};
@Saga()
notifyOnOrder = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(OrderCreatedEvent),
delay(1000), // 1초 지연 후 알림
map((event) =>
new SendNotificationCommand(event.userId, `주문 ${event.orderId} 접수`)
),
);
};
}
Controller에서 Bus 사용하기
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { CreateOrderCommand } from './commands/create-order.command';
import { GetOrderQuery } from './queries/get-order.query';
@Controller('orders')
export class OrderController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async createOrder(@Body() dto: CreateOrderDto): Promise<{ orderId: string }> {
const orderId = await this.commandBus.execute(
new CreateOrderCommand(dto.userId, dto.items, dto.shippingAddress),
);
return { orderId };
}
@Get(':id')
async getOrder(@Param('id') id: string) {
return this.queryBus.execute(new GetOrderQuery(id));
}
}
Controller는 Bus에만 의존하므로 비즈니스 로직과 완전히 분리된다. Handler를 교체하거나 새로운 Handler를 추가해도 Controller 코드는 변경할 필요가 없다.
실전 패턴: UnhandledExceptionBus
NestJS CQRS v10+에서는 UnhandledExceptionBus로 Handler에서 발생한 예외를 전역 처리할 수 있다.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { UnhandledExceptionBus } from '@nestjs/cqrs';
import { Subject, takeUntil } from 'rxjs';
@Injectable()
export class CqrsExceptionFilter implements OnModuleInit {
private destroy$ = new Subject<void>();
constructor(private readonly unhandledExceptionBus: UnhandledExceptionBus) {}
onModuleInit() {
this.unhandledExceptionBus.pipe(takeUntil(this.destroy$)).subscribe((exceptionInfo) => {
console.error(
`[CQRS] Unhandled exception in ${exceptionInfo.cause}:`,
exceptionInfo.exception,
);
// Sentry, Datadog 등 외부 모니터링에 전송
});
}
}
CQRS 도입 시 주의점과 안티패턴
| 안티패턴 | 문제점 | 해결책 |
|---|---|---|
| Command에서 값 반환 | CQRS 원칙 위반, 읽기/쓰기 결합 | ID만 반환하고 상세는 Query로 조회 |
| 모든 CRUD에 CQRS 적용 | 불필요한 복잡성 증가 | 복잡한 도메인에만 선택적 적용 |
| Saga에서 직접 DB 접근 | 관심사 혼재, 테스트 어려움 | 항상 Command를 통해 상태 변경 |
| 이벤트에 과도한 데이터 | 결합도 증가, 직렬화 비용 | ID + 최소 필요 정보만 포함 |
CQRS + Event Sourcing 조합
CQRS와 Event Sourcing은 별개 패턴이지만, 함께 사용하면 시너지가 크다. Event Sourcing은 상태 변경을 이벤트 시퀀스로 저장하고, CQRS의 읽기 모델은 이 이벤트를 프로젝션하여 최적화된 뷰를 만든다. NestJS에서는 헥사고날 아키텍처와 결합하면 도메인 계층의 순수성을 유지하면서 CQRS를 적용할 수 있다.
분산 환경에서는 Temporal 워크플로와 CQRS Saga를 조합하여 장기 실행 프로세스를 안정적으로 관리할 수 있다.
마무리
NestJS CQRS는 단순한 패턴 분리를 넘어 확장 가능한 도메인 중심 아키텍처의 기반이 된다. Command로 의도를 명확히 하고, Query로 읽기를 최적화하며, Event와 Saga로 느슨한 결합을 실현한다. 모든 프로젝트에 필요한 것은 아니지만, 복잡한 비즈니스 로직과 높은 읽기/쓰기 비율 차이가 있는 시스템에서는 강력한 무기가 된다.