CQRS 패턴 구현
지난 절에서는 NestJS를 사용하여 실시간 웹 애플리케이션의 핵심인 WebSocket을 구현하는 방법을 알아보았습니다. 이제 12장의 세 번째 절로, 대규모 엔터프라이즈 애플리케이션에서 확장성과 성능을 향상시키기 위한 고급 아키텍처 패턴인 CQRS (Command Query Responsibility Segregation) 패턴과 이를 NestJS에서 구현하는 방법에 대해 살펴보겠습니다.
기존의 애플리케이션은 대부분 CRUD(Create, Read, Update, Delete) 모델을 사용하여 데이터를 처리합니다. 즉, 데이터를 읽고(Query) 쓰는(Command) 작업이 동일한 데이터 모델과 로직을 공유합니다. 하지만 복잡한 시스템에서는 읽기 작업의 빈도와 요구사항이 쓰기 작업과는 매우 다르고, 이로 인해 성능 병목이나 확장성 문제가 발생할 수 있습니다. CQRS는 이러한 문제를 해결하기 위해 등장한 패턴입니다.
CQRS란?
CQRS는 애플리케이션의 작업(Operation)을 명령(Command) 과 조회(Query) 로 나누고, 이 두 가지 책임에 대해 서로 다른 모델을 사용하는 아키텍처 패턴입니다.
- 명령 (Command): 데이터 상태를 변경하는 작업입니다 (생성, 업데이트, 삭제). 명령은 비즈니스 로직을 포함하며, 일반적으로 결과 값을 반환하지 않고 작업의 성공/실패 여부만 알립니다.
- 조회 (Query): 데이터를 읽는 작업입니다. 조회는 데이터의 상태를 변경하지 않으며, 복잡한 비즈니스 로직 없이 데이터를 가져와 소비자에게 반환합니다.
CQRS의 핵심 아이디어
전통적인 CRUD 모델에서는 동일한 데이터 모델(예: ORM 엔티티)을 읽기(조회)와 쓰기(명령) 모두에 사용합니다. 이로 인해 다음과 같은 문제가 발생할 수 있습니다.
- 성능 병목: 읽기 작업이 쓰기 작업보다 훨씬 많을 때, 쓰기 작업에 최적화된 데이터 모델이 읽기 성능을 저하시킬 수 있습니다.
- 복잡성 증가: 읽기와 쓰기 로직이 얽히면서 코드 복잡도가 증가하고 유지보수가 어려워집니다.
- 확장성 제약: 읽기 스케일링과 쓰기 스케일링 요구사항이 다를 때, 동일한 모델로는 효율적인 확장이 어렵습니다.
CQRS는 이를 분리하여 각 책임에 최적화된 아키텍처를 구축할 수 있도록 합니다.
CQRS의 장점
- 확장성 (Scalability): 읽기 모델과 쓰기 모델을 독립적으로 확장할 수 있습니다. 예를 들어, 읽기 작업이 많으면 조회 모델의 인스턴스를 늘리고, 쓰기 작업이 많으면 명령 모델의 인스턴스를 늘릴 수 있습니다.
- 성능 최적화
- 쓰기 모델: 쓰기 작업에 최적화된 데이터베이스(예: NoSQL)나 데이터 구조를 사용할 수 있습니다.
- 읽기 모델: 읽기 작업에 최적화된 데이터베이스(예: 검색 엔진, Read Replica)나 캐싱 전략을 사용할 수 있습니다. 복잡한 JOIN 없이 쿼리에 필요한 데이터만 미리 평탄화(denormalization)하여 저장할 수 있습니다.
- 유연성: 각 모델에 독립적인 기술 스택을 적용할 수 있습니다.
- 복잡성 분리: 읽기/쓰기 로직을 분리하여 코드의 응집도를 높이고 유지보수를 용이하게 합니다.
- 이벤트 소싱(Event Sourcing)과의 시너지: CQRS는 종종 이벤트 소싱과 함께 사용됩니다. 모든 상태 변경을 이벤트로 기록하고, 이 이벤트를 통해 읽기 모델을 구축하는 방식입니다.
CQRS의 단점
- 복잡성 증가: 읽기/쓰기 모델을 분리하므로 시스템 아키텍처가 더 복잡해지고, 초기 설정 및 관리에 더 많은 노력이 필요합니다.
- 데이터 동기화: 쓰기 모델에서 변경된 데이터가 읽기 모델에 반영되는 과정에서 데이터 일관성(Eventual Consistency) 문제가 발생할 수 있습니다. 즉, 쓰기 후 읽기까지 약간의 지연이 발생할 수 있습니다.
- 학습 곡선: 새로운 아키텍처 패턴에 대한 이해와 구현 경험이 필요합니다.
NestJS에서 CQRS 구현
NestJS는 자체적으로 CQRS를 직접 지원하는 모듈은 없지만, 강력한 모듈 시스템과 커스텀 프로바이더, 그리고 메시지 버스 패턴을 활용하여 CQRS를 효과적으로 구현할 수 있습니다. 주로 @nestjs/cqrs
패키지를 사용하여 CQRS의 핵심 구성 요소를 쉽게 통합할 수 있습니다.
@nestjs/cqrs
패키지 설치
npm install @nestjs/cqrs @nestjs/event-emitter # event-emitter는 이벤트 처리에 유용
CQRS의 주요 구성 요소
@nestjs/cqrs
는 다음과 같은 핵심 빌딩 블록을 제공합니다.
- Command (명령): 시스템의 상태를 변경하는 작업을 정의하는 클래스입니다.
CommandBus
: 명령을 발행(dispatch)하고 해당 명령 핸들러로 라우팅합니다.CommandHandler
: 특정 명령을 처리하는 비즈니스 로직을 포함하는 클래스입니다.
- Query (조회): 시스템의 상태를 조회하는 작업을 정의하는 클래스입니다.
QueryBus
: 조회를 발행하고 해당 조회 핸들러로 라우팅합니다.QueryHandler
: 특정 조회를 처리하고 데이터를 반환하는 클래스입니다.
- Event (이벤트): 시스템에서 중요한 상태 변경이 발생했음을 나타내는 메시지입니다.
EventBus
: 이벤트를 발행하고 모든 관련 이벤트 핸들러로 브로드캐스트합니다.EventHandler
: 특정 이벤트를 구독하고 처리하는 클래스입니다. (예: 읽기 모델 업데이트)
- Saga: 여러 명령과 이벤트를 조율하여 복잡한 비즈니스 프로세스를 관리하는 클래스입니다.
NestJS에서 CQRS 구현 예시
사용자 생성(명령) 및 사용자 조회(조회) 기능을 CQRS 패턴으로 구현하는 예시입니다.
Commands (명령)
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; // 또는 생성 성공 여부만 반환
}
}
Queries (조회)
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;
}
}
Events (이벤트)
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,
// 필요한 추가적인 읽기 전용 필드
});
}
}
Repositories (저장소)
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}`); } // ... 다른 조회 관련 메서드 }
NestJS 모듈 설정
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;
}
}
CQRS 구현 시 고려사항
- 데이터 일관성 (Eventual Consistency): 쓰기 모델과 읽기 모델 간의 데이터 동기화 지연을 허용해야 합니다. 사용자에게 "지금은 데이터가 바로 보이지 않을 수 있지만, 잠시 후 반영됩니다"와 같은 메시지를 제공해야 할 수도 있습니다.
- 트랜잭션 관리: 쓰기 모델에서 복잡한 트랜잭션이 필요한 경우, 도메인 주도 설계(DDD)의 애그리게이트(Aggregate) 패턴과 함께 사용하는 것이 효과적입니다.
- 메시지 큐/브로커: 쓰기 모델에서 읽기 모델로 이벤트(데이터 변경 알림)를 전송할 때, Kafka, RabbitMQ, AWS SQS/SNS 등 신뢰할 수 있는 메시지 큐/브로커를 사용하는 것이 일반적입니다. 이는 비동기 통신을 통해 시스템의 결합도를 낮추고 확장성을 높입니다.
- 데이터베이스 선택: 읽기/쓰기 모델에 각각 최적화된 데이터베이스를 선택합니다.
- 쓰기 모델: 관계형 DB(PostgreSQL, MySQL), 문서 DB(MongoDB) 등
- 읽기 모델: 검색 엔진(Elasticsearch), 인메모리 캐시(Redis), Read Replica, NoSQL 등
- 로깅 및 모니터링: CQRS는 시스템을 더 분산시키므로, 각 컴포넌트 간의 데이터 흐름과 병목 지점을 추적할 수 있도록 분산 로깅 및 트레이싱 시스템(예: OpenTelemetry, Jaeger)을 구축하는 것이 중요합니다.
CQRS 패턴은 모든 애플리케이션에 필요한 것은 아닙니다. 시스템의 복잡성이 낮거나 읽기/쓰기 비율이 크게 다르지 않다면, 전통적인 CRUD 모델이 더 단순하고 관리하기 쉽습니다. 하지만 높은 확장성, 성능 최적화, 그리고 복잡한 도메인 모델이 필요한 대규모 엔터프라이즈 시스템에서는 CQRS가 강력한 아키텍처적 이점을 제공할 수 있습니다. NestJS의 모듈성과 @nestjs/cqrs
패키지는 이 복잡한 패턴을 구조적으로 구현할 수 있도록 돕습니다.