슬랙 데일리 허들, 누가 빠졌는지 봇이 알려줍니다

유영석·약 11시간 전

배경

우리 팀은 매일 오전 9시 15분에 Slack 허들로 데일리 스탠드업을 진행한다.

허들이 시작될 때마다 어김없이 누군가는 "아직 안 들어오셨어요"라는 말을 하고, 진행자가 멤버 목록을 하나하나 훑어봐야 했다. 팀이 7개에 총 인원이 수십 명이다 보니 한눈에 파악이 쉽지 않았다.

자동화하면 되겠다 싶어서 만들었다. 허들이 시작되면 자동으로 스레드에 출석 현황을 올리고, 참여자가 바뀔 때마다 메시지를 업데이트하는 봇.

결과물은 이렇게 생겼다:

📋 오늘 데일리 허들 출석 현황

✅ 이사진 3/3
✅ PMO팀 2/2
⏳ UX/UI팀 1/2  (미참여: 홍길동)
✅ 인프라서비스개발팀 3/3
⏳ 앱서비스개발팀 2/3  (미참여: 김철수)
✅ 웹서비스개발팀 3/3
✅ AI CORE팀 2/2

(전원 참여 시)
🟢 전 팀 준비 완료! 시작하세요!

전체 구조

EventBridge (평일 09:15 KST)
    └─▶ Lambda (최대 10분 실행)
            ├─ Slack User Group에서 팀/멤버 조회
            ├─ 허들 감지 (conversations.history)
            └─ 10초마다 참여자 폴링 → 메시지 업데이트

기술 스택:

  • 런타임: Node.js 20 (TypeScript → esbuild 번들)
  • 인프라: AWS SAM (Lambda + EventBridge)
  • Slack SDK: @slack/web-api

1. Slack 앱 생성 & 토큰 발급

api.slack.com/apps에서 앱을 생성한다. Manifest 방식을 쓰면 권한 설정이 편하다.

display_information:
  name: Huddle Bot
  background_color: "#2c2d30"

features:
  bot_user:
    display_name: Huddle Bot
    always_online: true

oauth_config:
  scopes:
    bot:
      - channels:read
      - channels:history   # 허들 메시지 감지
      - chat:write         # 메시지 전송/수정
      - chat:write.public
      - users:read         # 멤버 이름 조회
      - usergroups:read    # User Group 멤버 조회 ← 유료 플랜 전용
settings:
  socket_mode_enabled: false

앱 설치 후 Bot Token (xoxb-...)을 발급받는다: OAuth & Permissions → Bot User OAuth Token

채널 ID는 Slack에서 해당 채널 우클릭 → 채널 세부 정보 → 맨 아래에서 확인할 수 있다. C로 시작하는 11자리 문자열이다.

주의: usergroups:read scope는 유료 워크스페이스(Pro 이상) 에서만 동작한다. 무료 플랜이라면 User Group 대신 멤버 ID를 코드에 직접 관리해야 한다.


2. User Group으로 팀 멤버 관리

멤버 목록을 코드에 하드코딩하면 사람이 바뀔 때마다 재배포해야 한다. 대신 Slack User Group을 쓰면 Slack에서 멤버만 수정하면 자동 반영된다.

그룹 핸들 배열을 정의해두고, 런타임에 동적으로 멤버를 가져온다:

const USER_GROUP_HANDLES = [
  "executives",    // 이사진
  "pmo",           // PMO팀
  "ux-ui",         // UX/UI팀
  "infra-service", // 인프라서비스개발팀
  "app-service",   // 앱서비스개발팀
  "web-service",   // 웹서비스개발팀
  "ai-core",       // AI CORE팀
];

async function fetchTeams(): Promise<Team[]> {
  // 1. 워크스페이스의 전체 User Group 목록 조회
  const groupsRes = await slack.usergroups.list({ include_users: false });
  const allGroups = groupsRes.usergroups ?? [];

  // 2. 핸들 배열 순서대로 매칭 (메시지 표시 순서 보장)
  const matched = USER_GROUP_HANDLES.map((handle) => {
    const group = allGroups.find((g) => g.handle === handle);
    if (!group) throw new Error(`User Group @${handle} 를 찾을 수 없습니다.`);
    return { name: group.name!, groupId: group.id! };
  });

  // 3. 각 그룹의 멤버 ID → 이름까지 한 번에 조회
  return Promise.all(
    matched.map(async ({ name, groupId }) => {
      const usersRes = await slack.usergroups.users.list({ usergroup: groupId });
      const members = await Promise.all(
        (usersRes.users ?? []).map(async (id) => {
          const info = await slack.users.info({ user: id });
          const profile = info.user?.profile;
          return { id, name: profile?.display_name || profile?.real_name || id };
        })
      );
      return { name, groupId, members };
    })
  );
}

팀/멤버가 바뀌어도 코드 수정 없이 Slack User Group만 업데이트하면 된다. User Group은 slack.com/admin → User Groups에서 관리할 수 있다.


3. 허들 감지 — undocumented API의 세계

가장 까다로운 부분이다. Slack은 허들(Huddle)을 위한 전용 API를 공개하지 않는다. 대신 conversations.history로 채널 메시지를 읽으면 허들 메시지가 숨어 있다.

허들 메시지의 특징:

  • subtype === "huddle_thread"
  • room 객체 안에 참여자 정보가 담겨 있음
  • room.has_ended === false 이면 현재 진행 중
async function findHuddle(): Promise<HuddleInfo | null> {
  const res = await slack.conversations.history({
    channel: CHANNEL_ID,
    limit: 20,
  });

  interface HuddleMessage {
    subtype?: string;
    ts: string;
    room?: { has_ended: boolean; participants?: string[] };
  }

  const huddleMsg = (res.messages as HuddleMessage[]).find(
    (m) => m.subtype === "huddle_thread" && m.room != null && !m.room.has_ended
  );

  if (!huddleMsg) return null;

  return {
    ts: huddleMsg.ts,
    participants: new Set<string>(huddleMsg.room!.participants ?? []),
  };
}

room.participants는 Slack 공식 문서에 명시되지 않은 필드다. 실제 메시지 응답을 직접 까보면서 발견한 것. 향후 Slack이 스펙을 바꾸면 깨질 수 있는 부분이라 주의가 필요하다.


4. 메시지 생성 & 업데이트

출석 현황 메시지를 만들고, 허들 스레드에 붙인다. Lambda는 stateless라서 이전 메시지의 ts(타임스탬프)를 메모리에 저장할 수 없다. 매번 스레드를 조회해서 봇이 보낸 메시지를 찾아야 한다.

async function findOrCreateReply(text: string, huddleTs: string): Promise<void> {
  // 허들 스레드의 전체 메시지 조회
  const res = await slack.conversations.replies({
    channel: CHANNEL_ID,
    ts: huddleTs,
  });

  // 봇 자신의 User ID (최초 1회만 API 호출)
  if (!BOT_USER_ID) BOT_USER_ID = (await slack.auth.test()).user_id!;

  // 스레드에서 봇이 보낸 기존 메시지 찾기
  const existing = (res.messages ?? []).find(
    (m) => m.user === BOT_USER_ID && m.ts !== huddleTs
  );

  if (existing) {
    // 있으면 update
    await slack.chat.update({ channel: CHANNEL_ID, ts: existing.ts!, text });
  } else {
    // 없으면 새로 post
    await slack.chat.postMessage({
      channel: CHANNEL_ID,
      thread_ts: huddleTs,
      text,
    });
  }
}

메시지 내용은 팀별로 참여/미참여 인원을 계산해서 만든다:

function buildMessage(teams: Team[], participants: Set<string>): string {
  const lines: string[] = ["📋 *오늘 데일리 허들 출석 현황*\n"];
  let allReady = true;

  for (const team of teams) {
    const present = team.members.filter((m) => participants.has(m.id));
    const absent  = team.members.filter((m) => !participants.has(m.id));
    const status  = absent.length === 0 ? "✅" : "⏳";
    if (absent.length > 0) allReady = false;

    const absentStr = absent.length > 0
      ? `  _(미참여: ${absent.map((m) => m.name).join(", ")})_`
      : "";

    lines.push(`${status} *${team.name}* ${present.length}/${team.members.length}${absentStr}`);
  }

  if (allReady) lines.push("\n🟢 *전 팀 준비 완료! 시작하세요!*");
  return lines.join("\n");
}

5. Lambda 핸들러 — 왜 5분간 루프를 도는가

Lambda 핸들러의 실행 흐름이다:

export const handler = async () => {
  const teams = await fetchTeams();
  const start = Date.now();

  // 허들이 아직 안 시작됐을 수 있으니 최대 2분 대기
  let huddle = await findHuddle();
  while (!huddle && Date.now() - start < 2 * 60 * 1000) {
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); // 10초 대기
    huddle = await findHuddle();
  }

  if (!huddle) {
    console.log("허들을 찾지 못했습니다. 종료합니다.");
    return;
  }

  // 5분간 10초마다 폴링
  while (Date.now() - start < POLL_DURATION_MS) {
    const latest = await findHuddle();
    const participants = latest?.participants ?? new Set<string>();
    const message = buildMessage(teams, participants);
    await findOrCreateReply(message, huddle.ts);

    // 전원 참여 시 조기 종료
    const allPresent = teams.every((team) =>
      team.members.every((m) => participants.has(m.id))
    );
    if (allPresent) break;

    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
  }
};

"왜 EventBridge를 10초마다 트리거하지 않는가?"

당연히 떠오르는 질문이다. EventBridge cron의 최소 단위는 1분이다. 10초 간격 트리거는 불가능하다.

그러면 "1분마다 5번 트리거하면 되지 않나?" 싶은데, 그렇게 하면 상태 공유 문제가 생긴다. 봇이 이미 스레드에 메시지를 올렸는지 Lambda 인스턴스끼리 공유할 방법이 없다 (DynamoDB 같은 외부 저장소를 쓰지 않는 이상). 결국 중복 메시지가 쌓인다.

Lambda 1개를 최대 10분(Timeout: 600초)짜리로 띄우고 내부에서 루프를 도는 방식이 훨씬 단순하다:

방식장점단점
EventBridge 반복 트리거1분 미만 불가, 상태 공유 필요
Lambda 내부 루프단순, 상태 공유 불필요Lambda 실행 시간 길어짐

우리 케이스에서는 후자가 맞다. 5분간 실행되지만 대부분의 시간은 setTimeout 대기라 CPU 비용은 거의 없다.


6. 인프라 설정 (AWS SAM)

template.yaml 한 파일로 Lambda + EventBridge 스케줄을 한 번에 정의한다.

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Resources:
  HuddleBotFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: huddle-bot
      Handler: dist/index.handler
      Runtime: nodejs20.x
      Timeout: 600       # 최대 10분 — 내부 루프 때문에 넉넉하게
      MemorySize: 256
      Events:
        DailyTrigger:
          Type: ScheduleV2
          Properties:
            # 한국시간 09:15 = UTC 00:15, 평일(MON-FRI)만 실행
            ScheduleExpression: "cron(15 0 ? * MON-FRI *)"
            Name: huddle-bot-daily

Timeout을 600초로 설정한 이유: Lambda 기본 Timeout은 3초다. 내부에서 5분 루프를 돌아야 하므로 최소 300초 이상이어야 하고, 허들 대기 시간(최대 2분)까지 포함해서 넉넉하게 600초로 잡았다.


7. 토큰 관리

봇 토큰을 코드에 하드코딩하면 안 된다 (Git에 올라가는 순간 끝). AWS SSM Parameter Store에 저장하고, template.yaml에서 Lambda 환경변수로 주입하는 방식이 깔끔하다.

# SSM에 토큰 저장 (Lambda 환경변수 resolve는 String 타입만 지원)
aws ssm put-parameter \
  --name /huddle-bot/slack-bot-token \
  --value "xoxb-..." \
  --type String

aws ssm put-parameter \
  --name /huddle-bot/channel-id \
  --value "CXXXXXXXXXX" \
  --type String

template.yaml에서 SSM 값을 환경변수로 연결:

Environment:
  Variables:
    SLACK_BOT_TOKEN: "{{resolve:ssm:/huddle-bot/slack-bot-token}}"
    SLACK_CHANNEL_ID: "{{resolve:ssm:/huddle-bot/channel-id}}"

코드에서는 그냥 process.env로 읽으면 된다. 로컬에서는 .env 파일로, Lambda에서는 SSM에서 주입된 값으로 자동으로 동작한다.


마치며

만들고 나서 약 두 달째 매일 돌아가고 있다. 코드 한 줄 건드리지 않고도 Slack User Group 멤버만 바꾸면 자동으로 반영되고, 배포는 npm run deploy 한 줄로 끝난다. 작은 자동화인데 매일 아침 허들이 조금 더 매끄럽게 시작되는 게 체감이 된다.

전체 코드는 GitHub에 올려두었다. 팀 구성에 맞게 USER_GROUP_HANDLES만 수정하면 바로 쓸 수 있다.

github.com/AlvinYou/huddle-bot

profile
ENFP 풀스택 개발자

0개의 댓글