팀에서 달에 한 번 오후 4시부터 팀별 활동을 할 수 있는 시간을 가지고 있다. 그 때마다 팀원 중 한 명을 뽑아 어떤 활동을 할지 정해야 한다. 그 한 명을 뽑는 날이 오면 당첨자 추첨 웹사이트를 찾고, 일일이 이름을 기재하고 한 명을 뽑는 일을 하고 있었다. 이 과정이 아주 번거로운 일은 아니지만, 예전부터 Slack 앱을 만들어보고 싶기도 했고 추첨 앱을 만들어두면 다른 일을 할 때에도 활용할 수 있지 않을까 생각이 들었다.

물론 Slack에는 무작위 추첨 기능이 있는 앱이 이미 존재한다. lucky-draw라는 앱이다. 11번가에서 제작했으며, AWS Lambda를 통해 배포 과정에 대한 글도 있다. 다만, 사용성이 조금 아쉬웠다.

추첨을 하기 위해 채널을 새로 생성해야 했고, 생성된 채널에 참여된 대상만 한정 지어 추첨할 수 있으며 매번 추첨을 위한 인원을 초대해야 한다는 점이다. 그리고, 채널을 일일이 만들지 않고, 현재 채널에서 제외하고 싶은 인원(지난주 당첨 되었던 사람)을 제외할 수 없다는 점이다.

그래서 체험 겸 Slack 앱을 만들어보기로 했다.

코드 저장소는 아래와 같다.


웹훅(Webhook)

공식 홈페이지의 Slack API를 먼저 훑어보았다. 웹훅(Webhook)을 통해 메시지를 보낼 수 있는 기본 Slack 앱을 만들 수 있다고 한다. 먼저 Slack에서 Network 통신을 어떻게 하는지 알기 위해서 이 개념을 알아야 한다.

웹훅(Webhook)이란, 하나의 시스템이 다른 시스템에 어떤 이벤트가 발생했음을 알리는 방법이다(출처 - 포트원). 어느 시스템에서 다른 시스템에 이벤트가 발생했다고 알려주는 매커니즘이기 때문에 주로 ‘알림’ 기능에 많이 사용된다. (알림 외 코드 푸시, 블로그에 댓글 게시 등에도 사용됨)

알림 기능은 실시간으로 서로 정보를 주고받는 것이 중요하다. 실시간으로 이벤트가 발생한 것을 감지하기 위해서는 폴링(Polling)과 웹훅을 사용할 수 있는데, 폴링의 경우는 클라이언트에서 지속해서 서버에서 이벤트가 발생했는지를 알기 위한 HTTP 요청을 보낸다. 반면 웹훅은 서버에서 이벤트가 발생했을 경우 클라이언트로 이벤트가 발생했음을 알린다. 즉, 전통적인 네트워크 요청처럼 클라이언트 -> 서버의 요청은 알림 하나를 받기 위해 여러 번의 네트워크 요청이 있어야 하지만, 웹훅을 사용하면 서버 -> 클라이언트 네트워크 통신은 한 번 일어나기 때문에 매우 효율적이라고 할 수 있다.

api-vs-webhook

이미지에서 보듯, 웹훅은 서로 다른 시스템 간 양방향 통신을 위한 것이다. 3가지 용어를 기억하자. (아래의 용어는 설명을 위한 용어로, 공식 용어는 아니다)

  • 데스크탑 앱 (Desktop App)
    • Mac OS 또는 Windows, Mobile OS에 설치될 데스크탑 어플리케이션 (메신저).
  • 슬랙 자체 서버 (Slack API Server - 가명)
    • 데스크탑 앱과 우리가 만들 앱 서버를 중계해 주는 슬랙 서비스 자체 서버.
  • 나의 서드파티 앱 서버 (My Third-party App Server)
    • 우리가 Slack 데스크탑 앱에 설치할 App 자체를 위한 서버.

Slack App에서는 어떻게 웹훅을 사용하고 있는지 도식화해 보면 아래와 같다.

slack-webhook

먼저 슬랙 자체 서버에 우리가 만들 서드파티 앱의 URL을 등록해야 한다. 그래야 데스크탑 앱에서 특정 이벤트가 발생하면 내가 생성한 서버로 요청을 보낼 수 있기 때문이다. 그리고, 우리가 만들 서드파티 앱을 위한 서버는 Slack 자체 서버에서 검증된 요청인지를 판단하기 위해 토큰을 생성해 등록해 두어야 한다.

데스크탑 앱에서 서드파티 앱으로 인한 이벤트가 발생하면, 슬랙 자체 서버를 거쳐 나의 서드파티 앱 서버로 이어 요청이 발생하게 되고, 나의 서버에서 token을 검증한 다음, 특정 결과를 슬랙 자체 서버로, 슬랙 자체 서버에서는 데스트탑 앱으로 다시 결과를 돌려주며 데스트탑 앱의 특정 행동을 하게끔 하는 것이다.

그렇다면 서드파티 앱을 만들기 위해 우리는 Slack 자체 서버에 웹훅을 위한 URL을 등록하고, 원하는 결과를 돌려주는 서버를 제작해서 배포하면 된다. 따라서, Slack의 서드파티 앱을 만드는 과정은 데스크탑 앱에 설치된 서드파티 앱에서 특정 이벤트가 발생하면 처리할 서버 또는 서버리스 펑션을 만드는 과정이다.

참고로 Slack Desktop 앱은 Electron으로 만들어져있고, 크로미움 엔진으로 구동되기 때문에 개발자 도구를 켤 수 있다. 맥에서 Slack의 개발자 도구를 켜기 위해서는 아래와 같은 명령어를 터미널에 입력해 주면 된다. (출처)

$ export SLACK_DEVELOPER_MENU=true
$ open -a /Applications/Slack.app

Command + Option + I 를 눌러 다음과 같이 개발자 도구를 둘러보자.

slack-devtools

개발자 도구에서 Network 탭을 보면 어떤 주소로 데스크탑 앱이 요청을 보내는지 알 수 있다.


Slack 서드파티 앱 생성 및 세팅

먼저 테스트 할 워크스페이스를 생성해 두는 것이 좋다. 나 같은 경우는 블로그 CI/CD 파이프라인 중에 빌드가 완료되면 알림을 받을 워크스페이스가 미리 생성되어 있었으므로 이곳을 활용할 것이다.

앱 생성

Slack API 앱 생성 페이지로 들어가자. Create New App 버튼을 클릭하면 아래와 같은 모달이 뜨는데, ‘From scratch’ 를 선택해주자.

create-new-app

App 이름을 설정해 주고, 어느 워크스페이스에서 사용할 것인지 설정해 주도록 하자.

아래로 주욱 내려보면 ‘Display Information’ 란이 있다. 이 곳에서 App 이름을 다시 설정할 수 있으며 description 및 App Icon을 설정해 줄 수 있다.

권한 및 범위 설정

좌측 패널의 ‘OAuth & Permissions’ 를 클릭하면 앱의 권한과 범위(Scope)를 설정해 줄 수 있다. 권한은 OAuth Token을 생성하고 이를 서드파티 앱 서버에 등록함으로써 매번 요청 때마다 권한이 있는 요청인지를 판별하기 위해 생성하는 것이다.

그리고 범위(Scope)란, 내 서드파티 앱이 Slack에서 어떤 어떤 기능을 사용할 것인지를 설정하는 것이다. 가령 chat:write 라는 범위는 슬랙 앱이 채팅 메시지를 보낼 수 있다는 권한을 부여하는 것이고, channels:read 와 같은 범위는 채널의 멤버를 조회하는 등의 권한을 부여하는 것이다.

상세한 내용은 Permission scopes에서 확인할 수 있다.

scope

위와 같은 scope을 설정해 주었다.

Slack Bot Token 및 Signing Secret

Slcak Bot Token을 얻기 위해서는 먼저 데스트탑 앱에 우리 서드파티 앱을 설치해야 한다. 스크롤을 조금 올리면 ‘OAuth Tokens for Your Workspace’ 란에 ‘Install to Workspace’ 버튼을 눌러준다.

install-to-workspace

아래와 같이 scope에서 설정한 권한을 묻는 페이지가 나타나고, 허용을 눌러주면 데스트탑 앱에 서드파티 앱이 설치된다. 그리고 다시 웹 페이지로 돌아와 보면 ‘Bot User OAuth Token’이 생성된 것을 볼 수 있다. 이 녀석을 잘 가지고 있자.

OAuth-Tokens-for-Your-Workspace

그리고 Signing Secret은, 우리가 제작할 서드파티 앱의 서버에 검증 값으로 넣어주어야 할 비밀 키값이다. 좌측 패널의 ‘Basic Information’ 을 클릭하고 App Credentials 란에 보면 Signing Secret 값이 있다. show 버튼을 클릭하면 안에 내용이 보이기 때문에 잘 가지고 있자.

총 2개의 key를 얻게 되었다.


서드파티 앱을 위한 서버 환경 설정

서드파티 앱이 설치되었으므로, 서드파티 앱에서 생성하는 이벤트를 처리할 서버를 생성해야 한다. Slack에서는 서드파티 앱을 위한 3가지의 언어 프레임워크를 지원한다. Java, Python, JavaScript다. 우리는 JavaScript를 사용할 것이기 때문에, Bolt-js 라는 프레임워크를 사용해 서버를 생성할 것이다.

Bolt-js

소스 코드는 only-bolt Branch에 있다.

Bolt-js는 Slack의 서드파티 앱에서 발생한 이벤트를 감지해 데이터를 가공 후 다시 Slack 데스크탑 앱으로 결과를 내려주는 역할을 하는 서버를 만드는 프레임워크다. Slack에서 서드파티 앱 개발을 위해 자체적으로 만든 프레임워크다.

단, 추후 배포할 cloudflare는 Bolt-js와는 호환성이 좋지 않기 때문에 다른 프레임워크를 사용할 것이다. cloudflare 말고 AWS-Lambda 및 Heroku에 배포할 생각이라면, Bolt-js를 사용해도 좋다.

$ npm i @slack/bolt
$ npm i -D dotenv ts-node-dev typescript

dotenv는 위에서 얻은 2개의 key를 노출하지 않고 세팅하기 위해 설치했으며, ts-node-dev 는 타입스크립트로 작성된 코드를 실행하기 위한 런타임 환경을 위해 설치했다.

ts-node-dev

ts-node-dev는 내부적으로 node-dev를 사용하고 있는데, node-dev는 Nodemon과 같이 코드가 수정되면 Node.js 서버를 재시작 하지 않아도 즉시 다시 시작하게 해주는 라이브러리다. 그리고 ts-node는 Node.js 용 타입스크립트 REPL(실행기)이다. 이 둘을 섞어 사용한다. TypeScript를 JavaScript로 변환 과정 없이 코드를 수정하면 즉시 서버를 재시작 시켜주는 라이브러리이다.

시크릿 키 설정을 위해 프로젝트 root에 .env 파일을 생성한다.

# .evn
SLACK_BOT_TOKEN=...
SLACK_SIGNING_SECRET=...

이전에 알아두었던 SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET을 입력한다. 그리고 dotenv 라이브러리를 아래와 같이 사용한다.

// src/utils/env.ts
const dotenv = require('dotenv');
dotenv.config('./.env');

그리고 프로젝트 root에 src/app.ts 파일을 만든다.

// src/app.ts
import './utils/env';  // dotenv import 구문
import { App } from '@slack/bolt';

const app = new App({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  token: process.env.SLACK_BOT_TOKEN,
});

(async () => {
  await app.start(process.env.PORT || 3000);
  console.log('⚡️ Bolt app is running!');
})();

서드파티 앱을 위한 Node.js 서버가 하나 만들어졌다.

// package.json
"scripts": {
  "dev": "ts-node-dev --respawn --transpile-only src/app.ts"
},

다음과 같이 scripts를 설정해 주고 npm run dev 명령어로 서버를 띄울 수 있다.

브라우저에 localhost:3000 으로 접속해보면 아무것도 뜨지 않지만, 터미널 로그에는 GET 요청이 찍힌 것을 확인할 수 있다.

Ngrok 설정

이제 슬랙 자체 서버에 웹훅 URL을 등록할 차례이다. 하지만, Slack에서의 통신은 HTTPS만 가능하도록 설정이 되어있다. localhost는 HTTP이므로 임시 HTTPS 통로가 필요한 상황이다. Slack API에서도 공식적으로 Ngrok을 사용해 개발을 권장하고 있다.

※ Ngrok

프록시 서버다. localhost에 띄워진 서버를 외부에 HTTPS로 접근할 수 있게 도와준다. 네트워크 터널링으로, Ngrok 자체 서버에서 localhost로 이어주는 프로그램이다.

Ngrok에 회원가입을 하고 Setup & Installation 에 적힌 대로 따라해 보자. brew로 install 할 것을 권장한다. 터미널 명령어로 즉시 터널링을 해도 되지만, Ngrok에서는 ID 당 하나의 고정 URL을 제공한다.

왼쪽 탭의 ‘Domains’에 들어가서 Create Domain 버튼을 클릭한다.

Ngrok

그러면 고정 URL을 하나 지급해 주는데, 아래의 명령어와 같이 터미널에 입력한다. 끝에 80 포트를 3000 포트로 변경하면 된다.

$ ngrok http --domain=perfect-noticeably-escargot.ngrok-free.app 3000

Ngrok-terminal

터미널에서 포워딩 하고 있는 정보를 알려주고 있는데 이를 복사해두자. 이제 https://perfect-noticeably-escargot.ngrok-free.app URL로 들어오는 요청은 모두 현재 localhost:3000으로 요청이 들어올 것이다.

Slack API로 돌아와서 웹훅 URL을 입력해 줄 것이다. 우리가 만들 서드파티 앱은 Slack 입력 창에 /random 등의 명령어를 쳐 동작시킬 것이기 때문에 이를 등록해주어야 한다. ‘Slack Commands’ 란에 들어와 ‘Create New Command’ 를 클릭해주자.

Request URL에는 https://perfect-noticeably-escargot.ngrok-free.app/slack/events 와 같이 아까 저장한 URL 주소와 뒤에 /slack/events 를 붙여주도록 하자.

create-new-command

그리고 반드시 하단 우측에 ‘Save’ 버튼을 눌러 등록해 주는 것을 잊지 말자.

이제 Slack 데스크탑 앱에서 /random 을 입력하고 enter를 치면 Ngrok을 켜둔 터미널에 log가 잘 찍히는지 확인한다.

Ngrok-terminal-log

하지만, 데스크탑 앱에는 error가 찍히는데 잘 동작한 것이니 걱정하지 말자.


서드파티 앱 개발

먼저 서드파티 앱의 동작을 기획해 보았다.

모달

modal

  • /random 이라고 입력하면 모달이 뜬다.
  • 모달에는 아래의 요소가 존재한다.
    • 멤버 선택 select
    • ‘전체 선택’ 버튼
    • 인원수 input (defualt 1)
    • 제출 버튼
  • 제출 버튼을 클릭하면 현재 채널에 축하 메시지와 함께 추첨 인원 명이 나타난다.

이벤트 등록

가장 처음 기획했던 것은, Slack에서 모달이 나타나 추첨하고자 하는 인원을 넣고 제출 버튼을 누르면 랜덤으로 input에 적힌 인원수만큼 골라 메시지로 표현해 주는 것이다.

// src/app.ts
const app = new App({ /**/ });

app.command('/random', commands);
(async () => {
  await app.start(process.env.PORT || 3000);
  console.log('⚡️ Bolt app is running!');
})();

app.command 함수의 첫 번째 인자로 문자열을 받을 수 있는데, 데스트탑 앱에서 슬래시를 포함한 문자열을 입력하고 보내면 commands 라는 콜백 함수를 실행시킬 수 있다. 마치 addEventListener 와 비슷하다고 생각하면 된다.

export default async function commands({
  body,
  ack,
  client,
  logger,
  payload,
}: SlackCommandMiddlewareArgs & AllMiddlewareArgs<StringIndexed>) {
  try {
    await ack();

    // 데스트탑 앱에서 요소(모달)를 연다.
    const result = await client.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: 'modal',
        callback_id: 'random', // app.view() 의 첫 번째 인자로 들어가는 id
        blocks: [ /* 모달 요소들 정의 */ ],
      },
    });
    logger.info(result);
  } catch (error) {
    logger.error(error);
  }
}

Bolt-js에서 인자로 넘겨주는 client 에는 데스트탑의 다양한 정보를 얻을 수 있거나, 데스크탑 앱을 원하는 방식대로 실행시킬 수 있는 함수들이 들어가 있다.

client.views.open 함수는 데스크탑 앱에서 요소를 여는 역할을 하고 있는데, blocks 로 들어가는 배열은 슬랙에서 정의한 요소가 들어가게 된다. Block Kit Builder 에서 직접 형태를 제작해 볼 수 있다.

여기서 callback_id 가 중요하다. 모달에서 ‘제출’ 버튼을 클릭하면 app.command() 함수와 마찬가지로 app.view() 의 첫 번째 인자의 id와 일치해야 view()의 두 번째 인자인 콜백 함수가 실행된다.

view 객체는 후에 모달이 다시 렌더링 될 때 비슷한 객체가 사용되기 때문에 이 객체를 비슷한 틀로 생성해 주는 generateBlocks 함수로 빼내어 만들어주는 게 좋다.

// src/utils/generateBlocks.ts
import type { View } from '@slack/bolt';

export default function generateBlocks(): View {
  return {
    type: 'modal',
		// ...
    blocks: [ /* */ ],
  };
}

전체 선택 버튼 만들기

전체 선택 버튼을 클릭하면 select에 모든 멤버 리스트가 자동으로 들어가야 한다. 그렇게 하기 위해서는 멤버 리스트가 필요하다. client.conversations.members() 함수를 사용해서 들고 올 수 있는데, 이 함수는 이전에 설정 한 Slack API의 scope 중 [channels:read] 를 사용한다.

// 명령어를 받아 모달을 여는 콜백함수
export default async function commands(/* ... */)) {
  try {
    await ack();
    
    // 현재 채널의 멤버 목록을 가져온다.
    const { members } = await client.conversations.members({
      channel: body.channel_id,
    });
    
		// ...
  } catch (error) {
    logger.error(error);
  }
}

members를 console.log()로 찍어보자. 각 멤버의 고윳값이 들어있다.

member-list

multi_users_select 은 Block에서 멤버들을 선택할 수 있도록 해준다. multi_users_select 요소의 initial_users 프로퍼티 값으로 members 값을 넣어주면 다음 렌더링 때 적용된다.

blocks: [
  {
    type: 'input',
    // NOTE: view.update를 사용할 경우, 변경하고 싶은 element의 block_id가 변경되어야 value 등의 내용이 달라짐.
    // https://stackoverflow.com/questions/72788906/slack-block-kit-plain-text-input-element-update-text-value
    block_id: `member_input_block_${new Date().getMilliseconds()}`,
    element: {
      type: 'multi_users_select',
      placeholder: {
        type: 'plain_text',
        text: 'Select users',
        emoji: true,
      },
      action_id: 'multi_users_select-action',
      initial_users: isInitModal && members.length > 0 ? members : [],    },
  },
  // ...
],

block_id 값이 이전 렌더링과 다음 렌더링 시 다르다면 새롭게 그려준다. 마치 react의 key props와 비슷하다. 만약 다음 렌더링 때 변경되지 않았다면 변경된 initial_users 값이 적용되지 않는다. 따라서, 처음 렌더링 된 모달이라면 [] 값을, 아니라면 members 값을 넣어주었다.

이제 client.views.update() 함수를 이용해 렌더링을 일으킬 수 있도록 해야 한다. 버튼을 하나 만들어보자.

blocks: [
  // ...
  {
    type: 'section',
    text: {
      type: 'plain_text',
      text: '모든 인원을 선택합니다.',
    },
    accessory: {
      type: 'button',
      text: {
        type: 'plain_text',
        text: '전체 선택',
        emoji: true,
      },
      value: 'click_me',
      action_id: 'insert_all_users', // app.action() 첫 번째 인자로 들어가는 id
    },
  },
],

버튼을 하나 만들었고, action_idinsert_all_users 로 주었다. 이는 app.action() 콜백 함수를 실행시킨다.

// src/app.ts

// ...
app.command('/random', commands);
app.action('insert_all_users', insertAllUsers);
// ...

이제 insertAllUsers 함수를 만들 것이다. 하지만, insertAllUsers 함수에서는 members를 넣을 수 없다. app.action() 의 콜백 함수의 body에는 channel 정보가 있지만, channel.id 에는 ID 정보가 들어오지 않기 때문이다.

app.action() 이벤트가 발생할 때는 channel 정보를 얻을 수 없을 수 없다. 이 과정은 마치 HTTP의 stateless와 비슷하다. Slack API에서는 이런 경우를 대비해 private_metadata 필드를 사용할 수 있다.

맨 처음 모달을 열 때, channel 정보로 members 목록을 직렬화하여 blocks의 private_metadata 로 Slack 자체 서버로 보낸 뒤, app.action() 함수에서 body 필드에 담겨있는 members 정보를 역직렬화 하여 사용하도록 했다.

// src/handlers/insertAllUsers.ts
export default async function insertAllUsers({
  ack,
  client,
  body,
}: SlackActionMiddlewareArgs<BlockAction> & AllMiddlewareArgs) {
  await ack();

  // 역직렬화 하여 members 정보를 얻는다.  const { members, channelId } = JSON.parse(
    body.view?.private_metadata || '{}'
  );

  try {
    await client.views.update({
      view_id: body?.view?.id,
      hash: body?.view?.hash,  // hash 값이 다르면 view를 새로 그려준다.
      view: generateBlocks({ members, channelId, isInitModal: true }), // 모달 element가 담긴 blocks를 리턴함.
    });
  } catch (e) {
    console.log(e);
  }
}

client.views.update() 함수는 기존 모달을 업데이트하라는 명령이다.

// src/utils/generateBlocks.ts
import type { View } from '@slack/bolt';

type Props = {
  channelId: string;
  members?: string[];
  isInitModal?: boolean;
};

export default function generateBlocks({
  channelId,
  members = [],
  isInitModal = false,
}: Props): View {
  const privateMetadata = JSON.stringify({ members, channelId });  
  return {
    type: 'modal',
		// ...
    blocks: [
    	// ...
    	{
    		// ...
        element: {
          type: 'multi_users_select',
          // ...
          initial_users: isInitModal && members.length > 0 ? members : [],        },
      }
    ],
  };
}

이제 ‘전체 선택’ 버튼을 클릭하면, 모달을 열 때 받아두었던 members의 정보로 현재 채널에 들어와있는 멤버 목록을 select에 적용시킬 수 있게 되었다.

제출 기능 만들기

제출 버튼을 클릭하면 app.view() 함수의 콜백이 실행된다.

// src/app.ts

// ...
app.command('/random', commands);
app.action('insert_all_users', insertAllUsers);
app.view<ViewSubmitAction>('random', responseModal);
// ...

띄워졌던 모달의 callback_idrandom 이었기 때문에 식별자는 'random' 이다. responseModal 함수를 보자.

import type {
  AllMiddlewareArgs,
  SlackViewAction,
  SlackViewMiddlewareArgs,
} from '@slack/bolt';
import getResponseMessage from '../utils/getResponseResult';

export default async function responseModal({
  ack,
  view,
  client,
  logger,
  body,
}: SlackViewMiddlewareArgs<SlackViewAction> & AllMiddlewareArgs) {
  const { channelId } = JSON.parse(body.view?.private_metadata || '{}');

  try {
    const states = view['state'].values;
    // 몇 명을 뽑을지 number input에 들어있던 값을 가져온다.
    const count = Number(states['number_input']['number_input-action'].value);

    // 선택된 멤버를 가져온다.ㅏ
    let members: undefined | string[] = [];
    Object.keys(states).forEach((key) => {
      if (key.includes('member_input_block')) {
        members = states[key]['multi_users_select-action'].selected_users;
      }
    });

    if (count < 1 || count > members.length) {
      await ack({
        response_action: 'errors',
        errors: {
          // 에러가 났을 경우, number_input의 error 메시지를 표현한다.
          ['number_input']: '숫자의 범위가 맞지 않습니다.',
        },
      });
    }

    await ack();

    const message = getResponseMessage({
      author: body.user.id,
      members,
      count,
    });

    const result = await client.chat.postMessage({
      channel: channelId,
      text: message,
      mrkdwn: true,
    });
    logger.info(result);
  } catch (error) {
    logger.error(error);
  }
}

몇 명을 뽑을지 결정하는 count, 뽑을 대상 목록이 들어있는 members 를 변수에 넣고, getResponseMessage() 로 보내 평문 메시지로 만든다.

import getRandomUsers from './getRandomUsers';

type Props = {
  author: string;
  members: string[];
  count: number;
};

export default function getResponseMessage({
  author = '',
  members = [],
  count = 1,
}: Props) {
  const selectedUsers = getRandomUsers(members, count);
  const mentionedUser = selectedUsers
    .map((name, index) => `>• ${index + 1}등 : <@${name}>`)
    .join('\n');
  const authorUser = `<@${author}>`;

  return `🥳 *당첨! 축하드립니다.* 🎉- by ${authorUser}\n${mentionedUser}`;
}

함수는 이렇게 되어있다. 마크다운 구문을 사용할 수 있기 때문에 마크 다운이 포함되어 있으며, <@${name}> 과 같은 형태는 아래 이미지와 같이 맨션 된 유저가 나타내게 만든다. (Mentioning users)

name

그리고 위의 client.chat.postMessage() 함수로 getResponseMessage 함수를 통해 만들어진 string을 넘겨주면 슬랙에 뽑힌 사람이 표시되게 된다. getRandomUsers 는 간단하게 Math.random() 함수로 제작했다. 자세한 것은 repository에 있다.


command line으로 처리하기

모달을 띄우는 과정이 번거롭다면 오직 cli를 이용해 처리하게 만들고 싶었다.

/random @product 2

와 같은 명령어를 치면, product로 묶인 그룹에서 2명을 뽑으라는 명령어를 만들고 싶었다. 모달을 띄워야 하는 번거로운 과정 없이 그룹 안에서 뽑을 수 있는 기능이다.

cli

이렇게 @ 로 시작하는 그룹을 생성하기 위해서는 슬랙 유료 버전(Slack Pro)을 사용해야 한다. 슬랙에서 어떤 워크스페이스에 상관없이 맨 처음은 무료로 한 달간 Slack Pro를 사용할 수 있도록 해주기 때문에 무료로 Slack Pro로 변경시키고 그룹 기능을 얻었다.

mapping-group

이미지와 같이 더보기 > 사람 > 사용자 그룹 > 사용자 그룹 새로 만들기에 들어가면 그룹을 만들 수 있다. 나는 product 그룹을 생성했다.

이제 commands 함수를 수정해야 한다.

export default async function commands(/**/) {
  const [rawGroupText, count = 1] = payload.text.split(' ');
  // NOTE: rawGroupText: '<!subteam^S06MDLGF4RY|@product>',
  const groupId = rawGroupText?.match(/(?<=subteam\^)(.*?)(?=\|)/gm)?.[0];

  try {
    await ack();

    if (!groupId) {
      // 모달 open
      const result = await openModal(client, body);
      logger.info(result);
    } else {
      // 즉시 결과 return
      const { users } = await client.usergroups.users.list({
        usergroup: groupId,
      });
      if (!users || users.length <= 0) {
        throw new Error('no users');
      }

      // ...
    }
  } catch (error) {
    logger.error(error);
  }
}

payload에는 command line의 정보가 들어 있기 때문에 이를 가공하여 groupId를 뽑아내고 groupId가 없다면 기존과 같이 모달을 연다. 만약 groupId가 있다면 즉시 결과를 return 하도록 변경했다.

그룹 text는 <!subteam^S06MDLGF4RY|@product> 이런 형태로 나온다. 따라서 적절한 정규식을 이용해 groupId를 가려내고, groupId가 존재하지 않으면 모달을 띄워주고 그렇지 않은 경우 즉시 결과를 return 하도록 했다.


배포

개발이 완료되었기 때문에 Bolt-js로 만들어진 서버를 배포해야 했다. 왜냐하면 이 기능을 사용하기 위해 매번 Ngrok를 키고 localhost의 서버를 가동해야 하기 때문이다. Slack API 특성상 항상 서버가 이벤트를 받을 준비를 하고 있지 않아도 요청이 들어왔을 경우에만 함수를 실행시키는 serverless function 서비스에 물리기 괜찮아 보였다. 요청 수가 그렇게 많지 않기 때문이다.

Bolt-js에서는 AWS Lambda를 띄우기 위해 AwsLambdaReceiver 를 제공하고 있다(링크). 하지만, AWS Lambda를 띄우기 위해서 free tier 계정을 생성하고 요금을 조금이라도 지불해야 했기 때문에 무료 serverless 서비스를 찾아야 했다. 이전에 토이 프로젝트를 만들었을 때도 결국 1년이 지난 후에는 내릴 수밖에 없었던 것을 생각하면 무료 serverless 서비스를 이용하고 싶었다.


Cloudflare

Cloudflare에는 Workers라는 serverless 서비스가 있다. 무료로 제공하고, 구성 방법이 무척이나 간단하다. 내가 느꼈을 때 가장 좋았던 특징은 0ms 콜드 스타트 지원이다. V8 엔진 기반 런타임을 가지고 있어서 VM 위에 함수 여러 개를 띄워두는 형식이 아니라, 하나의 런타임이 여러 함수를 실행할 수 있다고 한다. V8 엔진 기반이라 JavaScript 또는 TypeScript를 무척 잘 지원하지만 타 언어는 완벽한 지원을 하지는 않는다는 점이다. 우리의 서버는 Node.js로 만들어졌기 때문에 문제없이 사용할 수 있다. (물론 이런 이야기도 있다.)

다만, 문제점이 하나 있었다. Bolt-js는 애초에 serverless 환경을 염두에 두고 만들어진 프레임워크가 아니라는 점이다. AWS Lambda는 이제는 무척 보편화된 서비스이기 때문에 Bolt-js에서 연결할 수 있는 리시버를 만들어두었다지만, Cloudflare 버전은 없었다.

Workers의 생김새를 보자.

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    return new Response('Hello World!');
  },
};

기존에 Bolt-js로 만든 서버의 형태와 너무 다르다. 요청에 따라 Response 인스턴스를 내보내는 구조이다.

결국 AWS Lambda를 띄워야 하나 고민 중에 Running Slack App on Cloudflare Workers 글을 보게 되었다. 저자는 Slack의 Relations 팀에 속해 있으며 Relations팀에서 Bolt-js를 개발했다고 한다. 따라서 스펙과 세부 구현 내용을 알 것이라 생각했기 때문에 저자가 만든 라이브러리를 사용하면 Cloudflare에 배포가 가능할 것 같았다.

slack-cloudflare-workers 라는 라이브러리이다.


Cloudflare 개발 환경 설정

wrangler는 Cloudflare 제품을 개발하기 위한 CLI다. Cloudflare Workers에 올라갈 소스를 관리하고 배포하기 위해서 필요하다. 환경변수를 wrangler를 통해 Cloudflare에 등록해야 하는데, 그렇게 하기 위해서는 전역에 설치해야 한다. 그리고 로컬 명령어도 필요하기 때문에 wrangler와 slack-cloudflare-workers 라이브러리는 로컬에 설치하자.

$ npm i -g wrangler@latest
$ npm i -D wrangler@latest slack-cloudflare-workers

package.json에 wrangler cli 명령어를 적어둔다.

// package.json
"scripts": {
  "dev": "wrangler dev src/app.ts",
  "deployment": "wrangler deploy src/app.ts"
}

프로젝트 루트에 .dev.vars 파일을 생성하고 Slack App에 필요한 환경 변수를 넣어준다.

SLACK_BOT_TOKEN=...
SLACK_SIGNING_SECRET=...

그리고 아래 명령어를 통해 cli에 환경 변수를 저장할 수 있도록 해주자.

$ wrangler secret put SLACK_SIGNING_SECRET
$ wrangler secret put SLACK_BOT_TOKEN

이 명령어는 .dev.vars 파일에 있는 환경 변수를 로컬에서 wrangler 명령어로 실행한 Workers에 환경변수를 등록시켜준다.

app.ts에 가장 기본이 되는 소스를 넣고 실행시킬 것이다.

type Env = {}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    return new Response('Hello World!');
  },
};

dev 스크립트를 실행시키면 localhost:8787로 실행이 되는 것을 볼 수 있다.

start-dev-script

그러면 이 녀석을 Ngrok에서 터널링 해주었던 것처럼 터널링 해주어야 한다. cloudflare에서 제공하는 cloudflared 패키지를 사용해도 되지만, 우리는 이전에 Ngrok에서 고정 URL을 받았기 때문에 굳이 cloudflared를 사용하지 않아도 상관없다. 설치하고 싶다면 brew install cloudflare/cloudflare/cloudflared 로 설치하고 공식 문서를 참고하여 터널링 해주면 된다. Ngrok port를 8787로 수정하여 터널링 해주자.

$ ngrok http --domain=perfect-noticeably-escargot.ngrok-free.app 8787

Cloudflare workers로 마이그레이션

app.ts 소스를 아래와 같이 수정한다.

import commands from './handlers/commands';
import responseModal from './handlers/responseModal';
import insertAllUsers from './handlers/insertAllUsers';
import {
  SlackApp,
  SlackEdgeAppEnv,
  ExecutionContext,
} from 'slack-cloudflare-workers';

export default {
  async fetch(
    request: Request,
    env: SlackEdgeAppEnv,
    ctx: ExecutionContext
  ): Promise<Response> {
    const app = new SlackApp({ env });
    app.command('/random', async () => {}, commands);
    app.action('insert_all_users', async () => {}, insertAllUsers);
    app.view('random', async () => {}, responseModal);

    return await app.run(request, ctx);
  },
};

slack-cloudflare-workers 에서 원하는 형태로 변경시켰다. app.command(), app.action 등은 기존 Bolt-js에서 입력한 것과 거의 유사한 모습이지만 두 번째 매개변수로 async () => {} 익명 함수를 넘겨주었다.

Slack API는 웹훅 URL로 이벤트 요청을 보냈을 때 3초 이내에 답변이 와야만 한다. 따라서 Bolt-js에서 await ack() 함수를 매 콜백 함수마다 실행했었다. slack-cloudflare-workers에서는 두 번째 매개변수로 3초 이내에 답변이 가지 않을 만한 상황이라면 ‘임시 메시지’를 보낼 수 있다. 3초 안에 답변을 줄 수 있고, 임시 메시지가 필요하지 않다면 나와 같이 비어있는 익명 함수를 적어줘도 무방하다.

그리고 뒤에 오는 함수는 기존과 동일하지만 인자로 받는 값은 다르다. 살짝 비교해 보자.

// Bolt-js의 responseModal 함수
export default async function responseModal({
  ack,
  view,
  client,
  logger,
  body,
}: SlackViewMiddlewareArgs<SlackViewAction> & AllMiddlewareArgs) {
  await ack();
  const { channelId } = JSON.parse(body.view?.private_metadata || '{}');

  try {
    const states = view['state'].values;
    const count = Number(states['number_input']['number_input-action'].value);
    // ...
  }
}
// slack-cloudflare-workers의 responseModal 함수
export default async function responseModal({
  context,
  payload,
}: SlackRequest<SlackEdgeAppEnv, ViewClosed | ViewSubmission>) {
  const { client } = context;
  const { view, user } = payload;
  const { channelId } = JSON.parse(view?.private_metadata || '{}');

  try {
    const states = view['state'].values;
    const count = Number(states['number_input']['number_input-action'].value);
    // ...
  }
}

Bolt-js는 인자의 객체 형태와 slack-cloudflare-workers의 인자 객체 형태가 다르다. 하지만, context 내부에 client 및 다른 값들이 모두 들어있는 것을 볼 수 있다. 이 규칙에 맞게 기존 소스를 마이그레이션 해주면 된다.


Cloudflare Workers에 배포

Cloudflare에 가입하고 이메일 인증을 마치면 대시보드에 들어온다. 그리고 터미널에 wrangler로 로그인하기 위해 아래 명령어를 입력한다.

$ wrangler login

그러면 브라우저로 연결되고, 아래와 같은 이미지가 뜨게 되는데 Allow를 눌러주자.

wrangler-login

이제 wrangler.toml 파일을 생성해 주어야 한다. toml 파일은, Workers에서 동작할 서버의 정보를 담는 파일이다.

name = "its-you"
main = "src/app.ts"
compatibility_date = "2024-03-04"

[vars]
SLACK_BOT_TOKEN = "..."
SLACK_SIGNING_SECRET = "..."

vars에는 Workers 내부에 코드를 참조할 수 있는 환경 변수다. 배포할 때마다 Workers의 함수는 변경되고, 빌드 시 생성될 환경 변수가 날아가므로 명시해 주면 문제없이 동작한다. 이제 아래의 배포 스크립트를 실행하면 된다.

$ npm run deployment

Cloudflare 대시보드로 돌아가 보면 Workers가 하나 띄워져 있는 것을 볼 수 있다. Workers를 클릭하고, 상단의 Triggers 탭으로 가면 이렇게 Workers의 공식 URL을 받을 수 있다.

workers-personal-url

이제 Slack API 공식 페이지로 돌아가서 Interactivity & Shortcuts, Slash Commands 에 설정해 두었던 웹훅 URL을 변경하자. 물론 /slack/events 를 뒤에 붙이는 것을 잊지 말자.

이제 모든 요청은, 터널링 하고 있는 local로 들어오는 것이 아니라 Cloudflare의 Workers로 요청이 들어올 것이고 serverless 함수가 실행되어 웹훅을 Slack에 돌려줄 것이다.

worker-status

대시보드에서 얼마나 요청을 받았는지, 현재 상태는 어떤지 체크할 수 있는 기능을 제공하고 있다. 그리고 실시간 로그도 볼 수 있다. 만약 Slack App에 기능을 자주 추가하거나 수정해야 한다면, GitHub Actions와 연동해 자동으로 Workers에 배포될 수 있도록 CI/CD 환경을 구성해 봐도 좋을 것 같다.


마치며

회사에서 Slack을 메신저로 사용한다면, Slack App을 직접 만들어 사용해 봐도 좋을 것 같다. 업무를 하려면 매일 들어가야 하는 곳이기 때문에 접근성이 매우 좋기 때문이다. 다만, Slack App을 만드는 과정은 그렇게 평탄한 과정은 아니다.

생각보다 내가 원하는 기능이 없을 때도 있고 공식 문서가 친절한 듯 친절하지 않기 때문이다. 맨 처음 기획했던 것이 있었는데, 모달 UI에서 지원을 하지 않아 개발 도중 방향을 틀기도 했다.

아주 복잡한 처리가 아니라면 재미로 한 번 만들어봄직하다고 생각한다. 그리고, 랜덤 뽑기 뿐 아니라 프로덕트 빌드가 완료되면 웹훅을 연동해 메시지를 보낸다든지, 버튼을 클릭하면 GitHub Actions 상에 빌드 된 결과물을 즉시 배포할 수 있는 버튼을 만든다든지 생산성 도구로 활용하면 더 빛을 발할 것 같다.

그리고 이번에 Cloudflare의 Workers를 한 번 활용해 봐서 재미있었다. 실제 Node.js 환경은 아니라서 fs 와 같은 Node.js API를 사용하지는 못하지만, 정말 가벼운 작업을 돌릴 수 있다는 점이 매력적이었다.

사실 아직 구현하고 싶은 모든 기능을 구현하지는 않았다. 메시지만 내뱉는게 정적인 것 같아 추후에 다른 이펙트를 넣어 극적인 효과를 내는 봇으로 추가 기능 개발을 하고 싶다.

덧, /random @product 2 대신 cli option을 추가해 /random @product -c 2 -d @pozafly 와 같은 명확한 옵션을 주어야만 동작하는 기능으로 변경시켰다.

-c [number] : 뽑을 인원
-d [@id] : 제외시킬 사람 이름


참고