본문 바로가기

Backend/NestJS

NestJS - 대충 서비스 만들어보기 (16)

- 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 를 활용해 할 수 있는데 이는 다음 포스팅에서 적어보도록 하겠습니다.