CQRS๋ ๋ฌด์์ธ๊ฐ
์๋น์ ์์ํด๋ณด์.
์ฃผ๋ฌธ๋ฐ๋ ์ง์๊ณผ ์์ ์๋นํ๋ ์ง์์ด ๋ฐ๋ก ์๋ค.
์ ๋ถ๋ฆฌํ ๊น?
์ฃผ๋ฌธ์ ๋น ๋ฅด๊ฒ, ์๋น์ ์ ํํ๊ฒ ํด์ผ ํ๊ธฐ ๋๋ฌธ์ด๋ค.
CQRS(Command Query Responsibility Segregation)๋
๋ฐ์ดํฐ๋ฅผ ๋ณ๊ฒฝํ๋ ์์
(Command)๊ณผ
๋ฐ์ดํฐ๋ฅผ ์กฐํํ๋ ์์
(Query)์ ๋ถ๋ฆฌํ๋ ํจํด์ด๋ค.
๋ง์น ์๋น์์ ์ญํ ์ ๋๋๋ฏ,
์์คํ
๋ ์ฐ๊ธฐ์ ์ฝ๊ธฐ๋ฅผ ๋ถ๋ฆฌํ๋ฉด
๊ฐ๊ฐ ์ต์ ํํ ์ ์๋ค.
์ CQRS๋ฅผ ๋ฐฐ์์ผ ํ ๊น?
์ด์ 1: ์ฑ๋ฅ ์ต์ ํ
์ฝ๊ธฐ์ ์ฐ๊ธฐ๋ฅผ ๊ฐ๊ฐ ์ต์ ํ ๊ฐ๋ฅ
์ด์ 2: ํ์ฅ์ฑ
์ฝ๊ธฐ ์๋ฒ์ ์ฐ๊ธฐ ์๋ฒ๋ฅผ ๋
๋ฆฝ์ ์ผ๋ก ํ์ฅ
์ด์ 3: ๋ณต์กํ ๋๋ฉ์ธ ์ฒ๋ฆฌ
DDD์ ํจ๊ป ์ฌ์ฉํ๋ฉด ๊ฐ๋ ฅํจ
์ด์ 4: ๋ฉด์ ํ์
CQRS, Event Sourcing์ ์๋์ด ๋ฉด์ ๋จ๊ณจ
๊ธฐ๋ณธ ๊ฐ๋ ์์ฝ
๐ท๏ธ CQRS ํต์ฌ ๊ตฌ์กฐ
[ํด๋ผ์ด์ธํธ]
โ
โโโ Command (์ฐ๊ธฐ) โโโ [Command Handler] โโโ [Write DB]
โ โ
โ โ (์ด๋ฒคํธ ๋ฐํ)
โ [Event Bus]
โ โ
โ โ
โโโ Query (์ฝ๊ธฐ) โโโโ [Query Handler] โโโโ [Read DB]
Command (๋ช ๋ น)
๊ฐ๋
: ๋ฐ์ดํฐ๋ฅผ ๋ณ๊ฒฝํ๋ ์์ฒญ
ํน์ง:
- ์ํ๋ฅผ ๋ณ๊ฒฝํจ
- ๋ฐํ๊ฐ ์๊ฑฐ๋ ์ต์ํ
- ์: ์ฃผ๋ฌธ ์์ฑ, ๊ฒฐ์ ์ฒ๋ฆฌ
Query (์กฐํ)
๊ฐ๋
: ๋ฐ์ดํฐ๋ฅผ ์กฐํํ๋ ์์ฒญ
ํน์ง:
- ์ํ๋ฅผ ๋ณ๊ฒฝํ์ง ์์
- ๋ฐ์ดํฐ๋ฅผ ๋ฐํ
- ์: ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํ, ์ํ ๊ฒ์
Event (์ด๋ฒคํธ)
๊ฐ๋
: ์์คํ
์์ ๋ฐ์ํ ์ฌ์ค
ํน์ง:
- ๊ณผ๊ฑฐํ์ผ๋ก ๋ช ๋ช (OrderCreated)
- ๋ถ๋ณ
- ๋ค๋ฅธ ํธ๋ค๋ฌ๊ฐ ๋ฐ์
๐ท๏ธ ์ ํต์ CRUD vs CQRS
| ๊ตฌ๋ถ | CRUD | CQRS |
|---|---|---|
| ๋ชจ๋ธ | ๋จ์ผ ๋ชจ๋ธ | ์ฝ๊ธฐ/์ฐ๊ธฐ ๋ถ๋ฆฌ |
| DB | ํ๋์ DB | ๋ถ๋ฆฌ ๊ฐ๋ฅ |
| ๋ณต์ก๋ | ๋จ์ | ๋ณต์ก |
| ํ์ฅ์ฑ | ์ ํ์ | ๋์ |
| ์ ํฉํ ๊ฒฝ์ฐ | ๋จ์ CRUD | ๋ณต์กํ ๋๋ฉ์ธ |
๐ท๏ธ NestJS CQRS ๋ชจ๋ ๊ตฌ์กฐ
src/
โโโ orders/
โ โโโ commands/
โ โ โโโ impl/
โ โ โ โโโ create-order.command.ts
โ โ โโโ handlers/
โ โ โโโ create-order.handler.ts
โ โโโ queries/
โ โ โโโ impl/
โ โ โ โโโ get-order.query.ts
โ โ โโโ handlers/
โ โ โโโ get-order.handler.ts
โ โโโ events/
โ โ โโโ impl/
โ โ โ โโโ order-created.event.ts
โ โ โโโ handlers/
โ โ โโโ order-created.handler.ts
โ โโโ dto/
โ โโโ entities/
โ โโโ orders.module.ts
์ค์ ์์
๐ท๏ธ ํ๋ก์ ํธ ์ค์
# NestJS ํ๋ก์ ํธ ์์ฑ
npm i -g @nestjs/cli
nest new nestjs-cqrs-demo
# CQRS ํจํค์ง ์ค์น
npm install @nestjs/cqrs
# TypeORM ์ค์น (์ ํ)
npm install @nestjs/typeorm typeorm pg
๐ท๏ธ Command ๊ตฌํ
1. Command ์ ์
// src/orders/commands/impl/create-order.command.ts
export class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: OrderItemDto[],
public readonly shippingAddress: string
) {}
}
// src/orders/commands/impl/cancel-order.command.ts
export class CancelOrderCommand {
constructor(
public readonly orderId: string,
public readonly reason: string
) {}
}
2. Command Handler ๊ตฌํ
// src/orders/commands/handlers/create-order.handler.ts
import { CommandHandler, ICommandHandler, EventBus } from "@nestjs/cqrs";
import { CreateOrderCommand } from "../impl/create-order.command";
import { OrderCreatedEvent } from "../../events/impl/order-created.event";
import { OrderRepository } from "../../repositories/order.repository";
import { Order } from "../../entities/order.entity";
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventBus: EventBus
) {}
async execute(command: CreateOrderCommand): Promise<string> {
const { userId, items, shippingAddress } = command;
// 1. ์ฃผ๋ฌธ ์์ฑ
const order = Order.create({
userId,
items,
shippingAddress,
status: "PENDING",
});
// 2. ์ ์ฅ
await this.orderRepository.save(order);
// 3. ์ด๋ฒคํธ ๋ฐํ
this.eventBus.publish(
new OrderCreatedEvent(order.id, order.userId, order.totalAmount)
);
return order.id;
}
}
// src/orders/commands/handlers/cancel-order.handler.ts
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventBus: EventBus
) {}
async execute(command: CancelOrderCommand): Promise<void> {
const { orderId, reason } = command;
// 1. ์ฃผ๋ฌธ ์กฐํ
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new NotFoundException("์ฃผ๋ฌธ์ ์ฐพ์ ์ ์์ต๋๋ค");
}
// 2. ์ทจ์ ๊ฐ๋ฅ ์ฌ๋ถ ํ์ธ
if (order.status === "SHIPPED") {
throw new BadRequestException("๋ฐฐ์ก๋ ์ฃผ๋ฌธ์ ์ทจ์ํ ์ ์์ต๋๋ค");
}
// 3. ์ฃผ๋ฌธ ์ทจ์
order.cancel(reason);
await this.orderRepository.save(order);
// 4. ์ด๋ฒคํธ ๋ฐํ
this.eventBus.publish(new OrderCancelledEvent(orderId, reason));
}
}
๐ท๏ธ Query ๊ตฌํ
1. Query ์ ์
// src/orders/queries/impl/get-order.query.ts
export class GetOrderQuery {
constructor(public readonly orderId: string) {}
}
// src/orders/queries/impl/get-orders-by-user.query.ts
export class GetOrdersByUserQuery {
constructor(
public readonly userId: string,
public readonly page: number = 1,
public readonly limit: number = 10
) {}
}
2. Query Handler ๊ตฌํ
// src/orders/queries/handlers/get-order.handler.ts
import { IQueryHandler, QueryHandler } from "@nestjs/cqrs";
import { GetOrderQuery } from "../impl/get-order.query";
import { OrderReadRepository } from "../../repositories/order-read.repository";
@QueryHandler(GetOrderQuery)
export class GetOrderHandler implements IQueryHandler<GetOrderQuery> {
constructor(private readonly orderReadRepository: OrderReadRepository) {}
async execute(query: GetOrderQuery): Promise<OrderDto> {
const { orderId } = query;
const order = await this.orderReadRepository.findById(orderId);
if (!order) {
throw new NotFoundException("์ฃผ๋ฌธ์ ์ฐพ์ ์ ์์ต๋๋ค");
}
return OrderDto.from(order);
}
}
// src/orders/queries/handlers/get-orders-by-user.handler.ts
@QueryHandler(GetOrdersByUserQuery)
export class GetOrdersByUserHandler
implements IQueryHandler<GetOrdersByUserQuery>
{
constructor(private readonly orderReadRepository: OrderReadRepository) {}
async execute(query: GetOrdersByUserQuery): Promise<PaginatedOrdersDto> {
const { userId, page, limit } = query;
const [orders, total] = await this.orderReadRepository.findByUserId(
userId,
page,
limit
);
return {
data: orders.map(OrderDto.from),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}
๐ท๏ธ Event ๊ตฌํ
1. Event ์ ์
// src/orders/events/impl/order-created.event.ts
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly totalAmount: number
) {}
}
// src/orders/events/impl/order-cancelled.event.ts
export class OrderCancelledEvent {
constructor(
public readonly orderId: string,
public readonly reason: string
) {}
}
2. Event Handler ๊ตฌํ
// src/orders/events/handlers/order-created.handler.ts
import { EventsHandler, IEventHandler } from "@nestjs/cqrs";
import { OrderCreatedEvent } from "../impl/order-created.event";
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedHandler implements IEventHandler<OrderCreatedEvent> {
constructor(
private readonly notificationService: NotificationService,
private readonly analyticsService: AnalyticsService
) {}
async handle(event: OrderCreatedEvent): Promise<void> {
const { orderId, userId, totalAmount } = event;
// 1. ์๋ฆผ ์ ์ก
await this.notificationService.sendOrderConfirmation(userId, orderId);
// 2. ๋ถ์ ๋ฐ์ดํฐ ์ ์ฅ
await this.analyticsService.trackOrder(orderId, totalAmount);
console.log(`์ฃผ๋ฌธ ์์ฑ๋จ: ${orderId}, ๊ธ์ก: ${totalAmount}`);
}
}
// src/orders/events/handlers/order-cancelled.handler.ts
@EventsHandler(OrderCancelledEvent)
export class OrderCancelledHandler
implements IEventHandler<OrderCancelledEvent>
{
constructor(
private readonly inventoryService: InventoryService,
private readonly paymentService: PaymentService
) {}
async handle(event: OrderCancelledEvent): Promise<void> {
const { orderId, reason } = event;
// 1. ์ฌ๊ณ ๋ณต๊ตฌ
await this.inventoryService.restoreStock(orderId);
// 2. ํ๋ถ ์ฒ๋ฆฌ
await this.paymentService.refund(orderId);
console.log(`์ฃผ๋ฌธ ์ทจ์๋จ: ${orderId}, ์ฌ์ : ${reason}`);
}
}
๐ท๏ธ Controller ๊ตฌํ
// src/orders/orders.controller.ts
import { Controller, Post, Get, Body, Param, Query } from "@nestjs/common";
import { CommandBus, QueryBus } from "@nestjs/cqrs";
import { CreateOrderCommand } from "./commands/impl/create-order.command";
import { CancelOrderCommand } from "./commands/impl/cancel-order.command";
import { GetOrderQuery } from "./queries/impl/get-order.query";
import { GetOrdersByUserQuery } from "./queries/impl/get-orders-by-user.query";
@Controller("orders")
export class OrdersController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus
) {}
// Command: ์ฃผ๋ฌธ ์์ฑ
@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 };
}
// Command: ์ฃผ๋ฌธ ์ทจ์
@Post(":id/cancel")
async cancelOrder(
@Param("id") orderId: string,
@Body() dto: CancelOrderDto
): Promise<void> {
await this.commandBus.execute(new CancelOrderCommand(orderId, dto.reason));
}
// Query: ์ฃผ๋ฌธ ์กฐํ
@Get(":id")
async getOrder(@Param("id") orderId: string): Promise<OrderDto> {
return this.queryBus.execute(new GetOrderQuery(orderId));
}
// Query: ์ฌ์ฉ์ ์ฃผ๋ฌธ ๋ชฉ๋ก
@Get("user/:userId")
async getOrdersByUser(
@Param("userId") userId: string,
@Query("page") page: number = 1,
@Query("limit") limit: number = 10
): Promise<PaginatedOrdersDto> {
return this.queryBus.execute(new GetOrdersByUserQuery(userId, page, limit));
}
}
๐ท๏ธ Module ์ค์
// src/orders/orders.module.ts
import { Module } from "@nestjs/common";
import { CqrsModule } from "@nestjs/cqrs";
import { TypeOrmModule } from "@nestjs/typeorm";
import { OrdersController } from "./orders.controller";
import { Order } from "./entities/order.entity";
// Command Handlers
import { CreateOrderHandler } from "./commands/handlers/create-order.handler";
import { CancelOrderHandler } from "./commands/handlers/cancel-order.handler";
// Query Handlers
import { GetOrderHandler } from "./queries/handlers/get-order.handler";
import { GetOrdersByUserHandler } from "./queries/handlers/get-orders-by-user.handler";
// Event Handlers
import { OrderCreatedHandler } from "./events/handlers/order-created.handler";
import { OrderCancelledHandler } from "./events/handlers/order-cancelled.handler";
// Repositories
import { OrderRepository } from "./repositories/order.repository";
import { OrderReadRepository } from "./repositories/order-read.repository";
const CommandHandlers = [CreateOrderHandler, CancelOrderHandler];
const QueryHandlers = [GetOrderHandler, GetOrdersByUserHandler];
const EventHandlers = [OrderCreatedHandler, OrderCancelledHandler];
@Module({
imports: [CqrsModule, TypeOrmModule.forFeature([Order])],
controllers: [OrdersController],
providers: [
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
OrderRepository,
OrderReadRepository,
],
})
export class OrdersModule {}
๐ท๏ธ Entity์ Repository
// src/orders/entities/order.entity.ts
import { AggregateRoot } from "@nestjs/cqrs";
@Entity()
export class Order extends AggregateRoot {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
userId: string;
@Column("jsonb")
items: OrderItem[];
@Column()
shippingAddress: string;
@Column()
status: OrderStatus;
@Column("decimal")
totalAmount: number;
@CreateDateColumn()
createdAt: Date;
static create(props: CreateOrderProps): Order {
const order = new Order();
order.userId = props.userId;
order.items = props.items;
order.shippingAddress = props.shippingAddress;
order.status = "PENDING";
order.totalAmount = props.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return order;
}
cancel(reason: string): void {
if (this.status === "SHIPPED") {
throw new Error("๋ฐฐ์ก๋ ์ฃผ๋ฌธ์ ์ทจ์ ๋ถ๊ฐ");
}
this.status = "CANCELLED";
}
}
// src/orders/repositories/order.repository.ts
@Injectable()
export class OrderRepository {
constructor(
@InjectRepository(Order)
private readonly repository: Repository<Order>
) {}
async save(order: Order): Promise<Order> {
return this.repository.save(order);
}
async findById(id: string): Promise<Order | null> {
return this.repository.findOne({ where: { id } });
}
}
// src/orders/repositories/order-read.repository.ts
// ์ฝ๊ธฐ ์ ์ฉ - ์ต์ ํ๋ ์ฟผ๋ฆฌ
@Injectable()
export class OrderReadRepository {
constructor(
@InjectRepository(Order)
private readonly repository: Repository<Order>
) {}
async findById(id: string): Promise<Order | null> {
return this.repository
.createQueryBuilder("order")
.select(["order.id", "order.status", "order.totalAmount"])
.where("order.id = :id", { id })
.getOne();
}
async findByUserId(
userId: string,
page: number,
limit: number
): Promise<[Order[], number]> {
return this.repository.findAndCount({
where: { userId },
skip: (page - 1) * limit,
take: limit,
order: { createdAt: "DESC" },
});
}
}
๐ท๏ธ Event Sourcing (์ ํ)
// src/orders/events/order-event-store.ts
@Injectable()
export class OrderEventStore {
constructor(
@InjectRepository(OrderEvent)
private readonly repository: Repository<OrderEvent>
) {}
async save(event: DomainEvent): Promise<void> {
const orderEvent = new OrderEvent();
orderEvent.aggregateId = event.aggregateId;
orderEvent.eventType = event.constructor.name;
orderEvent.payload = JSON.stringify(event);
orderEvent.occurredAt = new Date();
await this.repository.save(orderEvent);
}
async getEvents(aggregateId: string): Promise<DomainEvent[]> {
const events = await this.repository.find({
where: { aggregateId },
order: { occurredAt: "ASC" },
});
return events.map((e) => JSON.parse(e.payload));
}
// ์ด๋ฒคํธ๋ก๋ถํฐ ์ํ ๋ณต์
async rehydrate(aggregateId: string): Promise<Order> {
const events = await this.getEvents(aggregateId);
const order = new Order();
events.forEach((event) => {
order.apply(event);
});
return order;
}
}
์ค์ ์ฒดํฌ๋ฆฌ์คํธ
โ Command ์ค๊ณ
- Command๋ ์๋๋ฅผ ๋ช ํํ ํํ
- ํ์ํ ๋ฐ์ดํฐ๋ง ํฌํจ
- ๋ฐํ๊ฐ ์ต์ํ
- ์ ํจ์ฑ ๊ฒ์ฌ ํฌํจ
โ Query ์ค๊ณ
- ์ฝ๊ธฐ ์ ์ฉ Repository ๋ถ๋ฆฌ
- ํ์ํ ํ๋๋ง ์กฐํ
- ํ์ด์ง๋ค์ด์ ์ ์ฉ
- ์บ์ฑ ๊ณ ๋ ค
โ Event ์ค๊ณ
- ๊ณผ๊ฑฐํ ์ด๋ฆ ์ฌ์ฉ
- ๋ถ๋ณ ๊ฐ์ฒด๋ก ์ค๊ณ
- ํ์ํ ์ ๋ณด๋ง ํฌํจ
- ๋น๋๊ธฐ ์ฒ๋ฆฌ ๊ณ ๋ ค
โ ์ํคํ ์ฒ
- ๋ชจ๋๋ณ ๋ถ๋ฆฌ
- Handler ๋จ์ผ ์ฑ ์
- ์์กด์ฑ ์ฃผ์ ํ์ฉ
- ํ ์คํธ ์ฉ์ด์ฑ ํ๋ณด
์์ฝ
CQRS๋ ์ฝ๊ธฐ์ ์ฐ๊ธฐ๋ฅผ ๋ถ๋ฆฌํ์ฌ ๊ฐ๊ฐ ์ต์ ํํ๋ ํจํด์ด๋ค.
๐ ํต์ฌ ํฌ์ธํธ:
- Command: ์ํ ๋ณ๊ฒฝ ์์ฒญ
- Query: ๋ฐ์ดํฐ ์กฐํ ์์ฒญ
- Event: ๋ฐ์ํ ์ฌ์ค ์๋ฆผ
- Handler: ๊ฐ ์์ฒญ ์ฒ๋ฆฌ
- EventBus: ์ด๋ฒคํธ ์ ๋ฌ
- ๋ถ๋ฆฌ๋ Repository: ์ฝ๊ธฐ/์ฐ๊ธฐ ์ต์ ํ
๐ NestJS CQRS ํ๋ฆ:
| ๋จ๊ณ | ์ค๋ช |
|---|---|
| 1. Controller | ์์ฒญ ์์ |
| 2. CommandBus/QueryBus | ์ ์ ํ Handler๋ก ์ ๋ฌ |
| 3. Handler | ๋น์ฆ๋์ค ๋ก์ง ์คํ |
| 4. EventBus | ์ด๋ฒคํธ ๋ฐํ |
| 5. EventHandler | ๋ถ๊ฐ ์์ ์ฒ๋ฆฌ |
๐ Best Practices:
- ๋จ์ CRUD๋ CQRS ๋ถํ์
- ๋ณต์กํ ๋๋ฉ์ธ์ ์ ์ฉ
- Event Sourcing์ ์ ํ
- ์ฝ๊ธฐ ๋ชจ๋ธ ์บ์ฑ ํ์ฉ
- ๋น๋๊ธฐ ์ด๋ฒคํธ ์ฒ๋ฆฌ
- ํ ์คํธ ์ฝ๋ ํ์
CQRS๋ ๋ณต์กํ ๋๋ฉ์ธ์์ ๋น์ ๋ฐํ๋ค.
๋จ์ํ CRUD ์ฑ์๋ ๊ณผํ ์ ์์ง๋ง,
ํ์ฅ์ฑ๊ณผ ์ ์ง๋ณด์์ฑ์ด ์ค์ํ๋ค๋ฉด ๊ณ ๋ คํ์.