개발일지
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가 적합함
- 소규모 로직의 경우에는 오히려 생산성 저하