개발일지

Nest.JS CQRS

index.ys 2023. 8. 18. 17:09

CQRS

  • CQRS : command query responsibillity separation 패턴은 소프트웨어 아키텍처 패턴 중 하나
  • 데이터 저장과 데이터 조회작업을 분히라여 애플리케이션의 유지보수, 확장성, 보안성을 높힐 수 잇음
  • 데이터를 조회(READ) 했을때 현재의 복잡한 모델 구조의 데이터가 필요하지 않은 경우가 대부분이므로 조회시 모델과 데이터 조작(CREATE, UPDATE, DELETE)요청시의 모델을 분리하는 방식
  • 요약 : 조회(READ)와 조작(CREATE, UPDATE ,DELETE)를 구분하여 소프트웨어 아키텍처를 설계하는 방식

CQRS 패키지 설치

  • 설치 명령어
npm i @nestjs/cqrs
  • UsersModoule에 CqrsModule imports
import { CqrsModule } from '@nestjs/cqrs';


@Module({
  imports: [
	...다른모듈
    //Cqrs모듈 가져오기
    CqrsModule,
  ],
})
export class UsersModule {}

도식화

 

커맨드 Command

  • Create, Update, Delete는 커맨드를 이용하여 처리
  • 커맨드는 서비스 계층이나 컨트롤러 게이트웨이에서 직접 발송 할 수 잇음
  • 전송한 커맨드는 커맨드 핸들러가 받아서 처리

UserController

  • 전달받은 요청을 라우팅
  • UsersService계층이 아닌 Command 계층의 create-user.handler commandBus.exeute로 command전달
  • UsersController에서  =>  Create-user.handler로 Create-user.command를 전달함
  • Create-user.handler에서 실제 비즈니스 로직을 수행하여 처리
  @Post()
  async createUser(@Body() dto: CreateUserDto): Promise<void> {
    const { name, email, password } = dto;

    //command전송
    //CreateUserCommand는 command폴더에 생성한 creae-user.command.ts에 정의한 데이터
    const command = new CreateUserCommand(name, email, password);

    //execute메서드 선언, 인자로 command 전달
    return this.commandBus.execute(command);
  }

 

Create-user.command

  • 유저생성을 위애 필요한 커맨드 정의
import { ICommand } from '@nestjs/cqrs';

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

Create-user.handler

  • 정의한 커맨드를 전달받아 execute 메소드 실행하여 실제 비즈니스 로직을 수행함
@Injectable()
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(
    private dataSource: DataSource,
    private eventBus: EventBus,

    @InjectRepository(UserEntity)
    private usersRepository: Repository<UserEntity>,
  ) {}

  //UserService CreateUser메소드를 여기서 실행
  //execute 수행 로직
  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,
    );
    
    
    
  }

    //EmailService를 이용하는 부분을 UserCreatedEvent를 발송하도록 변경
    //또다른 이벤트 처리를 위해 eventBus.publish로 이벤트를 구독
    this.eventBus.publish(new UserCreatedEvent(email, signupVerifyToken));
    //이벤트 핸들러 동작을 위해 TestEvent도 함께 발송
    this.eventBus.publish(new TestEvent());
  }

Users.module

import { Logger, Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TypeOrmModule } from '@nestjs/typeorm';
import { query } from 'express';
import { AuthModule } from 'src/auth/auth.module';
import { EmailModule } from 'src/email/email.module';
import { CreateUserHandler } from './command/create-user.handler';
import { LoginHandler } from './command/login.handler';
import { VerifyAccessTokenHandler } from './command/verify-access-token.handler';
import { VerifyEmailHandler } from './command/verify-email.handler';
import { UserEntity } from './entity/user.entity';
import { UserEventsHandler } from './event/user-events.handler';
import { GetUserInfoQueryHandler } from './query/get-user-info.handler';
import { UsersController } from './users.controller';

const commandHandlers = [
  CreateUserHandler,
  VerifyEmailHandler,
  LoginHandler,
  VerifyAccessTokenHandler,
];

const queryHandlers = [GetUserInfoQueryHandler];

const eventHandlers = [UserEventsHandler];

@Module({
  imports: [
    EmailModule,
    TypeOrmModule.forFeature([UserEntity]),
    AuthModule,
    //Cqrs모듈 가져오기
    CqrsModule,
  ],
  controllers: [UsersController],
  providers: [
    //핸들러를 users 컴포넌트에서 사용하기 위해 프로바이더로 등록
    //배열 형태이기 때문에 전개연산자로 풀어서 등록
    ...commandHandlers,
    ...queryHandlers,
    ...eventHandlers,
    Logger,
  ],
})
export class UsersModule {}

이벤트

  • 이벤트가 발생했을때 처리해야 하는 비즈니스 로직이 또 있다면 또 다른 이벤트 핸들러에서 로직 처리를 구현

user-created.events

  • CqrsEvent를 상속, 이벤트 핸들러에서 이벤트를 구분하기 위해 만든 추상 클래스
  • 이벤트 핸들러는 커맨드 핸들러와 다르게 여러 이벤트를 같은 이벤트 핸들러가 받도록 할 수 있음
  • 이벤트를 처리할 이벤트 핸들러를 만들고 프로바이더로 제공
import { IEvent } from '@nestjs/cqrs';
import { CqrsEvent } from './cqrs-event';

//create-user.handler에서 실행한 UserCreatedEvent메소드
//UserCreatedEvent는 CqrsEvent를 상속 받음 => extends 키워드
export class UserCreatedEvent extends CqrsEvent implements IEvent {
  constructor(readonly email: string, readonly signupVerifyToken: string) {
    super(UserCreatedEvent.name);
  }
}

user-events.handler

  • @EventHandler의 정의를 보면 IEvent 인터페이스 리스트를 받을 수 있도록 되어 있음 이벤트 핸들러는 여러 이벤트를 받아서 처리할 수 있음
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { EmailService } from 'src/email/email.service';
import { TestEvent } from './test.event';
import { UserCreatedEvent } from './user-created.event';

//UserCreatedEvent와 TestEvent를 받아서 각각 처리
@EventsHandler(UserCreatedEvent, TestEvent)
export class UserEventsHandler implements IEventHandler<UserCreatedEvent | TestEvent> {
  constructor(
    private emailService: EmailService,
  ) { }

  async handle(event: UserCreatedEvent | TestEvent) {
    switch (event.name) {
    //이벤트이름이 UserCreatedEvnet일때
      case UserCreatedEvent.name: {
        console.log('UserCreatedEvent!');
        const { email, signupVerifyToken } = event as UserCreatedEvent;
        await this.emailService.sendMemberJoinVerification(email, signupVerifyToken);
        break;
      }
      //이벤트이름이 TestEvent일때
      case TestEvent.name: {
        console.log('TestEvent!');
        break;
      }
      default:
        break;
    }
  }
}

쿼리

  • 커맨드와 비슷한 방법으로 구현
  • IQuery를 구현하는 쿼리 클래스와 IQueryHandler를 구현하는 쿼리 핸들러 필요
  • 쿼리 핸들러 @QueryHandler 데커레이터를 달아주고 프로바이더로 등록

쿼리 클래스

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

//IQuery를 구현하는 쿼리 클래스
export class GetUserInfoQuery implements IQuery {
  constructor(readonly userId: string) {}
}

쿼리 핸들러

  • 실제 쿼리 로직을 처리함
import { NotFoundException } from '@nestjs/common';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../entity/user.entity';
import { UserInfo } from '../UserInfo';
import { GetUserInfoQuery } from './get-user-info.query';

//get-user-info.handler.ts에서 userId
//QueryHandler 데ㅇ커레이터를 달아 프로바이더로 등록
@QueryHandler(GetUserInfoQuery)
export class GetUserInfoQueryHandler
  implements IQueryHandler<GetUserInfoQuery>
{
  constructor(
    //repository 주입
    @InjectRepository(UserEntity)
    private usersRepository: Repository<UserEntity>,
  ) {}

  //execute메서드 실행
  async execute(query: GetUserInfoQuery): Promise<UserInfo> {
    const { userId } = query;

    const user = await this.usersRepository.findOne({
      where: { id: userId },
    });

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

    return {
      id: user.id,
      name: user.name,
      email: user.email,
    };
  }
}

users.controller

  • 쿼리 핸들러에 쿼리 클래스를 인자로 넣어 라우팅
  @UseGuards(AuthGuard)
  @Get(':id')
  async getUserInfo(@Param('id') userId: string): Promise<UserInfo> {
    const getUserInfoQuery = new GetUserInfoQuery(userId);

    //쿼리클래스를 쿼리버스에 실어 보내기
    return this.queryBus.execute(getUserInfoQuery);
  }

요약

  • CQRS는 controller ,service로 이루어진 아키텍처에서 service계층을 작업 관심사를 세밀하게 분리하여 데이터를 조작함
  • 커맨드와 쿼리로 분류하며 커맨드는 데이터 삽입 , 수정 ,삭제에 대한 로직을 처리하고 쿼리는 데이터 조회에 대한 요청을 처리함
  • 복잡한 도메인을 다루기 쉬운 경우 CQRS 적용 , CQRS를 사용하면 복잡성이 추가되어 생산성이 감소함
  • 시스템 전체가 아닌 DDD에서 말하는 bounded context 내에서만 사용해야함
  • 고성능 처리가 필요한 애플리케이션인 경우, 성능을 위해 쓰기 요청은 RDB, 읽기 요청은 Redis로 사용하는 경우 유용
  • 애플리케이션에서 읽기와 쓰기 사이에 성능 차이가 큰 경우 CQRS 사용
  • 복잡한 도메인을 다루고 , DDD를 적용할 때 CQRS가 적합함
  • 소규모 로직의 경우에는 오히려 생산성 저하