discord.js로 디스코드 봇 직접 만들기

discord.js로 디스코드 봇 직접 만들기

디스코드를 쓰다 보면 “이런 거 자동으로 처리해 주는 봇이 있으면 좋겠다” 싶은 순간이 한 번쯤은 있으셨을 텐데요. 출석 체크, 명령어 한 줄로 정보 조회, 새 멤버 환영 인사처럼 반복적인 일을 봇에게 맡기면 커뮤니티 운영이 한결 수월해집니다.

예전에 Discord MCP 서버로 AI에게 커뮤니티 운영 맡기기에서는 기성 봇을 AI의 손발로 빌려 쓰는 방법을 다뤘는데요. 이번에는 한 발 더 들어가서, discord.js라는 Node.js 라이브러리로 봇의 동작을 직접 코드로 짜는 법을 처음부터 따라가 보겠습니다. 봇을 등록하고, 온라인 상태로 만들고, /핑 같은 슬래시 커맨드를 만들어 응답하는 것까지 한 사이클을 완성하는 게 목표예요.

봇 애플리케이션 등록하고 토큰 받기

코드를 짜기 전에 디스코드 쪽에 “나 이런 봇 만들 거예요”라고 등록부터 해야 합니다. Discord Developer Portal에 접속해서 New Application 버튼으로 새 애플리케이션을 하나 만들어 주세요.

애플리케이션이 만들어지면 왼쪽 메뉴의 Bot 탭으로 이동합니다. Reset Token 버튼을 누르면 봇 토큰이 발급되는데요. 이 토큰은 봇의 비밀번호와 같아서 화면을 벗어나면 다시 볼 수 없으니 안전한 곳에 복사해 두세요. 혹시 유출됐다면 같은 버튼으로 즉시 재발급하면 기존 토큰은 무효가 됩니다.

마지막으로 봇을 내 서버에 초대합니다. OAuth2 > URL Generator에서 bot 스코프와 applications.commands 스코프를 함께 선택하고, 아래 권한 목록에서 Send Messages 정도만 골라 줍니다. 생성된 URL을 브라우저에서 열어 초대할 서버를 선택하면 봇이 들어옵니다. 아직은 오프라인 상태로 보일 텐데, 이제부터 코드로 깨워 보겠습니다.

프로젝트 셋업하기

먼저 프로젝트 폴더를 만들고 discord.js를 설치합니다.

mkdir my-discord-bot && cd my-discord-bot
bun init -y
bun add discord.js

npm을 쓴다면 npm install discord.js로도 됩니다. 설치하면 이런 출력을 볼 수 있어요.

결과
installed discord.js@14.26.4
23 packages installed

이 글은 discord.js 14 버전을 기준으로 합니다. 메이저 버전마다 API가 꽤 달라지는 라이브러리라, 13 이하 예제를 보고 따라 하면 막히는 부분이 생길 수 있으니 버전을 확인해 주세요.

토큰 같은 비밀 값은 코드에 직접 박지 않고 .env 파일로 빼는 게 안전합니다. Bun은 .env 파일을 자동으로 읽어 process.env에 넣어 주니 별도 라이브러리도 필요 없어요.

.env
DISCORD_TOKEN=여기에_봇_토큰
DISCORD_CLIENT_ID=애플리케이션_ID
DISCORD_GUILD_ID=내_서버_ID

세 값이 각각 어디서 오는지 정리하면 이렇습니다.

  • DISCORD_TOKEN — 봇의 비밀번호입니다. 앞서 Bot 탭의 Reset Token으로 발급받은 값이에요. 유출되면 봇이 통째로 탈취되니 가장 조심해야 합니다.
  • DISCORD_CLIENT_ID — 애플리케이션(봇)의 고유 ID입니다. Developer Portal의 General Information 탭에서 Application ID를 복사하면 됩니다. 커맨드를 어느 봇에 등록할지 식별하는 데 쓰여요.
  • DISCORD_GUILD_ID — 커맨드를 등록할 서버(길드)의 ID입니다. 디스코드 앱 설정에서 개발자 모드를 켠 뒤 서버 아이콘을 우클릭해 “서버 ID 복사”로 얻습니다. 개발 중 테스트 서버를 가리키며, 잠시 뒤 커맨드 등록에서 쓰입니다.

이 중 DISCORD_TOKEN은 비밀 값이므로 .env 파일은 절대 깃에 올리면 안 됩니다. .gitignore.env를 꼭 추가해 두세요. 참고로 process.env로 읽어온 값은 항상 문자열이라는 점도 기억해 두면 좋습니다.

봇을 온라인 상태로 만들기

이제 봇을 깨워 보겠습니다. index.js 파일을 만들고 다음 코드를 작성합니다.

index.js
const { Client, Events, GatewayIntentBits } = require("discord.js");

// 봇 클라이언트를 생성하면서 어떤 이벤트를 받을지(intents) 지정합니다
const client = new Client({ intents: [GatewayIntentBits.Guilds] });

// 봇이 준비되면 딱 한 번 실행됩니다
client.once(Events.ClientReady, (readyClient) => {
  console.log(`로그인 완료! ${readyClient.user.tag}`);
});

// 토큰으로 디스코드에 로그인합니다
client.login(process.env.DISCORD_TOKEN);

bun run index.js로 실행하면 봇이 디스코드에 연결되면서 콘솔에 “로그인 완료!” 메시지가 찍히고, 서버의 멤버 목록에서 봇이 온라인으로 바뀝니다. 첫 봇이 살아 움직이는 순간이에요. 🎉

여기서 한 가지 짚고 넘어갈 부분이 있는데요. 준비 완료 이벤트를 등록할 때 client.once("ready", ...)처럼 문자열을 직접 쓰는 예제를 많이 보셨을 거예요. 그런데 discord.js 14의 최신 버전에서는 이 이벤트의 내부 이름이 ready에서 clientReady로 바뀌었습니다. 문자열을 직접 박아두면 이런 변화에 깨질 수 있으니, 위 코드처럼 Events.ClientReady 같은 열거형(enum) 상수를 쓰는 습관을 들이는 게 안전합니다. 라이브러리가 알아서 올바른 값으로 연결해 주거든요.

인텐트가 대체 뭘까

방금 코드에서 intents라는 게 나왔는데요. 인텐트(Intents)는 봇이 디스코드로부터 어떤 종류의 이벤트를 받을지 미리 신청하는 구독 목록이라고 생각하면 됩니다. 디스코드는 수많은 이벤트가 오가는 곳이라, 봇이 필요한 것만 골라 받도록 해서 불필요한 트래픽을 줄이는 구조예요.

예를 들어 서버와 채널 관련 이벤트가 필요하면 GatewayIntentBits.Guilds를, 메시지 생성 이벤트가 필요하면 GatewayIntentBits.GuildMessages를 추가합니다. 우리가 만들 슬래시 커맨드 봇은 서버 정보만 있으면 되니 Guilds 하나로 충분해요.

여기서 초보자가 자주 헷갈리는 부분이 있습니다. 채널의 메시지 내용을 읽으려면 MessageContent라는 특별한 인텐트가 필요한데, 이건 “특권 인텐트(Privileged Intent)“라서 Developer Portal에서 따로 켜줘야 합니다. 하지만 슬래시 커맨드는 메시지 내용을 읽는 게 아니라 디스코드가 구조화된 상호작용으로 직접 전달해 주기 때문에 이 권한이 전혀 필요 없어요. 슬래시 커맨드만 쓸 거라면 특권 인텐트는 건드리지 않아도 된다는 점, 기억해 두면 좋습니다.

슬래시 커맨드 등록하기

슬래시 커맨드는 사용자가 채팅창에 /를 입력하면 자동완성으로 떠오르는 명령어인데요. 한 가지 알아둘 점은, 커맨드의 정의(이름, 설명, 옵션)를 디스코드 서버에 미리 등록해두는 절차와, 사용자가 그 커맨드를 실행했을 때 봇이 응답하는 처리가 서로 다른 단계라는 거예요.

먼저 등록부터 해보겠습니다. deploy-commands.js 파일을 따로 만듭니다.

deploy-commands.js
const { REST, Routes, SlashCommandBuilder } = require("discord.js");

// 등록할 커맨드들을 정의합니다
const commands = [
  new SlashCommandBuilder().setName("핑").setDescription("퐁으로 응답합니다"),
  new SlashCommandBuilder()
    .setName("인사")
    .setDescription("입력한 이름으로 인사합니다")
    .addStringOption((option) =>
      option.setName("이름").setDescription("인사할 상대 이름").setRequired(true),
    ),
].map((command) => command.toJSON());

const rest = new REST().setToken(process.env.DISCORD_TOKEN);

(async () => {
  try {
    console.log(`슬래시 커맨드 ${commands.length}개를 등록합니다...`);
    await rest.put(
      // 특정 서버(길드)에만 등록 — 즉시 반영됩니다
      Routes.applicationGuildCommands(
        process.env.DISCORD_CLIENT_ID,
        process.env.DISCORD_GUILD_ID,
      ),
      { body: commands },
    );
    console.log("등록 완료!");
  } catch (error) {
    console.error(error);
  }
})();

SlashCommandBuilder는 커맨드 정의를 만들어 주는 도우미예요. setName으로 이름을, setDescription으로 설명을 지정하고, toJSON()을 호출하면 디스코드 API가 이해하는 형태로 변환됩니다. 위 커맨드를 변환하면 실제로 이런 JSON이 만들어져요.

핑 커맨드의 toJSON() 결과
{
  "options": [],
  "name": "핑",
  "description": "퐁으로 응답합니다",
  "type": 1
}

bun run deploy-commands.js를 실행하면 커맨드가 서버에 등록됩니다. 여기서 applicationGuildCommands를 쓴 이유가 중요한데요. 커맨드를 등록하는 방법은 두 가지입니다. 특정 서버(길드)에만 등록하면 즉시 반영되어 개발 중에 바로 확인할 수 있지만, 모든 서버에 적용되는 전역(global) 커맨드로 등록하면 디스코드 전체에 퍼지는 데 최대 1시간까지 걸립니다. 그래서 개발할 때는 내 테스트 서버에만 등록하는 길드 커맨드를 쓰고, 실제 배포할 때 전역 커맨드(Routes.applicationCommands)로 바꾸는 게 일반적인 흐름이에요.

커맨드에 응답하기

커맨드를 등록했다고 봇이 알아서 답하지는 않습니다. 사용자가 커맨드를 실행하면 디스코드가 봇에게 “상호작용(interaction)이 들어왔어요”라고 알려주는데, 이걸 받아서 처리하는 코드를 index.js에 추가해야 해요.

index.js (이어서 추가)
client.on(Events.InteractionCreate, async (interaction) => {
  // 슬래시 커맨드가 아닌 상호작용은 무시합니다
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === "핑") {
    await interaction.reply("퐁! 🏓");
  }

  if (interaction.commandName === "인사") {
    // 등록할 때 정의한 '이름' 옵션 값을 꺼냅니다
    const name = interaction.options.getString("이름");
    await interaction.reply(`안녕하세요, ${name}님! 👋`);
  }
});

isChatInputCommand()로 슬래시 커맨드만 걸러내는 게 첫 단계예요. 버튼 클릭이나 메뉴 선택 같은 다른 상호작용도 같은 이벤트로 들어오기 때문에, 이 검사를 빼먹으면 엉뚱한 코드가 실행될 수 있습니다. 그다음 interaction.commandName으로 어떤 커맨드인지 구분하고, interaction.reply()로 답장을 보냅니다.

인사 커맨드에서는 사용자가 입력한 옵션 값을 interaction.options.getString("이름")으로 꺼내 쓰고 있어요. 등록할 때 addStringOption으로 정의한 이름 옵션과 정확히 같은 이름으로 가져와야 합니다.

봇을 다시 실행(bun run index.js)하고 디스코드 채팅창에 /핑을 입력해 보세요. 봇이 “퐁! 🏓“이라고 답하고, /인사 이름:달레를 보내면 “안녕하세요, 달레님! 👋“이라고 반갑게 맞아 줄 거예요.

한 가지 주의할 점은, 상호작용은 받은 뒤 3초 안에 응답해야 한다는 거예요. 데이터베이스 조회나 외부 API 호출처럼 시간이 걸리는 작업이라면 먼저 await interaction.deferReply()로 “생각 중…” 상태를 표시해 시간을 벌고, 결과가 준비되면 interaction.editReply()로 답을 채워 넣으면 됩니다.

Gateway와 HTTP 인터랙션, 두 가지 연결 방식

지금까지 만든 봇은 client.login()으로 디스코드와 연결을 맺었는데요. 이게 바로 게이트웨이(Gateway) 방식입니다. 봇이 디스코드 서버와 웹소켓(WebSocket) 연결을 항상 열어두고, 새 메시지, 멤버 입장, 리액션 추가 같은 모든 이벤트를 실시간으로 받아보는 구조예요. discord.js의 기본 동작이자, 우리가 Events.InteractionCreate로 커맨드를 받을 수 있었던 이유이기도 합니다.

이 방식의 핵심은 연결이 끊기지 않게 프로세스가 계속 살아 있어야 한다는 점이에요. 터미널을 닫거나 노트북을 끄면 웹소켓이 끊기면서 봇이 곧장 오프라인이 됩니다. 그래서 게이트웨이 봇은 24시간 켜져 있는 서버가 필요합니다.

그런데 슬래시 커맨드처럼 “사용자가 명령을 보냈을 때만 반응하면 되는” 봇이라면, 굳이 연결을 항상 열어둘 필요가 있을까요? 이런 경우를 위해 디스코드는 HTTP 인터랙션 방식도 제공합니다. 봇이 디스코드에 붙는 게 아니라, 거꾸로 디스코드가 슬래시 커맨드 같은 상호작용이 생길 때마다 내가 등록해 둔 URL로 요청을 보내주는 방식이에요. 평소에는 아무것도 떠 있지 않다가 요청이 올 때만 깨어나면 되니, Cloudflare Workers나 Vercel 같은 서버리스 환경에 올릴 수 있다는 게 가장 큰 장점입니다.

대신 두 가지 제약이 있어요. 우선 HTTP 방식은 상호작용만 받습니다. 슬래시 커맨드, 버튼 클릭, 모달 제출은 처리할 수 있지만, 채널에 올라온 모든 메시지를 감시하거나 멤버 입장을 실시간으로 감지하는 건 불가능해요. 그런 이벤트가 필요하면 게이트웨이를 써야 합니다. 또 하나는 요청 검증입니다. 내 URL은 인터넷에 공개되어 있으니 디스코드가 보낸 진짜 요청인지 확인해야 하는데, 디스코드는 모든 요청에 Ed25519 서명을 실어 보내고 우리는 이걸 공개 키로 검증해야 합니다.

이 검증은 discord-interactions 라이브러리의 verifyKey로 처리할 수 있습니다. 블로그가 올라가 있는 Cloudflare Workers를 예로 들면 이런 모습이에요.

worker.js (Cloudflare Workers)
import {
  verifyKey,
  InteractionType,
  InteractionResponseType,
} from "discord-interactions";

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const signature = request.headers.get("X-Signature-Ed25519");
    const timestamp = request.headers.get("X-Signature-Timestamp");
    const body = await request.text(); // 파싱하지 않은 원본 문자열이어야 합니다

    // 디스코드가 보낸 요청이 맞는지 Ed25519 서명을 검증합니다
    const isValid =
      signature &&
      timestamp &&
      (await verifyKey(body, signature, timestamp, env.DISCORD_PUBLIC_KEY));
    if (!isValid) {
      return new Response("잘못된 서명입니다", { status: 401 });
    }

    const interaction = JSON.parse(body);

    // 디스코드는 URL을 등록할 때 PING을 보내 살아 있는지 확인합니다
    if (interaction.type === InteractionType.PING) {
      return Response.json({ type: InteractionResponseType.PONG });
    }

    // 슬래시 커맨드에 응답합니다
    if (interaction.type === InteractionType.APPLICATION_COMMAND) {
      return Response.json({
        type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
        data: { content: "퐁! 🏓" },
      });
    }
  },
};

여기서 새로 등장한 DISCORD_PUBLIC_KEY는 Developer Portal의 General Information 탭에 있는 Public Key 값입니다. 그리고 같은 화면의 Interactions Endpoint URL 칸에 내 워커 주소를 등록하면, 디스코드가 곧바로 PING을 한 번 보내서 정상 응답하는지 확인해요. 위 코드가 PONG으로 답하기 때문에 등록이 통과됩니다. 서명 검증에서 한 가지 흔히 놓치는 부분은, verifyKey에 넘기는 본문이 JSON.parse를 거치지 않은 원본 문자열이어야 한다는 점이에요. 미리 파싱해버리면 서명이 맞지 않아 계속 401로 거부됩니다.

정리하면, 메시지 감시나 입장 환영처럼 다양한 이벤트가 필요하면 게이트웨이를, 슬래시 커맨드 위주의 가벼운 봇이고 서버를 따로 운영하기 부담스럽다면 HTTP 인터랙션을 고르면 됩니다. 이 글의 나머지는 가장 일반적이고 할 수 있는 일이 많은 게이트웨이 봇을 기준으로, 이걸 어떻게 24시간 띄워두는지 이어가겠습니다.

서버에 올려 24시간 운영하기

게이트웨이 봇을 실제로 돌리려면 내 컴퓨터가 아니라 늘 켜져 있는 서버가 필요합니다. 보통은 저렴한 VPS(가상 서버) 하나를 빌려서 올리는데요. 큰 흐름은 코드를 서버로 옮기고, 의존성을 설치하고, 환경 변수를 채운 뒤, 프로세스가 죽어도 알아서 되살아나게 만드는 순서입니다.

먼저 서버에 접속해 코드를 가져옵니다. 깃 저장소가 있다면 git clone이 가장 깔끔해요. 그다음 의존성을 설치하고 환경 변수를 준비합니다.

git clone https://github.com/내계정/my-discord-bot.git
cd my-discord-bot
bun install

서버에서는 .env 파일을 직접 만들어 토큰을 넣어주면 됩니다. 로컬의 .env는 깃에 올리지 않았으니 서버에서 새로 작성해야 해요. 슬래시 커맨드는 봇을 띄우기 전에 bun run deploy-commands.js로 한 번 등록해 줍니다. 실제 서비스라면 이때 길드 커맨드 대신 모든 서버에 적용되는 전역 커맨드로 바꿔 등록하는 게 보통이에요.

이제 봇을 계속 살려둘 차례입니다. 그냥 bun run index.js로 띄우면 SSH 접속을 끊는 순간 프로세스도 같이 종료되고, 봇이 에러로 죽어도 아무도 되살려주지 않아요. 그래서 프로세스 관리자를 씁니다. 리눅스 서버라면 추가 설치 없이 쓸 수 있는 systemd가 가장 견고한 선택이에요. 다음 같은 서비스 파일을 /etc/systemd/system/discord-bot.service에 만듭니다.

/etc/systemd/system/discord-bot.service
[Unit]
Description=My Discord Bot
After=network.target

[Service]
WorkingDirectory=/home/ubuntu/my-discord-bot
ExecStart=/home/ubuntu/.bun/bin/bun run index.js
EnvironmentFile=/home/ubuntu/my-discord-bot/.env
Restart=always
RestartSec=5
User=ubuntu

[Install]
WantedBy=multi-user.target

Restart=always가 핵심인데요. 봇 프로세스가 어떤 이유로든 종료되면 systemd가 5초 뒤에 자동으로 다시 띄워줍니다. EnvironmentFile.env를 그대로 읽어들이니 토큰 주입도 한 번에 해결돼요. 이제 서비스를 등록하고 시작합니다.

sudo systemctl daemon-reload
sudo systemctl enable --now discord-bot   # 부팅 시 자동 시작 + 지금 바로 실행
sudo systemctl status discord-bot         # 잘 돌고 있는지 확인
journalctl -u discord-bot -f              # 실시간 로그 확인

systemd 설정이 번거롭게 느껴진다면 Node.js 생태계에서 널리 쓰이는 PM2도 좋은 대안입니다. 전역으로 설치한 뒤 명령어 몇 줄이면 끝나거든요.

bun add --global pm2
pm2 start index.js --name discord-bot   # 봇 실행
pm2 startup                             # 부팅 시 자동 시작 설정
pm2 save                                # 현재 프로세스 목록 저장
pm2 logs discord-bot                    # 로그 확인

어느 쪽을 쓰든 목표는 같아요. SSH 세션과 무관하게 봇이 백그라운드에서 돌고, 죽으면 자동으로 되살아나며, 서버가 재부팅돼도 알아서 다시 켜지게 만드는 것입니다.

마치며

지금까지 discord.js로 봇을 등록하고, 온라인으로 띄우고, 슬래시 커맨드를 만들어 응답한 다음, 서버에 올려 24시간 운영하는 데까지 한 바퀴를 돌아봤습니다. 핵심 흐름을 다시 정리하면, 인텐트로 필요한 이벤트를 신청해 클라이언트를 만들고, 커맨드 정의를 서버에 등록한 뒤, InteractionCreate 이벤트에서 실행을 처리하는 세 단계예요. 그리고 다양한 이벤트가 필요하면 늘 켜져 있는 서버에 게이트웨이 봇을 올리고, 슬래시 커맨드만 가볍게 처리하면 된다면 HTTP 인터랙션으로 서버리스에 올리는 선택지가 있다는 것도 함께 봤습니다.

여기서 더 나아가고 싶다면 커맨드마다 파일을 나눠 관리하는 커맨드 핸들러 구조, 깔끔한 카드 형태로 정보를 보여주는 임베드(Embed), 그리고 버튼이나 선택 메뉴 같은 인터랙티브 컴포넌트를 다음 주제로 살펴보면 좋아요. 봇을 코드로 직접 짜는 게 부담스럽고 운영 자동화가 목적이라면, AI에게 디스코드 운영을 맡기는 Discord MCP 서버 활용법도 함께 보시면 도움이 됩니다.

더 자세한 내용은 discord.js 공식 가이드를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord