Use Cases (업데이트: 2026. 6. 3.)

Claude Code로 Slack Bot 만들기: 문의 triage부터 장애 1차 대응과 일일 리포트까지

Bolt JS, Socket Mode, Slash Command, 보안, 테스트, 운영 체크리스트로 Slack Bot을 구현합니다.

Claude Code로 Slack Bot 만들기: 문의 triage부터 장애 1차 대응과 일일 리포트까지

알림 bot에서 멈추지 않기

Slack Bot은 Slack 안에서 메시지, Slash Command, 버튼, 모달 제출에 반응하는 앱입니다. Bolt for JavaScript는 이런 이벤트를 적절한 handler로 보내 주는 Slack 공식 Node.js 프레임워크입니다. 처음 시작하는 사람에게는 “이 Slack 이벤트가 오면 이 함수를 실행한다”는 연결을 만들어 주는 발판이라고 보면 됩니다.

Claude Code로 Slack Bot을 만들 때 흔한 실수는 알림만 보내는 작은 bot에서 끝내는 것입니다. 실제로 쓸 만한 bot은 채널의 잡음을 구조화된 업무로 바꿉니다. 문의 triage, 장애 1차 대응, 일일 리포트, 승인 요청, 공개 전 체크가 대표적입니다. Masa의 운영에서도 첫 알림 bot은 며칠 동안 편했지만, 누가 담당자인지, 긴급도가 무엇인지, 처리가 끝났는지는 남지 않아 결국 사람이 다시 채널을 읽어야 했습니다.

이 글은 2026년 6월 3일 기준 Slack 공식 문서를 확인해 작성했습니다: Bolt for JavaScript, Bolt command listener, Socket Mode, Slash Command, Events API, chat.postMessage, request verification, tokens를 기준으로 합니다. 함께 보면 좋은 글은 Webhook 구현, API 개발, secrets 관리, workflow 자동화입니다.

Use Case를 먼저 정하기

Claude Code에 “Slack Bot 만들어 줘”라고만 요청하면 얇은 데모가 나오기 쉽습니다. 먼저 Slack에서의 진입점, 저장할 필드, 반환할 메시지, 실패했을 때의 동작을 정합니다.

Use caseSlack 진입점bot이 하는 일통제할 위험
문의 triage/triage add, modal제목, 긴급도, 요청자, 채널 알림을 표준화고객명, secret, 비공개 URL이 붙여 넣어짐
장애 1차 대응@bot mention, button초기 체크리스트와 담당자 thread를 남김bot이 확신하는 답처럼 보이고 escalaton을 놓침
일일 리포트/triage list, scheduled job미완료 항목을 daily나 보고용으로 요약메시지가 길어져 Slack에서 읽기 어려움
글 또는 LP 공개 전 체크Slash CommandCTA, 내부 링크, 담당자, 공개 URL을 확인draft URL과 production URL이 섞임

이번 구조는 의도적으로 작게 유지합니다.

flowchart LR
  A["Slack user"] --> B["/triage or @mention"]
  B --> C["Bolt listener"]
  C --> D["Triage logic"]
  D --> E["chat.postMessage"]
  D --> F["Modal and button"]

Claude Code에는 이렇게 구체적으로 요청합니다.

Bolt for JavaScript로 Slack Bot을 구현해 주세요.
목적은 문의 triage입니다.
포함할 것:
- 환경 변수로 Socket Mode와 Request URL 전환
- /triage add, /triage list, /triage modal
- modal 입력과 view_submission 처리
- Mark done 버튼
- app_mention에 대한 안내 답변
- scopes, secrets, request verification 설명
- triage.ts 단위 테스트
가짜 API를 쓰지 말고, 복사해서 실행 가능한 TypeScript로 작성해 주세요.

Socket Mode와 Request URL

Socket Mode는 앱이 Slack으로 WebSocket 연결을 열고 이벤트를 받는 방식입니다. 로컬 개발에서 공개 HTTPS endpoint가 필요하지 않기 때문에 prototype, 사내 tool, firewall 뒤의 PoC에 잘 맞습니다. Slack 문서는 Socket Mode를 켜고 xapp-로 시작하는 app-level token을 사용하는 구성을 설명합니다.

Request URL은 Slack이 내 HTTPS endpoint로 HTTP POST를 보내는 방식입니다. 일반적인 production 운영에서는 이 방식을 많이 씁니다. HTTP로 받을 때는 Signing Secret으로 request signature를 검증합니다. Bolt가 이 검증을 맡을 수 있지만, 설계 문서에는 deprecated verification token에 의존하지 않는다고 남겨야 합니다.

방식적합한 상황필요한 설정주의점
Socket Mode로컬 개발, 사내 PoCSLACK_APP_TOKEN, connections:writeprocess가 죽으면 event를 받을 수 없고 Marketplace 배포에는 부적합
Request URLproduction HTTP deployHTTPS URL, SLACK_SIGNING_SECRET느린 ack()는 Slack timeout으로 보임

처음에는 Socket Mode로 확인하고, production channel이나 외부 사용자가 관련되면 Request URL로 옮깁니다. 아래 코드는 SLACK_SOCKET_MODE=true로 전환합니다.

Slack Manifest와 Scopes

manifest는 repository에 두어 dev와 production app이 어긋나지 않게 합니다. 여기서 필요한 scope는 작습니다. commands는 Slash Command 수신, chat:write는 메시지 게시, app_mentions:read는 bot mention 수신에 씁니다.

display_information:
  name: Claude Triage Bot
  description: Collect triage requests from Slack
  background_color: "#2E2A24"
features:
  bot_user:
    display_name: Claude Triage
    always_online: false
  slash_commands:
    - command: /triage
      description: Add or list triage items
      usage_hint: "add Fix login | list | modal"
      should_escape: true
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - chat:write
      - commands
settings:
  event_subscriptions:
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
  socket_mode_enabled: true
  org_deploy_enabled: false
  token_rotation_enabled: false

channels:historygroups:history는 처음부터 넣지 않습니다. bot이 실제로 channel history를 읽는 설계가 되었고 privacy review가 끝났을 때만 추가합니다.

로컬 프로젝트 만들기

Node.js 20 이상을 기준으로 합니다.

mkdir claude-slack-triage-bot
cd claude-slack-triage-bot
npm init -y
npm install @slack/bolt @slack/types dotenv
npm install -D typescript tsx vitest @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx watch src/app.ts"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/app.js"
npm pkg set scripts.test="vitest run"
mkdir src tests
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

.env.example을 둡니다. 실제 값은 .env나 hosting secret manager에 넣고 Git에는 넣지 않습니다.

SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_SOCKET_MODE=true
SLACK_APP_TOKEN=xapp-your-app-level-token
TRIAGE_CHANNEL_ID=C0123456789
PORT=3000

xoxb-는 bot token입니다. xapp-는 Socket Mode에서 쓰는 app-level token입니다. Signing Secret은 HTTP request가 Slack에서 왔는지 확인하는 키입니다. Claude Code에는 실제 값을 붙여 넣을 필요가 없습니다. 변수 이름, 기대 동작, 로그 규칙만 주면 됩니다.

복사해서 실행하는 Bolt 구현

먼저 Slack에 의존하지 않는 로직을 src/triage.ts로 분리합니다.

// src/triage.ts
import type { KnownBlock, View } from "@slack/types";

export type Severity = "low" | "normal" | "high";

export interface Ticket {
  id: string;
  channelId: string;
  title: string;
  createdBy: string;
  severity: Severity;
  status: "open" | "done";
  createdAt: string;
}

const tickets = new Map<string, Ticket>();

export function resetForTest() {
  tickets.clear();
}

export function parseTriageText(text: string) {
  const [actionRaw, ...rest] = text.trim().split(/\s+/);
  return { action: actionRaw || "help", title: rest.join(" ").trim() };
}

export function addTicket(input: {
  channelId: string;
  title: string;
  createdBy: string;
  severity?: Severity;
}) {
  const ticket: Ticket = {
    id: `triage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
    channelId: input.channelId,
    title: input.title,
    createdBy: input.createdBy,
    severity: input.severity ?? "normal",
    status: "open",
    createdAt: new Date().toISOString(),
  };
  tickets.set(ticket.id, ticket);
  return ticket;
}

export function completeTicket(id: string) {
  const ticket = tickets.get(id);
  if (!ticket) return undefined;
  const updated: Ticket = { ...ticket, status: "done" };
  tickets.set(id, updated);
  return updated;
}

export function formatTicketList(channelId: string) {
  const open = [...tickets.values()].filter((ticket) => {
    return ticket.channelId === channelId && ticket.status === "open";
  });

  if (open.length === 0) return "No open triage items.";

  return open
    .map((ticket, index) => {
      return `${index + 1}. [${ticket.severity}] ${ticket.title} by <@${ticket.createdBy}>`;
    })
    .join("\n");
}

export function ticketBlocks(ticket: Ticket): KnownBlock[] {
  return [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*${ticket.title}*\nSeverity: ${ticket.severity}\nOwner: <@${ticket.createdBy}>`,
      },
    },
    {
      type: "actions",
      elements: [
        {
          type: "button",
          text: { type: "plain_text", text: "Mark done" },
          action_id: "triage_done",
          value: ticket.id,
        },
      ],
    },
  ];
}

export function modalView(): View {
  return {
    type: "modal",
    callback_id: "triage_modal_submit",
    title: { type: "plain_text", text: "New triage" },
    submit: { type: "plain_text", text: "Create" },
    close: { type: "plain_text", text: "Cancel" },
    blocks: [
      {
        type: "input",
        block_id: "title_block",
        label: { type: "plain_text", text: "What needs attention?" },
        element: {
          type: "plain_text_input",
          action_id: "title_input",
          min_length: 3,
          max_length: 120,
        },
      },
      {
        type: "input",
        block_id: "severity_block",
        label: { type: "plain_text", text: "Severity" },
        element: {
          type: "static_select",
          action_id: "severity_input",
          initial_option: {
            text: { type: "plain_text", text: "Normal" },
            value: "normal",
          },
          options: [
            { text: { type: "plain_text", text: "High" }, value: "high" },
            { text: { type: "plain_text", text: "Normal" }, value: "normal" },
            { text: { type: "plain_text", text: "Low" }, value: "low" },
          ],
        },
      },
    ],
  };
}

다음으로 Bolt listener를 연결합니다.

// src/app.ts
import "dotenv/config";
import { App, LogLevel } from "@slack/bolt";
import {
  addTicket,
  completeTicket,
  formatTicketList,
  modalView,
  parseTriageText,
  ticketBlocks,
  type Severity,
} from "./triage.js";

const socketMode = process.env.SLACK_SOCKET_MODE === "true";
const required = ["SLACK_BOT_TOKEN", socketMode ? "SLACK_APP_TOKEN" : "SLACK_SIGNING_SECRET"];

for (const key of required) {
  if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
}

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode,
  appToken: process.env.SLACK_APP_TOKEN,
  logLevel: LogLevel.INFO,
});

app.command("/triage", async ({ ack, command, respond, client }) => {
  await ack();
  const parsed = parseTriageText(command.text);

  if (parsed.action === "add") {
    if (!parsed.title) {
      await respond("Usage: `/triage add Fix login redirect`");
      return;
    }

    const ticket = addTicket({
      channelId: command.channel_id,
      title: parsed.title,
      createdBy: command.user_id,
      severity: "normal",
    });

    await respond({
      response_type: "in_channel",
      text: `Triage item added: ${ticket.title}`,
      blocks: ticketBlocks(ticket),
    });
    return;
  }

  if (parsed.action === "list") {
    await respond({ response_type: "ephemeral", text: formatTicketList(command.channel_id) });
    return;
  }

  if (parsed.action === "modal") {
    await client.views.open({ trigger_id: command.trigger_id, view: modalView() });
    return;
  }

  await respond("Usage: `/triage add ...`, `/triage list`, or `/triage modal`");
});

app.view("triage_modal_submit", async ({ ack, view, body, client }) => {
  const titleState = view.state.values.title_block.title_input;
  const severityState = view.state.values.severity_block.severity_input;
  const title = titleState.type === "plain_text_input" ? titleState.value?.trim() : "";
  const severity =
    severityState.type === "static_select"
      ? severityState.selected_option?.value ?? "normal"
      : "normal";

  if (!title) {
    await ack({ response_action: "errors", errors: { title_block: "Please enter a title." } });
    return;
  }

  await ack();

  const channelId = process.env.TRIAGE_CHANNEL_ID ?? "modal-only";
  const ticket = addTicket({
    channelId,
    title,
    createdBy: body.user.id,
    severity: severity as Severity,
  });

  if (process.env.TRIAGE_CHANNEL_ID) {
    await client.chat.postMessage({
      channel: process.env.TRIAGE_CHANNEL_ID,
      text: `New triage item: ${ticket.title}`,
      blocks: ticketBlocks(ticket),
    });
  }
});

app.action("triage_done", async ({ ack, action, respond }) => {
  await ack();
  const value = action.type === "button" ? action.value : undefined;
  if (!value) return;

  const ticket = completeTicket(value);
  await respond(ticket ? `Closed: ${ticket.title}` : "Ticket not found.");
});

app.event("app_mention", async ({ event, say }) => {
  await say({
    thread_ts: event.ts,
    text: "Use `/triage add ...`, `/triage list`, or `/triage modal`.",
  });
});

const port = Number(process.env.PORT ?? 3000);
if (socketMode) {
  await app.start();
} else {
  await app.start(port);
}

app.logger.info(`Slack bot started in ${socketMode ? "Socket Mode" : `HTTP mode on ${port}`}`);

Slack 연결 없이 돌릴 수 있는 단위 테스트를 추가합니다.

// tests/triage.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import {
  addTicket,
  completeTicket,
  formatTicketList,
  parseTriageText,
  resetForTest,
} from "../src/triage";

describe("triage helpers", () => {
  beforeEach(() => resetForTest());

  it("parses slash command text", () => {
    expect(parseTriageText("add Fix login")).toEqual({
      action: "add",
      title: "Fix login",
    });
  });

  it("lists only open tickets", () => {
    const ticket = addTicket({
      channelId: "C123",
      title: "Review pricing CTA",
      createdBy: "U123",
      severity: "high",
    });

    expect(formatTicketList("C123")).toContain("[high] Review pricing CTA");
    completeTicket(ticket.id);
    expect(formatTicketList("C123")).toBe("No open triage items.");
  });
});

실행 순서는 다음과 같습니다.

npm run test
npm run build
npm run dev

Socket Mode라면 npm run dev를 켜 둔 상태에서 Slack에 /triage add Test from Slack을 입력합니다. Request URL이라면 app을 deploy한 뒤 https://example.com/slack/events를 slash commands, interactivity, event subscriptions에 설정합니다.

함정과 보안

느린 작업보다 먼저 ack()를 호출합니다. command, button, modal은 DB나 외부 API보다 수신 확인이 먼저입니다.

trigger_id는 짧게 살아 있는 값으로 봅니다. modal을 먼저 열고, 자세한 검증은 view_submission에서 합니다.

권한 문제를 코드에서만 찾지 않습니다. chat:write 부족, bot 미초대, app_mention subscription 누락은 Slack settings에서 고쳐야 합니다.

모드를 섞지 않습니다. Socket Mode에는 SLACK_APP_TOKEN이 필요하고, Request URL에는 HTTPS와 SLACK_SIGNING_SECRET이 필요합니다. 시작 로그에 선택된 모드를 남깁니다.

secret을 노출하지 않습니다. xoxb-, xapp-, Signing Secret을 Claude Code prompt, screenshot, log, fixture, article에 붙이지 마세요. 노출되면 즉시 rotate합니다.

마지막으로 bot에게 판단을 너무 많이 맡기지 않습니다. support와 incident에서는 다음 확인 항목과 escalation rule을 알려주는 역할에 머물러야지, root cause를 단정하면 안 됩니다.

Production Checklist

  • manifest scopes가 code에서 쓰는 API와 일치한다.
  • /triage가 다른 installed app과 충돌하지 않는다.
  • modal과 button을 위해 Interactivity가 활성화되어 있다.
  • bot이 대상 channel에 초대되어 있다.
  • SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_SIGNING_SECRET이 secrets로 저장되어 있다.
  • npm run testnpm run build가 통과한다.
  • Request URL은 HTTPS와 Slack signature verification을 사용한다.
  • Socket Mode는 process monitoring과 restart가 있다.
  • logs에는 token, masking되지 않은 개인정보, 고객명, private URL이 없다.
  • bot이 처리하지 못할 때 누구에게 escalate하는지 channel topic에 적혀 있다.

Claude Code가 맡을 범위를 나눕니다. manifest와 scopes 제안, Slack과 무관한 로직, Bolt listener, unit test, deploy checklist를 따로 리뷰하면 Slack 설정 오류와 코드 버그를 분리할 수 있습니다.

ClaudeCodeLab은 이런 내부 bot, webhook, API, secrets workflow를 training과 consultation에서 다룹니다. 재사용 가능한 CLAUDE.md 규칙, 공개 전 리뷰 템플릿, 팀 체크리스트가 필요하다면 이 bot 패턴을 templates와 products와 함께 사용해 demo가 아니라 운영과 수익 경로에 연결하세요.

실제 테스트 결과

가장 빠른 길은 큰 Slack Bot을 한 번에 생성하는 것이 아니었습니다. 먼저 manifest와 scopes를 고정하고, 다음에 triage.ts 같은 순수 로직을 작성한 뒤, 마지막에 Bolt listener와 Slack admin settings를 연결하는 순서가 가장 안정적이었습니다. Claude Code는 code, permissions, secrets, tests, production checklist를 하나의 review 가능한 작업 단위로 맡길 때 가장 잘 작동합니다.

#Claude Code #Slack Bot #Bolt JS #Slash Command #업무 자동화
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.