[NestJs] CQRS 관심사 분리

Gmini.Y·2024년 3월 3일
0

1. CQRS 패턴

CQRS (command query responsibility separation) 패턴은 Command와 Query를 분리하여 성능, 확장성, 보안성을 높이는 아키텍처 패턴인다.
데이터를 조회한 쪽에서는 현재의 복잡한 모델 구조의 데이터가 필요하지 않은 경우가 대부분이무르ㅗ 조회 시의 모델과 데이터를 업데이트할 때의 모델을 다르게 가져가도록 하는 방식이다.

  • CQRS를 사용하면 복잡성이 추가되므로 모델을 공유하는 것이 도메인을 다루기 더 쉬운지 판단해야 한다.
  • CQRS를 사용하면 읽기 및 쓰기 작업에서 로드를 분리하여 각각을 독립적으로 확장할 수 있다. 성능을 위해 쓰기는 RDB로 읽기는 Document DB를 사용하는 경우가 많습니다. 앱에서 읽기와 쓰기 사이에 성능 차이가 큰 경우 CQRS를 쓰면 좋다.
  • 이벤트 기반 프로그래밍 모델과 잘 맞다. 이벤트 소싱을 쉽게 활용할 수 있다.
    복잡한 도메인을 다루고 DDD를 적용하는 데 적합하다.

2. 유저 서비스에 CQRS 적용하기

nest에서 cqrs 패키지를 설치한다.

$ npm i @nestjs/cqrs

CQRS 모듈을 UserModule로 가지고 온다.

  • users.module.ts
...
import { CqrsModule } from '@nestjs/cqrs';

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

2.1 커맨드

CRUD에서 Read를 제외한 나머지는 커맨드를 이용하여 처리한다. 커맨드는 서비스 계층이나 컨트롤러, 게이트웨이에서 직접 발송할 수 있다.
전송한 커맨드는 커맨드 핸들러가 받아서 처리한다.
유저 생성을 위한 커맨드를 정의한다.

  • create-user.command.ts
import { ICommand } from '@nestjs/cqrs';

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

컨트롤러에서 커맨드를 전달하도록 한다.

  • users.controller.ts
...

@Controller('users')
export class UsersController {
  constructor(
    @Inject(WINSTON_MODULE_NEST_PROVIDER)
    private readonly logger: WinstonLogger,
    private userService: UsersService,
    private commandBus: CommandBus,
  ) {}

  // 회원 가입
  @Post()
  async createUser(@Body() dto: CreateUserDto): Promise<void> {
    // this.printWinstonLog(dto);
    const { name, email, password } = dto;
    // await this.userService.createUser(name, email, password);
    const command = new CreateUserCommand(name, email, password);

    return this.commandBus.execute(command);
  }
...

이제 CreateUserHandler를 만든다.

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

@Injectable()
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(
    @InjectRepository(UserEntity)
    private userRepository: Repository<UserEntity>,
    private dataSource: DataSource,
  ) {}

  async execute(command: CreateUserCommand) {
    const { name, email, password } = command;
    const userExist = await this.checkUserExists(email);
    if (userExist) {
      throw new UnprocessableEntityException(
        '해당 이메일로는 가입할 수 없습니다.',
      );
    }
    const signupVerifyToken = uuid.v1();
    await this.saveUserUsingTransaction(
      name,
      email,
      password,
      signupVerifyToken,
    );
    // await this.sendMemberJoinEmail(email, signupVerifyToken);
  }

  private async checkUserExists(email: string) {
    const user = await this.userRepository.findOne({
      where: { email },
    });

    return user != undefined; // TODO: DB 연동 후 구현
  }
  private async saveUserUsingTransaction(
    name: string,
    email: string,
    password: string,
    signupVerifyToken: string,
  ) {
    await this.dataSource.transaction(async (manager) => {
      const user = new UserEntity();
      user.id = uuid.v1();
      user.name = name;
      user.email = email;
      user.password = password;
      user.signupVerifyToken = signupVerifyToken;

      await manager.save(user);
    });
  }
}

이제 유저 이메일 검증 로직을 커맨드로 변경한다.

  • verify-email.command.ts
import { ICommand } from '@nestjs/cqrs';

export class CreateUserCommand implements ICommand {
  constructor(readonly signupVerifyToken: string) {}
}
  • users.controller.ts
// 이메일 인증
  @Post('/email-verify')
  async verifyEmail(@Query() dto: VerifyEmailDto): Promise<string> {
    const { signupVerifyToken } = dto;
    const command = new VerifyEmailCommand(signupVerifyToken);

    return this.commandBus.execute(command);
  }
  • verify-email.handler.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { VerifyEmailCommand } from './verify-email.command';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { UserEntity } from './entity/user.entity';
import { AuthService } from 'src/auth/auth.service';

@Injectable()
@CommandHandler(VerifyEmailCommand)
export class VerifyEmailHandler implements ICommandHandler<VerifyEmailCommand> {
  constructor(
    @InjectRepository(UserEntity)
    private userRepository: Repository<UserEntity>,
    private dataSource: DataSource,
    private authService: AuthService,
  ) {}

  async execute(command: VerifyEmailCommand): Promise<any> {
    const { signupVerifyToken } = command;
    const user = await this.userRepository.findOne({
      where: { signupVerifyToken },
    });

    if (!user) {
      throw new NotFoundException('유저가 존재하지 않습니다.');
    }

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

이제 유저 로그인 로직을 커맨드로 구현한다.

  • login.command.ts
import { ICommand } from '@nestjs/cqrs';

export class LoginCommand implements ICommand {
  constructor(
    readonly email: string,
    readonly password: string,
  ) {}
}
  • users.controller.ts
...
  // 로그인
  @Post('login')
  async login(@Body() dto: UserLoginDto): Promise<string> {
    const { email, password } = dto;
    const command = new LoginCommand(email, password);

    return this.commandBus.execute(command);
  }
...
  • login.handler.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from './entity/user.entity';
import { AuthService } from 'src/auth/auth.service';
import { LoginCommand } from './login.command';

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

  async execute(command: LoginCommand): Promise<any> {
    const { email, password } = command;
    const user = await this.userRepository.findOne({
      where: { email, password },
    });

    if (!user) {
      throw new NotFoundException('유저가 존재하지 않습니다.');
    }

    return this.authService.login({
      id: user.id,
      name: user.name,
      email: user.email,
    });
  }
}
  • users.module.ts
...

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

2.2 이벤트

회원 가입 중 이메일 전송하는 로직을 회원 가입과는 별개롤 다룰 수 있어야 한다. 또 별개로 전송되도록 비동기 처리되는 것이 응답을 더 빨리 수행할 수 있다. 이럴 경우 회원 가입 이벤트를 발생하고 이벤트를 구독하는 다른 모듈에서 이벤트를 처리하도록 한다.
회원 가입 이메일 전송 로직을 회원 가입 이벤트를 통해 처리하도록 한다.

  • cqrs-event.ts
export abstract class CqrsEvent {
  constructor(readonly name: string) {}
}
  • user-create.event.ts
import { CqrsEvent } from './cqrs-event';
import { IEvent } from '@nestjs/cqrs';

export class UserCreateEvent extends CqrsEvent implements IEvent {
  constructor(
    readonly email: string,
    readonly signupVerifyToken: string,
  ) {
    super(UserCreateEvent.name);
  }
}
  • test.event.ts
import { CqrsEvent } from './cqrs-event';
import { IEvent } from '@nestjs/cqrs';

export class TestEvent extends CqrsEvent implements IEvent {
  constructor() {
    super(TestEvent.name);
  }
}
  • create-user.handler.ts
...
  constructor(
    ...
    private eventBus: EventBus,
  ) {}

  async execute(command: CreateUserCommand) {
    const { name, email, password } = command;
    const userExist = await this.checkUserExists(email);
    if (userExist) {
      throw new UnprocessableEntityException(
        '해당 이메일로는 가입할 수 없습니다.',
      );
    }
    const signupVerifyToken = uuid.v1();
    await this.saveUserUsingTransaction(
      name,
      email,
      password,
      signupVerifyToken,
    );

    this.eventBus.publish(new UserCreateEvent(email, signupVerifyToken));
    this.eventBus.publish(new TestEvent());
    // await this.sendMemberJoinEmail(email, signupVerifyToken);
  }
...

이벤트 핸들러를 만들고 프로바이더로 제공해야 한다.

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

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

  async handle(event: UserCreateEvent | TestEvent) {
    switch (event.name) {
      case UserCreateEvent.name: {
        console.log('UserCreatedEvent!');
        const { email, signupVerifyToken } = event as UserCreateEvent;
        await this.emailService.sendMemberJoinVerification(
          email,
          signupVerifyToken,
        );
        break;
      }
      case TestEvent.name: {
        console.log('TestEvent!');
        break;
      }
      default:
        break;
    }
  }
}
  • users.module.ts
...
import { UserEventsHandler } from './event.handler';

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

2.3 쿼리

유저 정보 조회 부분을 쿼리로 분리한다. IQuery를 구현하는 쿼리 클래스와 IQueryHandler를 구현하는 쿼리 핸들러가 필요하다. 쿼리 핸들러는 @QueryHandler 데커레이터를 달아 주고 프로바이더로 등록한다.

  • get-user-info.query.ts
import { IQuery } from '@nestjs/cqrs';

export class GetUserInfoQuery implements IQuery {
  constructor(readonly userId: string) {}
}
  • get-user-info-query.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetUserInfoQuery } from './get-user-info.query';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from './entity/user.entity';
import { Repository } from 'typeorm';
import { NotFoundException } from '@nestjs/common';
import { UserInfo } from './UserInfo';

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

  async execute(query: GetUserInfoQuery): Promise<UserInfo> {
    const { userId } = query;
    const user = await this.userRepository.findOne({
      where: { id: userId },
    });

    if (!user) {
      throw new NotFoundException('유저가 존재하지 않습니다.');
    }

    return {
      id: user.id,
      name: user.name,
      email: user.email,
    };
  }
}
  • users.controller.ts
@Controller('users')
export class UsersController {
  constructor(
    ...
    private queryBus: QueryBus,
  ) {}
...
  // 회원 정보 조회
  @UseGuards(AuthGuard)
  @Get(':id')
  async getUserInfo(
    @UserData() user: User,
    @Param('id') userId: string,
  ): Promise<UserInfo> {
    const getUserInfoQuery = new GetUserInfoQuery(userId);
    return this.queryBus.execute(getUserInfoQuery);
  }
...
}

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

0개의 댓글