CQRS 패턴 구현
읽기와 쓰기의 책임을 명령·조회 모델로 분리하고 핸들러와 이벤트를 연결해 NestJS CQRS 흐름을 구현합니다.
지난 절에서는 NestJS로 실시간 웹 애플리케이션 핵심 기술인 WebSocket을 구현하는 방법을 알아봤습니다.
이제 12장 세 번째 절에서는 대규모 엔터프라이즈 애플리케이션의 확장성과 성능을 높이는 고급 아키텍처 패턴, CQRS (Command Query Responsibility Segregation)와 이를 NestJS에서 구현하는 방법을 살펴보겠습니다.
기존 애플리케이션은 대부분 CRUD(Create, Read, Update, Delete) 모델로 데이터를 처리합니다.
즉 데이터를 읽는(Query) 작업과 쓰는(Command) 작업이 동일한 데이터 모델과 로직을 공유합니다.
하지만 복잡한 시스템에서는 읽기와 쓰기의 빈도와 요구사항이 크게 달라 성능 병목이나 확장성 문제가 생길 수 있습니다.
CQRS는 이런 문제를 해결하기 위해 등장한 패턴입니다.
Command는 상태 변경에, Query는 화면 조회에 맞춰 서로 다른 모델로 다룬다.
- 상태와 규칙을 지킨다
Command side 주문 생성, 결제 승인처럼 업무 규칙을 통과해야 하는 요청을 처리한다. CommandBus CommandHandler Write Model
- 조회 화면에 맞춘다
Query side 목록, 검색, 통계처럼 사용자가 빠르게 읽어야 하는 데이터를 준비한다. QueryBus QueryHandler Read Model
CQRS란?
CQRS는 애플리케이션의 작업(Operation)을 명령(Command)과 조회(Query)로 나누고, 이 두 가지 책임에 대해 서로 다른 모델을 사용하는 아키텍처 패턴입니다.
- 명령 (Command): 데이터 상태를 변경하는 작업입니다 (생성, 업데이트, 삭제). 명령은 비즈니스 로직을 포함하며, 일반적으로 결과 값을 반환하지 않고 작업의 성공/실패 여부만 알립니다.
- 조회 (Query): 데이터를 읽는 작업입니다. 조회는 데이터의 상태를 변경하지 않으며, 복잡한 비즈니스 로직 없이 데이터를 가져와 소비자에게 반환합니다.
전통적인 CRUD 모델에서는 동일한 데이터 모델(예: ORM 엔티티)을 읽기(조회)와 쓰기(명령) 모두에 사용합니다.
이로 인해 다음과 같은 문제가 발생할 수 있습니다.
- 성능 병목: 읽기 작업이 쓰기 작업보다 훨씬 많을 때, 쓰기 작업에 최적화된 데이터 모델이 읽기 성능을 저하시킬 수 있습니다.
- 복잡성 증가: 읽기와 쓰기 로직이 얽히면서 코드 복잡도가 증가하고 유지보수가 어려워집니다.
- 확장성 제약: 읽기 스케일링과 쓰기 스케일링 요구사항이 다를 때, 동일한 모델로는 효율적인 확장이 어렵습니다.
CQRS는 이를 분리하여 각 책임에 최적화된 아키텍처를 구축할 수 있도록 합니다.
CQRS의 장점- 확장성 (Scalability): 읽기 모델과 쓰기 모델을 독립적으로 확장할 수 있습니다. 예를 들어, 읽기 작업이 많으면 조회 모델의 인스턴스를 늘리고, 쓰기 작업이 많으면 명령 모델의 인스턴스를 늘릴 수 있습니다.
-
성능 최적화
- 쓰기 모델: 쓰기 작업에 최적화된 데이터베이스(예: NoSQL)나 데이터 구조를 사용할 수 있습니다.
- 읽기 모델: 읽기 작업에 최적화된 데이터베이스(예: 검색 엔진, Read Replica)나 캐싱 전략을 사용할 수 있습니다. 복잡한 JOIN 없이 쿼리에 필요한 데이터만 미리 평탄화(denormalization)하여 저장할 수 있습니다.
- 유연성: 각 모델에 독립적인 기술 스택을 적용할 수 있습니다.
- 복잡성 분리: 읽기/쓰기 로직을 분리하여 코드의 응집도를 높이고 유지보수를 용이하게 합니다.
- 이벤트 소싱(Event Sourcing)과의 시너지: CQRS는 종종 이벤트 소싱과 함께 사용됩니다. 모든 상태 변경을 이벤트로 기록하고, 이 이벤트를 통해 읽기 모델을 구축하는 방식입니다.
- 복잡성 증가: 읽기/쓰기 모델을 분리하므로 시스템 아키텍처가 더 복잡해지고, 초기 설정 및 관리에 더 많은 노력이 필요합니다.
- 데이터 동기화: 쓰기 모델에서 변경된 데이터가 읽기 모델에 반영되는 과정에서 데이터 일관성(Eventual Consistency) 문제가 발생할 수 있습니다. 즉, 쓰기 후 읽기까지 약간의 지연이 발생할 수 있습니다.
- 학습 곡선: 새로운 아키텍처 패턴에 대한 이해와 구현 경험이 필요합니다.
NestJS에서 CQRS 구현
NestJS는 자체적으로 CQRS를 직접 지원하는 모듈은 없지만, 모듈 시스템과 커스텀 프로바이더, 메시지 버스 패턴을 활용하여 CQRS를 구현할 수 있습니다.
주로 @nestjs/cqrs 패키지를 사용하여 CQRS의 핵심 구성 요소를 통합합니다.
@nestjs/cqrs 패키지 설치
npm install @nestjs/cqrs @nestjs/event-emitter # event-emitter는 이벤트 처리에 유용CQRS의 주요 구성 요소
같은 데이터를 쓰더라도 변경 규칙과 조회 최적화는 서로 다른 속도로 변한다.
- 검증과 트랜잭션을 가진 쓰기 모델
Command side Command 사용자의 변경 의도 Handler 권한, 검증, 트랜잭션 Aggregate 불변식과 상태 변경
- Command 사용자의 변경 의도
- Handler 권한, 검증, 트랜잭션
- Aggregate 불변식
상태 변경
- 화면 응답에 맞춘 읽기 모델
Query side Query 입력과 필터 조건 Read model 목록/상세/검색 구조 Cache 조회 성능 최적화
- Query 입력
필터 조건
- Read model 목록/상세/검색 구조
- Cache 조회 성능 최적화
| 질문 | Command 쪽 힌트 | Query 쪽 힌트 | 결정 |
|---|---|---|---|
| 상태 변경이 복잡한가? | 도메인 규칙, 트랜잭션, 이벤트 필요 | 단순 조회에는 과한 구조 | 쓰기 모델 분리 |
| 읽기 요구가 다른가? | 원본 모델 유지 | projection, search index, cache 사용 | 읽기 모델 분리 |
| 지연을 허용하는가? | 변경 성공을 먼저 확정 | eventual consistency 표시 필요 | UX 기준 명시 |
@nestjs/cqrs는 다음과 같은 핵심 빌딩 블록을 제공합니다.
- Command (명령): 시스템의 상태를 변경하는 작업을 정의하는 클래스입니다.
CommandBus: 명령을 발행(dispatch)하고 해당 명령 핸들러로 라우팅합니다.CommandHandler: 특정 명령을 처리하는 비즈니스 로직을 포함하는 클래스입니다.
- Query (조회): 시스템의 상태를 조회하는 작업을 정의하는 클래스입니다.
QueryBus: 조회를 발행하고 해당 조회 핸들러로 라우팅합니다.QueryHandler: 특정 조회를 처리하고 데이터를 반환하는 클래스입니다.
- Event (이벤트): 시스템에서 중요한 상태 변경이 발생했음을 나타내는 메시지입니다.
EventBus: 이벤트를 발행하고 모든 관련 이벤트 핸들러로 브로드캐스트합니다.EventHandler: 특정 이벤트를 구독하고 처리하는 클래스입니다. (예: 읽기 모델 업데이트)
- Saga: 여러 명령과 이벤트를 조율하여 복잡한 비즈니스 프로세스를 관리하는 클래스입니다.
NestJS에서 CQRS 구현 예시
사용자 생성(명령) 및 사용자 조회(조회) 기능을 CQRS 패턴으로 구현하는 예시입니다.
export class CreateUserCommand {
constructor(
public readonly name: string,
public readonly email: string,
) {}
}import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from '../impl/create-user.command';
import { Logger } from '@nestjs/common';
import { UserRepository } from '../../user.repository'; // 사용자 데이터 저장소 (쓰기 모델)
import { UserCreatedEvent } from '../../events/impl/user-created.event';
import { EventBus } from '@nestjs/cqrs'; // EventBus 주입
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
private readonly logger = new Logger(CreateUserHandler.name);
constructor(
private readonly userRepository: UserRepository,
private readonly eventBus: EventBus, // EventBus 주입
) {}
async execute(command: CreateUserCommand) {
this.logger.log(`Handling CreateUserCommand: ${command.email}`);
// 실제 데이터베이스에 사용자 생성 로직
const newUser = await this.userRepository.createUser(command.name, command.email);
// 사용자 생성 후 이벤트 발행
this.eventBus.publish(new UserCreatedEvent(newUser.id, newUser.email, newUser.name));
return newUser; // 또는 생성 성공 여부만 반환
}
}export class GetUserByIdQuery {
constructor(public readonly userId: string) {}
}import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetUserByIdQuery } from '../impl/get-user-by-id.query';
import { Logger } from '@nestjs/common';
import { UserReadModelRepository } from '../../user-read-model.repository'; // 조회 모델 데이터 저장소
@QueryHandler(GetUserByIdQuery)
export class GetUserByIdHandler implements IQueryHandler<GetUserByIdQuery> {
private readonly logger = new Logger(GetUserByIdHandler.name);
constructor(private readonly userReadModelRepository: UserReadModelRepository) {} // 읽기 모델 저장소 주입
async execute(query: GetUserByIdQuery) {
this.logger.log(`Handling GetUserByIdQuery: ${query.userId}`);
// 읽기 전용 데이터베이스에서 사용자 정보 조회 로직
const user = await this.userReadModelRepository.findById(query.userId);
return user;
}
}export class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly name: string,
) {}
}import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { UserCreatedEvent } from '../impl/user-created.event';
import { Logger } from '@nestjs/common';
import { UserReadModelRepository } from '../../user-read-model.repository'; // 조회 모델 데이터 저장소
@EventsHandler(UserCreatedEvent)
export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> {
private readonly logger = new Logger(UserCreatedHandler.name);
constructor(private readonly userReadModelRepository: UserReadModelRepository) {} // 읽기 모델 저장소 주입
async handle(event: UserCreatedEvent) {
this.logger.log(`Handling UserCreatedEvent: User ID ${event.userId}`);
// 사용자가 생성되면, 읽기 모델을 업데이트하는 로직
// 예: 별도의 읽기 전용 DB(Elasticsearch, Redis 등)에 사용자 정보 저장
await this.userReadModelRepository.createOrUpdate({
id: event.userId,
name: event.name,
email: event.email,
// 필요한 추가적인 읽기 전용 필드
});
}
}UserRepository(쓰기 모델): 주 데이터베이스(예: PostgreSQL, MongoDB)에 데이터를 쓰고 수정하는 로직을 담당합니다.src/user/user.repository.ts (개념적 코드) import { Injectable } from '@nestjs/common'; @Injectable() export class UserRepository { // 실제로는 TypeORM이나 Mongoose 등을 사용 private users: any[] = []; private nextId = 1; async createUser(name: string, email: string) { const newUser = { id: (this.nextId++).toString(), name, email, createdAt: new Date() }; this.users.push(newUser); return newUser; } // ... update, delete 등 쓰기 관련 메서드 }UserReadModelRepository(읽기 모델): 읽기 전용 데이터베이스(예: Redis, Elasticsearch, 또는 주 데이터베이스의 Read Replica)에서 데이터를 조회하고, 이벤트에 의해 업데이트되는 로직을 담당합니다.src/user/user-read-model.repository.ts (개념적 코드) import { Injectable } from '@nestjs/common'; @Injectable() export class UserReadModelRepository { // 실제로는 Redis 클라이언트, Elasticsearch 클라이언트 등 사용 private readUsers: Map<string, any> = new Map(); async findById(id: string) { return this.readUsers.get(id); } async createOrUpdate(user: { id: string; name: string; email: string }) { this.readUsers.set(user.id, user); console.log(`Read model updated for user: ${user.id}`); } // ... 다른 조회 관련 메서드 }
CqrsModule을 임포트하고, Command/Query/Event 핸들러를 프로바이더에 등록합니다.
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { UserController } from './user.controller';
import { UserRepository } from './user.repository';
import { UserReadModelRepository } from './user-read-model.repository';
// Command Handlers
import { CreateUserHandler } from './commands/handlers/create-user.handler';
// Query Handlers
import { GetUserByIdHandler } from './queries/handlers/get-user-by-id.handler';
// Event Handlers
import { UserCreatedHandler } from './events/handlers/user-created.handler';
// CQRS 관련 프로바이더들을 배열로 정의
export const CommandHandlers = [CreateUserHandler];
export const QueryHandlers = [GetUserByIdHandler];
export const EventHandlers = [UserCreatedHandler];
@Module({
imports: [CqrsModule], // CqrsModule 임포트
controllers: [UserController],
providers: [
UserRepository, // 쓰기 모델 리포지토리
UserReadModelRepository, // 읽기 모델 리포지토리
...CommandHandlers, // 모든 Command Handler 등록
...QueryHandlers, // 모든 Query Handler 등록
...EventHandlers, // 모든 Event Handler 등록
],
})
export class UserModule {}import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
@Module({
imports: [UserModule], // UserModule 임포트
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}컨트롤러에서 CommandBus와 QueryBus를 주입받아 사용합니다.
import { Controller, Post, Get, Body, Param, Logger } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; // CQRS 버스 주입
import { CreateUserCommand } from './commands/impl/create-user.command';
import { GetUserByIdQuery } from './queries/impl/get-user-by-id.query';
@Controller('users')
export class UserController {
private readonly logger = new Logger(UserController.name);
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async createUser(@Body() body: { name: string; email: string }) {
this.logger.log('Received createUser request');
// Command 발행
const user = await this.commandBus.execute(new CreateUserCommand(body.name, body.email));
return { message: 'User created successfully', user };
}
@Get(':id')
async getUser(@Param('id') id: string) {
this.logger.log(`Received getUser request for ID: ${id}`);
// Query 발행
const user = await this.queryBus.execute(new GetUserByIdQuery(id));
if (!user) {
// NestJS 예외 처리 (NotFoundException 등)
throw new Error('User not found');
}
return user;
}
}CommandHandler는 트랜잭션을 끝낸 뒤 이벤트를 발행하고, EventHandler는 화면 조회에 맞는 projection을 갱신한다.
- 규칙을 통과한 상태
Aggregate / Write Model 사용자 생성처럼 검증을 통과한 변경만 저장하고 이벤트를 남긴다. CreateUserCommand 처리
- 변경 사실을 전달
EventBus / EventHandler UserCreatedEvent를 받아 검색 색인, 통계, 캐시처럼 읽기 모델을 갱신한다.
- 조회 화면에 맞춘 모델
Projection / Read Model 목록, 상세, 검색 화면에서 바로 읽을 수 있는 형태로 저장한다.
CQRS 구현 시 고려사항
- 데이터 일관성 (Eventual Consistency): 쓰기 모델과 읽기 모델 간의 데이터 동기화 지연을 허용해야 합니다. 사용자에게 지금은 데이터가 바로 보이지 않을 수 있지만, 잠시 후 반영됩니다와 같은 메시지를 제공해야 할 수도 있습니다.
- 트랜잭션 관리: 쓰기 모델에서 복잡한 트랜잭션이 필요한 경우, 도메인 주도 설계(DDD)의 애그리게이트(Aggregate) 패턴과 함께 사용하는 것이 효과적입니다.
- 메시지 큐/브로커: 쓰기 모델에서 읽기 모델로 이벤트(데이터 변경 알림)를 전송할 때, Kafka, RabbitMQ, AWS SQS/SNS 등 신뢰할 수 있는 메시지 큐/브로커를 사용하는 것이 일반적입니다. 이는 비동기 통신을 통해 시스템의 결합도를 낮추고 확장성을 높입니다.
- 데이터베이스 선택: 읽기/쓰기 모델에 각각 최적화된 데이터베이스를 선택합니다.
- 쓰기 모델: 관계형 DB(PostgreSQL, MySQL), 문서 DB(MongoDB) 등
- 읽기 모델: 검색 엔진(Elasticsearch), 인메모리 캐시(Redis), Read Replica, NoSQL 등
- 로깅 및 모니터링: CQRS는 시스템을 더 분산시키므로, 각 컴포넌트 간의 데이터 흐름과 병목 지점을 추적할 수 있도록 분산 로깅 및 트레이싱 시스템(예: OpenTelemetry, Jaeger)을 구축하는 것이 중요합니다.
Command 규칙과 Query 최적화를 분리할 가치가 비동기 반영·추적 복잡도보다 큰지 판단한다.
- 아니오 · CRUD 유지
Controller → Service → Repository 조회와 변경 요구가 비슷하면 한 모델이 더 단순하고 일관적이다.
- 예 · CQRS 검토
Command 규칙 → Event → Read Model 반영 지연과 Handler·Event·Projection 추적을 운영할 수 있어야 한다.
| 판단 질문 | CQRS 신호 | 경고 신호 |
|---|---|---|
| 쓰기 규칙 | 검증·트랜잭션이 복잡 | 단순 CRUD |
| 읽기 모델 | 화면별 캐시·색인 필요 | 원본 모델로 충분 |
| 일관성 | 허용 지연을 제품이 설명 | 즉시 일관성 필수 |
| 운영 | Command→Event→Read 추적 | 장애 위치를 찾기 어려움 |
CQRS를 도입할 때는 쓰기 성공 이후 조회 반영까지의 지연과 운영 추적을 함께 설계해야 합니다.
원본 상태가 확정된 뒤 이벤트와 Projection이 읽기 모델을 갱신하므로 제품은 잠시 오래된 화면을 다뤄야 한다.
- Command
Bus가 상태 변경 의도를 Handler에 전달한다.
- Write Model
비즈니스 규칙과 트랜잭션으로 원본 상태를 확정한다.
- Event
변경 사실을 발행하고 재처리 가능한 기록을 남긴다.
- Projection
테이블·캐시·색인을 조회 모양으로 갱신한다.
- Query
Bus가 반영된 Read Model을 반환한다.
| 제품 상태 | 화면 처리 | 운영 신호 |
|---|---|---|
| 쓰기 직후 | 처리 중·낙관적 상태 표시 | commandId·eventId |
| 반영 대기 | 새로고침·재조회 정책 | projection lag |
| 반영 완료 | 최신 읽기 모델 표시 | lastProjectedAt |
CQRS 패턴은 모든 애플리케이션에 필요한 것은 아닙니다.
시스템 복잡성이 낮거나 읽기/쓰기 비율이 크게 다르지 않다면, 전통적인 CRUD 모델이 더 단순하고 관리하기 쉽습니다.
높은 확장성, 성능 최적화, 복잡한 도메인 모델이 필요한 대규모 엔터프라이즈 시스템에서는 CQRS가 구조적 이점을 제공할 수 있습니다.
NestJS의 모듈성과 @nestjs/cqrs 패키지는 이 복잡한 패턴을 구조적으로 구현하는 데 도움을 줍니다.
명령은 쓰기 모델을 변경하고 이벤트를 발행하며, 이벤트 핸들러가 읽기 모델을 갱신한 뒤 조회가 새 상태를 가져온다.
- CommandBus.execute()
command 컨트롤러는 CreateUserCommand처럼 상태 변경 의도를 명령 객체로 만들어 전달한다.
- 쓰기 모델과 비즈니스 규칙
handler CreateUserHandler는 리포지토리와 저장소를 통해 규칙을 지키고 이벤트를 발행한다.
- EventBus.publish()
event 읽기 모델 갱신, 알림, 로그 작업 같은 후속 처리를 이벤트 핸들러로 분리한다.
- 읽기 모델 재구성
read Redis, 검색 색인, Read Replica처럼 조회에 맞춘 저장소를 최신 상태로 맞춘다.
- 지연과 실패 추적
trace 메시지 큐, 재시도, 버전 관리로 이벤트가 읽기 모델까지 도달했는지 추적해야 한다.
CQRS 개념과 NestJS에서 CQRS 구현 흐름을 함께 정리한 보조 다이어그램입니다.
CQRS는 Command와 Query를 물리적으로 나누는 문법이 아니라 변경 규칙, 이벤트, Projection의 책임 경계를 설계하는 방식이다.
- 변경 의도와 불변식
Command 권한, 유효성, 트랜잭션을 한 트랜잭션 안에서 다룬다.
- 성공한 변경 사실
Event outbox나 domain event로 projection과 외부 작업을 깨운다.
- 조회 전용 모델
Query 목록, 검색, 통계, 캐시처럼 화면/API 요구에 맞춘다.
| 구분 | 책임 | 삶이 변하는 쪽 | NestJS 위치 |
|---|---|---|---|
| Command 변경 요청 | 도메인 규칙과 트랜잭션 | 조회 최적화 코드가 쓰기 규칙을 오염 | CommandBus, Handler |
| Event 변경 결과 | Projection 갱신과 외부 작업 | 복구 기준 없는 느린 반영 위험 | EventBus, outbox worker |
| Query 조회 모델 | 조회 모델과 캐시 최적화 | 읽기 요구 때문에 쓰기 모델이 복잡 | QueryBus, Handler |