NestJS - CQRS


이 포스트는 NestJS 에서 CQRS 패턴을 적용하는 법에 대해 알아본다.

소스는 user-service 에 있습니다.


1. CQRS 패턴

CQRS(Command Query Responsibility Separation) 은 command(명령) 과 query(조회) 를 분리하여 성능, 확장설, 보안성을 높일 수 있는 아키텍쳐 패턴이다.

요구 사항이 복잡해질수록 도메인 모델도 복잡해지는데 데이터를 조회한 쪽에서는 현재의 복잡한 모델 구조의 데이터가 필요하지 않은 경우가 대부분이므로 조회 시의 모델과 데이터 변경 시의 모델을 다르게 가져가는 방식이다.

<CQRS 사용 시 이점을 얻을 수 있는 상황>

  • CQRS 를 사용하면 복잡한 도메인을 다루기 더 쉬운 경우
    • CQRS 를 사용하면 복잡성이 추가되어 생산성이 감소하므로 모델을 공유하는 것이 도메인을 다루기 더 쉬워지는 상황인지 판단해야 함
    • 시스템 전체가 아닌 DDD(Domain-Driven Design, 도메인 주도 설계) 에서 말하는 bounded context 내에서만 사용해야 함
  • 고성능 처리가 필요한 애플리케이션인 경우
    • CQRS 를 사용하면 읽기 및 쓰기 작업에서 로드를 분리하여 각각 독립적으로 확장 가능
    • 성능을 위해 쓰기는 RDB 로, 읽기는 Redis 로 사용하는 경우 유용
    • 애플리케이션에서 읽기와 쓰기 사이에 성능 차이가 큰 경우에 CQRS 사용하면 편리함

복잡한 도메인을 다루고, DDD 를 적용할 때 CQRS 가 적합!


2. 유저 서비스

서비스가 커질수록 변경 영향도도 커지고, 컨트롤러와 서비스, 영속화 및 도메인 레이어에서 주고 받는 데이터가 복잡해질 뿐 아니라 콘텍스트가 상이한 곳에서 모델을 그대로 전달하여 사용하는 경우가 발생하기 때문에 CQRS 패턴을 적용하여 구현하는 것이 좋다.

Nest 에서 제공하는 간단한 CQRS 모듈을 구현해보도록 하자.

CQRS 설치

$ npm i @nestjs/cqrs

CQRS 를 모듈을 사용하도록 UsersModule 로 가져온다.

/src/users/users.module.ts

import { CqrsModule } from '@nestjs/cqrs';

@Module({
  imports: [
    ...
    CqrsModule,
  ], // UsersModule 에 forFeature() 로 유저 모듈 내에서 사용할 저장소 등록
})
export class UsersModule {}

2.1. Command: @CommandHandler()

  • ICommand 를 구현하는 Command 클래스 필요
  • ICommandHandler 를 구현하는 Command 핸들러 클래스 필요 (실제 로직 들어감)

CRUD 는 Create, Update, Delete Command 를 이용하여 처리한다.

Command 는 서비스 계층이나 컨트롤러, 게이트웨이에서 직접 발송 가능하여, 전송한 Command 는 Command Handler 가 받아서 처리한다.

그럼 이제 유저 생성을 위한 Command 를 정의하여 유저 생성 로직을 Command 로 처리해본다.

2.1.1. 회원가입 로직 Command 로 구현

/src/users/command/create-user.command.ts

import { ICommand } from '@nestjs/cqrs';

export class CreateUserCommand implements ICommand {
  constructor(
    readonly name: string,
    readonly email: string,
    readonly password: string,
  ) {}
}

이제 컨트롤러에서 유저 생성 요청이 왔을 때 직접 UsersService 의 함수를 호출하지 말고 Command 를 전달한다.

/src/users/users.controller.ts

...
import { CommandBus } from '@nestjs/cqrs';
import { CreateUserCommand } from './command/create-user.command';

@Controller('users')
export class UsersController {
  // UsersService 를 컨트롤러에 주입
  constructor(
   ...    
    // @nestjs/cqrs 패키지에서 제공하는 CommandBus 주입
    private commandBus: CommandBus,
  ) {}

  // 회원 가입
  @Post()
  async createUser(@Body() dto: CreateUserDto): Promise<void> {
    const { name, email, password } = dto;

    const command = new CreateUserCommand(name, email, password);

    // 직접 만든 CreateUserCommand 전송
    return this.commandBus.execute(command);
  }
}

이제 회원 가입 시 UsersController 는 UsersService 에 직접 의존하지 않으므로 관련 코드는 삭제한다.

CreateUserCommand 를 처리하는 CreateUserHandler 를 만들고, 기존에 UsersService 에서 수행한 로직을 이동시킨다.

/src/users/create-user.handler.ts

import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from './create-user.command';
import { UnprocessableEntityException } from '@nestjs/common';
import * as uuid from 'uuid';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../entity/user.entity';
import { DataSource, Repository } from 'typeorm';
import { ulid } from 'ulid';

@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(
    private dataSource: DataSource,
    //`@InjectRepository` 데커레이터로 유저 저장소 주입
    @InjectRepository(UserEntity)
    private userRepository: Repository<UserEntity>,
  ) {}

  async execute(command: CreateUserCommand) {
    const { name, email, password } = command;
    // 가입 유무 확인
    const userExist = await this.checkUserExists(email);
    if (userExist) {
      throw new UnprocessableEntityException('Email already exists');
    }

    const signupVerifyToken = uuid.v1();
    console.log('signupVerifyToken: ', signupVerifyToken);

    // 유저 정보 저장
    await this.saveUserUsingTransaction(
      name,
      email,
      password,
      signupVerifyToken,
    );

    // 회원 가입 이메일 발송- TODO: Event 로 처리
    // await this.sendMemberJoinEmail(email, signupVerifyToken);
  }

  // 가입 유무 확인
  private async checkUserExists(email: string) {
    const user = await this.userRepository.findOne({
      where: { email: email },
    });

    console.log('user: ', user);
    return user !== null;
  }

  // 유저 정보 저장 - transaction 함수 직접 이용하여 트랜잭션 제어
  private async saveUserUsingTransaction(
    name: string,
    email: string,
    password: string,
    signupVerifyToken: string,
  ) {
    await this.dataSource.transaction(async (manager) => {
      const user = new UserEntity(); // 유저 엔티티 객체 생성
      user.id = ulid();
      user.name = name;
      user.email = email;
      user.password = password;
      user.signupVerifyToken = signupVerifyToken;

      await manager.save(user);

      // 일부러 에러 발생 시 데이터 저장 안됨
      //throw new InternalServerErrorException();
    });
  }
}

이제 CreateUserHandler 를 UsersModule 의 Provider 로 제공하고 기존의 Provider 로 제공하고 있던 UsersService 는 삭제한다.

/src/users/users.module.ts

...
import { CqrsModule } from '@nestjs/cqrs';
import { CreateUserHandler } from './command/create-user.handler';

@Module({
  ...
  providers: [
    //UsersService,
    CreateUserHandler,
  ],
})
export class UsersModule {}

로그인 로직과 회원 가입 이메일 인증, 액세스 토큰 검증도 Command 로 처리하도록 구현한다.


2.1.2. 회원 가입 이메일 인증 로직 Command 로 구현

/src/users/command/verify-email.command.ts (Command 정의)

import { ICommand } from '@nestjs/cqrs';

export class VerifyEmailCommand implements ICommand {
  constructor(readonly signupVerifyToken: string) {}
}

/src/users/users.controller.ts (컨트롤러에서 회원가입 이메일 인증 요청이 왔을 때 UsersService 의 함수가 아닌 Command 를 전달)

// 이메일 인증 - Command 사용
@Post('/email-verify')
async verifyEmail(@Query() dto: VerifyEmailDto): Promise<string> {
  const { signupVerifyToken } = dto;

  const command = new VerifyEmailCommand(signupVerifyToken);

  return this.commandBus.execute(command);
}

/src/users/verify-email.handler.ts (CreateUserCommand 를 처리하는 CreateUserHandler 를 만들고, 기존에 UsersService 에서 수행한 로직을 이동)

import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { VerifyEmailCommand } from './verify-email.command';
import { NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../entity/user.entity';
import { Repository } from 'typeorm';
import { AuthService } from '../../auth/auth.service';

@CommandHandler(VerifyEmailCommand)
export class VerifyEmailHandler implements ICommandHandler<VerifyEmailCommand> {
  constructor(
    //`@InjectRepository` 데커레이터로 유저 저장소 주입
    @InjectRepository(UserEntity)
    private userRepository: Repository<UserEntity>,
    private authService: AuthService,
  ) {}

  async execute(command: VerifyEmailCommand) {
    const { signupVerifyToken } = command;
    // DB 에 signupVerifyToken 으로 회원 가입 처리중인 유저가 있는지 조회 후 없다면 에러 처리
    const user = await this.userRepository.findOne({
      where: { signupVerifyToken },
    });

    if (!user) {
      throw new NotFoundException('존재하지 않는 유저');
    }

    // 바로 로그인 상태가 되도록 JWT 발급
    return this.authService.login({
      id: user.id,
      name: user.name,
      email: user.email,
    });
  }
}

/src/users/users.module.ts (VerifyEmailHandler 를 UsersModule 의 Provider 로 제공)

import { CqrsModule } from '@nestjs/cqrs';
import { CreateUserHandler } from './command/create-user.handler';
import { VerifyEmailHandler } from './command/verify-email.handler';

const commandHandlers = [CreateUserHandler, VerifyEmailHandler];

@Module({
  imports: [
    CqrsModule,
  ],
  controllers: [UsersController],
  providers: [...commandHandlers],
})
export class UsersModule {}

2.1.3. 로그인 로직 Command 로 구현

/src/users/command/login.command.ts (Command 정의)

import { ICommand } from '@nestjs/cqrs';

export class LoginCommand implements ICommand {
  constructor(readonly email: string, readonly password: string) {}
}

/src/users/users.controller.ts (컨트롤러에서 로그인 요청이 왔을 때 UsersService 의 함수가 아닌 Command 를 전달)

// 로그인
@Post('login')
async login(@Body() dto: UserLoginDto): Promise<string> {
  const { email, password } = dto;
  const command = new LoginCommand(email, password);
  return this.commandBus.execute(command);
}

/src/users/login.handler.ts (LoginCommand 를 처리하는 LoginHandler 를 만들고, 기존에 UsersService 에서 수행한 로직을 이동)

import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { LoginCommand } from './login.command';
import { NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../entity/user.entity';
import { Repository } from 'typeorm';
import { AuthService } from '../../auth/auth.service';

@CommandHandler(LoginCommand)
export class LoginHandler implements ICommandHandler<LoginCommand> {
  constructor(
    @InjectRepository(UserEntity)
    private userRepository: Repository<UserEntity>,
    private authService: AuthService,
  ) {}

  async execute(command: LoginCommand) {
    const { email, password } = command;
    // DB 에 email, password 가진 유저 존재 여부 조회 후 없다면 에러 처리
    const user = await this.userRepository.findOne({
      where: { email, password },
    });

    if (!user) {
      throw new NotFoundException('존재하지 않는 유저');
    }

    // JWT 발급
    return this.authService.login({
      id: user.id,
      name: user.name,
      email: user.email,
    });
  }
}

/src/users/users.module.ts (LoginHandler 를 UsersModule 의 Provider 로 제공)

import { CqrsModule } from '@nestjs/cqrs';
import { CreateUserHandler } from './command/create-user.handler';
import { VerifyEmailHandler } from './command/verify-email.handler';
import { LoginHandler } from './command/login.handler';

const commandHandlers = [CreateUserHandler, VerifyEmailHandler, LoginHandler];

@Module({
  imports: [
    CqrsModule,
  ],
  controllers: [UsersController],
  providers: [...commandHandlers],
})
export class UsersModule {}

2.2. Event: @EventHandler()

  • IEvent 를 구현하는 Event 클래스 필요
  • ICommandHandler 를 구현하는 Event 핸들러 클래스 필요 (실제 로직 들어감)

회원 가입 처리 과정에 이메일 전송 로직이 있지만 이메일 발송은 회원 가입과는 별개로 다루는 것이 좋다.

별개로 다루게 되면 이메일 인증을 다른 수단으로 변경한다면 회원 가입 로직을 수정하지 않아도 되고(= 회원 가입 처리와 이메일 발송의 결합도 낮춤), 회원 가입 이메일 전송은 회원 가입 절차가 완료된 후 별개로 전송되도록 비동기 처리하는 것이 응답을 더 빨리 수행할 수 있다.

이럴 경우 회원 가입 이벤트를 발송하고, 그 이벤트를 구독하는 다른 모듈에서 이벤트를 처리하도록 한다.
만일 회원 가입 이벤트 발생 시 처리해야 하는 로직이 추가될 경우 또 다른 이벤트 핸들러에서 그 요구 사항을 처리하는 로직을 구현하면 된다.

/src/users/event/cqrs-event.ts

// 이벤트 핸들러에서 이벤트를 구분하기 위해 만든 추상 클래스
export abstract class CqrsEvent {
  constructor(readonly name: string) {}
}

/src/users/event/user-create.event.ts

import { CqrsEvent } from './cqrs-event';
import { IEvent } from '@nestjs/cqrs';

// CqrsEvent 와 TestEvent 상속받음
// CqrsEvent 는 이벤트 핸들러에서 이벤트를 구분하기 위해 만든 추상 클래스
export class UserCreateEvent extends CqrsEvent implements IEvent {
  constructor(readonly email: string, readonly signupVerifyToken: string) {
    super(UserCreateEvent.name); // CqrsEvent 의 constructor()
  }
}

/src/users/event/test.event.ts

import { CqrsEvent } from './cqrs-event';
import { IEvent } from '@nestjs/cqrs';

// 이벤트 핸들러는 커맨드 핸들러와는 다르게 여러 이벤트를 같은 이벤트 핸들러가 받도록 할 수 있기 때문에 예시로 생성
export class TestEvent extends CqrsEvent implements IEvent {
  constructor() {
    super(TestEvent.name); // CqrsEvent 의 constructor()
  }
}

/src/users/command/create-user.handler.ts (회원 가입 시 EmailService 를 이용하여 메일을 보내던 부분을 UserCreateEvent 를 publish 하도록 변경)

...
import { UserCreateEvent } from '../event/user-create.event';
import { TestEvent } from '../event/test.event';

@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(
    ...
    private eventBus: EventBus,
  ) {}

  async execute(command: CreateUserCommand) {
    ...
    // 회원 가입 이메일 발송
    this.eventBus.publish(new UserCreateEvent(email, signupVerifyToken));
    this.eventBus.publish(new TestEvent());
  }
  ...
}

이제 Command 와 마찬가지로 Event 를 처리할 이벤트 핸들러를 만들고 Provider 로 제공한다.

/src/users/event/user-event.handler.ts

import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { UserCreateEvent } from './user-create.event';
import { TestEvent } from './test.event';
import { EmailService } from '../../email/email.service';

@EventsHandler(UserCreateEvent, TestEvent)
export class UserEventHandler implements IEventHandler<UserCreateEvent | TestEvent> {
  constructor(private emailService: EmailService) {}

  // 이벤트 핸들러는 커맨드 핸들러와는 다르게 여러 이벤트를 같은 이벤트 핸들러가 받도록 할 수 있음
  async handle(event: UserCreateEvent | TestEvent) {
    switch (event.name) {
      case UserCreateEvent.name: {
        console.log('UserCreateEvent~');
        const { email, signupVerifyToken } = event as UserCreateEvent;
        await this.emailService.sendMemberJoinVerification(
          email,
          signupVerifyToken,
        );
        break;
      }
      case TestEvent.name: {
        console.log('TestEvent~');
        break;
      }
      default:
        break;
    }
  }
}

/src/users/users.module.ts

...
import { UserEventHandler } from './event/user-event.handler';

const commandHandlers = [CreateUserHandler, VerifyEmailHandler, LoginHandler];
const eventHandlers = [UserEventHandler];

@Module({
  ...
  providers: [...commandHandlers, ...eventHandlers],
})
export class UsersModule {}

@EventsHandler 데커레이터 시그니처

/**
 * Decorator that marks a class as a Nest command handler. A command handler
 * handles commands (actions) executed by your application code.
 *
 * The decorated class must implement the `ICommandHandler` interface.
 *
 * @param command command *type* to be handled by this handler.
 *
 * @see https://docs.nestjs.com/recipes/cqrs#commands
 */
export declare const CommandHandler: (command: ICommand | (new (...args: any[]) => ICommand)) => ClassDecorator;

@EventsHandler 데커레이터의 정의를 보면 IEvent 인터페이스 리스트를 받을 수 있도록 되어있기 때문에 이벤트 핸들러가 여러 이벤트를 받아서 처리 가능하다.

IEventHandler 시그니처

import { IEvent } from './event.interface';
export interface IEventHandler<T extends IEvent = any> {
    handle(event: T): any;
}

IEventHandler 는 IEvent 타입을 제네릭 타입으로 정의하는데 위에선 UserEventHandler 가 처리할 수 있는 이벤트인 UserCreateEvent | TestEvent 타입을 정의하였다.

이제 회원 가입 요청을 다시 해보면 이메일 가입 인증까지 정상적으로 되는 부분을 확인할 수 있다.

$ npm run start:dev

$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "assu2",
    "email": "test@naver.com",
    "password": "test1234"
}' | jq

2.3. Query: @QueryHandler()

  • IQuery 를 구현하는 Query 클래스 필요
  • IQueryHandler 를 구현하는 Query 핸들러 클래스 필요 (실제 로직 들어감)

/src/users/query/get-user-info.query.ts

import { IQuery } from '@nestjs/cqrs';

export class GetUserInfoQuery implements IQuery {
  constructor(readonly userId: string) {}
}

/src/users/query/get-user-info.handler.ts

import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetUserInfoQuery } from './get-user-info.query';
import { UserInfo } from '../UserInfo';
import { NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../entity/user.entity';
import { Repository } from 'typeorm';

@QueryHandler(GetUserInfoQuery)
export class GetUserInfoQueryHandler
  implements IQueryHandler<GetUserInfoQuery>
{
  constructor(
    @InjectRepository(UserEntity)
    private userRepository: Repository<UserEntity>,
  ) {}

  async execute(query: GetUserInfoQuery): Promise<UserInfo> {
    const { userId } = query;

    // DB 에 userId 가진 유저 존재 여부 조회 후 없다면 에러 처리
    const user = await this.userRepository.findOne({
      where: { id: userId },
    });

    if (!user) {
      throw new NotFoundException('존재하지 않는 유저');
    }

    // 조회 데이터를 userInfo 타입으로 리턴
    return {
      id: userId,
      name: user.name,
      email: user.email,
    };
  }
}

/src/users/users.module.ts

...
import { GetUserInfoQueryHandler } from './query/get-user-info.handler';

const commandHandlers = [CreateUserHandler, VerifyEmailHandler, LoginHandler];
const eventHandlers = [UserEventHandler];
const queryHandlers = [GetUserInfoQueryHandler];

@Module({
  ...
  providers: [
    ...commandHandlers,
    ...eventHandlers,
    ...queryHandlers,
  ],
})
export class UsersModule {}

/src/users/users.controller.ts

...
import { GetUserInfoQuery } from './query/get-user-info.query';

@Controller('users')
export class UsersController {
  // UsersService 를 컨트롤러에 주입
  constructor(
          ...
          private queryBus: QueryBus,
  ) {}

  // 유저 정보 조회
  @UseGuards(AuthGuard)
  @Get(':id')
  async getUserInfo(
          @Headers() headers: any,
          @Param('id') userId: string,
  ): Promise<UserInfo> {
    const getUserInfoQuery = new GetUserInfoQuery(userId);

    return this.queryBus.execute(getUserInfoQuery);
  }
}

이제 유저 정보를 조회해보면 잘 조회되는 것을 확인할 수 있다.

$ curl --location 'http://localhost:3000/users/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "test2@test.com",
    "password": "test1234"
}' | jq

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAxR1pBTlFOUThRMllWM1pEMkNZNDcwMU1UIiwibmFtZSI6ImFzc3UyIiwiZW1haWwiOiJ0ZXN0MkB0ZXN0LmNvbSIsImlhdCI6MTY4MjkxOTA0NCwiZXhwIjoxNjgzMDA1NDQ0LCJhdWQiOiJ0ZXN0LmNvbSIsImlzcyI6InRlc3QuY29tIn0.jHvdSlNk4Tdt31G6s0XKgAyb5-AFukIQ1sWDFGVBerc
$ curl --location --request GET 'http://localhost:3000/users/01GZANQNQ8Q2YV3ZD2CY4701MT' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAxR1pBTlFOUThRMllWM1pEMkNZNDcwMU1UIiwibmFtZSI6ImFzc3UyIiwiZW1haWwiOiJ0ZXN0MkB0ZXN0LmNvbSIsImlhdCI6MTY4MjkxOTA0NCwiZXhwIjoxNjgzMDA1NDQ0LCJhdWQiOiJ0ZXN0LmNvbSIsImlzcyI6InRlc3QuY29tIn0.jHvdSlNk4Tdt31G6s0XKgAyb5-AFukIQ1sWDFGVBerc' \
--data-raw '{
    "email": "test2@test.com",
    "password": "test1234"
}' | jq

{
  "id": "01GZANQNQ8Q2YV3ZD2CY4701MT",
  "name": "assu2",
  "email": "test2@test.com"
}

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10