CQRS란? 왜 필요한가
CQRS(Command Query Responsibility Segregation)는 쓰기(Command)와 읽기(Query)의 모델을 분리하는 아키텍처 패턴이다. 전통적인 CRUD에서는 하나의 모델이 생성·조회·수정·삭제를 모두 처리하지만, CQRS에서는 쓰기 최적화된 모델과 읽기 최적화된 모델을 독립적으로 설계한다.
복잡한 도메인에서 조회 성능과 비즈니스 로직의 복잡도를 동시에 해결할 수 있으며, 이벤트 소싱(Event Sourcing)과 결합하면 시스템의 모든 상태 변화를 추적할 수 있다. NestJS는 @nestjs/cqrs 패키지로 이 패턴을 공식 지원한다.
NestJS CQRS 아키텍처
| 구성 요소 | 역할 | 예시 |
|---|---|---|
| Command | 상태 변경 의도 (DTO) | CreateOrderCommand |
| CommandHandler | Command 실행 로직 | CreateOrderHandler |
| Query | 데이터 조회 요청 | GetOrderQuery |
| QueryHandler | Query 실행 로직 | GetOrderHandler |
| Event | 발생한 사실 (과거형) | OrderCreatedEvent |
| EventHandler (Saga) | 이벤트 반응 로직 | OrderCreatedSaga |
설치와 모듈 설정
# 설치
npm install @nestjs/cqrs
# order.module.ts
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 { GetOrderListHandler } from './queries/get-order-list.handler';
import { OrderCreatedHandler } from './events/order-created.handler';
import { OrderSaga } from './sagas/order.saga';
const CommandHandlers = [CreateOrderHandler, CancelOrderHandler];
const QueryHandlers = [GetOrderHandler, GetOrderListHandler];
const EventHandlers = [OrderCreatedHandler, OrderCancelledHandler];
@Module({
imports: [CqrsModule],
controllers: [OrderController],
providers: [
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
OrderSaga,
OrderRepository,
],
})
export class OrderModule {}
Command 정의와 핸들러
Command는 시스템의 상태를 변경하는 의도를 표현한다. 이름은 명령형으로 짓는다.
// commands/create-order.command.ts
export class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: Array<{
productId: string;
quantity: number;
price: number;
}>,
public readonly shippingAddress: string,
) {}
}
// commands/cancel-order.command.ts
export class CancelOrderCommand {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly reason: string,
) {}
}
// commands/create-order.handler.ts
import { CommandHandler, ICommandHandler, EventPublisher } from '@nestjs/cqrs';
import { CreateOrderCommand } from './create-order.command';
@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;
// 도메인 로직: 주문 생성
const totalAmount = items.reduce(
(sum, item) => sum + item.price * item.quantity, 0,
);
const order = this.publisher.mergeObjectContext(
Order.create({
userId,
items,
shippingAddress,
totalAmount,
status: OrderStatus.PENDING,
}),
);
await this.repository.save(order);
// 도메인 이벤트 발행
order.commit();
return order.id;
}
}
Aggregate Root와 도메인 이벤트
// domain/order.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';
export class Order extends AggregateRoot {
private id: string;
private userId: string;
private items: OrderItem[];
private status: OrderStatus;
private totalAmount: number;
static create(props: CreateOrderProps): Order {
const order = new Order();
order.id = generateId();
order.userId = props.userId;
order.items = props.items;
order.status = OrderStatus.PENDING;
order.totalAmount = props.totalAmount;
// 이벤트 등록 (commit() 호출 시 발행됨)
order.apply(new OrderCreatedEvent(
order.id,
order.userId,
order.items,
order.totalAmount,
));
return order;
}
cancel(reason: string): void {
if (this.status === OrderStatus.SHIPPED) {
throw new DomainException('배송된 주문은 취소할 수 없습니다');
}
this.status = OrderStatus.CANCELLED;
this.apply(new OrderCancelledEvent(
this.id,
this.userId,
reason,
));
}
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new DomainException('대기 중인 주문만 확인할 수 있습니다');
}
this.status = OrderStatus.CONFIRMED;
this.apply(new OrderConfirmedEvent(this.id, this.userId));
}
}
Event 정의와 핸들러
Event는 이미 발생한 사실을 표현한다. 이름은 과거형으로 짓는다.
// events/order-created.event.ts
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: OrderItem[],
public readonly totalAmount: number,
) {}
}
// events/order-created.handler.ts
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedHandler
implements IEventHandler<OrderCreatedEvent>
{
constructor(
private readonly notificationService: NotificationService,
private readonly analyticsService: AnalyticsService,
) {}
async handle(event: OrderCreatedEvent): Promise<void> {
// 부수 효과 처리 (비동기)
await Promise.all([
this.notificationService.sendOrderConfirmation(
event.userId,
event.orderId,
),
this.analyticsService.trackOrder(
event.orderId,
event.totalAmount,
),
]);
}
}
// 하나의 이벤트에 여러 핸들러 등록 가능
@EventsHandler(OrderCreatedEvent)
export class UpdateInventoryHandler
implements IEventHandler<OrderCreatedEvent>
{
constructor(private readonly inventoryService: InventoryService) {}
async handle(event: OrderCreatedEvent): Promise<void> {
for (const item of event.items) {
await this.inventoryService.decreaseStock(
item.productId,
item.quantity,
);
}
}
}
Query 정의와 핸들러
Query는 데이터를 읽기만 하며 상태를 변경하지 않는다. 읽기 최적화된 별도 모델(Read Model)을 사용할 수 있다.
// queries/get-order.query.ts
export class GetOrderQuery {
constructor(
public readonly orderId: string,
public readonly userId: string,
) {}
}
export class GetOrderListQuery {
constructor(
public readonly userId: string,
public readonly page: number = 1,
public readonly limit: number = 20,
public readonly status?: OrderStatus,
) {}
}
// queries/get-order.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
@QueryHandler(GetOrderQuery)
export class GetOrderHandler
implements IQueryHandler<GetOrderQuery>
{
constructor(
private readonly readRepository: OrderReadRepository,
) {}
async execute(query: GetOrderQuery): Promise<OrderDetailDto> {
const order = await this.readRepository.findDetailById(
query.orderId,
);
if (!order || order.userId !== query.userId) {
throw new NotFoundException('주문을 찾을 수 없습니다');
}
return order;
}
}
@QueryHandler(GetOrderListQuery)
export class GetOrderListHandler
implements IQueryHandler<GetOrderListQuery>
{
constructor(
private readonly readRepository: OrderReadRepository,
) {}
async execute(query: GetOrderListQuery): Promise<PaginatedDto<OrderSummaryDto>> {
return this.readRepository.findByUserId(
query.userId,
query.page,
query.limit,
query.status,
);
}
}
컨트롤러에서 Command/Query 디스패치
// order.controller.ts
import { CommandBus, QueryBus } from '@nestjs/cqrs';
@Controller('orders')
export class OrderController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async create(
@CurrentUser() user: UserPayload,
@Body() dto: CreateOrderDto,
): Promise<{ orderId: string }> {
const orderId = await this.commandBus.execute(
new CreateOrderCommand(user.id, dto.items, dto.shippingAddress),
);
return { orderId };
}
@Delete(':id')
async cancel(
@CurrentUser() user: UserPayload,
@Param('id') orderId: string,
@Body() dto: CancelOrderDto,
): Promise<void> {
await this.commandBus.execute(
new CancelOrderCommand(orderId, user.id, dto.reason),
);
}
@Get(':id')
async findOne(
@CurrentUser() user: UserPayload,
@Param('id') orderId: string,
): Promise<OrderDetailDto> {
return this.queryBus.execute(
new GetOrderQuery(orderId, user.id),
);
}
@Get()
async findAll(
@CurrentUser() user: UserPayload,
@Query() query: OrderListQueryDto,
): Promise<PaginatedDto<OrderSummaryDto>> {
return this.queryBus.execute(
new GetOrderListQuery(
user.id, query.page, query.limit, query.status,
),
);
}
}
Saga: 이벤트 기반 워크플로
Saga는 이벤트를 감지하여 새로운 Command를 발행하는 오케스트레이터다. 복잡한 비즈니스 프로세스를 이벤트 체인으로 구성한다.
// sagas/order.saga.ts
import { Injectable } from '@nestjs/common';
import { Saga, ICommand, ofType } from '@nestjs/cqrs';
import { Observable, map, delay, filter } from 'rxjs';
@Injectable()
export class OrderSaga {
// 주문 생성 → 결제 처리 Command 발행
@Saga()
orderCreated = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(OrderCreatedEvent),
map((event) => new ProcessPaymentCommand(
event.orderId,
event.userId,
event.totalAmount,
)),
);
};
// 결제 완료 → 주문 확인 Command 발행
@Saga()
paymentCompleted = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(PaymentCompletedEvent),
map((event) => new ConfirmOrderCommand(event.orderId)),
);
};
// 결제 실패 → 주문 취소
@Saga()
paymentFailed = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(PaymentFailedEvent),
map((event) => new CancelOrderCommand(
event.orderId,
'system',
`결제 실패: ${event.reason}`,
)),
);
};
}
CQRS 적용 판단 기준
| 적합한 경우 | 과도한 경우 |
|---|---|
| 읽기/쓰기 비율이 크게 다를 때 | 단순 CRUD 앱 |
| 복잡한 도메인 로직이 있을 때 | 팀 규모가 1~2명일 때 |
| 읽기/쓰기 독립 스케일링 필요 시 | 도메인이 단순할 때 |
| 이벤트 기반 비동기 처리가 많을 때 | 강한 일관성만 필요할 때 |
CQRS는 강력하지만 복잡도를 추가한다. NestJS Middleware 가이드에서 다룬 요청 추적과 결합하면 Command/Event 흐름을 Request ID로 엔드투엔드 추적할 수 있다. 또한 Spring Events 도메인 이벤트 가이드의 이벤트 패턴과 개념적으로 동일하므로 함께 참고하면 좋다.
정리: CQRS 설계 체크리스트
- Command는 명령형, Event는 과거형: CreateOrder vs OrderCreated
- Command는 void 또는 ID만 반환: 조회 결과를 Command에서 반환하지 않는다
- Query는 부수 효과 없음: 상태를 변경하지 않는다
- EventHandler는 멱등성 보장: 같은 이벤트가 재처리되어도 안전해야 한다
- Saga로 워크플로 오케스트레이션: 이벤트 → Command 체이닝
- 점진적 도입: 복잡한 도메인부터 적용, 단순 CRUD는 기존 방식 유지
- Read Model 분리: 조회 성능이 중요한 경우 별도 읽기 전용 저장소 활용