Discord 인터랙션과 슬래시 커맨드 완전 정복

Discord 인터랙션과 슬래시 커맨드 완전 정복

디스코드 봇을 만들어 서버에 초대하고 REST로 디스코드에 요청도 보내봤다면, 이제 반대로 사용자가 우리 앱을 부르는 쪽을 볼 차례입니다. /를 입력하면 뜨는 슬래시 커맨드가 대표적이죠. 그런데 슬래시 커맨드는 생각보다 깊어서, 옵션으로 입력을 받고, 시간이 걸리는 작업을 처리하고, 나만 볼 수 있는 답장을 보낼 수 있어요.

이번 글에서는 이걸 Discord 공식 문서처럼 라이브러리 없이 파헤쳐 봅니다. 커맨드 등록은 REST 요청으로, 사용자가 부를 때 받고 응답하는 건 서버리스 봇 만들기에서 만든 HTTP 인터랙션 워커에 얹어서요.

인터랙션이란 무엇인가

인터랙션(interaction)은 사용자가 우리 앱의 기능과 상호작용할 때 디스코드가 보내주는 이벤트입니다. 종류는 네 가지예요.

  • 애플리케이션 커맨드: /로 부르는 슬래시 커맨드, 유저나 메시지를 우클릭하는 컨텍스트 메뉴 커맨드
  • 메시지 컴포넌트: 버튼, 셀렉트 메뉴 등 메시지에 붙는 인터랙티브 요소
  • 모달: 폼처럼 입력을 받는 팝업 창
  • 자동완성: 옵션 입력 중 실시간 추천

이번 글은 그중 애플리케이션 커맨드에 집중합니다. 버튼과 모달 같은 컴포넌트는 버튼과 셀렉트 메뉴, 모달에서 따로 다뤄요.

세 가지 커맨드 타입

애플리케이션 커맨드에는 세 종류가 있고, 등록할 때 type 숫자로 구분합니다.

  • type: 1 (CHAT_INPUT): /를 입력하면 뜨는 슬래시 커맨드. 옵션, 서브커맨드, 자동완성까지 지원하는 가장 기능이 풍부한 타입입니다. type을 생략하면 기본값이 이거예요.
  • type: 2 (USER): 사용자를 우클릭하면 나타나는 컨텍스트 메뉴. 인자가 없고 대상 사용자를 그대로 넘겨받아요.
  • type: 3 (MESSAGE): 메시지를 우클릭하면 나타나는 컨텍스트 메뉴. 대상 메시지를 넘겨받습니다.

대부분의 봇은 슬래시 커맨드로 시작하니, 이 글도 슬래시 커맨드를 기준으로 설명할게요.

커맨드 등록: 전역과 길드

메시지를 REST로 직접 보내봤듯이, 커맨드를 등록하는 것도 결국 같은 REST 요청이에요. 커맨드 정의를 JSON 배열로 만들어 한 번에 PUT하면 됩니다.

그 전에 .env부터 손봅니다. 봇 토큰에 더해, 어느 앱의 커맨드인지 식별할 Application ID와, 어느 서버에 등록할지 정할 서버 ID가 필요하거든요.

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

DISCORD_GUILD_ID는 디스코드 앱에서 개발자 모드를 켠 뒤 서버 아이콘을 우클릭해 “서버 ID 복사”로 얻습니다.

deploy-commands.js
const commands = [
  { name: "ping", description: "퐁으로 응답합니다" },
  {
    name: "greet",
    description: "입력한 이름으로 인사합니다",
    options: [
      {
        type: 3,
        name: "name",
        description: "인사할 상대 이름",
        required: true,
      },
    ],
  },
];

// 길드 커맨드는 PUT 한 번으로 통째로 등록됩니다 (즉시 반영)
const res = await fetch(
  `https://discord.com/api/v10/applications/${process.env.DISCORD_CLIENT_ID}/guilds/${process.env.DISCORD_GUILD_ID}/commands`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bot ${process.env.DISCORD_TOKEN}`,
      "Content-Type": "application/json",
      "User-Agent": "DiscordBot (https://example.com, 1.0)",
    },
    body: JSON.stringify(commands),
  },
);
console.log(res.status); // 200

greet의 옵션에서 type: 3이 문자열(STRING)이에요(옵션 타입은 잠시 뒤에 정리합니다). 그리고 PUT전체 교체라, 이 배열이 그 서버의 커맨드 목록을 통째로 덮어씁니다(빠진 커맨드는 삭제돼요).

커맨드를 등록하는 범위는 두 가지입니다.

  • 길드 커맨드: /applications/{app}/guilds/{guild}/commands. 특정 서버에만 등록되고 즉시 반영됩니다. 개발 중 테스트에 딱이에요.
  • 전역 커맨드: /applications/{app}/commands. 봇이 들어간 모든 서버에 적용되지만, 디스코드 전체에 퍼지는 데 최대 1시간이 걸립니다. 실제 배포용이에요.

그래서 보통 개발할 때는 길드 커맨드로 빠르게 확인하고, 배포 시점에 전역으로 바꿉니다. 참고로 전역 슬래시 커맨드는 앱당 최대 100개로 제한되니 한 번에 묶어 올리는 게 좋아요.

옵션으로 입력 받기

greet처럼 options 배열로 입력값을 받습니다. 각 옵션은 { type, name, description, required } 꼴이고, type 숫자가 입력 종류를 정해요. 3(STRING), 4(INTEGER), 5(BOOLEAN), 6(USER), 7(CHANNEL), 8(ROLE), 9(MENTIONABLE), 10(NUMBER), 11(ATTACHMENT) 중에서 고르고, 한 커맨드에 최대 25개까지 넣을 수 있어요.

한 가지 규칙이 있는데, 필수 옵션(required: true)은 선택 옵션보다 배열에서 앞에 와야 합니다. 디스코드 UI가 필수 입력을 먼저 받도록 강제하기 때문이에요.

커맨드에 응답하기

등록한 커맨드를 사용자가 부르면, 디스코드가 애플리케이션 커맨드 인터랙션을 우리 워커로 POST합니다. 서버리스 봇에서 만든 fetch 핸들러 안에서, interaction.type으로 걸러내고 interaction.data.name으로 어떤 커맨드인지 구분해요.

// 서버리스 워커의 fetch 핸들러 안 (서명 검증 뼈대는 서버리스 편 그대로)
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
  if (interaction.data.name === "ping") {
    return Response.json({
      type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
      data: { content: "퐁! 🏓" },
    });
  }

  if (interaction.data.name === "greet") {
    const name = interaction.data.options.find((o) => o.name === "name").value;
    return Response.json({
      type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
      data: { content: `안녕하세요, ${name}님! 👋` },
    });
  }
}

옵션 값은 interaction.data.options에서 꺼내는데, 각 원소가 { name, type, value }라 이름으로 찾아 value를 씁니다. 등록할 때 정한 옵션 이름과 정확히 같아야 해요.

여기서 가장 중요한 제약이 3초 규칙이에요. 인터랙션은 받은 뒤 3초 안에 응답해야 하는데, 데이터베이스 조회나 외부 API 호출처럼 느린 작업이면 넘기기 십상이죠. 그럴 땐 먼저 지연 응답(DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE)을 돌려 “생각 중” 상태로 시간을 벌고(최대 15분), 결과가 준비되면 팔로업으로 채워 넣습니다.

if (interaction.type === InteractionType.APPLICATION_COMMAND) {
  ctx.waitUntil(handleSlowWork(interaction, env)); // 응답 후 결과를 채웁니다
  return Response.json({
    type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
  });
}

ctx.waitUntil로 응답을 먼저 보낸 뒤 무거운 작업을 이어서 하고, 인터랙션 토큰으로 원래 응답을 수정하는 팔로업 패턴은 서버리스 봇 만들기에서 코드까지 다룹니다.

나만 볼 수 있는 답장을 보내고 싶다면 ephemeral을 씁니다. data.flags64를 넣기만 하면 돼요. 권한 안내나 에러 메시지처럼 다른 사람에게 보일 필요 없는 응답에 유용합니다.

return Response.json({
  type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
  data: { content: "이 메시지는 당신만 볼 수 있어요.", flags: 64 }, // 64 = ephemeral
});

커맨드 이름은 영문으로, 라벨은 현지화로

커맨드 이름을 ping, greet처럼 영문으로 짓는 건 관례예요. 한국인 대상 서버라도 사용자가 /를 누르고 칠 때 한영 전환이 없도록 영문 이름을 씁니다. 그래도 한국어 사용자에게 한글 라벨을 보여주고 싶다면, 이름은 영문으로 두고 현지화를 얹으면 됩니다. 등록 JSON에 name_localizationsdescription_localizations를 넣어요.

const greet = {
  name: "greet",
  name_localizations: { ko: "인사" }, // 한국어 클라이언트엔 '인사'로 보임
  description: "Greets the given name",
  description_localizations: { ko: "입력한 이름으로 인사합니다" },
};

이러면 canonical 이름은 영문(greet)으로 유지되면서, 한국어 사용자에게는 /인사로 표시됩니다. 봇이 보내는 답장은 처음부터 한국어로 둬도 전혀 문제없고요.

인터랙션을 받는 두 가지 방법

지금까지는 HTTP 엔드포인트로 인터랙션을 받았는데요. 사실 디스코드는 인터랙션을 받는 방법을 두 가지 제공하고, 둘은 상호 배타적입니다.

  • HTTP 엔드포인트: 디스코드가 내가 등록한 URL로 인터랙션을 POST해 줍니다. 평소엔 떠 있을 필요가 없어 Cloudflare Workers 같은 서버리스에 어울리고, 이 글이 쓴 방식이에요.
  • 게이트웨이: WebSocket 연결로 INTERACTION_CREATE 이벤트를 받습니다. 봇이 항상 켜져 있어야 하는 대신, 인터랙션뿐 아니라 메시지나 멤버 입장 같은 서버 이벤트까지 받을 수 있어요.

즉 슬래시 커맨드만 받으면 되면 HTTP 엔드포인트가, 서버에서 벌어지는 이벤트에도 반응해야 하면 게이트웨이가 답입니다. 게이트웨이 쪽 이야기는 게이트웨이로 실시간 이벤트 받기에서 다뤄요.

마치며

슬래시 커맨드를 라이브러리 없이 훑었습니다. REST로 커맨드를 등록하고, 옵션으로 입력을 받고, 3초 규칙과 지연 응답, ephemeral, 현지화까지요. 등록은 REST 요청 하나, 응답은 HTTP 인터랙션 워커의 JSON 하나로 끝난다는 게 핵심이에요.

한 걸음 더 나아가면, 클릭만으로 동작하는 버튼과 셀렉트 메뉴, 모달이 기다리고 있어요. 그리고 이 응답 코드가 올라가는 워커의 전체 뼈대(서명 검증, 배포, 엔드포인트 등록)는 서버리스 봇 만들기에 있으니 함께 보면 그림이 완성됩니다.

더 자세한 내용은 Discord 인터랙션 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

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

Discord