- CQRS
CQRS 는 command query responsibility separation 의 줄임말로써, command 와 query 의 책임을 분리하는 패턴입니다.
여기서 command 는 시스템의 상태를 변경하는 명령을 뜻하고, query 는 상태를 변경하지 않으며 조회만 하는 요청입니다.
흔히 알고 있는 CRUD 로 구분하면 command 는 CUD / query 는 R 에 해당하겠습니다.
CQRS 를 도입했을 때 성능, 확장성, 보안성이 향상된다고 합니다. 아무래도 일단 서비스가 고도화되다보면 조회시 필요한 데이터 모델과
상태를 변경할 때 필요한 모델이 차이가 나기 마련인데 command 와 query 가 분리되어 있지 않은 상태에선 이를 같이 가져가야하지만,
이를 분리하면 조회시 훨씬 가벼운 모델을 다룰 수 있습니다. 결국 이런 것들은 시스템이 커져가는 상황에서 컴퍼넌트간의 결합도를 더
줄이고, 그 안에서 유지보수를 쉽게 하기 위해 나아가는 방향이라고 볼 수 있습니다.
하지만 확실히 CQRS 를 도입하기 위해선 여러 논의와 컨센서스가 필요하다고 봅니다. 아키텍쳐가 복잡해지면서 그만큼 러닝커브도
올라가고, 데이터 일관성도 지켜지지 않을 수 있죠. 사실은 일관성이 지켜지지 않는 차이를 인정하고 들어가는 패턴이긴 합니다만..
현 회사에서도 아직 CQRS 를 사용중인 팀은 없고, READ 와 WRITE 용 DB 를 물리적으로 분리한게 전부죠.
모델이 분리되어야하는 만큼 아직은 그정도 사이즈가 아니라면 CQRS 를 사용하지 않는게 나을 겁니다. 복잡한 도메인 모델을 다루는데 더 효과적이라고 판단될 때 CQRS 를 사용해야 하구요. command 와 query 를 분리하는만큼 아예 스토리지부터 분리해서 독립적으로
확장 또한 가능합니다. 실제로 어느정도 차이가 있을지는 모르겠지만 query 에는 NoSQL 을, command 에는 RDB 를 사용하는 케이스도
많다고 하네요.
DDD 를 적용하는데 적합하고 이벤트 기반 프로그래밍에 잘 맞다고 하는 내용과 함께 저도 아직 읽어보지 않은 마틴 파울러의 CQRS
블로그 링크를 남깁니다.
- @nestjs/cqrs - Command
NestJS 에서도 cqrs 모듈을 지원합니다. (링크) 지금까지 만든 광고 서버가 cqrs 를 사용할만큼의 사이즈는 당연히 아니지만
NestJS 가 제공하는 여러가지 기능을 확인하기 위해 적용을 한번 해보도록 하겠습니다.
> npm install @nestjs/cqrs
먼저 @nestjs/cqrs 모듈을 설치합니다.
...
import { CqrsModule } from '@nestjs/cqrs';
@Module({
imports: [
...,
CqrsModule,
],
...
})
export class AdsModule {}
광고를 생성하는 부분에 적용을 먼저 해볼 생각이므로, AdsModule 에 CqrsModule 을 import 해줍니다.
import { ICommand } from '@nestjs/cqrs';
import { CountryCode } from '../../types/country';
export class CreateAdCommand implements ICommand {
constructor(
readonly adType: string,
readonly title: string,
readonly thumbnail: string,
readonly content: string,
readonly country: CountryCode,
) {}
}
저는 src/ads 하위에 command 라는 디렉토리를 생성하고 create-ad.command.ts 파일을 위와 같이 작성했습니다.
Create 는 command 이므로, 생성 명령을 만들어 전달하는 형태로 바뀔 예정입니다.
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { AdsRepository } from '../ads.repository';
import { SlackService } from '../../slack/slack.service';
@CommandHandler(CreateAdCommand)
export class CreateAdHandler implements ICommandHandler<CreateAdCommand> {
constructor(
private readonly slackService: SlackService,
private readonly adsRepository: AdsRepository,
) {}
async execute(command: CreateAdCommand): Promise<any> {
const { adType, title, country } = command;
await this.adsRepository.createUser(command);
await this.slackService.send(adType, title, country);
}
}
동일한 위치에 create-ad.handler.ts 파일도 위와 같이 작성해주었습니다.
개인적으로 handler 라는 네이밍을 선호하진 않지만 @nestjs/cqrs 에서 이렇게 되어있군요. 작성한 CreateAdHandler 는
생성 명령을 전달받아 적합한 로직을 실행하는 놈입니다. 핸들러를 구현하는 방식은 위처럼 execute 메서드를 구현하면 됩니다.
저는 ads.service.ts 에 있던 로직을 그대로 옮겨왔습니다.
@nestjs/cqrs 의 CommandHandler 데코레이터를 사용하면 ICommand 를 구현한 명령들을 CommandBus 로부터 전달받을 수
있습니다. 물론 이 데코레이터를 사용하는 클래스는 반드시 ICommandHandler 인터페이스를 구현해야 합니다.
...
import { CommandBus } from '@nestjs/cqrs';
import { CreateAdCommand } from './command/create-ad.command';
@Controller('ads')
export class AdsController {
constructor(
private readonly commandBus: CommandBus,
private readonly adsService: AdsService,
) {}
@Post()
create(@Body() createAdDto: CreateAdDto) {
const { adType, title, thumbnail, content, country } = createAdDto;
const command = new CreateAdCommand(
adType,
title,
thumbnail,
content,
country,
);
return this.commandBus.execute(command);
}
...
}
변경된 AdsController 입니다. 전달받은 dto 로 command 를 생성해 @nestjs/cqrs 가 제공하는 CommandBus 를 통해
명령을 실행하고 있습니다. CommandBus 라는건 command 의 스트림을 나타내고, command 들을 적합한 핸들러로 보내는 역할을
담당하고 있습니다.
최종적으론 AdsService 가 없어져서 controller <-> service 간의 디펜던시가 아예 없어지긴 하겠지만 아직 query 까지
변경하진 않았으므로 생성자에 주입받고 있는 AdsService 는 그대로 둡니다.
...
import { CreateAdHandler } from './command/create-ad.handler';
@Module({
...,
providers: [
...,
CreateAdHandler,
],
})
export class AdsModule {}
작성한 CreateAdHandler 를 AdsModule 에 provider 로 등록해줍니다. 아 그리고 저의 경우엔 AdsRepository 의 createUser
메서드가 dto 타입의 파라미터를 받던 것을 comamnd 로 수정했습니다. 이제 서버를 구동하고 생성 요청을 보내봅니다.
정상 동작하는 것을 확인할 수 있습니다.
- Query
위에서 Command 부분을 분리했으니 Query 도 마저 분리해서 AdsService 라는걸 아예 없애보도록 하겠습니다.
방식은 Command 에서 했던 것과 거의 유사합니다.
import { IQuery } from '@nestjs/cqrs';
export class GetAdInfoQuery implements IQuery {
constructor(readonly adId: number) {}
}
export class GetAdsInfoQuery implements IQuery {
constructor() {
//
}
}
이번엔 src/ads 하위에 query 라는 디렉토리를 생성하고 get-ad-info.query.ts 파일을 위와 같이 작성했습니다.
GetAdInfoQuery 는 단일 광고를 조회할 Query 이고, GetAdsInfoQuery 는 다수의 광고를 조회할 Query 입니다.
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { AdsRepository } from '../ads.repository';
import { GetAdInfoQuery, GetAdsInfoQuery } from './get-ad-info.query';
@QueryHandler(GetAdInfoQuery)
export class GetAdInfoHandler implements IQueryHandler<GetAdInfoQuery> {
constructor(private readonly adsRepository: AdsRepository) {}
execute(query: GetAdInfoQuery): Promise<any> {
const { adId } = query;
return this.adsRepository.findAdById(adId);
}
}
@QueryHandler(GetAdsInfoQuery)
export class GetAdsInfoHandler implements IQueryHandler<GetAdsInfoHandler> {
constructor(private readonly adsRepository: AdsRepository) {}
execute(): Promise<any> {
return this.adsRepository.findAds();
}
}
마찬가지로 동일한 위치에 get-ad-info.handler.ts 파일을 작성합니다. 방식은 @nestjs/cqrs 에서 제공하는 QueryHandler
데코레이터를 사용하면서 IQueryHandler 를 구현하면 됩니다. 역시나 execute 에는 기존 AdsService 에 있던 로직을 옮겨왔습니다.
...
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { GetAdInfoQuery, GetAdsInfoQuery } from './query/get-ad-info.query';
@Controller('ads')
export class AdsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
...
@Get()
findAll() {
const query = new GetAdsInfoQuery();
return this.queryBus.execute(query);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
const query = new GetAdInfoQuery(id);
return this.queryBus.execute(query);
}
}
변경한 AdsController 입니다. CommandBus 가 있었던 것처럼 QueryBus 를 통해 Query 를 전달해주면 됩니다.
Command 와 거의 동일해서 특별히 더 설명할건 없네요. 이번엔 정말 필요가 없어졌으니 Controller 에 주입해주던 AdsService 를
제거했습니다.
...
import {
GetAdInfoHandler,
GetAdsInfoHandler,
} from './query/get-ad-info.handler';
@Module({
...,
providers: [
...,
GetAdInfoHandler,
GetAdsInfoHandler,
],
})
export class AdsModule {}
AdsModule 에 두개의 쿼리 핸들러를 provider 로 등록해주면 끝입니다.
각각 /ads 와 /ads/1 등의 path 로 api 를 호출해보면, 잘 동작하는 것을 확인할 수 있습니다.
이렇게해서 Command 와 Query 를 분리했지만 사실 앞서 얘기했던 것처럼 서버가 하는일도 작고, 도메인이라는 것도 없는 수준으로
단순해서 이것만 보면 뭔 이점이 있는지, 오히려 단점이 되어버리는 것처럼 보이긴 합니다.
진짜 효과를 보기위해선 복잡한 도메인 모델을 다루는 경우 도메인. 복잡한 도메인 속에서 우리는 비즈니스 로직에 집중해야 하고
그것은 대부분 CUD 에 집중되어있을텐데 모델이 같음으로써 여기에 Query 가 침투하는 경우 복잡성이 올라가버릴테니 이를 CQRS 로
해결할 수 있을거구요.
처음에 적었던 이벤트 기반 프로그래밍도 @nestjs/cqrs 를 활용해 할 수 있는데 이는 다음 포스팅에서 적어보도록 하겠습니다.
'Backend > NestJS' 카테고리의 다른 글
NestJS - 대충 서비스 만들어보기 (18) (0) | 2023.07.29 |
---|---|
NestJS - 대충 서비스 만들어보기 (17) (0) | 2023.07.15 |
NestJS - 대충 서비스 만들어보기 (15) (0) | 2023.07.01 |
NestJS - 대충 서비스 만들어보기 (14) (0) | 2023.06.17 |
NestJS - 대충 서비스 만들어보기 (13) (0) | 2023.06.12 |