Claude Code로 Discord Bot 만들기: discord.js Slash Command 실전 가이드
discord.js Discord Bot에 /support, /faq, /handoff와 안전한 slash command 흐름을 구현합니다.
로컬에서는 됐는데 사용자 화면에는 “응답하지 않음”이 뜬다
개발 PC에서 Bot 로그인 메시지를 확인하고 작업을 끝냈다고 생각하기 쉽다. 하지만 사용자가 /support를 실행하면 Discord에 “애플리케이션이 응답하지 않았습니다”가 뜬다. 사용자는 일반 채널에 문제를 다시 쓰고, 모더레이터는 오류 내용과 긴급도를 재차 묻는다. 교대할 때는 맥락도 다시 정리해야 한다. 로그인 성공만 확인한 Bot에서 자주 생기는 실패다.
이 글은 Claude Code와 discord.js 14.27.0으로 그 흐름을 바로잡는다. /support는 요청을 구조화하고, /faq는 검토된 짧은 답을 주며, /handoff는 모더레이터만 인수인계 메모를 남기게 한다. 세 명령은 먼저 deferReply()로 접수를 알리고, 처리가 끝나면 editReply()로 비공개 응답을 완성한다.
처음부터 Discord API 용어를 전부 외울 필요는 없다. application command는 Discord 화면에 나타나는 명령이고, interaction은 사용자가 명령을 실행했을 때 Bot이 받는 이벤트다. 초보자가 기억할 순서는 “먼저 응답 예약, 입력 확인, 작업 실행, 응답 완료”다.
이 글에서 만드는 것
latest없이 버전을 고정한 Node 24 LTS 프로젝트/support,/faq,/handoff세 가지 실무 명령- 개발 중 한 서버에만 등록하는 guild commands
- Message Content intent와 Administrator 권한을 쓰지 않는 구조
- token 보호, mention 차단, 반복 가능한 로컬 점검
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"]
첫 버전에는 데이터베이스, LLM, CRM을 넣지 않는다. 명령 계약과 권한 경계, 실패 처리부터 검토한 다음 확장한다. 기능이 적어도 책임 범위가 분명한 Bot이 권한과 복구 절차가 없는 데모보다 운영하기 쉽다.
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이 단순하다.
규칙은 공식 문서를 기준으로 확인한다. 명령 종류, context, 등록 방식은 Discord Application Commands, 지연 응답과 interaction token은 Receiving and Responding to Interactions, 이 예제의 API는 discord.js 14.27.0을 참고한다.
권한, 환경 변수, 최소 아키텍처
Developer Portal에서 Discord application을 만들고 Bot 사용자를 추가한 뒤, bot과 applications.commands scopes가 들어간 초대 URL을 만든다. 처음부터 administrator 권한을 주면 안 된다. 이 Bot은 지원 채널을 볼 수 있고 메시지를 보낼 수 있으면 충분하다. /handoff는 모더레이터 수준 권한, 예를 들어 Manage Messages 권한을 가진 사용자만 실행하게 제한한다.
| 항목 | 값 | 운영 메모 |
|---|---|---|
| Node.js | 이 글은 24 LTS로 통일 | 로컬, CI, 운영 버전을 맞춤 |
| OAuth2 scopes | bot, applications.commands | Bot과 slash command에 필요 |
| Bot permissions | View Channels, Send Messages | 최소 권한에서 시작 |
DISCORD_TOKEN | Bot token | 커밋, 스크린샷, 로그 금지 |
DISCORD_CLIENT_ID | Application ID | 명령 등록에 사용 |
DISCORD_GUILD_ID | 테스트 서버 ID | 개발 중 guild command 등록 |
SUPPORT_CHANNEL_ID | 내부 지원 채널 | Bot이 전송 가능한지 확인 |
Claude Code에는 이렇게 요청할 수 있다. “Node 24와 discord.js 14.27.0으로 /support, /faq, /handoff 지원 Bot을 만들어라. 패키지 버전을 고정하고 .env, guild command, 최소 권한, 지연 ephemeral 응답, mention 차단, 로컬 테스트를 포함하라. 테스트 중 Discord에는 연결하지 마라.” 기능뿐 아니라 보안 경계까지 프롬프트에 넣는 것이 핵심이다.
관련 글로는 환경 변수 관리, 에러 처리 패턴, 코드 리뷰 체크리스트를 함께 읽으면 좋다. 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 --save-exact [email protected] [email protected]
mkdir src
package.json에 type과 start를 추가한다.
{
"name": "discord-support-bot",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "14.27.0",
"dotenv": "17.2.3"
}
}
.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) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
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.editReply("Thanks. Your request was sent to the support team.");
}
async function handleFaq(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const topic = interaction.options.getString("topic", true);
const answers = {
setup: "Install Node.js 24 LTS, 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.editReply(answers[topic]);
}
async function handleHandoff(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.editReply("You need Manage Messages permission to use this command.");
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.editReply("Handoff note created.");
}
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.deferred && !interaction.replied) await interaction.editReply({ content });
else if (interaction.replied) 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으로 Node 24 LTS인지 확인한다. package.json의 engines는 요구 사항에 따라 >=18로 기록하지만, 이 글의 개발과 배포 런타임은 Node 24 LTS로 통일한다. 아래 로컬 검사를 끝내기 전에는 실제 token을 넣거나 npm start로 Discord에 연결할 필요가 없다.
사람의 판단이 남아 있는 세 가지 Use case
Bot은 정보를 수집하고 형식을 맞추는 도구다. 고객이 지원 대상인지, 사건이 정말 긴급한지, 모더레이터 조치가 적절한지는 결정하지 않는다. 각 장면에서 입력, Bot 출력, 사람의 판단을 분리하면 자동화 범위가 분명해진다.
1. 지원 요청 접수
입력: 사용자가 /support summary:"로그인 시 403 발생" severity:high context:"오늘 배포 이후 시작"을 실행한다.
Bot 출력: 사용자에게만 보이는 접수 확인을 보내고, 내부 지원 채널에는 작성자, 심각도, 원래 채널, 요약, 맥락을 보낸다. 전달 전에 mention을 무력화하므로 사용자 입력이 전체 알림을 만들지 않는다.
사람의 판단: 모더레이터가 재현 가능성을 확인하고, 심각도를 수정하며, 담당자를 고른다. Bot은 응답 시간을 약속하거나 사용자가 고른 값만으로 보안 사고를 판정하면 안 된다.
2. 검토된 FAQ 안내
입력: 사용자가 /faq topic:permissions를 실행한다.
Bot 출력: 유지 관리되는 짧은 답을 비공개로 돌려준다. 실제 프로젝트에서는 정책 전문을 코드에 복사하지 말고 기준 문서로 연결한다. 권한 질문이라면 Claude Code 권한 가이드를 기준 페이지로 둘 수 있다.
사람의 판단: 문서 책임자가 답변을 승인하고 운영 정책이 바뀔 때 갱신한다. Bot은 즉석에서 회사 정책을 만들거나 예외를 승인하지 않는다.
3. 모더레이터 인수인계
입력: 모더레이터가 /handoff target:@member note:"마스킹된 오류 로그를 기다리는 중이며 token은 요청하지 말 것"을 실행한다.
Bot 출력: 내부 채널에 대상, 작성자, 정리된 메모가 남는다. 명령 정의에서 일반 사용자에게 명령을 숨기고, handler에서도 Manage Messages 권한을 다시 검사한다.
사람의 판단: 다음 담당자가 메모를 검증하고 사용자에게 연락한 뒤 종료 또는 상향 여부를 정한다. 기록은 맥락을 제공할 뿐 승인 시스템은 아니다.
도입 전후와 ROI 가정
도입 전에는 자유 입력으로 지원이 시작되고, 모더레이터가 오류, 환경, 긴급도를 다시 질문한다. 담당자가 바뀌면 내용을 수동으로 옮긴다. 도입 후에는 Discord가 입력 옵션을 먼저 확인하고 Bot이 같은 필드를 같은 순서로 전달한다. 진단과 우선순위는 여전히 사람이 정하지만 출발점은 일정해진다.
이 차이를 곧바로 “시간 절감”이라고 주장해서는 안 된다. ROI에는 가정이 필요하다. 한 서버에서 월 80건을 받고, 그중 **40%**가 구조화된 입력 덕분에 2분짜리 확인 질문 한 번을 피한다고 가정하면 80 × 0.40 × 2 = 월 64분이다. 개발, 리뷰, 유지에 첫 달 여섯 시간이 들면 첫 달 ROI는 음수다. 요청량, 여러 커뮤니티에서의 재사용, 실수 감소가 유지 비용보다 클 때만 투자 가치가 생긴다. 약속하기 전에 실제 요청 수와 확인 시간을 측정해야 한다.
Claude Code와 사람이 맡을 범위
Claude Code에는 프로젝트 뼈대, 명령 정의, 환경 변수 검증, 로컬 fixture, 변경점 리뷰, 롤백 체크리스트를 맡길 수 있다. 각 권한이 왜 필요한지 설명하게 하고, 소스나 로그에 secret이 출력되는지도 찾게 할 수 있다.
사람은 Discord application 생성, 운영 채널 선택, 권한 승인, token 저장과 교체, FAQ 문구 검토, guild commands를 global commands로 승격할 시점을 맡는다. 사고 대응과 고객 처분도 사람의 책임이다. 실제 token을 프롬프트, 문서, 스크린샷, 터미널 기록에 넣지 않는다.
첫 구현 뒤에는 다음 프롬프트로 검토할 수 있다.
이 discord.js 14.27.0 Bot을 보안에 민감한 변경으로 리뷰하라.
모든 interaction 경로가 deferReply 후 editReply를 수행하는지 확인하라.
Administrator, Message Content intent, latest 의존성, 길이 제한 없는 입력,
mention 가능한 출력, 소스나 로그의 token을 거부하라.
문제마다 파일과 줄을 제시하고 Discord에는 연결하지 마라.
Pitfall: 배포 전에 제거할 실패 요인
token이 Git이나 화면에 노출된다. 이미 유출된 것으로 보고 Developer Portal에서 교체하고, 배포 secret을 바꾸고, 로그를 확인한다. 특정 commit을 지웠다고 이전 token이 안전해지는 것은 아니다. .env는 무시하고 placeholder만 있는 .env.example만 커밋한다.
편의를 위해 Administrator를 요청한다. 이 예제에는 지원 채널의 View Channel과 Send Messages면 충분하다. /handoff는 Manage Messages를 사용자 권한 경계로 쓴다. 바꾸려면 업무상 이유를 먼저 문서화한다.
느린 작업 때문에 interaction이 끝난다. 채널 조회, 데이터베이스, 외부 API는 지연될 수 있다. 먼저 deferReply({ flags: MessageFlags.Ephemeral })를 호출하고 결과를 editReply()로 마친다. 오류 처리도 deferred 상태와 replied 상태를 모두 다뤄야 한다.
사용자 문장이 알림을 만든다. @everyone, 역할 mention, 인코딩된 사용자 mention은 신뢰할 수 없는 입력이다. 전송 메시지에 allowedMentions: { parse: [] }를 유지하고, 저장하거나 전달하는 문자열도 한 번 더 중화한다.
개발 중 global commands를 직접 바꾼다. 먼저 한 테스트 guild에 등록한다. 리뷰된 릴리스 단계에서만 global로 승격한다. 프로세스가 재시작할 때마다 명령을 등록하면 변경과 롤백을 추적하기 어렵다.
FAQ가 오래된 정책이 된다. 답변마다 책임자와 검토일을 둔다. 기준 문서가 바뀌면 같은 pull request에서 Bot도 고치고, 함께 유지할 수 없다면 링크만 반환한다.
보안과 배포 전 체크리스트
- 로컬, CI, 호스팅 모두 Node 24 LTS를 사용한다
discord.js는14.27.0,dotenv는17.2.3으로 고정한다.env를 Git에서 제외하고 과거 기록에도 token이 없다- 초대 링크에는
bot,applications.commandsscopes만 사용한다 - Bot에 Administrator와 Message Content intent가 없다
- 지원 채널에는 필요한 View Channel, Send Messages만 허용한다
/handoff에 기본 권한과 런타임 권한 검사가 모두 있다- 세 명령 모두 비공개 defer 후
editReply()로 끝난다 - 전달하는 입력은 길이가 제한되고 mention을 만들 수 없다
- 로그에 token과 비공개 지원 내용을 남기지 않는다
- guild 등록, 재시작, 롤백, token 교체 절차가 문서화되어 있다
- global command를 등록하기 전에 사람이 검토한다
체크리스트를 통과한 뒤 Gateway 프로세스를 유지하고 환경 변수로 secret을 넣으며 로그를 확인할 수 있는 곳에 배포한다. 플랫폼 이름보다 재시작 방법, 로그, token 담당자가 중요하다. 재사용 가능한 점검표와 구현 템플릿은 ClaudeCodeLab 제품에서 이어서 볼 수 있다.
실제로 테스트한 결과
이번 검증은 로컬 fixture에서만 수행했다. 고정 버전 package.json 파싱, JavaScript 예제 구문 확인, 세 명령 정의 확인, mention 중화 사례 실행, deferReply()와 editReply() 경로, 최소 intents, 지정된 공식 문서 링크를 검사했다. 실제 token은 사용하지 않았다.
실제 Discord application, 테스트 guild, Gateway에는 연결하지 않았고 명령 등록, 채널 권한, 운영 배포도 검증하지 않았다. 따라서 실제 사용자를 처리했거나 문의 왕복과 시간을 줄였다고 주장하지 않는다. 배포 전에는 별도 테스트 서버에서 모더레이터 계정과 일반 계정으로 /support, /faq, /handoff를 각각 실행해야 한다.
관련 글
Claude Code로 Slack Bot 만들기: 문의 triage부터 장애 1차 대응과 일일 리포트까지
Bolt JS, Socket Mode, Slash Command, 보안, 테스트, 운영 체크리스트로 Slack Bot을 구현합니다.
Claude Code로 차트 라이브러리 선택하기: Recharts, Chart.js, D3
Claude Code로 Recharts, Chart.js, D3를 선택하고 실제 데이터에 강한 대시보드를 만듭니다.
Claude Code로 Vue 3 개발하기: TypeScript, Pinia, 테스트 실전
Claude Code로 Vue 3, TypeScript, Pinia, Composable, Vitest를 실무 흐름에 맞게 개선하는 방법.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.