해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. Discord.js TypeScript Boilerplate
안녕하세요! 지난 시간에는 명령어에 쿨타임을 설정하고 예상치 못한 오류를 효과적으로 처리하는 방법을 배웠습니다. 덕분에 우리 봇은 한층 더 안정적이고 사용자 친화적으로 발전했네요.
이번 시간에는 봇이 보내는 메시지를 훨씬 더 보기 좋고 다채롭게 만들어주는 임베드(Embed) 메시지와, 사용자와의 상호작용을 한 단계 끌어올릴 수 있는 버튼(Button) 컴포넌트에 대해 알아보겠습니다. 단순한 텍스트 응답을 넘어, 봇과의 대화가 더욱 즐거워질 준비, 되셨나요?
평범한 메시지는 이제 그만! 임베드(Embed) 메시지 활용하기
임베드 메시지는 디스코드에서 일반 텍스트 메시지보다 훨씬 풍부한 정보를 담을 수 있는 특별한 형식의 메시지입니다. 제목, 설명, 색상, 이미지, 썸네일, 필드, 푸터 등 다양한 요소를 활용하여 마치 잘 디자인된 카드처럼 정보를 표현할 수 있습니다.
봇이 중요한 정보를 전달하거나, 명령어의 결과를 깔끔하게 보여주고 싶을 때 임베드는 아주 강력한 도구가 됩니다. discord.js
에서는 EmbedBuilder
클래스를 사용하여 쉽게 임베드를 만들고 커스터마이징할 수 있습니다.
EmbedBuilder
기본 사용법
EmbedBuilder
는 여러 메서드를 체이닝 방식으로 호출하여 임베드의 각 부분을 설정합니다.
setTitle(title)
: 임베드의 제목을 설정합니다.setDescription(description)
: 임베드의 주요 내용을 설정합니다. Markdown도 지원합니다.setColor(color)
: 임베드 좌측에 표시될 세로줄의 색상을 설정합니다. 16진수 색상 코드, 특정 색상 이름 (예: 'Random', 'Blue') 등을 사용할 수 있습니다.setAuthor({ name, iconURL, url })
: 임베드 상단에 작성자 정보를 표시합니다. 이름, 아이콘 URL, 클릭 시 이동할 URL을 설정할 수 있습니다.setThumbnail(url)
: 임베드 우측 상단에 작은 이미지를 표시합니다.setImage(url)
: 임베드 본문에 큰 이미지를 표시합니다.addFields(...fields)
: 여러 개의 필드를 추가합니다. 각 필드는{ name: string, value: string, inline?: boolean }
형태의 객체입니다.inline: true
로 설정하면 여러 필드가 한 줄에 나란히 표시될 수 있습니다.setFooter({ text, iconURL })
: 임베드 하단에 푸터 텍스트와 아이콘을 표시합니다.setTimestamp(timestamp?)
: 임베드 푸터 옆에 타임스탬프를 표시합니다. 인자를 생략하면 현재 시간으로 설정됩니다.setURL(url)
: 제목을 클릭했을 때 이동할 URL을 설정합니다.
예제: /정보
명령어에 임베드 적용하기
지난 시간에 만들었던 /정보 서버
명령어를 임베드를 사용하여 더 보기 좋게 만들어 봅시다.
명령어 파일 수정:
src/commands/info.ts
파일을 열고execute
함수 내서버
서브커맨드 부분을 수정합니다.// src/commands/info.ts import { SlashCommandBuilder, ChatInputCommandInteraction, Guild, EmbedBuilder } from 'discord.js'; // EmbedBuilder 추가 // ... (data 정의는 동일) ... export async function execute(interaction: ChatInputCommandInteraction) { const subcommand = interaction.options.getSubcommand(); if (subcommand === '사용자') { // ... (사용자 정보 로직 - 이전과 동일하게 두거나, 여기도 임베드로 개선 가능) ... const user = interaction.options.getUser('대상', true); const member = interaction.guild?.members.cache.get(user.id); if (!member) { await interaction.reply({ content: '해당 사용자를 찾을 수 없습니다.', ephemeral: true }); return; } const userEmbed = new EmbedBuilder() .setColor('Random') // 랜덤 색상 .setTitle(\`👤 \${user.username}님의 정보\`) .setThumbnail(user.displayAvatarURL({ dynamic: true })) // 동적 프로필 이미지 (gif 등 지원) .addFields( { name: 'ID', value: user.id, inline: true }, { name: '태그', value: user.tag, inline: true }, { name: '계정 생성일', value: user.createdAt.toLocaleDateString('ko-KR'), inline: false }, { name: '서버 참여일', value: member.joinedAt ? member.joinedAt.toLocaleDateString('ko-KR') : '알 수 없음', inline: false } ) .setTimestamp() .setFooter({ text: \`요청자: \${interaction.user.username}\`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }); await interaction.reply({ embeds: [userEmbed] }); } else if (subcommand === '서버') { const guild = interaction.guild as Guild; if (!guild) { await interaction.reply({ content: '서버 정보를 가져올 수 없습니다. (DM에서는 사용 불가)', ephemeral: true }); return; } await guild.members.fetch(); // 멤버 수 정확히 가져오기 const owner = await guild.fetchOwner(); const serverEmbed = new EmbedBuilder() .setColor(0x0099FF) // 16진수 색상 코드 (파란색 계열) .setTitle(\`🏰 \${guild.name} 서버 정보\`) .setThumbnail(guild.iconURL({ dynamic: true })) // 서버 아이콘 .addFields( { name: 'ID', value: guild.id, inline: true }, { name: '서버 주인', value: owner.user.tag, inline: true }, { name: '멤버 수', value: \`총 \${guild.memberCount}명\`, inline: true }, // (온라인: \${guild.presences.cache.filter(p => p.status !== 'offline').size}명) 등 추가 가능 { name: '채널 수', value: \`텍스트: \${guild.channels.cache.filter(c => c.isTextBased()).size}개, 음성: \${guild.channels.cache.filter(c => c.isVoiceBased()).size}개\`, inline: false }, { name: '역할 수', value: \`\${guild.roles.cache.size}개\`, inline: true }, { name: '이모지 수', value: \`\${guild.emojis.cache.size}개\`, inline: true }, { name: '생성일', value: guild.createdAt.toLocaleDateString('ko-KR'), inline: false } ) .setTimestamp() .setFooter({ text: \`요청자: \${interaction.user.username}\`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }); await interaction.reply({ embeds: [serverEmbed] }); } }
봇 실행 및 테스트: 봇을 재시작하고 디스코드에서
/정보 서버
와/정보 사용자
명령어를 실행해보세요. 이전보다 훨씬 깔끔하고 보기 좋은 형태로 정보가 표시될 겁니다!응답을 보낼 때는
interaction.reply()
의embeds
속성에EmbedBuilder
인스턴스를 배열로 담아 전달합니다. ({ embeds: [myEmbed] }
)
사용자와의 다음 단계: 버튼 (Buttons) 추가하기
버튼은 사용자가 메시지 아래에 있는 버튼을 클릭하여 봇과 상호작용할 수 있게 하는 기능입니다. 예를 들어, "예/아니오" 선택, 페이지 넘기기, 특정 작업 실행 등을 버튼으로 구현할 수 있습니다.
discord.js
에서는 ButtonBuilder
를 사용하여 버튼을 만들고, ActionRowBuilder
를 사용하여 이 버튼들을 한 줄에 배치합니다. 버튼 클릭 이벤트는 Events.InteractionCreate
이벤트 핸들러에서 interaction.isButton()
으로 감지하여 처리합니다.
ButtonBuilder
기본 사용법
setCustomId(id)
: 버튼을 식별하는 고유 ID를 설정합니다. 이 ID를 통해 어떤 버튼이 클릭되었는지 구분합니다.setLabel(label)
: 버튼에 표시될 텍스트를 설정합니다.setStyle(style)
: 버튼의 스타일(색상)을 설정합니다.ButtonStyle
열거형 값을 사용합니다.ButtonStyle.Primary
: 파란색 (기본)ButtonStyle.Secondary
: 회색ButtonStyle.Success
: 초록색ButtonStyle.Danger
: 빨간색ButtonStyle.Link
: URL로 이동하는 링크 버튼 (이 경우setURL()
도 필요)
setEmoji(emoji)
: 버튼에 이모지를 추가합니다.setDisabled(disabled)
: 버튼을 비활성화할지 여부를 설정합니다. (기본값:false
)setURL(url)
:ButtonStyle.Link
스타일일 때, 클릭 시 이동할 URL을 설정합니다.
예제: /투표
명령어 만들기 (버튼 활용)
간단한 찬반 투표 기능을 가진 /투표
명령어를 만들어 봅시다. 사용자가 투표 주제를 입력하면, 봇이 임베드 메시지와 함께 "찬성"과 "반대" 버튼을 표시하고, 각 버튼이 클릭될 때마다 콘솔에 로그를 남기는 예제입니다. (실제 투표 수 집계는 다음 단계에서 다룰 수 있습니다.)
명령어 파일 생성:
src/commands/vote.ts
파일을 새로 만듭니다.// src/commands/vote.ts import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType // ActionRowBuilder에 버튼을 담을 때 필요 } from 'discord.js'; export const data = new SlashCommandBuilder() .setName('투표') .setDescription('간단한 찬반 투표를 시작합니다.') .addStringOption(option => option.setName('주제') .setDescription('투표할 주제를 입력해주세요.') .setRequired(true)); export async function execute(interaction: ChatInputCommandInteraction) { const topic = interaction.options.getString('주제', true); const voteEmbed = new EmbedBuilder() .setColor(0x5865F2) // 디스코드 보라색 .setTitle(\`🗳️ 투표: \${topic}\`) .setDescription('아래 버튼을 눌러 찬성 또는 반대 의견을 표시해주세요!') .setTimestamp() .setFooter({ text: \`투표 시작자: \${interaction.user.username}\`, iconURL: interaction.user.displayAvatarURL() }); // 버튼 생성 const approveButton = new ButtonBuilder() .setCustomId('vote_approve') // 각 버튼을 구별할 고유 ID .setLabel('찬성 👍') .setStyle(ButtonStyle.Success); // 초록색 버튼 const disapproveButton = new ButtonBuilder() .setCustomId('vote_disapprove') .setLabel('반대 👎') .setStyle(ButtonStyle.Danger); // 빨간색 버튼 // ActionRowBuilder에 버튼들을 추가 (한 줄에 최대 5개까지 가능) // ActionRowBuilder<ButtonBuilder> 타입 명시 const row = new ActionRowBuilder<ButtonBuilder>() .addComponents(approveButton, disapproveButton); await interaction.reply({ embeds: [voteEmbed], components: [row] // components 속성에 ActionRow 배열을 전달 }); }
명령어 등록:
src/commands/index.ts
파일에vote
명령어를 추가합니다.// src/commands/index.ts import * as ping from "./ping"; import * as greet from "./greet"; import * as info from "./info"; import * as vote from "./vote"; // vote 명령어 import 추가 export const commands = { ping, greet, info, vote, // commands 객체에 vote 추가 };
버튼 상호작용 처리:
src/index.ts
파일의InteractionCreate
이벤트 핸들러를 수정하여 버튼 클릭을 감지하고 처리합니다.// src/index.ts (일부 발췌) // ... (client, commands 등 기존 코드) ... client.on(Events.InteractionCreate, async (interaction) => { try { if (interaction.isChatInputCommand()) { const command = commands[interaction.commandName as keyof typeof commands]; if (!command) { console.error(\`No command matching \${interaction.commandName} was found.\`); await interaction.reply({ content: '알 수 없는 명령어입니다.', ephemeral: true }); return; } await command.execute(interaction); } else if (interaction.isButton()) { // 버튼 상호작용 처리 const customId = interaction.customId; if (customId === 'vote_approve') { // ephemeral: true 로 설정하면 클릭한 사용자에게만 보임 await interaction.reply({ content: '찬성표를 던졌습니다!', ephemeral: true }); console.log(\`\${interaction.user.tag}님이 '\${interaction.message.embeds[0]?.title}' 투표에 찬성했습니다.\`); } else if (customId === 'vote_disapprove') { await interaction.reply({ content: '반대표를 던졌습니다!', ephemeral: true }); console.log(\`\${interaction.user.tag}님이 '\${interaction.message.embeds[0]?.title}' 투표에 반대했습니다.\`); } // 실제 투표 데이터를 저장하고 업데이트하는 로직은 여기에 추가될 수 있습니다. // 예를 들어, interaction.message.edit()을 사용하여 임베드나 버튼을 수정할 수 있습니다. } // 여기에 다른 타입의 상호작용(SelectMenu, Modal 등) 처리 로직을 추가할 수 있습니다. } catch (error) { console.error('Error handling interaction:', error); if (interaction.isRepliable()) { const replyMethod = interaction.replied || interaction.deferred ? 'followUp' : 'reply'; await interaction[replyMethod]({ content: '명령어 처리 중 알 수 없는 오류가 발생했습니다.', ephemeral: true }).catch(e => console.error('오류 응답 전송 실패:', e)); } } }); // ... (봇 로그인) ...
중요: 버튼 클릭에 대한 응답(`interaction.reply`, `interaction.update`, `interaction.deferUpdate` 등)은 반드시 3초 이내에 이루어져야 합니다. 그렇지 않으면 상호작용 실패로 간주됩니다. 만약 응답 준비에 시간이 더 걸린다면, 먼저 `interaction.deferUpdate()` (버튼 상태는 그대로 두고 로딩 상태만 표시) 또는 `interaction.deferReply()` (새로운 메시지로 응답할 것임을 알리고 로딩 상태 표시)를 호출해야 합니다.
봇 실행 및 테스트: 봇을 재시작하고
/투표 주제:새로운 기능 추가
와 같이 명령어를 실행해보세요. 임베드 메시지와 함께 찬성/반대 버튼이 나타나고, 버튼을 클릭하면 콘솔에 로그가 찍히고 사용자에게 ephemeral 메시지가 표시될 겁니다.
마무리하며
오늘은 EmbedBuilder
를 사용하여 정보를 시각적으로 아름답게 표현하는 방법과, ButtonBuilder
및 ActionRowBuilder
를 활용하여 사용자와의 상호작용을 한층 끌어올리는 버튼 컴포넌트를 만드는 방법을 배웠습니다. 이제 여러분의 봇은 단순한 텍스트를 넘어, 풍부한 UI를 통해 사용자와 소통할 수 있게 되었습니다.
다음 시간에는 디스코드 봇 개발에서 빼놓을 수 없는 중요한 부분인 이벤트 핸들링에 대해 더 깊이 파고들어 보겠습니다. 단순히 명령어 입력(`InteractionCreate`)뿐만 아니라, 메시지 수정, 삭제, 멤버의 서버 참여/퇴장 등 다양한 서버 내 활동들을 감지하고 그에 맞는 동작을 수행하는 방법을 배우게 될 겁니다. 봇이 더욱 능동적으로 서버 환경에 반응하도록 만들어 봅시다!
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
[DiscordJS 봇 개발 튜토리얼] 4. 명령어 쿨타임과 안정적인 오류 처리 (1) | 2025.06.05 |
---|---|
[DiscordJS 봇 개발 튜토리얼] 3. 슬래시 명령어: 옵션과 서브커맨드로 더욱 강력하게! (1) | 2025.06.04 |
[DiscordJS 봇 개발 튜토리얼] 2. 명령어 구조 만들기: 슬래시 명령어를 위한 첫걸음 (1) | 2025.06.03 |
[DiscordJS 봇 개발 튜토리얼] 1. 봇 상태 메시지 설정하기 (2) | 2025.06.02 |
[DiscordJS 봇 개발 튜토리얼] 0. 프로젝트, 봇 생성하기 (3) | 2025.06.01 |