CQRS: 독립적인 확장을 위한 별도의 읽기 및 쓰기
클래식 REST API는 동일한 모델을 사용하여 데이터를 읽고 씁니다. 그러나 요구 사항 읽기와 쓰기는 종종 매우 다릅니다. 쓰기는 일관성을 보장해야 하며 복잡한 비즈니스 규칙을 검증합니다. 읽기는 빠르고 확장 가능하며 반환되어야 합니다. UI에 최적화된 형식의 데이터입니다. 그것으로 두 가지 요구를 모두 충족 시키십시오. 동일한 모델로 인해 타협이 발생함: 뷰에 대한 복잡한 쿼리 또는 "비대해진" 모델 모든 유형의 독서를 지원합니다.
CQRS(명령 쿼리 책임 분리) 명시적으로 구분합니다. 글쓰기 모델 (명령 측) 읽기 모델에서 (쿼리 측). 명령 측은 상태를 변경하는 작업(생성, 업데이트, 삭제)을 관리합니다. 쿼리 측은 일반적으로 최적화된 데이터 모델을 사용하여 읽기 작업을 처리합니다. 특정 애플리케이션 보기의 경우. 둘 사이의 동기화는 다음을 통해 발생합니다. 비동기 이벤트 또는 프로젝션.
무엇을 배울 것인가
- CQRS 아키텍처: 명령 측, 쿼리 측, 비동기 동기화
- 유효성 검사를 통해 TypeScript에서 명령 처리기 구현
- 프로젝션: 뷰에 최적화된 읽기 모델 구축
- 이벤트를 통한 비동기식 동기화(이벤트 소싱 + CQRS)
- 이벤트 소싱이 없는 CQRS: 단순화된 하이브리드 모델
- 독립적인 읽기 및 쓰기 확장
- 절충: 가능한 일관성 및 운영 복잡성
CQRS 아키텍처
CQRS에서 각 작업은 또는 명령 (상태 변경) 또는 하나 쿼리 (수정하지 않고 상태를 읽습니다). 절대로 동시에 둘 다 방법(Bertrand Meyer의 CQS 원리):
// WRONG: un metodo che fa sia command che query
async function reserveInventory(productId: string, quantity: number): Promise<Stock> {
const stock = await db.getStock(productId);
stock.reserved += quantity; // MODIFICA
await db.saveStock(stock);
return stock; // LETTURA
}
// RIGHT CQRS: separa command e query
async function reserveInventory(productId: string, quantity: number): Promise<void> {
// Command: solo modifica, nessun ritorno di stato
const stock = await stockRepo.findById(productId);
stock.reserve(quantity);
await stockRepo.save(stock);
// Pubblica evento per aggiornare il read model
await eventBus.publish(new InventoryReservedEvent(productId, quantity));
}
async function getStockLevel(productId: string): Promise<StockLevelDto> {
// Query: legge dal read model ottimizzato, ZERO side effects
return await stockReadModel.findById(productId);
}
명령 측면: 명령 관리
명령 측은 Command(의도를 설명하는 불변 객체), li를 받습니다. 유효하고 Aggregate에서 비즈니스 논리를 실행하고 이벤트를 게시합니다. 패턴 명령 처리기 그리고 이 흐름을 조정하는 구성 요소는 다음과 같습니다.
// ---- COMMANDS ----
// Ogni command e un DTO immutabile
class PlaceOrderCommand {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly items: ReadonlyArray<{
productId: string;
quantity: number;
}>,
public readonly shippingAddress: Readonly<Address>
) {}
}
// ---- COMMAND HANDLER ----
class PlaceOrderCommandHandler {
constructor(
private readonly orderRepo: OrderRepository,
private readonly productRepo: ProductRepository,
private readonly eventBus: EventBus
) {}
async handle(command: PlaceOrderCommand): Promise<void> {
// 1. Validazione: prodotti esistono?
const products = await Promise.all(
command.items.map((item) => this.productRepo.findById(item.productId))
);
if (products.some((p) => p === null)) {
throw new Error('One or more products not found');
}
// 2. Crea l'Aggregate (logica business)
const order = new OrderAggregate();
order.create(command.orderId, command.customerId);
for (let i = 0; i < command.items.length; i++) {
const product = products[i]!;
order.addItem(
command.items[i].productId,
command.items[i].quantity,
product.price
);
}
order.confirm();
// 3. Persisti gli eventi generati dall'Aggregate
await this.orderRepo.save(order);
// 4. Pubblica gli eventi per aggiornare il read model e notificare altri servizi
const events = order.getUncommittedEvents();
await this.eventBus.publishAll(events);
}
}
// ---- COMMAND BUS ----
// Dispatcher che instrada i command all'handler corretto
class CommandBus {
private handlers = new Map<string, (cmd: unknown) => Promise<void>>();
register<T>(commandType: string, handler: (cmd: T) => Promise<void>): void {
this.handlers.set(commandType, handler as (cmd: unknown) => Promise<void>);
}
async dispatch<T>(commandType: string, command: T): Promise<void> {
const handler = this.handlers.get(commandType);
if (!handler) {
throw new Error(`No handler registered for command: ${commandType}`);
}
await handler(command);
}
}
// Setup
const commandBus = new CommandBus();
commandBus.register('PlaceOrder', (cmd: PlaceOrderCommand) =>
placeOrderHandler.handle(cmd)
);
쿼리 측: 모델 및 예측 읽기
쿼리 측은 모델 읽기: 데이터의 표현 특정 애플리케이션 쿼리에 최적화되었습니다. 명령 측이 다음과 같이 작동하는 동안 Aggregate, 쿼리 측은 다음을 통해 구성된 비정규화된 뷰와 함께 작동합니다. 예상.
// ---- READ MODEL ----
// Viste denormalizzate ottimizzate per le query dell'UI
// View per la lista ordini: include dati del cliente + totale + stato
interface OrderListItemReadModel {
orderId: string;
customerName: string; // denormalizzato (non serve join)
customerEmail: string;
totalAmount: number;
currency: string;
status: string;
itemCount: number;
placedAt: string;
}
// View per il dettaglio ordine
interface OrderDetailReadModel {
orderId: string;
customerId: string;
customerName: string;
items: Array<{
productId: string;
productName: string; // denormalizzato
quantity: number;
unitPrice: number;
subtotal: number;
}>;
totalAmount: number;
shippingAddress: Address;
status: string;
statusHistory: Array<{ status: string; changedAt: string }>;
estimatedDelivery?: string;
}
// ---- PROIEZIONE ----
// Aggiorna il read model in risposta agli eventi del command side
class OrderReadModelProjection {
constructor(
private readonly readModelDb: ReadModelDatabase,
private readonly customerRepo: CustomerReadRepository
) {}
// Quando un ordine viene confermato, crea/aggiorna le view nel read model
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
// Carica i dati aggiuntivi necessari per la view denormalizzata
const customer = await this.customerRepo.findById(event.customerId);
const orderState = await this.orderRepo.findById(event.orderId);
// Inserisce/aggiorna la view della lista ordini
await this.readModelDb.upsert('order_list_view', {
order_id: event.orderId,
customer_name: customer.fullName,
customer_email: customer.email,
total_amount: orderState.totalAmount,
currency: orderState.currency,
status: 'Confirmed',
item_count: orderState.items.size,
placed_at: orderState.createdAt,
});
// Inserisce la view di dettaglio
const itemsWithNames = await this.enrichItemsWithProductNames(orderState.items);
await this.readModelDb.upsert('order_detail_view', {
order_id: event.orderId,
// ... tutti i campi della view dettaglio
items: JSON.stringify(itemsWithNames),
status_history: JSON.stringify([
{ status: 'Confirmed', changedAt: event.occurredAt }
]),
});
}
async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
// Aggiorna solo lo status nella view
await this.readModelDb.update('order_list_view',
{ order_id: event.orderId },
{ status: 'Cancelled' }
);
// Aggiungi alla status history nella view dettaglio
await this.readModelDb.appendToJsonArray('order_detail_view',
{ order_id: event.orderId },
'status_history',
{ status: 'Cancelled', changedAt: event.occurredAt }
);
}
}
// ---- QUERY HANDLER ----
class OrderQueryHandler {
constructor(private readonly readModelDb: ReadModelDatabase) {}
// Query velocissima sul read model denormalizzato
async getOrderList(customerId: string, page: number, pageSize: number):
Promise<OrderListItemReadModel[]>
{
return this.readModelDb.query(
`SELECT * FROM order_list_view
WHERE customer_id = $1
ORDER BY placed_at DESC
LIMIT $2 OFFSET $3`,
[customerId, pageSize, page * pageSize]
);
}
async getOrderDetail(orderId: string): Promise<OrderDetailReadModel | null> {
return this.readModelDb.queryOne(
'SELECT * FROM order_detail_view WHERE order_id = $1',
[orderId]
);
}
}
이벤트 소싱이 없는 CQRS
CQRS와 이벤트 소싱은 직교합니다. 함께 잘 작동하지만 사용할 수 있습니다. 별도로. 이벤트 소싱이 없는 단순화된 CQRS 모델은 데이터베이스를 사용합니다. 쓰기를 위한 기본 데이터베이스 및 읽기를 위한 별도의 데이터베이스(또는 구체화된 뷰):
// CQRS semplificato: stesso database, modelli separati
// Write side usa le entita ORM normali
// Read side usa query SQL ottimizzate o viste materializzate
// View materializzata PostgreSQL per la lista ordini
CREATE MATERIALIZED VIEW order_list_view AS
SELECT
o.id AS order_id,
c.full_name AS customer_name,
c.email AS customer_email,
o.total_amount,
o.currency,
o.status,
COUNT(oi.id) AS item_count,
o.created_at AS placed_at
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.full_name, c.email;
-- Refresh automatico della vista materializzata
CREATE INDEX idx_order_list_customer ON order_list_view (customer_email);
-- Con PostgreSQL 17: INCREMENTAL REFRESH (solo le righe cambiate)
-- REFRESH MATERIALIZED VIEW CONCURRENTLY order_list_view;
-- Query sul read model: 100x piu veloce di una query con JOIN
SELECT * FROM order_list_view
WHERE customer_email = 'mario@example.com'
ORDER BY placed_at DESC
LIMIT 20;
독립적인 스케일링 읽기 및 쓰기
CQRS를 사용하면 읽기 측과 쓰기 측을 독립적으로 확장할 수 있습니다. 시스템의 경우 쓰기보다 읽기가 100배 더 많으므로(전자상거래의 경우 일반적) 다음을 수행할 수 있습니다.
- 쓰는 쪽: PostgreSQL 기본 데이터베이스가 있는 인스턴스 2개
- 읽기 측면: Redis 캐시 + 읽기 전용 PostgreSQL 복제가 포함된 인스턴스 10개
// Architettura di scaling con Kubernetes
# command-side-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-command-service
spec:
replicas: 2 # write side: pochi ma consistenti
template:
spec:
containers:
- name: api
image: company/order-service:v1
env:
- name: DB_URL
value: "postgres://primary-db:5432/orders" # database primario
- name: SERVICE_MODE
value: "command"
---
# query-side-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-query-service
spec:
replicas: 10 # read side: molte repliche, stateless
template:
spec:
containers:
- name: api
image: company/order-service:v1
env:
- name: DB_URL
value: "postgres://read-replica:5432/orders" # replica read-only
- name: REDIS_URL
value: "redis://cache:6379"
- name: SERVICE_MODE
value: "query"
프런트엔드의 최종 일관성 관리
비동기식 동기화를 사용하는 CQRS에는 짧은 기간(일반적으로 밀리초)이 있습니다. 읽기 모델은 쓰기 후에 아직 업데이트되지 않습니다. 프론트엔드가 처리해야 함 이것은 정확하게:
// Pattern 1: Optimistic Update
// Il frontend aggiorna l'UI immediatamente, senza aspettare la query
async function placeOrder(orderData: PlaceOrderDto) {
// 1. Ottimisticamente aggiorna l'UI locale
dispatch({ type: 'ADD_ORDER_OPTIMISTIC', order: { ...orderData, status: 'Pending' } });
try {
// 2. Invia il command al backend
const response = await api.placeOrder(orderData);
// 3. Dopo N ms, ricarica dal read model (che dovrebbe essere aggiornato)
setTimeout(async () => {
const updatedOrder = await api.getOrder(response.orderId);
dispatch({ type: 'UPDATE_ORDER', order: updatedOrder });
}, 500);
} catch (error) {
// 4. In caso di errore, reverta l'ottimistic update
dispatch({ type: 'REVERT_ORDER_OPTIMISTIC' });
throw error;
}
}
// Pattern 2: Poll finche il read model non e aggiornato
async function pollUntilUpdated(orderId: string, expectedStatus: string) {
const MAX_ATTEMPTS = 10;
const DELAY_MS = 200;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const order = await api.getOrder(orderId);
if (order.status === expectedStatus) return order;
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
throw new Error('Read model not updated within expected time');
}
CQRS 절충안
이익
- 독립적인 확장: 실제 부하에 따라 측면 스케일을 읽고 측면 스케일을 별도로 작성합니다.
- 최적화된 템플릿: 읽기 모델은 뷰에 대해 정확하게 비정규화되어 복잡한 쿼리를 제거할 수 있습니다.
- 높은 읽기 성능: 읽기 모델에 대한 쿼리는 미리 계산된 테이블에 대한 간단한 SELECT입니다.
- 책임 분리: 코드 쓰기와 읽기는 서로를 오염시키지 않습니다.
관리가 복잡함
- 가능한 일관성: 읽기 모델은 쓰기보다 약간 뒤처질 수 있습니다. 프론트엔드가 이를 처리해야 합니다.
- 논리 복제: 일부 유효성 검사는 명령 처리기와 읽기 모델 모두에 있어야 할 수도 있습니다.
- 배포할 추가 구성 요소: 명령 서비스, 쿼리 서비스, 예측, 모델 데이터베이스 읽기
- 간단한 CRUD에는 적합하지 않습니다. 복잡한 비즈니스 로직이 없는 작업에는 CQRS의 복잡성이 가치가 없습니다.
결론 및 다음 단계
CQRS는 읽기 및 쓰기 로드가 있는 시스템에 가장 효과적인 아키텍처 중 하나입니다. 매우 다릅니다. 명령 측의 명시적인 분리(일관성, 검증됨, 이벤트 기반) 쿼리 측면에서(빠르고, 비정규화되고, 확장 가능) 단일 모델의 장단점을 해결합니다.
다음 기사에서는 Event Sourcing과 CQRS를 함께 결합합니다. 예측이 어떻게 이루어지는지 살펴보겠습니다. 읽기 모델을 구축하기 위해 이벤트 저장소에서 읽기, 재시도를 통해 예측을 관리하는 방법 오류가 발생한 경우 스냅샷을 사용하여 읽기 모델 재구축을 최적화하는 방법 실패 후.
이벤트 중심 아키텍처 시리즈의 향후 기사
관련 시리즈
- 이벤트 소싱 — CQRS의 자연스러운 보완
- 실용적인 소프트웨어 아키텍처 — 전체 아키텍처의 맥락에서 CQRS를 배치할 위치







