NestJS에서 CQRS 패턴 완벽 가이드: 확장 가능한 아키텍처 구축하기

NestJS에서 CQRS 패턴 완벽 가이드: 확장 가능한 아키텍처 구축하기

D
dongAuthor
5 min read

복잡한 비즈니스 로직과 높은 성능을 요구하는 백엔드 시스템을 개발하다 보면 기존의 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의 핵심은 CommandQuery를 명확히 분리하는 것입니다.

  • 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는 작은 프로젝트에는 과도할 수 있습니다. 복잡한 비즈니스 로직, 이벤트 중심 아키텍처, 대규모 트래픽 환경일 때 도입이 적절합니다.

References