클린 아키텍처 적용해서 TypeScript로 슬랙봇 만들기

코드 레벨에서 하나하나 살펴보기

Hyunmin Woo
wafflestudio
22 min readOct 5, 2023

--

안녕하세요, 와플스튜디오 SNUTT 팀에서 프론트엔드 개발을 하고 있는 우현민입니다. 저희 SNUTT 팀에서는 칸반 운영을 더 효율적으로 하기 위해 간단한 슬랙봇을 제작했습니다.

이 봇을 제작하는 과정에서 어떤 프레임워크의 도움도 받지 않고 순수 TypeScript 로만 클린 아키텍처를 적용해서 구현했는데요, 코드가 꽤 괜찮게 구현되었습니다. JavaScript / TypeScript 생태계에 클린 아키텍처를 적용하려는 시도가 그리 많지 않은 만큼, 이 글이 클린 아키텍처를 적용하는 레퍼런스를 찾아보시는 분들께 도움이 될까 하여 이 슬랙봇을 만든 과정을 자세하게 공유드려보려 합니다.

이 글을 작성하는 시점 (커밋 5081523) 의 전체 소스코드는 아래에서 확인하실 수 있습니다.

뭐하는 봇인가요?

무슨 봇을 만든 건지 먼저 말씀드려야 아래 내용을 설명드리기 더 좋을 것 같아서, 이 부분을 먼저 공유드리겠습니다.

저희 SNUTT 팀에서는 서로의 태스크를 더 잘 파악하기 위해 Notion으로 Kanban을 운영하고 있는데요, 아무래도 회사가 아닌 동아리다 보니 팀원들끼리 Kanban 을 통해 작업 일정을 관리하고 잘 최신화하는 게 쉽지 않았습니다.

SNUTT 칸반

팀 내부적으로 칸반을 잘 이용하면 좋다는 공감대가 있었기에 “칸반 최신화가 놓쳐진 것 같을 때 / 칸반을 잘못 이용하고 있을 때 알려주는 봇이 있으면 좋을 것 같다” 라는 의견이 모였습니다.

요구된 기능은 아래와 같습니다.

  • 설정한 schedule이 지났을 경우 / schedule이 설정되지 않았을 경우 / backlog 상태가 아닌데 assignee가 없을 경우 이렇게 세 경우를 이슈로 정한다.
  • 매주 수/토 오전에 이 슬랙봇이 Slack 채널로 칸반 이슈를 알려줘야 한다.

글을 이해하는 데에 도움이 되기 위해 스포를 드리자면, 결과물은 아래와 같이 구현되었습니다.

매주 수/토 오전에 칸반봇이 자동으로 칸반 이슈를 알려줍니다.

기술 및 아키텍처 선정

저희는 Kanban 관리를 위해 Notion을 이용하고 있고, 소통에는 Slack을 이용하고 있습니다. Slack은 아마 계속 이용하겠지만, Notion의 경우 상황이 변하면 Jira 나 Trello, Asana 등의 다른 도구로 바뀔 수 있음을 염두해야 했습니다.

따라서 당장의 코드량이 조금 많아지더라도 세부사항이 변경될 때 유연하게 대응할 수 있는 클린 아키텍처를 적용하기로 했습니다.

언어는 담당자인 제가 가장 익숙하고 좋아하는 언어인 TypeScript 를 선정했습니다. 생태계가 워낙 크기에 Notion/Slack 등 무슨 도구든 TypeScript 에서 사용할 수 있는 퍼스트파티 라이브러리들이 존재한다는 장점도 있었습니다. 이에 따라 런타임 역시 TypeScript 로 된 스크립트를 가장 편하게 돌릴 수 있는 ts-node 로 결정했습니다.

수행 방식의 경우 서버를 띄운 다음 node-cron 등의 라이브러리로 크론잡을 돌릴 수도 있었지만, 정해진 시간에 스크립트를 수행하기만 하면 되었기 때문에 서버를 띄우기보단 스크립트 형태로 가기로 했습니다.

인프라는 복잡한 설정 없이 무료로 사용할 수 있는 GitHub Actions 의 schedule 기능을 선택했습니다.

추상화와 엔티티 설계

엔티티

가장 먼저 엔티티를 설계했습니다. 엔티티는 서비스의 핵심이 되는 개체들로, 심지어는 어플리케이션 없이 사람이 수작업을 한다고 해도 존재하는 것들입니다.

개발 파트와 팀 멤버

export enum Part {
ALL = 'ALL',
FRONTEND = 'FRONTEND',
ANDROID = 'ANDROID',
IOS = 'IOS',
SERVER = 'SERVER',
DESIGN = 'DESIGN',
}

export enum Member {
WOOHM402 = 'WOOHM402',
SHP7724 = 'SHP7724',
JUTAK97 = 'JUTAK97',
DAVIN111 = 'DAVIN111',
// ... 이하 멤버 목록
}

먼저 개발 파트와 팀 멤버가 있습니다. 이는 카드가 어떤 개발 파트의 태스크인지, 그리고 담당자가 누구인지 알 수 있게 해 줍니다. 모든 카드는 파트와 담당자를 가지고 있으며, 칸반 이슈가 있을 경우 담당자를 (담당자가 없다면 파트 사람 모두를) 멘션해서 이슈를 알려줘야 합니다.

멤버 enum 값에는 멤버의 본명을 쓸 수도 있었지만, 추후 팀에 동명이인이 생길 수도 있고 / 와플스튜디오에서는 기본적으로 멤버를 식별하는 데에 github username 을 활용하고 있기에 해당 정책에 맞췄습니다.

칸반 카드

// 칸반 카드
export type Card = {
id: string;
url: string;
part: Part | null;
assignee: Member[];
status: 'Backlog' | 'To Do' | 'In Progress' | 'In Review' | 'Archived' | 'Done' | 'Released';
title: string;
schedule: [Date | null, Date] | null;
};

또한 칸반 카드가 있습니다. Notion API가 제공하는 데이터 구조와 완전히 다른데요, Notion이 아닌 다른 도구로 칸반을 관리하게 되더라도 엔티티가 아닌 어댑터만 수정하면 되도록 하기 위해 의도적으로 Notion의 데이터 구조를 전혀 신경쓰지 않고 설계했습니다. Notion이 아닌 어떤 칸반 도구더라도 칸반 아이템의 id값이 있을 것이고, 해당 아이템으로 바로 갈 수 있는 웹 url이 있을 것이고, part 정도의 속성을 설정할 수 있을 것이고, assignee를 여러 명 할당할 수 있을 것이고, … 등의 근거로 위와 같이 설정했습니다.

export enum CardAbnormalReason {
DUE_DATE_PASSED = 'DUE_DATE_PASSED',
NO_ASSIGNEE = 'NO_ASSIGNEE',
NO_SCHEDULE = 'NO_SCHEDULE',
}

export const isCardAbnormal = (card: Card): { abnormal: false } | { abnormal: true; reason: CardAbnormalReason } => {
if (
(card.status === 'To Do' || card.status === 'In Progress' || card.status === 'In Review') &&
card.schedule === null
) {
return { abnormal: true, reason: CardAbnormalReason.NO_SCHEDULE };
}

if (card.status !== 'Done' && card.status !== 'Archived' && card.status !== 'Released' && card.schedule !== null) {
const today = new Date().getTime();
const dueDate = card.schedule[1].getTime();
if (today > dueDate) return { abnormal: true, reason: CardAbnormalReason.DUE_DATE_PASSED };
}

if (card.status !== 'Done' && card.status !== 'Archived' && card.status !== 'Released' && card.status !== 'Backlog') {
if (card.assignee.length === 0) return { abnormal: true, reason: CardAbnormalReason.NO_ASSIGNEE };
}

return { abnormal: false };
};

마지막으로 칸반 카드가 위에서 정의한 이상 상태인지 확인하는 로직이 있습니다. 이 역시 “설령 어플리케이션이 없더라도 필요한” 이 어플리케이션의 핵심 엔티티이기 때문에 엔티티 레이어에 존재합니다.

isCardAbnormal 함수는 Card 를 type 대신 class 로 만든 다음 해당 class의 static method 로 존재하는 게 좀더 일반적일 수도 있지만, class 문법을 선호하지 않기도 하고 이 패턴도 충분히 자연스럽다고 생각했습니다.

유스케이스 타입

export type KanbanService = {
sendAbnormalCardStatuses: () => Promise<void>;
};

이 봇에는 “Kanban 이슈를 확인해서 쏜다” 라는 한 개의 유스케이스만 있습니다. 따라서 위와 같이 타입을 설정했습니다.

Q. 왜 interface 가 아닌 type alias 를이용했나요?

A. 이 타입에 선언 병합이 필요한 경우가 있을 리 없기 때문에 type alias 를 이용했습니다. 다른 타입들도 마찬가지로 선언 병합이 필요하지 않을 것이기에, 이 프로젝트에서는 interface 를 하나도 이용하지 않았습니다.

어댑터 타입 — repository

export type KanbanRepository = {
listCards: (args: { status?: Record<Card['status'], boolean> }) => Promise<Card[]>;
};

KanbanService 가 유즈케이스를 처리하려면 먼저 검사할 칸반 카드의 목록이 있어야 할 것입니다. 이를 위해 KanbanRepository 를 만들어줬습니다. Completed 상태가 되어 더 이상 확인할 필요가 없는 칸반까지 불러올 필요는 없기에, 파라미터를 통해 카드의 상태로 필터해서 가져올 수 있도록 설계했습니다.

당연하지만, Notion이 아닌 다른 것으로 칸반을 관리하더라도 구현체만 갈아끼우면 되어야 하므로 이름에 Notion 이 포함되지 않고 어떤 칸반 관리 도구에서도 범용적으로 사용할 수 있게 지었습니다.

어댑터 타입 — presenter

type MessageHelpers = {
formatMemberMention: (member: Member) => string;
formatPartMention: (part: Part) => string;
formatLink: (text: string, args: { url: string }) => string;
};

type GenerateMessage = (helpers: MessageHelpers) => string;

export type MessengerPresenter = {
sendThread: (message: GenerateMessage, threadMessages: GenerateMessage[]) => Promise<unknown>;
};

KanbanService 가 이슈가 되는 목록을 확인했다면, 해당 카드들을 적절하게 포매팅하여 Slack 으로 쏴야 할 것입니다. 이를 위해 MessengerPresenter 를 만들어 줬습니다.

Q. 왜 sendThread 함수가 string 타입의 문자열이 아닌 GenerateMessage 타입의 콜백함수를 받나요?

A. 구현상의 이슈 때문입니다. Slack API는 '<@U1234asdf> 안녕하세요' 와 같은 형식으로 문자열을 쐈을 때 쏴야 Slack에서 멤버가 정상적으로 멘션되어 @우현민 안녕하세요라는 형태로 메세지가 보이게 됩니다. 링크의 경우에도 '<https://wafflestudio.com|wafflestudio>'형태로 쏴야 링크로 포매팅됩니다. U1234asdf 라는 Slack user id 도, <@U1234asdf> 라는 메세지의 포맷도 slack의 구현 세부사항이기에 KanbanService 의 구현체가 slack에 종속되지 않게 하려면 해당 정보를 알지 못하게 해야 했습니다. 해당 세부사항들을 MessengerPresenter의 구현체 안에 격리시키기 위해MessengerPresenter.sendThreadstring이 아닌 (helpers: MessageHelpers) => string 타입의 콜백들을 인자로 받습니다.

이렇게 하면 추상화 단계가 Slack에 종속되는 것처럼 보일 수도 있지만, 다른 메신저 앱들도 유사한 인터페이스를 가질 것이고 & 인터페이스가 다르더라도 콜백 형태로 만들면 어떤 형태도 커버할 수 있기에 Slack에 종속된 추상화는 아닙니다.

KanbanRepository이름에 Notion이 없는 것과 마찬가지로, MessengerPresenter 역시 의도적으로 이름에 Slack 을 포함하지 않았습니다.

구현체 세부사항

구현체의 경우 코드가 길기 때문에 이 글에서 모두 다루지는 않고, 의사코드로만 작성하고 넘어가겠습니다. 정확히 어떻게 구현했는지 궁금하신 분들은 글 상단에 첨부된 github repository에서 전체 소스코드를 확인하실 수 있습니다.

KanbanService 구현체

export const createKanbanService = ({
kanbanRepository,
messengerPresenter,
}: {
kanbanRepository: KanbanRepository;
messengerPresenter: MessengerPresenter;
}): KanbanService => {
return {
sendAbnormalCardStatuses: async () => {
// kanbanRepository.listCards() 해서 칸반 목록을 받아온다
// 엔티티의 isCardAbnormal() 함수를 통해 각 카드들에 이슈가 있는지 확인한다
// messengerPresenter.sendThread() 로 결과를 넘긴다
},
};
};

이 구현체는 KanbanRepositoryMessengerPresenter 를 주입받아서 유즈케이스를 처리합니다. 과정에서 다른 구현체에 의존하지 않았고 Slack이나 Notion이라는 세부사항에도 전혀 의존하지 않아 코드를 고수준의 정책으로 보호하고 있음을 알 수 있습니다.

Q. class KanbanServiceImpl implements KanbanService 이런 식으로 하는 게 더 일반적이지 않나요?

A. 의도적으로 class문법을 이용하지 않았습니다. JavaScript 의 class 문법은 좋지 않습니다. 일부 사람들은 class 문법이 JavaScript의 가장 큰 실수 중 하나라고 말하는데, 저는 해당 의견에 강하게 공감합니다. class 대신 함수/객체 문법과 클로저 기능을 통해 훨씬 JavaScript 스럽고 유연한 코드를 짤 수 있습니다. TypeScript에서 메서드의 파라미터 타입 자동 추론이 깔끔하게 되는 것은 덤입니다.

KanbanRepository 구현체

export const createNotionKanbanRepository = ({
databaseId,
notionClient,
}: {
databaseId: string;
notionClient: Client;
}): KanbanRepository => {
return {
listCards: async ({ status }) => {
// notionClient 를 통해 databaseId 에 해당하는 노션DB에 있는 카드 목록을 받아온 다음
// 우리 entity 에 정의된 Card 타입으로 변환해서 반환
},
};
};

const MEMBER_NOTION_ID_MAP: Record<string, Member | undefined> = {
'a60a2b22-e58c-4cf8-a100-764f60cac65c': Member.WOOHM402,
// ... 나머지
};

이 구현체는 이름이 createNotionKanbanRepository 인 만큼 Notion에 대한 세부사항을 담당하고 있습니다. Notion에서 제공하는 member id 와 우리 엔티티에서 정의한 member enum 간의 매핑 정보도 여기서 관리하는 것을 알 수 있습니다.

추상화가 Notion에 종속되지 않아야 하는 것이지, 구현체는 종속되어도 괜찮습니다

이 구현체가 주입받는 notionClient 는 Notion api 엔드포인트나 api에 필요한 권한을 가진 토큰 등을 들고 있는 더 저수준의, 클린 아키텍처에서는 더 바깥쪽 원에 존재하는 객체입니다.

MessangerPresenter 구현체

export const createSlackMessengerPresenter = ({
slackClient,
slackChannel,
}: {
slackChannel: string;
slackClient: WebClient;
}): MessengerPresenter => {
return {
sendThread: async (text, thread) => {
// slackClient 를 통해 슬랙 채널에 본문 메세지 보내고
// slackClient 를 통해 본문 메세지 하위 스레드 댓글로 메세지를 보낸다
},
};
};

const MEMBER_SLACK_ID_MAP: Record<Member, string> = {
[Member.WOOHM402]: 'U01JQM3GNBW',
// ...
};

const PART_SLACK_ID_MAP: Record<Part, string> = {
[Part.ALL]: 'S032EFLT1FT',
// ...
};

createNotionKanbanRepository 와 유사하게, 이 구현체는 slack 과 관련된 세부사항을 담당합니다. 멤버-slackId 매핑, 파트-slackId 매핑 모두 여기서 관리하는 것을 알 수 있습니다.

slackClient 는 앞의 notionClient 처럼, 토큰이나 엔드포인트 등에 대한 정보를 가지고 있습니다.

그리고.. main

지금까지 아키텍처 레이어 안에 있는 고수준/저수준의 정책들의 추상화와 구현체들을 확인했는데요, 이제 테스트를 제외하면 가장 바깥쪽의 레이어인, 시스템의 플러그인이자 시스템을 수행하는 첫 진입점인 Main 모듈만 남았습니다. Main은 아래와 같이 src/index.ts 파일에 간단하게 구현했습니다.

import { Client } from '@notionhq/client';
import { WebClient } from '@slack/web-api';

import { createKanbanService } from './infrastructures/createKanbanService';
import { createNotionKanbanRepository } from './infrastructures/createNotionKanbanRepository';
import { createSlackMessengerPresenter } from './infrastructures/createSlackMessengerPresenter';

const NOTION_KANBAN_DATABASE_ID = process.env.NOTION_KANBAN_DATABASE_ID;
const NOTION_KANBANBOT_TOKEN = process.env.NOTION_KANBANBOT_TOKEN;
const SLACK_TTUNS_TOKEN = process.env.SLACK_TTUNS_TOKEN;
const SLACK_CHANNEL = process.env.SLACK_CHANNEL;

if (!NOTION_KANBAN_DATABASE_ID) throw new Error();
if (!NOTION_KANBANBOT_TOKEN) throw new Error();
if (!SLACK_TTUNS_TOKEN) throw new Error();
if (!SLACK_CHANNEL) throw new Error();

const kanbanService = createKanbanService({
kanbanRepository: createNotionKanbanRepository({
databaseId: NOTION_KANBAN_DATABASE_ID,
notionClient: new Client({ auth: NOTION_KANBANBOT_TOKEN }),
}),
messengerPresenter: createSlackMessengerPresenter({
slackChannel: SLACK_CHANNEL,
slackClient: new WebClient(SLACK_TTUNS_TOKEN),
}),
});

kanbanService.sendAbnormalCardStatuses();

그리고 package.json 에 이렇게 설정해 줬습니다.

  "scripts": {
"send": "ts-node src/index.ts",
},

이제 yarn send 를 하면 스크립트가 수행됩니다.

main 모듈은 모든 세부사항을 다 알고 있습니다. slack 과 notion을 이용한다는 것, 시크릿 정보들이 환경변수로 들어온다는 것, 우리 시스템 아키텍처가 KanbanService KanbanRepository MessengerPresenter 이렇게 크게 세개라는 것 등등을요.

slack 이나 notion 과 직접 소통하는 client 들은 각 회사에서 제공하는 퍼스트파티 라이브러리를 이용했습니다. 만약 추후 해당 라이브러리들에 버그가 생기는 등의 이유로 사용할 수 없게 되더라도, 같은 역할을 하는 SlackClient / notionClient 모듈을 간단하게 직접 만들어서 이용할 수 있을 것입니다.

그래서 이 모듈은 모든 서비스들을 생성하고 요구사항에 맞게 주입해 준 다음, 마지막 줄에서 서비스를 수행하도록 명령하여 고수준의 서비스에게 제어권을 넘깁니다.

이 어플리케이션은 스크립트의 형태이기에 main 모듈이 각 서비스를 생성한 다음 함수를 수행하고 종료됩니다. 만약 스크립트가 아니라 서버 등이었다면, 대신 이런 형태였을 것입니다.

// express.js 로 띄운 서버였다면
app.use('/check', async (req, res) => {
await kanbanService.sendAbnormalCardStatuses();
return res.sendStatus(200);
})

// node-cron 으로 설정했다면
cron.schedule('* * * * *', () => {
kanbanService.sendAbnormalCardStatuses()
});

// 기타 등등..

인프라

인프라의 경우 앞서 말씀드린 대로 별도의 서버나 설정 같은 것 없이 github actions 의 schedule 기능을 활용했습니다.

아래와 같이 .github/kanban-check-script.yml 을 설정했습니다.

name: send-kanban-check-script

on:
schedule:
- cron: "20 1 * * 3,6" # 수, 토 한국시간 오전 10시 20분

jobs:
cron:
runs-on: ubuntu-latest

# ... (중략)

- name: run script
run: |
cd apps/kanban-check-script
yarn install
NOTION_KANBAN_DATABASE_ID=${{ secrets.NOTION_KANBAN_DATABASE_ID }} \
NOTION_KANBANBOT_TOKEN=${{ secrets.NOTION_KANBANBOT_TOKEN }} \
SLACK_TTUNS_TOKEN=${{ secrets.SLACK_TTUNS_TOKEN }} \
SLACK_CHANNEL=${{ secrets.SLACK_CHANNEL }} \
yarn send

그러면, 이렇게 시간에 맞춰 github action이 자동으로 스크립트를 수행해 줍니다.

마치며

이렇게 TypeScript 에서 클린 아키텍처를 적용해서 간단한 슬랙봇을 만든 결과를 공유드렸습니다.

SNUTT 프론트엔드에서는 모든 프로젝트들에 클린 아키텍처를 적용하여 구현하고 있습니다. 클린 아키텍처를 적용하면서, 처음 파일을 여러 개 만들어야 할 때는 꽤나 번거로웠지만 유지보수를 할 때 훨씬 편했고, 버그 등 결함도 훨씬 적게 발생한다는 것을 느낄 수 있었습니다.

또한 추후 우리 팀이 사용하는 도구가 변경되거나, 퍼스트파티 라이브러리에 버그가 생기는 등 코드에 변경사항이 필요해지더라도, 해당하는 1개의 모듈만 변경하거나 갈아끼우면 간단하게 반영할 수 있을 것으로 예상됩니다.

JavaScript / TypeScript 진영에서 의존성 주입 프레임워크 없이 & class 문법 없이 의존성 주입을 하고 클린 아키텍처를 적용하는 예시는 정말 찾기 힘든데요, 이 글이 클린 아키텍처를 적용해보려는 분들께 작게나마 도움이 되길 바랍니다.

감사합니다 :)

--

--