Discord 버튼, 셀렉트 메뉴, 모달 다루기

Discord 버튼, 셀렉트 메뉴, 모달 다루기

슬래시 커맨드로 사용자 입력을 받는 방법을 봤는데요. 사용자가 매번 명령어를 타이핑하는 대신 버튼을 누르거나 메뉴에서 고르게 하면 훨씬 편하겠죠. Discord의 메시지 컴포넌트가 바로 그걸 가능하게 합니다.

이번 글에서는 버튼, 셀렉트 메뉴, 입력 폼인 모달을 Discord 공식 문서처럼 라이브러리 없이 다뤄볼게요. 버튼이나 모달도 결국 슬래시 커맨드와 같은 인터랙션이라, 서버리스 봇 만들기에서 만든 HTTP 인터랙션 워커에 그대로 얹힙니다. 아래 코드는 모두 그 워커의 fetch 핸들러 안에서 돌아가는 코드예요. 서명 검증 뼈대(verifyKey)와 import는 서버리스 편 그대로 두고, 여기서는 컴포넌트 분기만 채웁니다.

import { InteractionType, InteractionResponseType } from "discord-interactions";

컴포넌트는 액션 로우에 담는다

먼저 알아야 할 규칙이 하나 있어요. 버튼이나 셀렉트 메뉴 같은 컴포넌트는 그냥 메시지에 붙이는 게 아니라 액션 로우(Action Row)라는 가로 줄에 담습니다. 한 액션 로우에는 버튼 5개까지, 또는 셀렉트 메뉴 1개를 넣을 수 있고, 한 메시지에는 액션 로우를 5줄까지 둘 수 있어요.

라이브러리 없이 다루면 이 구조가 그대로 드러납니다. components는 액션 로우 객체(type: 1)의 배열이고, 각 액션 로우의 components에 실제 버튼이나 셀렉트를 넣어요. 타입 숫자로 종류를 구분하는데, 1이 액션 로우, 2가 버튼, 3이 셀렉트 메뉴, 4가 텍스트 입력입니다.

버튼 붙이기

슬래시 커맨드 응답에 “확인/취소” 버튼을 달아볼게요. 커맨드 인터랙션에 메시지로 답하면서(CHANNEL_MESSAGE_WITH_SOURCE) data.components에 액션 로우를 넣으면 됩니다.

if (interaction.type === InteractionType.APPLICATION_COMMAND) {
  return Response.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: {
      content: "정말 삭제할까요?",
      components: [
        {
          type: 1, // 액션 로우
          components: [
            { type: 2, style: 1, label: "확인", custom_id: "confirm" },
            { type: 2, style: 4, label: "취소", custom_id: "cancel" },
          ],
        },
      ],
    },
  });
}

버튼(type: 2)의 style 숫자가 색과 의미를 정하는데, 여섯 가지예요. 1 Primary(파랑, 주요 행동), 2 Secondary(회색, 보조), 3 Success(초록, 긍정), 4 Danger(빨강, 되돌릴 수 없는 행동), 그리고 클릭 시 상호작용 대신 URL로 보내는 5 Link와 결제용 6 Premium입니다. 한 줄에 주요 버튼(1)은 하나만 두는 게 보기 좋아요.

여기서 custom_id로 지정한 confirm, cancel이 핵심인데, 이게 버튼을 구분하는 열쇠입니다. 잠시 뒤 다시 설명할게요.

버튼 클릭에 응답하기

사용자가 버튼을 누르면 디스코드가 메시지 컴포넌트 인터랙션을 보냅니다. 슬래시 커맨드와 똑같은 워커로 들어오는데, interaction.typeMESSAGE_COMPONENT인 걸로 걸러내고 어떤 버튼인지는 interaction.data.custom_id로 판단해요.

if (interaction.type === InteractionType.MESSAGE_COMPONENT) {
  if (interaction.data.custom_id === "confirm") {
    return Response.json({
      type: InteractionResponseType.UPDATE_MESSAGE, // 원래 메시지를 그 자리에서 수정
      data: { content: "삭제했어요. ✅", components: [] },
    });
  }
  if (interaction.data.custom_id === "cancel") {
    return Response.json({
      type: InteractionResponseType.UPDATE_MESSAGE,
      data: { content: "취소했어요.", components: [] },
    });
  }
}

여기서 UPDATE_MESSAGE 응답 타입에 주목하세요. 이건 버튼이 달려 있던 원래 메시지를 그 자리에서 수정합니다. components: []로 버튼을 비워서 한 번 누르면 더는 못 누르게 만든 거예요. 원래 메시지를 두고 새 메시지로 답하고 싶으면 CHANNEL_MESSAGE_WITH_SOURCE를, 시간이 걸리는 작업이라 응답을 미뤄야 하면 DEFERRED_UPDATE_MESSAGE를 먼저 돌려보내면 됩니다.

링크 버튼과 비활성 버튼

버튼이 항상 상호작용을 일으키는 건 아니에요. 링크 버튼은 클릭하면 외부 URL로 보내기만 하고 봇에게는 아무 인터랙션도 주지 않습니다. 그래서 custom_id 대신 url을 쓰고 style5(Link)로 둡니다. 또 한 번 누르면 끝나는 버튼은 disabled: true로 회색 처리해두면 같은 행동을 두 번 하는 걸 막을 수 있어요. 둘 다 components 배열에 넣는 버튼 객체의 모양만 다를 뿐입니다.

// 링크 버튼(인터랙션이 오지 않음)과 비활성 버튼을 한 줄에 담은 예
const row = {
  type: 1,
  components: [
    { type: 2, style: 5, label: "문서 보기", url: "https://example.com" },
    { type: 2, style: 2, label: "처리됨", custom_id: "done", disabled: true },
  ],
};

셀렉트 메뉴로 고르게 하기

선택지가 여러 개라면 버튼보다 셀렉트 메뉴가 깔끔합니다. 문자열 셀렉트는 type: 3이고, options에 최대 25개까지 넣을 수 있어요. 버튼과 똑같이 인터랙션 응답의 data.components에 담아 보냅니다.

return Response.json({
  type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
  data: {
    content: "좋아하는 색은?",
    components: [
      {
        type: 1,
        components: [
          {
            type: 3, // 문자열 셀렉트
            custom_id: "color",
            placeholder: "색을 골라주세요",
            options: [
              { label: "빨강", value: "red" },
              { label: "파랑", value: "blue" },
              { label: "초록", value: "green" },
            ],
          },
        ],
      },
    ],
  },
});

사용자가 고르면 역시 MESSAGE_COMPONENT 인터랙션이 오는데, 이번엔 고른 값이 interaction.data.values 배열에 담깁니다.

if (
  interaction.type === InteractionType.MESSAGE_COMPONENT &&
  interaction.data.custom_id === "color"
) {
  const choice = interaction.data.values[0]; // 예: "blue"
  return Response.json({
    type: InteractionResponseType.UPDATE_MESSAGE,
    data: { content: `${choice}를 골랐군요!`, components: [] },
  });
}

값이 배열로 오는 이유가 있어요. 기본은 하나만 고르지만, min_valuesmax_values를 주면 여러 개를 동시에 고르게 할 수 있거든요. 관심 태그를 여러 개 선택받는 UI 같은 데 딱입니다. 그리고 옵션을 직접 나열하는 문자열 셀렉트 말고도, 서버 멤버를 고르는 유저 셀렉트, 역할을 고르는 롤 셀렉트, 채널을 고르는 채널 셀렉트가 따로 있어서 “관리할 역할을 선택하세요” 같은 UI를 options 없이 만들 수 있습니다.

모달로 입력 폼 띄우기

버튼과 셀렉트가 “고르기”라면, 자유 입력이 필요할 때는 모달(modal)을 씁니다. 화면 가운데 뜨는 팝업 폼이에요. 모달은 그 자체가 하나의 응답 타입(MODAL)이라, 버튼이나 슬래시 커맨드 인터랙션에 이걸 돌려주면 폼이 뜹니다.

if (
  interaction.type === InteractionType.MESSAGE_COMPONENT &&
  interaction.data.custom_id === "rename"
) {
  return Response.json({
    type: InteractionResponseType.MODAL,
    data: {
      custom_id: "nickModal",
      title: "닉네임 변경",
      components: [
        {
          type: 1,
          components: [
            {
              type: 4, // 텍스트 입력
              custom_id: "nickname",
              label: "새 닉네임",
              style: 1, // 1=한 줄(Short), 2=여러 줄(Paragraph)
              required: true,
            },
          ],
        },
      ],
    },
  });
}

텍스트 입력도 액션 로우에 담아야 한다는 점은 버튼과 똑같습니다. 한 가지 주의할 건, 모달을 띄우는 응답은 그 자체가 인터랙션에 대한 응답이라, 그전에 메시지 응답이나 지연 응답을 먼저 보내면 안 된다는 거예요.

사용자가 폼을 제출하면 이번엔 모달 제출 인터랙션(MODAL_SUBMIT)이 옵니다. 입력값은 보낼 때와 똑같은 중첩 구조 안에 value로 담겨 와요.

if (
  interaction.type === InteractionType.MODAL_SUBMIT &&
  interaction.data.custom_id === "nickModal"
) {
  const nickname = interaction.data.components[0].components[0].value;
  return Response.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: { content: `닉네임을 "${nickname}"(으)로 바꿀게요!` },
  });
}

값을 꺼낼 때 components를 한 겹 파고드는 건, 텍스트 입력도 액션 로우에 담겨 있기 때문이에요. 그다음엔 보통 메시지로 “바꿨어요” 하고 답해주면 됩니다.

custom_id로 라우팅하기

지금까지 모든 컴포넌트에 custom_id를 붙였는데요. 이 값이 인터랙션이 돌아올 때 interaction.data.custom_id로 그대로 실려 오기 때문에, 어떤 컴포넌트가 눌렸는지 식별하는 라우팅 키 역할을 합니다. 한 메시지 안에서 컴포넌트끼리 custom_id가 겹치면 안 돼요.

여기서 유용한 패턴이 하나 있습니다. custom_id는 1~100자 문자열이라, 단순한 이름뿐 아니라 데이터를 인코딩할 수 있어요. 예를 들어 게시물 ID를 함께 담아 delete:42처럼 만들면, 응답을 처리할 때 잘라서 씁니다.

// 버튼 만들 때: 대상 ID를 custom_id에 실어 보냅니다
const button = {
  type: 2,
  style: 4,
  label: "삭제",
  custom_id: `delete:${postId}`,
};

// 응답 처리할 때: custom_id를 잘라 대상을 알아냅니다
if (interaction.type === InteractionType.MESSAGE_COMPONENT) {
  const [action, id] = interaction.data.custom_id.split(":");
  if (action === "delete") {
    // id로 해당 게시물을 삭제
  }
}

이렇게 하면 버튼마다 핸들러를 따로 두지 않고도 어느 대상에 대한 클릭인지 알 수 있어서, 목록의 각 항목에 버튼을 다는 UI를 깔끔하게 만들 수 있습니다.

마치며

버튼, 셀렉트 메뉴, 모달까지 인터랙티브 UI의 3종 세트를 라이브러리 없이 다뤘습니다. 핵심은 모든 컴포넌트가 액션 로우(type: 1)에 담기고, custom_id로 식별되며, 클릭에 대한 응답은 응답 타입으로 갈린다는 것이었어요. 원래 메시지를 고치는 UPDATE_MESSAGE, 모달을 여는 MODAL, 새 메시지를 보내는 CHANNEL_MESSAGE_WITH_SOURCE처럼요. 참고로 더 풍부한 레이아웃이 필요하면 컨테이너와 섹션을 지원하는 “Components V2”라는 새 시스템도 있지만, 대부분은 오늘 다룬 액션 로우로 충분합니다.

이 코드가 올라가는 HTTP 인터랙션 워커의 전체 뼈대는 서버리스 봇 만들기에, 슬래시 커맨드 자체는 인터랙션과 슬래시 커맨드에 있으니 함께 보면 그림이 완성됩니다. 다음으로 버튼을 아무나 누르지 못하게 막는 권한과 역할도 짚어두면 좋고요.

더 자세한 내용은 Discord 메시지 컴포넌트 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

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

Discord