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

유영석·2026년 5월 7일

배경

우리 팀은 매일 오전 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에서 주입된 값으로 자동으로 동작한다.


8. 두 달 뒤 — 카카오웍스 연차 연동

봇을 두 달쯤 굴리다 보니 눈에 밟히는 게 하나 생겼다. 연차 쓴 사람이 "미참여"로 뜨는 것.

당연히 연차니까 허들에 안 들어오는데, 봇은 그걸 모르니 ⏳ PMO팀 3/4 (미참여: 홍길동) 이렇게 매일 깜빡이며 지적했다. 진행자는 "아 저 사람 오늘 연차예요" 하고 매번 손으로 걸러야 했다. 출석 체크를 자동화해놓고 정작 부재를 손으로 거르고 있으니 반쪽짜리였던 셈.

마침 회사가 카카오웍스 캘린더에 연차/반차를 등록하고 있었고, iCal(.ics) 공개 링크를 뽑을 수 있었다. 이거면 되겠다 싶어서 붙였는데 — 예상대로(?) 데이터가 순순히 협조해주지 않았다.

iCal은 이렇게 생겼다

BEGIN:VEVENT
DTSTART;VALUE=DATE:20260608
DTEND;VALUE=DATE:20260609
SUMMARY:[오후 반차] 홍길동
ORGANIZER;CN="홍길동":mailto:hong@example.com
END:VEVENT

부재 일정은 전부 종일 이벤트(DTSTART;VALUE=DATE)이고, 제목(SUMMARY)이 [유형] 이름 형태였다. 라이브러리 없이 BEGIN:VEVENT~END:VEVENT 블록만 잘라서 SUMMARY/DTSTART/DTEND를 읽으면 되겠다 싶었다. (RFC 5545의 line-folding — 긴 줄이 다음 줄로 접히면서 앞에 공백이 붙는 것 — 만 풀어주면 된다.)

여기까지는 순조로웠다. 문제는 그 다음이었다.

삽질 1: ORGANIZER 이메일을 믿었다가 절반이 안 맞음

처음엔 당연히 ORGANIZER의 이메일로 Slack 멤버(profile.email)와 매칭하려 했다. 이메일은 유일하고 동명이인 걱정도 없으니 교과서적인 정답처럼 보였다.

그런데 어떤 연차 이벤트의 ORGANIZER를 까보니:

SUMMARY:[연차] 홍길동
ORGANIZER;CN="김철수":mailto:kim@example.com   ← ??? 홍길동 연차인데 등록자는 김철수

휴가를 본인이 아니라 다른 사람(팀 관리자·대리자)이 등록하는 경우가 많았다. 전체 부재 이벤트에서 "SUMMARY 이름 == ORGANIZER 이름"이 맞는 비율을 세어보니:

OK       142건
MISMATCH 149건   ← 절반이 넘음

이메일 매칭은 폐기. 결국 신뢰할 수 있는 건 SUMMARY 제목에 적힌 이름뿐이었다. "정석"이 데이터 앞에서 무너지는 전형적인 케이스.

삽질 2: 제목 양식이 하나가 아니었다

이름으로 매칭하기로 하고 SUMMARY에서 이름을 뽑는데, 양식이 두 종류였다.

[출산휴가] 홍길동      ← 대괄호 양식
출산휴가 : 홍길동      ← 콜론 양식

거기다 이름 뒤에 직급이나 부연설명이 붙는 것도 있었다.

[외근-오후] 홍길동 차장 (오후)
[출장] 홍길동 본사 출장

[...]만 떼는 걸로는 어림도 없었다. 그래서 이름 추출을 이렇게 정리했다 — 앞의 [태그]를 떼고, 콜론이 있으면 콜론 뒤를 취하고, 남은 문자열의 맨 앞 한글 토큰만 이름으로 본다(한국 이름엔 중간 공백이 없다는 전제).

export function extractName(summary: string): string | null {
  let rest = summary.trim();
  rest = rest.replace(/^\[[^\]]*\]\s*/, "");         // [태그] 제거
  if (rest.includes(":")) rest = rest.slice(rest.lastIndexOf(":") + 1); // 콜론 뒤
  rest = rest.trim();
  const m = rest.match(/^[가-힣]+/);                  // 맨 앞 한글 토큰
  return m ? normalizeName(m[0]) : null;
}

여기에 하나 더. 캘린더엔 동명이인 구분용으로 홍길동B 처럼 이름 뒤에 알파벳이 붙은 사람이 있었다. Slack 표시이름은 그냥 홍길동이라 이대로면 안 맞는다. 그래서 양쪽 다 통과시키는 정규화 함수로 접미사를 떼어냈다.

export function normalizeName(name: string): string {
  return name.trim().replace(/\s+/g, "").replace(/[A-Za-z]+$/, "");
}

(우리 팀엔 실제 동명이인이 없어서 접미사를 떼도 안전하다는 걸 확인하고 적용했다. 동명이인이 있는 조직이라면 이 전제부터 다시 봐야 한다.)

삽질 3: "오전에 자리를 비우는가"가 핵심이었다

연차라고 다 같은 연차가 아니었다. 데일리 허들은 오전 9시 15분이다. 그러니 판단 기준은 "오늘 하루 쉬느냐"가 아니라 "9시 15분에 자리에 있느냐" 여야 했다.

  • 연차·출산휴가·오전 반차·예비군 → 오전에 없음 → 허들 면제 (출석 분모에서 빼야 함)
  • 오후 반차·오후 외근 → 오전엔 있음 → 면제 아님 (평소처럼 출석 대상)

그런데 앞서 말했듯 부재는 전부 종일 이벤트라, 이벤트의 시작 시각으로는 오전/오후를 가를 수 없다. 결국 판단 근거는 제목의 키워드밖에 없었다. 그래서 키워드 → (라벨, 종류) 매핑 테이블 하나로 몰아넣었다. 나중에 새 유형이 생겨도 여기만 고치면 된다.

export type LeaveKind = "day" | "morning" | "afternoon";
// day = 종일, morning = 오전만 비움, afternoon = 오후만 비움
// 허들 면제 여부 = (day 또는 morning)

const LEAVE_RULES = [
  { keyword: "오전 반차",  label: "오전 반차",  kind: "morning" },
  { keyword: "오후 반차",  label: "오후 반차",  kind: "afternoon" },
  { keyword: "외근-오후",  label: "오후 외근",  kind: "afternoon" },
  { keyword: "연차",       label: "연차",       kind: "day" },
  { keyword: "출산휴가",   label: "출산휴가",   kind: "day" },
  { keyword: "예비군",     label: "예비군",     kind: "day" },
  // ...
];

처음엔 종류를 morning/afternoon 둘로만 나눴는데, 그러면 출산휴가가 "오전 부재"로 분류돼서 표시가 어색했다. "종일 쉬는 사람"과 "오전만 비우는 사람"은 의미가 다르니 day를 하나 더 두는 게 맞았다.

결과

이제 오전에 자리를 비우는 사람은 출석 분모에서 빠지고(3/43/3), 부재 사유는 메시지 맨 아래에 따로 모아 브리핑한다. 오후 반차처럼 오전엔 있어야 하는 사람은 출석 계산엔 그대로 두고 브리핑에만 표시한다.

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

✅ PMO팀 3/3
✅ 웹서비스개발팀 5/5
⏳ AI Core 1/2  (미참여: 홍길동)

🟢 출근 인원 전원 참여! 시작하세요!

────────────
🌴 오늘의 부재
· PMO팀 이몽룡 (출산휴가)
· 웹서비스개발팀 성춘향 (오후 외근)

연차 쓴 사람이 더 이상 "미참여"로 깜빡이지 않고, 오늘 누가 자리를 비우는지도 한눈에 들어온다.

한 가지 신경 쓴 건 연동을 옵션으로 둔 것. KAKAOWORK_ICAL_URL 환경변수가 있을 때만 동작하고, iCal을 못 불러와도(카카오웍스 장애 등) 출석 체크는 그대로 돌아간다. 부가 기능이 본체를 멈추게 하면 안 되니까.

배운 것

이번 연동에서 제일 크게 남은 건 결국 "공식 스펙보다 실제 데이터" 였다. 이메일 매칭이라는 교과서적 정답은 대리 등록이라는 현실 앞에서 절반이 틀렸고, 제목 양식은 문서엔 없는 두 종류가 섞여 있었다. 허들 감지 때 room.participants를 응답 까보면서 찾았던 것처럼, 이번에도 결국 실제 .ics를 수십 번 들여다보면서 규칙을 맞춰나갔다. 자동화는 언제나 "데이터가 실제로 어떻게 생겼는가"에서 시작하는 것 같다.


마치며

처음엔 출석 현황만 올리던 봇이었는데, 두 달을 굴리다 보니 연차 연동까지 붙게 됐다. 지금은 매일 아침 조용히 돌면서, 누가 왔고 누가 자리를 비우는지를 스레드 하나로 정리해준다.

돌아보면 이 봇에서 손이 제일 많이 간 두 지점 — 허들 감지와 연차 매칭 — 은 둘 다 공식 문서에 안 나오는 실제 데이터를 직접 까보면서 풀어야 했다. 정작 프레임워크나 인프라는 큰 고민이 아니었다. 작은 자동화일수록 "현실의 데이터가 어떻게 생겼는가"가 8할인 것 같다.

운영 부담도 거의 없다. 팀 구성이 바뀌면 Slack User Group만 고치고, 배포는 npm run deploy 한 줄. 연차 연동은 KAKAOWORK_ICAL_URL 환경변수만 지우면 언제든 끌 수 있다.

전체 코드는 GitHub에 있다. 팀 구성에 맞게 USER_GROUP_HANDLESLEAVE_RULES만 손보면 바로 쓸 수 있다.

github.com/AlvinYou/huddle-bot

profile
ENFP 풀스택 개발자

0개의 댓글