[NestJS] 데코레이터를 이용하여 서비스코드 관심사 분리하기

ahn__jh·2022년 7월 11일
0
post-thumbnail

안녕하세요
회사 프로젝트인 파트너센터 입점에 관련된 스프린트를 하는도중 입점 검수요청, 입점 검수가 완료되면 슬랙으로 알림을 주는 기능을 개발하게 되었습니다.
이때 서비스코드에 알림을 보내는 기능이 과연 입점 도메인에있는 CRUD 서비스코드에 들어가는게 맞을까?.. 라는 생각이 들어서 데코레이터로 분리하는 리팩토링을 진행하게 되었습니다.

기존 코드를 확인하기

기존 코드를 확인 하면서 설명하겠습니다.

async completeAsDoneOrRejectionByInspector(
    input: CompleteAsDoneOrRejectionInputDto,
    gomiUser: ReturnMdlUserDto,
  ): Promise<void> {
	//input으로 들어온 요청의 Id를 통해 해당 요청의 상태값을 확인하고 "요청완료" 또는 "반려완료" 상태로 업데이트하고 history를 남깁니다.
    await this.companyRequestImplementation.complete(input.id);

	//요청이 완료된 데이터를 "완료" 또는 "반려" 상태의 데이터를 생성합니다.
    await this.companyRequestImplementation.create(
      CompleteAsDoneOrRejectionInputDto.toEntity(input.update, gomiUser.id),
    );
	
	//이 때 상태가 완료(DONE)라면 temp테이블에서 실제 company테이블로 upsert하고 알림을 보내도록합니다. 
    if (input.update.status === CompanyRequestStatusEnum.DONE) {
      await this.companyImplementation.upsertByCompanyRequest(input.update as CompanyRequestEntity);
      await this.alarm.send(
        `[검수자-${gomiUser.userName}]님이 [${input.update.companyName}]의 검수 요청을 완료하였습니다.`,
        {
          url: slackDictionary.informationManagementSystem,
        },
      );
      return;
    }
	
	//반려시 반려상태로 complete메소드에서 업데이트 후 반려되었다고 슬랙으로 알람을 보내게 됩니다.
    await this.alarm.send(
      `[검수자-${gomiUser.userName}]님이 [${input.update.companyName}]의 검수 요청을 반려하였습니다.`,
      {
        url: slackDictionary.informationManagementSystem,
      },
    );
  }

이 메소드는 검수자가 검수완료 하거나 반려하는 기능인데 슬랙으로 알림을 보내는 코드까지 같이있으니 뭔가 이상한것 같죠?

바로~ 데코레이터를 생성해서 관심사를 분리하는 리팩토링 해보겠습니다.

Alarm 모듈생성

import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core/discovery';

import { SlackDecoratorProvider } from './providers/slack-decorator.provider';

@Module({
  imports: [DiscoveryModule],
  providers: [SlackDecoratorProvider],
})
export class AlarmModule {}

DiscoveryModuel이 핵심인데 이 모듈의 서비스를 통해서 nestjs container로 접근 해 provider 또는 controller를 가져올 수 있습니다.

Decorator 생성

import { applyDecorators, SetMetadata } from '@nestjs/common';

export const SLACK_ALARM = 'SLACK_ALARM';

export type Message = {
  message: string;
  url: string;
};

export interface ISlackCallback {
  callback: (data: object) => Message;
}

export function SlackAlarm(callback: ISlackCallback): MethodDecorator {
  return applyDecorators(SetMetadata(SLACK_ALARM, callback));
}

callBack함수를 받도록 데코레이터를 생성합니다.
SetMetadata를 통해 데코레이터의 instance에 대한 메타데이터를 접근할수 있도록 해줍니다.

Provider 생성

import { Inject } from '@nestjs/common';
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';

import { ISlackCallback, Message, SLACK_ALARM } from '../../../utils/decorator/slack-alarm.decorator';
import { IAlarmName } from '../alarm.constant';
import { IAlarm } from '../alarm.interface';

export class SlackDecoratorProvider {
  constructor(
    private readonly discovery: DiscoveryService,
    private readonly scanner: MetadataScanner,
    private readonly reflector: Reflector,
    @Inject(IAlarmName) private readonly alarm: IAlarm,
  ) {}

  //호스트 모듈의 종속성이 해결되면 호출됩니다.
  onModuleInit(): void {
    this.getInstance();
  }

  //dicovery 서비스를 통해 provider들을 가져오고
  getInstance(): void {
    this.discovery
      .getProviders()
      .filter((wrapper) => wrapper.isDependencyTreeStatic())
      .filter(({ instance }) => instance && Object.getPrototypeOf(instance))
	  // MetadataScanner로 decorator의 instance에 대한 metadata를 가져옵니다.
      .forEach(({ instance }) => {
        this.scanner.scanFromPrototype(instance, Object.getPrototypeOf(instance), this.sendSlackAlarm(instance));
      });
  }

  sendSlackAlarm(instance) {
    //콜백함수로 받아온 instance들로
    return (methodName) => {
      //해당 메소드와 metadata를 가져옵니다.
      const methodRef = instance[methodName];
      const metadata: ISlackCallback = this.reflector.get(SLACK_ALARM, methodRef);
      //meatadata가 없다면 early return 하도록 합니다.
      if (!metadata) {
        return;
      }
      
	  //메타데이터에서 데코레이터로 받은 callback 함수를 정의하고
      const { callback } = metadata;
	  
      //기존 동작 메소드(completeAsDoneOrRejectionByInspector)를 정의하고
      const originMethod = async (...args: unknown[]) => methodRef.call(instance, ...args);
	  
      //기존 메소드인 originMethod에 로직을 추가합니다.
      instance[methodName] = async (...args: unknown[]) => {
        //호출된 입력값(...args)을 가지고 originMethod를 호출하여 return된 값을 이용하기 위해 변수에 따로 저장해 둡니다.
        const response = await originMethod(...args);
        
        //callback함수에 넘겨 실행후 슬랙으로 알림 메세지를 전송합니다.
        const { message, url } = callback(response);

        return this.alarm.send(message, {
          url,
        });
      };
    };
  }

OnModuleInit은 호스트 모듈의 종속성이 해결되면 호출되는 NestJS의 Life cycle event 입니다.

DiscoveryService로 Singleton Container에 있는 instance에 접근할 수 있으며,
MetadataScanner로 decorator의 instance에 대한 metadata를 가져올 수 있습니다.
데코레이터 생성시 SetMetadata로 등록된 값들을 조회할수 있습니다.

데코레이터가 적용된 서비스 코드

@SlackAlarm({
    callback: ({
      userName,
      companyName,
      status,
    }: {
      userName: string;
      companyName: string;
      status: CompanyRequestStatusEnum;
    }) => {
      if (status === CompanyRequestStatusEnum.DONE) {
        return {
          message: `[검수자-${userName}]님이 [${companyName}]의 검수 요청을 완료 하였습니다.`,
          url: slackDictionary.informationManagementSystem,
        };
      }

      if (status === CompanyRequestStatusEnum.REJECTION) {
        return {
          message: `[검수자-${userName}]님이 [${companyName}]의 검수 요청을 반려하였습니다.`,
          url: slackDictionary.informationManagementSystem,
        };
      } else {
        return {
          message: `[${process.env.NODE_ENV}] 검수자 검수처리 잘못된 status 에러발생`,
          url: slackDictionary.error,
        };
      }
    },
  })
  @Transactional()
  async completeAsDoneOrRejectionByInspector(
    input: CompleteAsDoneOrRejectionInputDto,
    gomiUser: ReturnMdlUserDto,
  ): Promise<{ userName: string; companyName: string; status: CompanyRequestStatusEnum }> {
    await this.companyRequestImplementation.complete(input.id);
    await this.companyRequestImplementation.create(
      CompleteAsDoneOrRejectionInputDto.toEntity(input.update, gomiUser.id),
    );

    if (input.update.status === CompanyRequestStatusEnum.DONE) {
      await this.companyImplementation.upsertByCompanyRequest(input.update as CompanyRequestEntity);
    }
    return { userName: gomiUser.userName, companyName: input.update.companyName, status: input.update.status };
  }

SlackAlarm 이라는 데코레이터는 callback 함수를 던져주고 callback 함수는 userName, companyName, status를 파라미터로 받습니다.
completeAsDoneOrRejectionByInspector 메소드는 callback 함수에서 사용될 파라미터를 리턴하고 그 리턴된 값을 통해서 데코레이터의 const { message, url } = callback(response); 콜백함수를 실행합니다.

정리

  1. 호스트 모듈의 종속성이 해결되면 SlackAlarm Provider의 onModuleInit이 실행됩니다.
  2. onModuleInit 메소드가 실행되면서 getInstance dicovery서비스를 통해 instance와 meatadata를 가져옵니다.
  3. 가져온 데코레이터의 meatadata와 데코레이터를 통해 불러온 메소드를 실행합니다.
  4. 불러온 메소드가 실행되어 return된 데이터를 통해 callback 함수를 실행하여 알람을 처리합니다.

이렇게 하면 callback함수와 해당 서비스에서 return되는 값을 잘 사용하면 SlackAlarm 데코레이터를 이용하여 여기저기 사용할수 있게되었습니다!!🤗

reference : https://zuminternet.github.io/nestjs-custom-decorator/

2개의 댓글

comment-user-thumbnail
2022년 7월 12일

DiscoveryModuel 오타가 있네요~

1개의 답글