진심으로 업무(?) 자동화 슬랙봇 만들기 (1)

Chung Hwan·2022년 5월 30일
4

NestJS

목록 보기
1/1
post-thumbnail

밥에 진심인 사람들

#bob 채널

우리 회사 사람들은 밥에 진심이다. 식대가 꽤나 높기도 하고 회사 근처 맛집이 많기 때문이기도 하다. 그리하여 슬랙에 "#bob" 채널을 만들어 메뉴를 의논하고 식사팀을 만들어 나가게 되었다. 문제는 매번 "오늘 돈까스 드실 분 계신가요?" "저요!" "저두요~" 하는 과정이 지나치게 귀찮았다는 것이다. 그래서 이 사람들은 슬랙 웹훅을 이용해, 간단하게 정해진 인원을 가지고 랜덤으로 조를 나눠주는 봇을 만들게 되었다. 파이썬 스크립트 한 장으로 이루어진 작고 귀여운 봇이었다.

초기의 밥봇

그러나 이 밥봇은 여러가지로 부족한 점이 많았다. 우선 자체적으로 DB가 있는 것이 아니기 때문에 팀원들의 이름을 하드코딩해야 하는 문제가 있었다.

하드코딩 된 전체 팀원에서, 점심을 먹지 않는다고 표시한 사람을 수동으로 제외하고 조를 짜는 방식이었다.

새로운 분이 입사하셨는데 미처 코드를 수정하지 않아 그 분이 아무 조에도 포함되지 못한 민망한 상황이 종종 발생하곤 했다.


사실 내 얘기였다

또한 확장성이 매우 부족했다. OOP 보다는 스크립트 형식으로 작성되어 구조랄게 없었고, 새로운 기능을 작성할 때 재사용할 수 있는 코드가 거의 없었다. 가장 큰 문제는 bot이 아닌 webhook으로 구현되었다는 것이었다. webhook을 이용하면 http를 쏴서 채널에 메세지를 아주 쉽게 보낼 수 있지만, 그 이상을 하기가 쉽지 않다. 때문에 우리의 밥봇은 인터렉션이 별로 없는 무뚝뚝한 봇이 되었다.

문제 정의

이로써 문제가 정의되었다. (솔직히 이걸 처음에 만들 때는 이렇게까지 일을 크게 벌일 생각이 없었기 때문에 이런 식으로 문제를 정의하고 UML을 그리고 테스트 케이스를 짜고 스크럼을 하고 그런 프로세스를 거치진 않았다. 그냥 지금 생각해보니 문제가 이거였던 것 같다~ 정도)
1-1. 자체적으로 DB를 갖춘다.
1-2. DB를 슬랙 이벤트를 기반으로 알아서 잘 업데이트하여 민망한 상황을 방지한다.
2-1. 가독성과 유지보수, 확장성을 고려해 OOP 스타일로 코드 구조를 다시 확립한다.
2-2. 밥봇에 너무 많은 시간을 투자하는 것은 좋지 않을 것이다.
2-3. 그러므로 다른 팀원분들이 쉽게 이해하고 쉽게 컨트리뷰션하실 수 있도록 해야한다.
3. 제대로 된 bot으로 구현하여 다양한 인터렉션을 가능케하고 다정한 봇을 만든다.

개발

NestJS

NestJS로 봇을 구현해야겠다는 결정을 내리기까지는 많은 시간이 필요하지 않았다.

1-1. 웹 프레임워크이므로 DB 연결 당연히 쉽다. TypeORM 이라는 궁합 좋은 ORM도 있고, 보일러플레이트가 꽤 크다보니 DB 연결을 위해 따로 작업할 것이 별로 많지 않다.
1-2. 슬랙 이벤트는 HTTP POST로 날라온다. 그러므로 웹 프레임워크가 적당하다. 하지만 슬랙에서 오는 요청은 일반적인 HTTP 요청과 결이 다르다. path가 하나로만 들어오고 모든 정보가 post body에 포함되어 있기 때문이다. 때문에 이를 좀 더 잘 핸들링하기 위해 커스텀 컨트롤러를 만드는 것이 좋을 것 같았다. NestJS는 컨트롤러를 포함, 모든 것이 모듈과 프로바이더 단위로 관리되고, DI 패턴으로 결합이 매우 쉽기 때문에 커스텀 컨트롤러를 제작하고 사용하기에 매우 적합해 보였다. 사실 이 부분이 NestJS를 선택한 가장 큰 이유이기도 하다.
2-1. NestJS는 매우 OOP스럽다. 처음 코드를 봤을 때 Java 코드인 줄 알았다.
2-2,3. DI 패턴으로 의존성이 관리되므로 기본적으로 클래스들이 분리되어 있다. 때문에 여러명이 함께 작업하기에도 적합하다. 팀원분들이 본인이 원하는 기능을 모듈로 만들어 봇에 추가할 수 있다면 재밌을 것 같았다. (그리고 실제로 그렇게 되었다.)
3. 인터렉션도 이벤트처럼 단일 path의 HTTP POST로 구현된다. 이를 위해서도 커스텀 컨트롤러가 필요했다.

SlackEventListener

커스텀 컨트롤러를 만들어 놓고 보니 너무 맘에 들어서 npm 패키지로 퍼블리쉬했다. (컨트리뷰션 언제나 환영합니다.)
레포

목표와 결과

NestJS를 선택한 이유를 읽다보면 자연스럽게 알 수 있겠지만 슬랙 이벤트/인터렉션 용 커스텀 컨트롤러를 만드는 것이 가장 핵심적인 태스크였다. 이 커스텀 컨트롤러는 각각 SlackEventListener, SlackInteractivityListener라는 이름의 데코레이터로 개발되었다. 최대한 NestJS 기본 HTTP 컨트롤러처럼 쓸 수 있도록 구현하고 싶었기 때문에 데코레이터로 개발했다. HTTP 컨트롤러를 클래스 데코레이터 Controller, 메소드 데코레이터 Get, Post 등으로 구현하는 것처럼, 봇의 슬랙 컨트롤러도 비슷한 방식으로 구현할 수 있었으면 좋겠다고 생각했다.

결과적으로 커스텀 컨트롤러를 아래처럼 구현할 수 있도록 데코레이터들이 개발되었다.

@Controller('on-boarding')
@SlackEventListener()
export class OnBoardingController {
  constructor(private readonly onboardingService: OnBoardingService) {}

  @SlackEventHandler('team_join')
  async onTeamJoin({ event: { user } }: IncomingSlackEvent<TeamJoinEvent>) {
    this.onboardingService.startOnBoarding({ user });
  }
}

해당 데코레이터는 HTTP 컨트롤러 데코레이터와 매우 유사한 방법으로 사용할 수 있도록 개발했다.

데코레이터의 파라미터로 path를 넣듯이, 이벤트 혹은 인터렉션을 필터링할 수 있는 함수 등을 파라미터로 받을 수 있도록 구현했다.

@Controller('memo')
@SlackEventListener(({ event }) => event.channel === MEMO_CHANNEL)
export class MemoController {
  constructor(private readonly memoService: MemoService) {}

  @SlackEventHandler({
    eventType: 'message',
    filter: ({ event }) => event.text.includes('write this down!'),
  })
  async onMessage({ event: { user } }: IncomingSlackEvent<MessageEvent>) {
    this.memoService.takeMemo({ message });
  }
}

구현 과정

데코레이터 개발은 쉽지 않았다. NestJS의 Controller 데코레이터 등 소스코드를 많이 참고하기도 했지만 레퍼런스를 찾기가 어려웠다. 결론적으로는, 1. Nest app 초기화 시 메소드 데코레이터 (SlackEventHandler, SlackInteractivityHandler) 로 표시된 메소드를 싹 긁어와서 핸들러 Array로 쌓아놓고, 2. 단일 HTTP 컨트롤러에서 이벤트/인터렉션 POST 요청을 받은 뒤, 3. 핸들러 Array를 돌면서 filtering된 핸들러에만 파싱된 요청을 파라미터로 넣어 call하는 방법으로 구현했다.

1번 과정이 구현하기 정말 어려웠는데, NestJS 소스코드를 참고해 데코레이터로 메소드에 Metadata를 넣고 Explorer(Scanner)로 메소드를 훑어가며 이 Metadata를 확인하는 방법을 사용했다. 설명은 SlackEventHandler, SlackEventListener 만 가지고 하겠다. interactivity에 대한 데코레이터도 거의 동일한 방법으로 개발되었다.

export function SlackEventHandler(
  params:
    | {
        eventType?: SlackEventType;
        filter?: (event: IncomingSlackEvent) => boolean;
      }
    | SlackEventType,
) {
  return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    // ...
    target[SUB_SLACK_EVENT_HANDLER].set(propertyKey, {
      eventType,
      filter,
      target,
      propertyKey,
      descriptor,
    });
  };
}

메소드 데코레이터를 통해, 이 메소드가 slack event handler 메소드임을 표시한다.

export function SlackEventListener(
  listenerFilter: (event: IncomingSlackEvent) => boolean = () => true,
) {
  return function _<T extends { new (...args: any[]): any }>(Base: T) {
    return class extends Base {
      constructor(...args: any[]) {
        // ...
        const subMethods = Base.prototype[SUB_SLACK_EVENT_HANDLER];
        if (subMethods) {
          subMethods.forEach(
            (config: SlackEventHandlerSubMethodConfig, method: string) => {
              SetMetadata<string, SlackEventHandlerConfig>(
                SLACK_EVENT_HANDLER,
                {
                  eventType: config.eventType,
                  filter: (event: IncomingSlackEvent) =>
                    listenerFilter(event) &&
                    (config.filter ? config.filter(event) : true),
                  handler: (...args: any[]) => (this as any)[method](...args),
                },
              )(config.target, config.propertyKey, config.descriptor);
            },
          );
        }
      }
    };
  };
}

클래스 데코레이터를 통해 해당 클래스의 메소드들 중 slack event handler로 표시된 메소드를 가져와 metadata를 설정한다.

이걸 만들다보니 NestJS의 HTTP 컨트롤러 클래스 데코레이터의 존재 이유를 분명히 알 수 있었다. 어떤 메소드가 Get, Post로 표시되어 있더라도, 그 메소드를 포함하는 클래스가 메소드의 메타데이터를 설정해주어야 한다. 이러한 작업을 하려면 Controller 데코레이터가 필요해진다. 그리고 항상 잊지 말아야 할 것, 메소드들은 static하지 않으므로 this가 필요하다.

@Injectable()
export class SlackHandlerExplorer {
  constructor(
    private readonly modulesContainer: ModulesContainer,
    private readonly metadataScanner: MetadataScanner,
  ) {}

  public explore(): {
    eventHandlers: SlackEventHandlerConfig[];
    interactivityHandlers: SlackInteractivityHandlerConfig[];
  } {
    // ...
    return {
      eventHandlers: instanceWrappers
        .map(({ instance }) => {
          const instancePrototype = Object.getPrototypeOf(instance);
          return this.metadataScanner.scanFromPrototype(
            instance,
            instancePrototype,
            (method) =>
              this.exploreEventHandler(instance, instancePrototype, method),
          );
        })
        .reduce((prev, curr) => {
          return prev.concat(curr);
        }),
      // ...
    };
  }

  public exploreEventHandler(
    instance: object,
    instancePrototype: Controller,
    methodKey: string,
  ): SlackEventHandlerConfig | null {
    const targetCallback = instancePrototype[methodKey];
    const handler = Reflect.getMetadata(SLACK_EVENT_HANDLER, targetCallback);
    if (handler == null) {
      return null;
    }
    return handler;
  }
}

이제 모듈에 존재하는 모든 컨트롤러를 돌면서, slack event handler 의 metadata를 스캔해서 handler array를 만들어주는 SlackHandlerExplorer를 만들었다. 마지막으로 application 초기화 시에 모듈에서 이 explorer를 invoke하고 만들어진 handler array를 저장한다.

@Module({
  providers: [MetadataScanner, SlackHandlerExplorer],
})
export class SlackHandlerModule implements OnModuleInit {
  // ...

  constructor(
    private readonly explorer: SlackHandlerExplorer,
    private readonly slackHandler: SlackHandler,
  ) {}

  onModuleInit() {
    const { eventHandlers, interactivityHandlers } = this.explorer.explore();

    for (const eventHandler of eventHandlers) {
      this.slackHandler.addEventHandler(eventHandler);
    }

    for (const interactivityHandler of interactivityHandlers) {
      this.slackHandler.addInteractivityHandler(interactivityHandler);
    }
  }
}

거의 다 왔다. 실제 슬랙에서 오는 HTTP POST 요청을 받을 단일 path 컨트롤러를 만든다.

@Controller('slack')
export class SlackEventsController {
  constructor(private readonly slackHandler: SlackHandler) {}

  @Post(`events`)
  async handleEvent(@Body() event: IncomingSlackEvent) {
    //...
    return this.slackHandler.handleEvent(event);
  }

  //...
}

이 이벤트를 받아 handler array를 돌며 filtering해서 알맞은 핸들러 메소드를 call하는 SlackHander 프로바이더를 만들었다.

@Injectable()
export class SlackHandler {
  private readonly _eventHandlers: SlackEventHandlerConfig[];
  private readonly _interactivityHandlers: SlackInteractivityHandlerConfig[];
  //...
  async handleSingleEvent(
    event: IncomingSlackEvent,
    handlerConfig: SlackEventHandlerConfig,
  ) {
    const { eventType, filter, handler } = handlerConfig;

    // ...
    if (filter) {
      try {
        if (!filter(event)) {
          return;
        }
      } catch (e) {
        return;
      }
    }

    return handler(event);
  }

  async handleEvent(event: IncomingSlackEvent) {
    return Promise.all(
      this._eventHandlers.map(
        async (handlerConfig) =>
          await this.handleSingleEvent(event, handlerConfig),
      ),
    );
  }
}

완성! 간단히 메세지 다이어그램으로 표현해보면 아래처럼 돌아간다.

이제 만들어진 데코레이터를 이용해 우리가 원하는 비즈니스 로직의 어플리케이션을 만드는 재미있는 부분만 남았다. 글이 너무 길어진 것 같으니, 이 파트는 다음에 이어서 작성하도록 하겠다.

0개의 댓글