[DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기

2025. 6. 11. 17:15·DiscordJS 개발 튜토리얼

해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. Discord.js TypeScript Boilerplate

안녕하세요! 지난 시간에는 봇을 실제 서버에 배포해서 24/7 운영하는 방법을 알아봤습니다. 이제 우리 봇은 안정적으로 서비스를 제공할 수 있게 되었네요.

이번 시간에는 사용자와의 상호작용을 한층 더 풍부하게 만들어줄 셀렉트 메뉴(Select Menu)와 모달(Modal)에 대해 알아보겠습니다. 지금까지는 주로 슬래시 명령어와 버튼을 사용했는데, 이제는 드롭다운 메뉴로 여러 선택지를 제공하거나, 팝업 창으로 복잡한 정보를 입력받을 수 있게 될 거예요.

특히 설정 메뉴나 다중 선택이 필요한 상황에서 이런 UI 컴포넌트들은 정말 유용합니다. 사용자 경험도 훨씬 좋아지고, 봇이 더욱 전문적으로 보이게 만들어주는 요소들이죠.

셀렉트 메뉴(Select Menu) 기본 이해하기

셀렉트 메뉴는 드롭다운 형태로 여러 선택지를 제공하는 UI 컴포넌트입니다. 버튼과 비슷하지만 공간을 절약하면서도 더 많은 옵션을 제공할 수 있어요.

셀렉트 메뉴에는 여러 종류가 있습니다:

  • StringSelectMenu: 일반적인 텍스트 선택지
  • UserSelectMenu: 사용자 선택
  • RoleSelectMenu: 역할 선택
  • ChannelSelectMenu: 채널 선택
  • MentionableSelectMenu: 멘션 가능한 대상 선택

가장 많이 사용하는 StringSelectMenu부터 알아보겠습니다.

기본 셀렉트 메뉴 만들기

먼저 간단한 음식 선택 메뉴를 만들어보겠습니다.

// src/commands/food-menu.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  StringSelectMenuBuilder,
  ActionRowBuilder,
  EmbedBuilder,
} from "discord.js";

export const data = new SlashCommandBuilder()
  .setName("음식메뉴")
  .setDescription("오늘 먹을 음식을 선택해보세요!");

export async function execute(interaction: ChatInputCommandInteraction) {
  const selectMenu = new StringSelectMenuBuilder()
    .setCustomId("food-select")
    .setPlaceholder("음식을 선택해주세요...")
    .addOptions([
      {
        label: "한식",
        description: "김치찌개, 불고기, 비빔밥 등",
        value: "korean",
        emoji: "🍚",
      },
      {
        label: "중식",
        description: "짜장면, 탕수육, 마파두부 등",
        value: "chinese",
        emoji: "🥢",
      },
      {
        label: "일식",
        description: "초밥, 라멘, 돈카츠 등",
        value: "japanese",
        emoji: "🍣",
      },
      {
        label: "양식",
        description: "파스타, 피자, 스테이크 등",
        value: "western",
        emoji: "🍝",
      },
      {
        label: "분식",
        description: "떡볶이, 순대, 어묵 등",
        value: "snack",
        emoji: "🌭",
      },
    ]);

  const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
    selectMenu
  );

  const embed = new EmbedBuilder()
    .setTitle("🍽️ 오늘의 음식 선택")
    .setDescription("아래 메뉴에서 원하는 음식 종류를 선택해주세요!")
    .setColor(0xffb347);

  await interaction.reply({
    embeds: [embed],
    components: [row],
  });
}

셀렉트 메뉴 상호작용 처리하기

이제 사용자가 셀렉트 메뉴에서 선택했을 때의 동작을 처리해야 합니다. 기존의 InteractionCreate 이벤트 핸들러를 확장해보겠습니다.

// src/index.ts (또는 기존 InteractionCreate 핸들러)
client.on("interactionCreate", async (interaction) => {
  // 기존 슬래시 명령어 처리
  if (interaction.isChatInputCommand()) {
    // ...existing code...
  }

  // 셀렉트 메뉴 상호작용 처리
  if (interaction.isStringSelectMenu()) {
    if (interaction.customId === "food-select") {
      await handleFoodSelection(interaction);
    }
  }
});

async function handleFoodSelection(interaction: StringSelectMenuInteraction) {
  const selectedValue = interaction.values[0]; // 선택된 첫 번째 값

  const foodRecommendations = {
    korean: ["김치찌개", "불고기", "비빔밥", "삼겹살", "냉면"],
    chinese: ["짜장면", "탕수육", "마파두부", "깐풍기", "짬뽕"],
    japanese: ["초밥", "라멘", "돈카츠", "우동", "규동"],
    western: ["파스타", "피자", "스테이크", "햄버거", "리조또"],
    snack: ["떡볶이", "순대", "어묵", "김밥", "튀김"],
  };

  const recommendations =
    foodRecommendations[selectedValue as keyof typeof foodRecommendations];
  const randomFood =
    recommendations[Math.floor(Math.random() * recommendations.length)];

  const embed = new EmbedBuilder()
    .setTitle("🎲 음식 추천 결과")
    .setDescription(`오늘은 **${randomFood}** 어떠세요?`)
    .setColor(0x57f287)
    .setFooter({ text: "맛있는 식사 되세요!" });

  await interaction.update({
    embeds: [embed],
    components: [], // 컴포넌트 제거
  });
}

다중 선택 가능한 셀렉트 메뉴

셀렉트 메뉴는 여러 항목을 동시에 선택할 수도 있습니다. 취미를 선택하는 메뉴를 만들어보겠습니다.

// src/commands/hobby-select.ts
export const data = new SlashCommandBuilder()
  .setName("취미선택")
  .setDescription("관심있는 취미를 선택해보세요! (복수 선택 가능)");

export async function execute(interaction: ChatInputCommandInteraction) {
  const selectMenu = new StringSelectMenuBuilder()
    .setCustomId("hobby-select")
    .setPlaceholder("취미를 선택해주세요... (최대 3개)")
    .setMinValues(1) // 최소 선택 개수
    .setMaxValues(3) // 최대 선택 개수
    .addOptions([
      {
        label: "게임",
        description: "컴퓨터, 모바일, 콘솔 게임",
        value: "gaming",
        emoji: "🎮",
      },
      {
        label: "독서",
        description: "소설, 에세이, 전문서적",
        value: "reading",
        emoji: "📚",
      },
      {
        label: "운동",
        description: "헬스, 러닝, 요가, 수영",
        value: "exercise",
        emoji: "💪",
      },
      {
        label: "음악",
        description: "듣기, 연주, 작곡",
        value: "music",
        emoji: "🎵",
      },
      {
        label: "요리",
        description: "베이킹, 한식, 양식",
        value: "cooking",
        emoji: "👨‍🍳",
      },
      {
        label: "여행",
        description: "국내외 여행, 캠핑",
        value: "travel",
        emoji: "✈️",
      },
    ]);

  const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
    selectMenu
  );

  const embed = new EmbedBuilder()
    .setTitle("🎭 취미 선택")
    .setDescription("아래 메뉴에서 관심있는 취미를 선택해주세요!")
    .setColor(0x57f287);

  await interaction.reply({
    embeds: [embed],
    components: [row],
  });
}

모달(Modal) 활용하기

모달은 팝업 창 형태로 사용자로부터 복잡한 정보를 입력받을 수 있는 컴포넌트입니다. 여러 개의 텍스트 입력 필드를 포함할 수 있어서 설문조사나 피드백 수집 등에 아주 유용해요.

기본 모달 만들기

사용자 프로필을 설정하는 모달을 만들어보겠습니다.

// src/commands/profile-setup.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  ModalBuilder,
  TextInputBuilder,
  ActionRowBuilder,
  TextInputStyle,
} from "discord.js";

export const data = new SlashCommandBuilder()
  .setName("프로필설정")
  .setDescription("사용자 프로필을 설정합니다");

export async function execute(interaction: ChatInputCommandInteraction) {
  const modal = new ModalBuilder()
    .setCustomId("profile-modal")
    .setTitle("프로필 설정");

  // 닉네임 입력 필드
  const nicknameInput = new TextInputBuilder()
    .setCustomId("nickname-input")
    .setLabel("표시할 닉네임")
    .setStyle(TextInputStyle.Short) // 한 줄 입력
    .setMaxLength(20)
    .setRequired(true)
    .setPlaceholder("예: 디스호스트");

  // 자기소개 입력 필드
  const bioInput = new TextInputBuilder()
    .setCustomId("bio-input")
    .setLabel("자기소개")
    .setStyle(TextInputStyle.Paragraph) // 여러 줄 입력
    .setMaxLength(500)
    .setRequired(false)
    .setPlaceholder("자신을 소개해보세요...");

  // 나이 입력 필드
  const ageInput = new TextInputBuilder()
    .setCustomId("age-input")
    .setLabel("나이")
    .setStyle(TextInputStyle.Short)
    .setMaxLength(3)
    .setRequired(false)
    .setPlaceholder("예: 25");

  // 지역 입력 필드
  const locationInput = new TextInputBuilder()
    .setCustomId("location-input")
    .setLabel("거주 지역")
    .setStyle(TextInputStyle.Short)
    .setMaxLength(50)
    .setRequired(false)
    .setPlaceholder("예: 서울특별시");

  // 취미 입력 필드
  const hobbiesInput = new TextInputBuilder()
    .setCustomId("hobbies-input")
    .setLabel("취미/관심사")
    .setStyle(TextInputStyle.Paragraph)
    .setMaxLength(200)
    .setRequired(false)
    .setPlaceholder("예: 게임, 독서, 영화감상");

  // ActionRow로 감싸기 (한 row당 하나의 TextInput만 가능)
  const firstRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    nicknameInput
  );
  const secondRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    bioInput
  );
  const thirdRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    ageInput
  );
  const fourthRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    locationInput
  );
  const fifthRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    hobbiesInput
  );

  modal.addComponents(firstRow, secondRow, thirdRow, fourthRow, fifthRow);

  await interaction.showModal(modal);
}

모달 제출 처리하기

사용자가 모달을 제출했을 때의 처리 로직을 추가해보겠습니다.

// src/index.ts (InteractionCreate 이벤트 핸들러에 추가)
client.on("interactionCreate", async (interaction) => {
  // ...existing code...

  // 모달 제출 처리
  if (interaction.isModalSubmit()) {
    if (interaction.customId === "profile-modal") {
      await handleProfileModal(interaction);
    }
  }
});

async function handleProfileModal(interaction: ModalSubmitInteraction) {
  // 입력된 값들 가져오기
  const nickname = interaction.fields.getTextInputValue("nickname-input");
  const bio =
    interaction.fields.getTextInputValue("bio-input") || "자기소개가 없습니다.";
  const age = interaction.fields.getTextInputValue("age-input") || "비공개";
  const location =
    interaction.fields.getTextInputValue("location-input") || "비공개";
  const hobbies =
    interaction.fields.getTextInputValue("hobbies-input") ||
    "특별한 취미가 없습니다.";

  // 데이터베이스에 저장 (Prisma 사용 예시)
  try {
    await prisma.userProfile.upsert({
      where: { discordId: interaction.user.id },
      create: {
        discordId: interaction.user.id,
        nickname,
        bio,
        age: age !== "비공개" ? parseInt(age) : null,
        location: location !== "비공개" ? location : null,
        hobbies: hobbies !== "특별한 취미가 없습니다." ? hobbies : null,
      },
      update: {
        nickname,
        bio,
        age: age !== "비공개" ? parseInt(age) : null,
        location: location !== "비공개" ? location : null,
        hobbies: hobbies !== "특별한 취미가 없습니다." ? hobbies : null,
      },
    });

    const embed = new EmbedBuilder()
      .setTitle("✅ 프로필 설정 완료")
      .setDescription("프로필이 성공적으로 업데이트되었습니다!")
      .addFields([
        { name: "닉네임", value: nickname, inline: true },
        { name: "나이", value: age, inline: true },
        { name: "지역", value: location, inline: true },
        { name: "자기소개", value: bio },
        { name: "취미/관심사", value: hobbies },
      ])
      .setColor(0x57f287)
      .setThumbnail(interaction.user.displayAvatarURL())
      .setTimestamp();

    await interaction.reply({ embeds: [embed], ephemeral: true });
  } catch (error) {
    console.error("프로필 저장 중 오류:", error);

    await interaction.reply({
      content: "프로필 저장 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
      ephemeral: true,
    });
  }
}

User/Role/Channel Select Menu 활용하기

StringSelectMenu 외에도 Discord의 다른 요소들을 선택할 수 있는 특별한 메뉴들이 있습니다.

역할 관리 메뉴 만들기

관리자가 사용자에게 역할을 부여하는 기능을 만들어보겠습니다.

// src/commands/role-manager.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  UserSelectMenuBuilder,
  RoleSelectMenuBuilder,
  ActionRowBuilder,
  PermissionsBitField,
} from "discord.js";

export const data = new SlashCommandBuilder()
  .setName("역할관리")
  .setDescription("사용자에게 역할을 부여하거나 제거합니다")
  .setDefaultMemberPermissions(PermissionsBitField.Flags.ManageRoles);

export async function execute(interaction: ChatInputCommandInteraction) {
  if (!interaction.inGuild()) {
    return interaction.reply({
      content: "이 명령어는 서버에서만 사용할 수 있습니다.",
      ephemeral: true,
    });
  }

  // 1단계: 사용자 선택
  const userSelect = new UserSelectMenuBuilder()
    .setCustomId("role-user-select")
    .setPlaceholder("역할을 변경할 사용자를 선택하세요...");

  const userRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
    userSelect
  );

  await interaction.reply({
    content: "먼저 역할을 변경할 사용자를 선택해주세요:",
    components: [userRow],
    ephemeral: true,
  });
}

채널 선택 메뉴

공지사항을 보낼 채널을 선택하는 기능을 만들어보겠습니다.

// src/commands/announcement.ts
export const data = new SlashCommandBuilder()
  .setName("공지작성")
  .setDescription("공지사항을 작성하고 채널을 선택합니다")
  .addStringOption((option) =>
    option.setName("내용").setDescription("공지 내용").setRequired(true)
  )
  .setDefaultMemberPermissions(PermissionsBitField.Flags.ManageMessages);

export async function execute(interaction: ChatInputCommandInteraction) {
  const content = interaction.options.getString("내용", true);

  const channelSelect = new ChannelSelectMenuBuilder()
    .setCustomId("announcement-channel-select")
    .setPlaceholder("공지를 보낼 채널을 선택하세요...")
    .setChannelTypes([ChannelType.GuildText, ChannelType.GuildAnnouncement]);

  const row = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
    channelSelect
  );

  // 공지 내용을 임시 저장하기 위해 customId에 포함시키거나 별도 저장소 사용
  await interaction.reply({
    content: `다음 공지를 보낼 채널을 선택해주세요:\n\`\`\`${content}\`\`\``,
    components: [row],
    ephemeral: true,
  });
}

복합적인 상호작용 시나리오

셀렉트 메뉴와 모달을 조합해서 더 복잡한 상호작용을 만들어보겠습니다. 티켓 시스템을 예로 들어볼게요.

티켓 생성 시스템

// src/commands/ticket.ts
export const data = new SlashCommandBuilder()
  .setName("티켓")
  .setDescription("고객 지원 티켓을 생성합니다");

export async function execute(interaction: ChatInputCommandInteraction) {
  const categorySelect = new StringSelectMenuBuilder()
    .setCustomId("ticket-category-select")
    .setPlaceholder("문의 유형을 선택해주세요...")
    .addOptions([
      {
        label: "일반 문의",
        description: "서비스 이용 관련 일반적인 질문",
        value: "general",
        emoji: "❓",
      },
      {
        label: "기술 지원",
        description: "봇 오류나 기술적 문제",
        value: "technical",
        emoji: "🔧",
      },
      {
        label: "신고",
        description: "부적절한 행동이나 규칙 위반 신고",
        value: "report",
        emoji: "🚨",
      },
      {
        label: "제안",
        description: "새로운 기능이나 개선사항 제안",
        value: "suggestion",
        emoji: "💡",
      },
    ]);

  const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
    categorySelect
  );

  const embed = new EmbedBuilder()
    .setTitle("🎫 티켓 생성")
    .setDescription("문의하실 내용의 유형을 선택해주세요.")
    .setColor(0x5865f2);

  await interaction.reply({
    embeds: [embed],
    components: [row],
    ephemeral: true,
  });
}

// 카테고리 선택 후 모달 표시
async function handleTicketCategorySelect(
  interaction: StringSelectMenuInteraction
) {
  const category = interaction.values[0];

  const modal = new ModalBuilder()
    .setCustomId(`ticket-modal-${category}`)
    .setTitle("티켓 상세 정보");

  const titleInput = new TextInputBuilder()
    .setCustomId("ticket-title")
    .setLabel("제목")
    .setStyle(TextInputStyle.Short)
    .setMaxLength(100)
    .setRequired(true);

  const descriptionInput = new TextInputBuilder()
    .setCustomId("ticket-description")
    .setLabel("상세 설명")
    .setStyle(TextInputStyle.Paragraph)
    .setMaxLength(1000)
    .setRequired(true);

  const priorityInput = new TextInputBuilder()
    .setCustomId("ticket-priority")
    .setLabel("우선순위 (낮음/보통/높음/긴급)")
    .setStyle(TextInputStyle.Short)
    .setValue("보통")
    .setRequired(true);

  const firstRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    titleInput
  );
  const secondRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    descriptionInput
  );
  const thirdRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
    priorityInput
  );

  modal.addComponents(firstRow, secondRow, thirdRow);

  await interaction.showModal(modal);
}

상호작용 제한시간과 에러 처리

UI 컴포넌트들은 15분의 제한시간이 있습니다. 이를 고려한 에러 처리도 중요해요.

// 상호작용 만료 처리
setTimeout(async () => {
  try {
    await interaction.editReply({
      content: "이 상호작용은 만료되었습니다.",
      components: [],
    });
  } catch (error) {
    // 이미 다른 상호작용으로 업데이트되었거나 삭제된 경우
    console.log("상호작용 만료 처리 중 오류:", error);
  }
}, 15 * 60 * 1000); // 15분

// 에러 핸들링 래퍼 함수
async function safeInteractionReply(interaction: any, options: any) {
  try {
    if (interaction.replied || interaction.deferred) {
      await interaction.editReply(options);
    } else {
      await interaction.reply(options);
    }
  } catch (error) {
    console.error("상호작용 응답 중 오류:", error);

    // 백업 응답 시도
    try {
      await interaction.followUp({
        content: "응답 처리 중 오류가 발생했습니다.",
        ephemeral: true,
      });
    } catch (followUpError) {
      console.error("백업 응답도 실패:", followUpError);
    }
  }
}

마무리하며

이번 시간에는 셀렉트 메뉴와 모달을 활용해서 사용자와 더욱 풍부한 상호작용을 구현하는 방법을 알아봤습니다. 이런 UI 컴포넌트들을 잘 활용하면 봇의 사용성을 크게 향상시킬 수 있어요.

특히 설정 메뉴, 설문조사, 티켓 시스템 등에서 이런 요소들이 정말 빛을 발합니다. 사용자 입장에서도 명령어를 일일이 입력하는 것보다 클릭 몇 번으로 원하는 작업을 할 수 있으니 훨씬 편리하죠.

다음 시간에는 사용자 반응(이모지) 기반 기능 만들기에 대해 알아보겠습니다. 메시지에 이모지 반응을 추가하거나 제거할 때 특정 동작을 수행하는 기능들을 구현해보겠어요. 역할 부여, 투표 시스템 등 다양한 활용 방법이 있으니 기대해주세요!

'DiscordJS 개발 튜토리얼' 카테고리의 다른 글

[DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작  (0) 2025.06.12
[DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자!  (2) 2025.06.11
[DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기  (2) 2025.06.10
[DiscordJS 봇 개발 튜토리얼] 7. 역할과 권한 체크 구현하기: 봇에게 질서를 부여하자!  (1) 2025.06.09
[DiscordJS 봇 개발 튜토리얼] 6. 이벤트 핸들링 마스터하기: 봇을 살아 움직이게 만드는 비법  (1) 2025.06.08
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
  • [DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작
  • [DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자!
  • [DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기
  • [DiscordJS 봇 개발 튜토리얼] 7. 역할과 권한 체크 구현하기: 봇에게 질서를 부여하자!
디스호스트
디스호스트
쉽고 안정적인 디스코드 봇 호스팅 서비스, 디스호스트의 기술 블로그입니다. 디스호스트는 24시간 구동되는 서버를 통해 디스코드 봇을 대신 구동시켜 드리는 서비스를 제공하고 있습니다.
  • 디스호스트
    디스호스트 기술 블로그
    디스호스트
  • 블로그 메뉴

    • 홈
    • 디스호스트 사용 가이드
    • 디스코드 봇 호스팅, 24시간 서버 구동
    • 분류 전체보기 (39) N
      • 디스코드 (6)
      • 디스호스트 가이드 (11)
      • 봇 개발 팁 (10)
        • Discord.js (8)
        • Discord.py (1)
      • DiscordJS 개발 튜토리얼 (12) N
  • 링크

    • 디스호스트
    • 디스호스트 패널
  • hELLO· Designed By정상우.v4.10.3
디스호스트
[DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기
상단으로

티스토리툴바