NestJS에서 CQRS 패턴 완벽 가이드: 확장 가능한 아키텍처 구축하기
복잡한 비즈니스 로직과 높은 성능을 요구하는 백엔드 시스템을 개발하다 보면 기존의 CRUD 패턴이 한계를 드러내는 경우가 많습니다. 이때 CQRS(Command Query Responsibility Segregation) 패턴이 해결책이 될 수 있습니다. 이 가이드에서는 NestJS에서 CQRS 패턴을 구현하는 방법을 단계별로 살펴보고, 실제 프로젝트에 적용할 수 있는 실무 노하우를 공유하겠습니다.
CQRS 패턴의 핵심 개념부터 NestJS에서의 실제 구현, 테스팅 전략까지 종합적으로 다루어 여러분의 애플리케이션 아키텍처를 한 단계 끌어올릴 수 있도록 도와드립니다.
CQRS 패턴이란? 기존 CRUD와의 차이점
CQRS(Command Query Responsibility Segregation)는 명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴입니다. 이 패턴은 읽기와 쓰기 작업을 완전히 별도의 모델로 분리하여 각각을 독립적으로 최적화할 수 있게 해줍니다.
전통적인 CRUD 방식의 한계
기존의 CRUD 아키텍처에서는 하나의 서비스에서 모든 작업을 처리합니다:
@Injectable()
export class UserService {
createUser(userData: CreateUserDto) { /* ... */ }
getUser(id: string) { /* ... */ }
updateUser(id: string, userData: UpdateUserDto) { /* ... */ }
deleteUser(id: string) { /* ... */ }
}
이런 구조는 작은 프로젝트에서는 문제없지만, 비즈니스 로직이 복잡해지면서 다음과 같은 문제들이 발생합니다:
- 비즈니스 로직의 얽힘: 읽기와 쓰기 로직이 하나의 서비스에 혼재
- 확장성 제약: 읽기와 쓰기 작업의 성능 요구사항이 다른데도 함께 묶여 있음
- 테스트 복잡성: 모든 기능이 하나의 클래스에 있어 단위 테스트가 어려움
CQRS가 제공하는 해결책
CQRS 패턴은 이러한 문제를 다음과 같이 해결합니다:
- 책임 분리: 상태 변경 작업(Command)과 데이터 조회 작업(Query)을 명확히 분리
- 독립적 확장: 읽기와 쓰기 작업을 각각 최적화하고 독립적으로 확장 가능
- 명확한 플로우: "무엇을 해야 하는지"가 명확하게 드러남
- 테스트 용이성: 각 핸들러가 독립적이어서 단위 테스트가 쉬워짐
NestJS CQRS 모듈 설정하기
NestJS에서 CQRS 패턴을 구현하려면 @nestjs/cqrs 패키지를 사용합니다. 이 패키지는 CommandBus, QueryBus, EventBus 등 CQRS 구현에 필요한 핵심 컴포넌트들을 제공합니다.
프로젝트 설정
먼저 필요한 패키지를 설치합니다:
npm install --save @nestjs/cqrs
다음으로 루트 모듈에 CqrsModule을 등록합니다:
// app.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { UserModule } from './user/user.module';
@Module({
imports: [
CqrsModule.forRoot(),
UserModule,
],
})
export class AppModule {}
CqrsModule은 CommandBus, QueryBus, EventBus를 자동으로 주입하여 CQRS 패턴의 기반을 제공합니다.
Command와 Query 구현하기
CQRS의 핵심은 Command와 Query를 명확히 분리하는 것입니다.
- Command → 상태를 변경하는 작업 (Create, Update, Delete)
- Query → 데이터를 읽는 작업 (Read)
각각의 예시를 살펴보겠습니다.
// commands/create-user.command.ts
export class CreateUserCommand {
constructor(
public readonly name: string,
public readonly email: string,
) {}
}
// queries/get-user.query.ts
export class GetUserQuery {
constructor(public readonly id: string) {}
}
Command & Query Handler 구현
핸들러는 실제 로직을 수행하는 핵심 부분입니다.
// commands/handlers/create-user.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from '../create-user.command';
import { UserService } from '../../user.service';
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
constructor(private readonly userService: UserService) {}
async execute(command: CreateUserCommand): Promise<any> {
const { name, email } = command;
return this.userService.create({ name, email });
}
}
// queries/handlers/get-user.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { GetUserQuery } from '../get-user.query';
import { UserService } from '../../user.service';
@QueryHandler(GetUserQuery)
export class GetUserHandler implements IQueryHandler<GetUserQuery> {
constructor(private readonly userService: UserService) {}
async execute(query: GetUserQuery): Promise<any> {
return this.userService.findById(query.id);
}
}
핸들러는 단일 책임 원칙(SRP)에 따라 각각의 Command나 Query만 담당해야 합니다.
Controller에서 CQRS 적용
Controller는 CommandBus와 QueryBus를 통해 각 핸들러로 요청을 위임합니다.
@Controller('users')
export class UserController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async createUser(@Body() userData: { name: string; email: string }) {
return this.commandBus.execute(
new CreateUserCommand(userData.name, userData.email)
);
}
@Get(':id')
async getUser(@Param('id') id: string) {
return this.queryBus.execute(new GetUserQuery(id));
}
}
Controller는 CQRS 환경에서 ‘입력 포트’ 역할만 수행하며, 로직은 모두 핸들러로 위임됩니다.
Event Handling과 Sagas
CQRS의 진정한 힘은 Event 시스템과 결합될 때 나타납니다.
// events/user-created.event.ts
export class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
) {}
}
// events/handlers/user-created.handler.ts
@EventsHandler(UserCreatedEvent)
export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> {
handle(event: UserCreatedEvent) {
console.log(`사용자가 생성되었습니다: ${event.userId}`);
}
}
Event는 Command/Query의 실행 결과로 발생하며, 시스템의 다른 부분들이 이를 감지하고 반응할 수 있습니다.
CQRS 구현 테스팅
CQRS는 핸들러 단위 테스트가 매우 쉽습니다.
describe('CreateUserHandler', () => {
it('userService.create를 올바르게 호출해야 함', async () => {
const command = new CreateUserCommand('Alice', 'alice@email.com');
const result = await handler.execute(command);
expect(result).toBeDefined();
});
});
각 핸들러는 독립적인 유닛이기 때문에, 외부 의존성을 Mocking하여 손쉽게 테스트할 수 있습니다.
CQRS Best Practices
- Command와 Query를 반드시 분리할 것
- EventBus를 적극적으로 활용해 비동기 플로우 구성
- 핸들러 단위 테스트를 습관화할 것
- 핸들러 내부에서 비즈니스 로직 최소화: 서비스 계층에 위임
CQRS는 작은 프로젝트에는 과도할 수 있습니다. 복잡한 비즈니스 로직, 이벤트 중심 아키텍처, 대규모 트래픽 환경일 때 도입이 적절합니다.