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

Claude Code로 Discord Bot 만들기: discord.js Slash Command 실전 가이드

Claude Code와 discord.js로 지원 접수용 Discord Bot을 만들고 권한, 환경 변수, 배포 점검까지 정리합니다.

Claude Code로 Discord Bot 만들기: discord.js Slash Command 실전 가이드

Discord Bot은 장난감 챗봇이 아니라 운영 흐름이다

Discord Bot은 Discord 서버에 들어가 사용자의 동작에 반응하는 애플리케이션이다. 쉽게 말하면 커뮤니티 안에 있는 자동 접수 담당자다. 지원 요청을 받고, 짧은 FAQ 답변을 돌려주고, 모더레이터에게 알리고, 인수인계 메모를 정리해 내부 채널로 보내는 일을 맡길 수 있다.

실제 커뮤니티에서 중요한 것은 “대화를 많이 만드는 것”보다 “문제가 사라지지 않게 하는 것”이다. 사용자가 “설치가 막혔어요”, “어디 문서를 보면 되나요”, “누가 이 이슈를 봐야 하나요”라고 일반 채널에 쓰면 금방 묻힌다. 반대로 application commands로 접수하면 입력 항목, 담당자, 다음 행동이 일정해진다.

Claude Code는 여기서 단순한 코드 생성기보다 운영 체크리스트 작성자에 가깝게 쓰는 편이 좋다. Discord Bot에는 명령 등록, 환경 변수, 권한, interaction 응답, 에러 처리, token 보호, 배포 절차가 모두 필요하다. “Discord Bot 만들어줘”라고만 하면 데모는 빨리 나오지만, 실제 서버에 넣자마자 권한이나 응답 누락으로 실패할 수 있다.

이 글에서는 discord.js로 /support, /faq, /handoff 세 명령을 구현한다. message content intent에 의존하지 않고 slash command를 사용하며, mention 악용을 막고, 개발 중에는 테스트 서버에만 명령을 등록한다. Node.js는 현재 discord.js 문서 기준에 맞춰 22.12.0 이상을 전제로 한다.

flowchart LR
  A["User runs /support"] --> B["Discord interaction"]
  B --> C["discord.js bot"]
  C --> D["Ephemeral user reply"]
  C --> E["Support channel message"]
  E --> F["Moderator handoff"]

첫 버전의 목표는 똑똑한 AI 상담사가 아니다. 안정적인 접수 경로다. 데이터베이스, 큐, LLM 요약, CRM 연동은 이 흐름이 매번 동작한 뒤에 붙여도 늦지 않다. Claude Code에도 처음부터 “운영 가능한 최소 구조”를 요청해야 결과가 좋아진다.

application commands와 interactions를 쉽게 이해하기

Discord application commands는 Discord 클라이언트 안에 기본으로 표시되는 명령이다. 가장 익숙한 형태가 /support 같은 slash command다. 예전 방식인 !help 접두사 명령보다 지원 업무에 적합한 이유는, Discord가 명령 이름, 설명, 입력 옵션, 선택지, 권한을 제출 전에 보여주기 때문이다.

Interactions는 사용자가 slash command를 실행하거나 버튼을 누르거나 선택 메뉴와 모달을 사용할 때 애플리케이션으로 전달되는 이벤트다. discord.js를 Gateway 방식으로 사용할 때는 보통 Events.InteractionCreate에서 처리한다. HTTP endpoint로 interactions를 받을 수도 있지만, 작은 팀이 로컬에서 실행하고 로그를 보며 디버깅하기에는 Gateway Bot이 단순하다.

정확한 규칙은 공식 문서를 기준으로 봐야 한다. 명령 타입, 이름 규칙, guild command와 global command의 차이는 Discord Application Commands에 있다. 초기 응답, followup, interaction token 동작은 Receiving and Responding to Interactions를 확인한다. 라이브러리 API와 런타임 요구 사항은 discord.js documentationdiscord.js guide를 함께 보는 것이 안전하다.

권한, 환경 변수, 최소 아키텍처

Developer Portal에서 Discord application을 만들고 Bot 사용자를 추가한 뒤, botapplications.commands scopes가 들어간 초대 URL을 만든다. 처음부터 administrator 권한을 주면 안 된다. 이 Bot은 지원 채널을 볼 수 있고 메시지를 보낼 수 있으면 충분하다. /handoff는 모더레이터 수준 권한, 예를 들어 Manage Messages 권한을 가진 사용자만 실행하게 제한한다.

항목운영 메모
Node.js22.12.0 이상현재 discord.js 문서 기준
OAuth2 scopesbot, applications.commandsBot과 slash command에 필요
Bot permissionsView Channels, Send Messages최소 권한에서 시작
DISCORD_TOKENBot token커밋, 스크린샷, 로그 금지
DISCORD_CLIENT_IDApplication ID명령 등록에 사용
DISCORD_GUILD_ID테스트 서버 ID개발 중 guild command 등록
SUPPORT_CHANNEL_ID내부 지원 채널Bot이 전송 가능한지 확인

Claude Code 프롬프트는 이렇게 구체적으로 쓰는 것이 좋다. “Node.js 22와 discord.js로 /support, /faq, /handoff를 가진 지원 Bot을 만들어라. .env를 사용하고, 개발 중에는 guild commands로 등록하며, 최소 권한과 ephemeral reply, 배포 체크리스트를 포함하라.” 이 정도로 요청해야 일반적인 챗봇 예제가 아니라 실제 운영 가능한 시작점이 나온다.

관련 글로는 환경 변수 관리, 에러 처리 패턴, 코드 리뷰 체크리스트를 함께 읽으면 좋다. Bot은 작아도 운영 습관은 큰 서비스와 같다.

바로 실행할 수 있는 discord.js 스타터

아래 예제는 TypeScript 설정 없이 실행할 수 있는 JavaScript ES modules 코드다. DISCORD_GUILD_ID가 있으면 테스트 서버에 guild commands로 등록하고, 없으면 global commands로 등록한다. 개발 중에는 DEPLOY_COMMANDS=true를 쓰고, 운영 환경의 일반 재시작에서는 의도적으로 끄는 편이 안전하다.

mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install discord.js dotenv
mkdir src

package.jsontypestart를 추가한다.

{
  "type": "module",
  "scripts": {
    "start": "node src/bot.js"
  },
  "dependencies": {
    "discord.js": "latest",
    "dotenv": "latest"
  }
}

.env 파일을 만든다.

DISCORD_TOKEN=replace_with_bot_token
DISCORD_CLIENT_ID=replace_with_application_id
DISCORD_GUILD_ID=replace_with_test_guild_id
SUPPORT_CHANNEL_ID=replace_with_support_channel_id
DEPLOY_COMMANDS=true

src/bot.js를 만든다.

import "dotenv/config";
import {
  Client,
  Events,
  GatewayIntentBits,
  MessageFlags,
  PermissionFlagsBits,
  REST,
  Routes,
  SlashCommandBuilder,
} from "discord.js";

const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const supportChannelId = process.env.SUPPORT_CHANNEL_ID;

for (const [name, value] of Object.entries({ token, clientId, supportChannelId })) {
  if (!value) throw new Error(`${name} is required.`);
}

const commands = [
  new SlashCommandBuilder()
    .setName("support")
    .setDescription("Send a support request to the team")
    .addStringOption((option) =>
      option
        .setName("summary")
        .setDescription("What happened?")
        .setMaxLength(900)
        .setRequired(true),
    )
    .addStringOption((option) =>
      option
        .setName("severity")
        .setDescription("How urgent is it?")
        .setRequired(true)
        .addChoices(
          { name: "low", value: "low" },
          { name: "normal", value: "normal" },
          { name: "high", value: "high" },
        ),
    )
    .addStringOption((option) =>
      option
        .setName("context")
        .setDescription("Steps, links, or error messages")
        .setMaxLength(1500),
    ),
  new SlashCommandBuilder()
    .setName("faq")
    .setDescription("Show a short answer for a common topic")
    .addStringOption((option) =>
      option
        .setName("topic")
        .setDescription("FAQ topic")
        .setRequired(true)
        .addChoices(
          { name: "setup", value: "setup" },
          { name: "permissions", value: "permissions" },
          { name: "rollout", value: "rollout" },
        ),
    ),
  new SlashCommandBuilder()
    .setName("handoff")
    .setDescription("Create a moderator handoff note")
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
    .addUserOption((option) =>
      option.setName("target").setDescription("User to hand off").setRequired(true),
    )
    .addStringOption((option) =>
      option
        .setName("note")
        .setDescription("What should the next moderator know?")
        .setMaxLength(1500)
        .setRequired(true),
    ),
].map((command) => command.toJSON());

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Logged in as ${readyClient.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  try {
    if (!interaction.inGuild()) {
      await interaction.reply({
        content: "Please use this command inside the server.",
        flags: MessageFlags.Ephemeral,
      });
      return;
    }

    if (interaction.commandName === "support") await handleSupport(interaction);
    else if (interaction.commandName === "faq") await handleFaq(interaction);
    else if (interaction.commandName === "handoff") await handleHandoff(interaction);
    else await safeReply(interaction, "Unknown command.");
  } catch (error) {
    console.error("Interaction failed:", error);
    await safeReply(interaction, "Something went wrong. Please contact a moderator.");
  }
});

async function handleSupport(interaction) {
  const summary = interaction.options.getString("summary", true);
  const severity = interaction.options.getString("severity", true);
  const context = interaction.options.getString("context") ?? "No extra context.";
  const channel = await fetchSupportChannel();

  await channel.send({
    content: [
      "**New support request**",
      `Reporter: ${interaction.user.tag} (${interaction.user.id})`,
      `Severity: ${severity}`,
      `Channel: <#${interaction.channelId}>`,
      `Summary: ${neutralizeMentions(summary)}`,
      `Context: ${neutralizeMentions(context)}`,
    ].join("\n"),
    allowedMentions: { parse: [] },
  });

  await interaction.reply({
    content: "Thanks. Your request was sent to the support team.",
    flags: MessageFlags.Ephemeral,
  });
}

async function handleFaq(interaction) {
  const topic = interaction.options.getString("topic", true);
  const answers = {
    setup: "Install Node.js 22.12+, invite the bot with bot and applications.commands scopes, then run npm start.",
    permissions: "Start with View Channels and Send Messages. Reserve Manage Messages for moderator-only commands.",
    rollout: "Use guild commands for testing. Promote to global commands only after rollback and logging are checked.",
  };

  await interaction.reply({
    content: answers[topic],
    flags: MessageFlags.Ephemeral,
  });
}

async function handleHandoff(interaction) {
  if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
    await interaction.reply({
      content: "You need Manage Messages permission to use this command.",
      flags: MessageFlags.Ephemeral,
    });
    return;
  }

  const target = interaction.options.getUser("target", true);
  const note = interaction.options.getString("note", true);
  const channel = await fetchSupportChannel();

  await channel.send({
    content: [
      "**Moderator handoff**",
      `Target: ${target.tag} (${target.id})`,
      `From: ${interaction.user.tag} (${interaction.user.id})`,
      `Note: ${neutralizeMentions(note)}`,
    ].join("\n"),
    allowedMentions: { parse: [] },
  });

  await interaction.reply({
    content: "Handoff note created.",
    flags: MessageFlags.Ephemeral,
  });
}

async function fetchSupportChannel() {
  const channel = await client.channels.fetch(supportChannelId);
  if (!channel || !channel.isTextBased() || typeof channel.send !== "function") {
    throw new Error("SUPPORT_CHANNEL_ID must be a text channel the bot can send to.");
  }
  return channel;
}

function neutralizeMentions(value) {
  return value
    .replaceAll("@everyone", "@ everyone")
    .replaceAll("@here", "@ here")
    .replace(/<@!?(\d+)>/g, "user:$1")
    .replace(/<@&(\d+)>/g, "role:$1");
}

async function safeReply(interaction, content) {
  const payload = { content, flags: MessageFlags.Ephemeral };
  if (interaction.replied || interaction.deferred) await interaction.followUp(payload);
  else await interaction.reply(payload);
}

async function deployCommands() {
  const rest = new REST({ version: "10" }).setToken(token);
  const route = guildId
    ? Routes.applicationGuildCommands(clientId, guildId)
    : Routes.applicationCommands(clientId);

  await rest.put(route, { body: commands });
  console.log(guildId ? "Guild commands deployed." : "Global commands deployed.");
}

if (process.env.DEPLOY_COMMANDS === "true") {
  await deployCommands();
}

await client.login(token);

로컬에서는 node --version으로 22.12.0 이상인지 확인하고 npm start를 실행한다. 먼저 테스트 서버에서 /support를 실행해 사용자는 비공개 응답만 보고, 내부 지원 채널에는 정리된 메시지가 도착하는지 확인한다. 그다음 /faq를 확인하고, 마지막으로 모더레이터 계정과 일반 계정으로 /handoff를 각각 테스트한다.

Use case: 실제로 유지할 만한 세 가지 장면

첫 번째 Use case는 지원 접수다. 좋은 접수 명령은 summary, severity, context만 받아도 충분히 triage를 시작할 수 있다. triage는 “무시, 답변, 재현, 배정, 긴급 상승” 중 다음 행동을 고르는 작업이다. 작은 테스트 서버에서 자유 입력보다 /support가 훨씬 읽기 쉬웠다. 처음 메시지에 긴급도와 재현 힌트가 같이 들어가서 모더레이터가 다시 물어보는 횟수가 줄었다.

두 번째 Use case는 FAQ 라우팅이다. Bot이 긴 문서를 채팅에 그대로 붙여 넣으면 오히려 읽기 어렵다. 짧은 답과 정확한 링크가 더 낫다. 설치 문제는 Claude Code 시작 가이드, CLI 관련 질문은 CLI 도구 개발, 팀 규칙은 CLAUDE.md 템플릿으로 보내는 식이다. Bot은 별도 문서가 아니라 사이트의 안내판이 된다.

세 번째 Use case는 모더레이터 인수인계다. 유료 커뮤니티, 교육 코호트, 게임 서버, 제품 지원 서버에서는 담당자가 바뀐다. “누가 좀 봐주세요”라는 메시지는 시간이 지나면 맥락이 사라진다. /handoff는 대상 사용자, 작성자, 다음 담당자가 알아야 할 내용을 한 줄의 내부 기록으로 남긴다.

네 번째로는 교육 지원이 있다. Claude Code 워크숍에서는 여러 사람이 같은 Node 버전, 환경 변수, 명령 실행 오류를 겪는다. Bot이 먼저 버전, 실패한 명령, 마지막 로그 몇 줄을 받으면 강사는 기초 정보를 다시 묻지 않고 진단부터 시작할 수 있다. 팀 서버에 이런 운영을 넣고 싶다면 교육과 상담으로 이어지는 CTA가 자연스럽다.

Pitfall: 초기에 제거해야 할 운영 위험

첫 번째 Pitfall은 Bot token 유출이다. Git 기록, 스크린샷, CI 로그, 문서에 token이 보이면 이미 유출된 것으로 보고 Developer Portal에서 즉시 rotate해야 한다. Claude Code에 요청할 때도 .env.example에는 placeholder만 넣고, 실제 secret은 출력하지 말라고 적어야 한다.

두 번째 위험은 개발 중 global command를 반복해서 등록하는 것이다. guild command는 테스트 서버에만 적용되어 빠르고 안전하다. global command는 실제 사용자에게 보이는 표면이다. 명령 이름, 설명, 권한, 롤백 절차가 확정된 뒤에 승격해야 한다. 운영 재시작마다 명령을 자동 등록하는 것도 피하는 편이 좋다.

세 번째는 interaction에 답하지 않는 분기가 남는 것이다. 성공 경로만 reply하고 예외나 알 수 없는 명령에서 아무것도 하지 않으면 사용자는 명령이 고장났다고 느낀다. 모든 분기는 reply, defer, follow up 중 하나를 가져야 한다. 외부 API나 LLM 요약을 붙일 때는 먼저 defer하고 나중에 결과를 보낸다.

네 번째는 mention 악용이다. 사용자의 입력을 내부 채널에 그대로 전달하면 @everyone, @here, 사용자 mention, 역할 mention이 알림 사고를 만들 수 있다. 예제 코드는 allowedMentions: { parse: [] }와 문자열 정리를 함께 사용한다. 공개 서버라면 이 방어는 유지해야 한다.

다섯 번째는 편하다는 이유로 Bot에 administrator 권한을 주는 것이다. 그러면 필요한 권한을 검토하지 않게 되고 token이 탈취됐을 때 피해 범위가 커진다. View Channels와 Send Messages에서 시작하고, 리뷰에서 설명 가능한 권한만 추가한다.

보안과 배포 전 체크리스트

  • Developer Portal에서 token을 rotate할 담당자가 있다
  • .env는 Git에 들어가지 않고 .env.example은 placeholder만 포함한다
  • 초대 링크는 botapplications.commands scopes에서 시작한다
  • Bot permissions는 View Channels와 Send Messages가 기본이다
  • /handoff는 모더레이터급 사용자만 실행한다
  • guild commands로 먼저 테스트한 뒤 global commands로 승격한다
  • 모든 interaction 분기가 reply, defer, follow up 중 하나를 수행한다
  • 사용자 입력의 mention을 무력화한다
  • 로그에 token, 결제 정보, 이메일, 개인 지원 내용이 남지 않는다
  • 배포 환경은 Node.js 22.12.0 이상이다
  • 재시작과 롤백 절차가 문서화되어 있다

작은 Bot은 Railway, Render, Fly.io, VPS, 내부 서버 어디서든 시작할 수 있다. 중요한 것은 호스팅 이름이 아니라 secret을 코드와 분리할 수 있는지, 로그를 볼 수 있는지, 재시작과 롤백을 설명할 수 있는지다. README도 Claude Code에게 이 기준으로 쓰게 한다.

개인 개발자가 재사용 가능한 템플릿을 원한다면 ClaudeCodeLab 제품을 볼 수 있다. 팀이 Discord 지원, Claude Code 권한 설계, 리뷰 기준, 운영 교육까지 함께 정리해야 한다면 교육과 상담이 자연스러운 다음 단계다. Bot을 실제 기능으로 유지한다면 코드 리뷰 자동화도 함께 연결해 두는 것이 좋다.

직접 테스트한 결과

작은 테스트 서버에서 이 starter를 실행했을 때 가장 크게 달라진 부분은 Bot의 지능이 아니라 요청의 형태였다. 자유 입력은 “안 됩니다”로 시작하는 경우가 많았지만, /support는 summary, severity, context를 강제로 남겼다. 그래서 첫 모더레이터 응답이 정보 요청이 아니라 진단에 가까워졌다. 반대로 사고가 날 수 있는 부분은 token, global command, mention, 권한이었다. Claude Code는 코드만 쓰게 할 때보다 .env.example, 권한표, 실패 응답, 배포 체크리스트까지 같이 만들게 할 때 더 실무적이었다.

#Claude Code #Discord Bot #discord.js #chatbot #커뮤니티
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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