해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. Discord.js TypeScript Boilerplate
안녕하세요! 지난 시간에는 봇을 실제 서버에 배포하는 방법을 알아봤습니다. 이제 우리 봇이 24시간 안정적으로 돌아가고 있다면, 더 재미있고 유용한 기능들을 추가해볼 시간입니다.
오늘은 Discord의 가장 직관적인 상호작용 방식 중 하나인 반응(이모지)을 활용한 기능들을 만들어보겠습니다. 투표 시스템, 역할 부여, 간단한 게임까지 다양한 활용법을 살펴볼 예정이에요.
반응 기반 기능의 장점은 사용자가 별도의 명령어를 외울 필요 없이 직관적으로 이모지만 클릭하면 된다는 점입니다. 특히 모바일 환경에서 타이핑보다 훨씬 편리하죠.
기본 반응 시스템 구현하기
먼저 가장 기본적인 반응 수집기(Reaction Collector)부터 구현해보겠습니다.
src/commands/vote.ts
import {
SlashCommandBuilder,
EmbedBuilder,
AttachmentBuilder,
} from "discord.js";
import { Command } from "../types";
export const vote: Command = {
data: new SlashCommandBuilder()
.setName("투표")
.setDescription("투표를 생성합니다")
.addStringOption((option) =>
option.setName("제목").setDescription("투표 제목").setRequired(true)
)
.addStringOption((option) =>
option
.setName("내용")
.setDescription("투표 내용 (선택사항)")
.setRequired(false)
)
.addIntegerOption((option) =>
option
.setName("시간")
.setDescription("투표 시간 (분 단위, 기본: 30분)")
.setMinValue(1)
.setMaxValue(1440)
.setRequired(false)
),
async execute(interaction) {
const title = interaction.options.getString("제목", true);
const content = interaction.options.getString("내용") || "";
const timeMinutes = interaction.options.getInteger("시간") || 30;
const embed = new EmbedBuilder()
.setTitle(`📊 투표: ${title}`)
.setDescription(content || "찬성/반대로 투표해주세요!")
.setColor(0x00ae86)
.addFields([
{ name: "✅ 찬성", value: "0표", inline: true },
{ name: "❌ 반대", value: "0표", inline: true },
{
name: "⏰ 종료 시간",
value: `<t:${Math.floor(
(Date.now() + timeMinutes * 60000) / 1000
)}:R>`,
inline: false,
},
])
.setFooter({ text: "아래 이모지를 클릭해서 투표하세요!" })
.setTimestamp();
const message = await interaction.reply({
embeds: [embed],
fetchReply: true,
});
// 투표 이모지 추가
await message.react("✅");
await message.react("❌");
// 반응 수집기 설정
const filter = (reaction: any, user: any) => {
return ["✅", "❌"].includes(reaction.emoji.name) && !user.bot;
};
const collector = message.createReactionCollector({
filter,
time: timeMinutes * 60000, // 분을 밀리초로 변환
});
// 투표 현황 저장
const voteData = new Map();
voteData.set("✅", new Set());
voteData.set("❌", new Set());
collector.on("collect", async (reaction, user) => {
const emoji = reaction.emoji.name;
// 다른 투표에서 사용자 제거 (한 번만 투표 가능)
if (emoji === "✅") {
voteData.get("❌").delete(user.id);
voteData.get("✅").add(user.id);
} else if (emoji === "❌") {
voteData.get("✅").delete(user.id);
voteData.get("❌").add(user.id);
}
// 임베드 업데이트
const updatedEmbed = EmbedBuilder.from(embed).setFields([
{
name: "✅ 찬성",
value: `${voteData.get("✅").size}표`,
inline: true,
},
{
name: "❌ 반대",
value: `${voteData.get("❌").size}표`,
inline: true,
},
{
name: "⏰ 종료 시간",
value: `<t:${Math.floor(
(Date.now() +
(collector.options.time - (Date.now() - collector.startAt))) /
1000
)}:R>`,
inline: false,
},
]);
await message.edit({ embeds: [updatedEmbed] });
});
collector.on("end", async () => {
const yesVotes = voteData.get("✅").size;
const noVotes = voteData.get("❌").size;
const totalVotes = yesVotes + noVotes;
let result = "";
if (yesVotes > noVotes) {
result = "✅ 찬성이 승리했습니다!";
} else if (noVotes > yesVotes) {
result = "❌ 반대가 승리했습니다!";
} else {
result = "🤝 무승부입니다!";
}
const finalEmbed = EmbedBuilder.from(embed)
.setTitle(`📊 투표 종료: ${title}`)
.setColor(0xff6b6b)
.setFields([
{
name: "✅ 찬성",
value: `${yesVotes}표 (${
totalVotes > 0 ? Math.round((yesVotes / totalVotes) * 100) : 0
}%)`,
inline: true,
},
{
name: "❌ 반대",
value: `${noVotes}표 (${
totalVotes > 0 ? Math.round((noVotes / totalVotes) * 100) : 0
}%)`,
inline: true,
},
{ name: "🎯 결과", value: result, inline: false },
])
.setFooter({ text: "투표가 종료되었습니다." });
await message.edit({ embeds: [finalEmbed] });
});
},
};
다중 선택 투표 시스템
숫자 이모지를 활용해서 더 복잡한 투표 시스템도 만들 수 있습니다.
src/commands/multipoll.ts
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { Command } from "../types";
export const multipoll: Command = {
data: new SlashCommandBuilder()
.setName("다중투표")
.setDescription("여러 선택지로 투표를 생성합니다")
.addStringOption((option) =>
option.setName("제목").setDescription("투표 제목").setRequired(true)
)
.addStringOption((option) =>
option
.setName("선택지")
.setDescription("선택지들을 |로 구분해서 입력하세요 (최대 9개)")
.setRequired(true)
)
.addIntegerOption((option) =>
option
.setName("시간")
.setDescription("투표 시간 (분 단위, 기본: 30분)")
.setMinValue(1)
.setMaxValue(1440)
.setRequired(false)
),
async execute(interaction) {
const title = interaction.options.getString("제목", true);
const choicesText = interaction.options.getString("선택지", true);
const timeMinutes = interaction.options.getInteger("시간") || 30;
const choices = choicesText
.split("|")
.map((choice) => choice.trim())
.filter((choice) => choice.length > 0);
if (choices.length < 2) {
return interaction.reply({
content: "❌ 최소 2개 이상의 선택지가 필요합니다!",
ephemeral: true,
});
}
if (choices.length > 9) {
return interaction.reply({
content: "❌ 최대 9개까지만 선택지를 만들 수 있습니다!",
ephemeral: true,
});
}
const numberEmojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"];
const embed = new EmbedBuilder()
.setTitle(`📊 다중 투표: ${title}`)
.setColor(0x00ae86)
.addFields([
...choices.map((choice, index) => ({
name: `${numberEmojis[index]} ${choice}`,
value: "0표",
inline: true,
})),
{
name: "⏰ 종료 시간",
value: `<t:${Math.floor(
(Date.now() + timeMinutes * 60000) / 1000
)}:R>`,
inline: false,
},
])
.setFooter({ text: "아래 숫자 이모지를 클릭해서 투표하세요!" })
.setTimestamp();
const message = await interaction.reply({
embeds: [embed],
fetchReply: true,
});
// 선택지만큼 이모지 추가
for (let i = 0; i < choices.length; i++) {
await message.react(numberEmojis[i]);
}
const filter = (reaction: any, user: any) => {
return (
numberEmojis.slice(0, choices.length).includes(reaction.emoji.name) &&
!user.bot
);
};
const collector = message.createReactionCollector({
filter,
time: timeMinutes * 60000,
});
const voteData = new Map();
for (let i = 0; i < choices.length; i++) {
voteData.set(numberEmojis[i], new Set());
}
collector.on("collect", async (reaction, user) => {
const emoji = reaction.emoji.name;
// 다른 모든 투표에서 사용자 제거 (한 번만 투표 가능)
for (const [emojiKey, voters] of voteData) {
if (emojiKey !== emoji) {
voters.delete(user.id);
}
}
voteData.get(emoji).add(user.id);
// 임베드 업데이트
const updatedEmbed = EmbedBuilder.from(embed).setFields([
...choices.map((choice, index) => ({
name: `${numberEmojis[index]} ${choice}`,
value: `${voteData.get(numberEmojis[index]).size}표`,
inline: true,
})),
{
name: "⏰ 종료 시간",
value: `<t:${Math.floor(
(Date.now() +
(collector.options.time - (Date.now() - collector.startAt))) /
1000
)}:R>`,
inline: false,
},
]);
await message.edit({ embeds: [updatedEmbed] });
});
collector.on("end", async () => {
const results = choices.map((choice, index) => ({
choice,
emoji: numberEmojis[index],
votes: voteData.get(numberEmojis[index]).size,
}));
results.sort((a, b) => b.votes - a.votes);
const totalVotes = results.reduce((sum, result) => sum + result.votes, 0);
const finalEmbed = EmbedBuilder.from(embed)
.setTitle(`📊 투표 종료: ${title}`)
.setColor(0xff6b6b)
.setFields([
...results.map((result, index) => ({
name: `${
index === 0
? "🏆"
: index === 1
? "🥈"
: index === 2
? "🥉"
: "📊"
} ${result.emoji} ${result.choice}`,
value: `${result.votes}표 (${
totalVotes > 0 ? Math.round((result.votes / totalVotes) * 100) : 0
}%)`,
inline: true,
})),
{
name: "🎯 우승자",
value:
results[0].votes > 0
? `${results[0].emoji} ${results[0].choice}`
: "투표 없음",
inline: false,
},
])
.setFooter({ text: "투표가 종료되었습니다." });
await message.edit({ embeds: [finalEmbed] });
});
},
};
반응 기반 역할 부여 시스템
관리자가 설정한 메시지에 반응하면 자동으로 역할을 부여받는 시스템을 만들어보겠습니다.
src/commands/reactionrole.ts
import {
SlashCommandBuilder,
EmbedBuilder,
PermissionFlagsBits,
} from "discord.js";
import { Command } from "../types";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const reactionrole: Command = {
data: new SlashCommandBuilder()
.setName("반응역할")
.setDescription("반응으로 역할을 부여하는 메시지를 생성합니다")
.addRoleOption((option) =>
option.setName("역할").setDescription("부여할 역할").setRequired(true)
)
.addStringOption((option) =>
option.setName("이모지").setDescription("사용할 이모지").setRequired(true)
)
.addStringOption((option) =>
option.setName("제목").setDescription("메시지 제목").setRequired(false)
)
.addStringOption((option) =>
option.setName("설명").setDescription("메시지 설명").setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles),
async execute(interaction) {
const role = interaction.options.getRole("역할", true);
const emoji = interaction.options.getString("이모지", true);
const title = interaction.options.getString("제목") || "역할 부여";
const description =
interaction.options.getString("설명") ||
`${emoji}를 클릭하여 ${role.name} 역할을 받으세요!`;
// 봇이 해당 역할을 관리할 수 있는지 확인
if (
!interaction.guild?.members.me?.permissions.has(
PermissionFlagsBits.ManageRoles
)
) {
return interaction.reply({
content: "❌ 봇에게 역할 관리 권한이 없습니다!",
ephemeral: true,
});
}
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(role.color || 0x00ae86)
.addFields([
{ name: "역할", value: role.toString(), inline: true },
{ name: "이모지", value: emoji, inline: true },
])
.setFooter({
text: "이모지를 클릭하여 역할을 받거나 제거할 수 있습니다.",
})
.setTimestamp();
const message = await interaction.reply({
embeds: [embed],
fetchReply: true,
});
try {
await message.react(emoji);
// 데이터베이스에 반응 역할 정보 저장
await prisma.reactionRole.create({
data: {
guildId: interaction.guildId!,
channelId: interaction.channelId,
messageId: message.id,
roleId: role.id,
emoji: emoji,
},
});
await interaction.followUp({
content: "✅ 반응 역할 메시지가 성공적으로 생성되었습니다!",
ephemeral: true,
});
} catch (error) {
console.error("반응 추가 오류:", error);
await interaction.followUp({
content:
"❌ 이모지 추가에 실패했습니다. 올바른 이모지인지 확인해주세요.",
ephemeral: true,
});
}
},
};
반응 역할 이벤트 처리: src/events/messageReactionAdd.ts
import { Events } from "discord.js";
import { Event } from "../types";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const messageReactionAdd: Event = {
name: Events.MessageReactionAdd,
async execute(reaction, user) {
// 부분적으로 로드된 메시지 처리
if (reaction.partial) {
try {
await reaction.fetch();
} catch (error) {
console.error("반응 페치 오류:", error);
return;
}
}
// 봇의 반응은 무시
if (user.bot) return;
try {
// 데이터베이스에서 반응 역할 정보 조회
const reactionRole = await prisma.reactionRole.findFirst({
where: {
messageId: reaction.message.id,
emoji: reaction.emoji.name || reaction.emoji.toString(),
},
});
if (!reactionRole) return;
const guild = reaction.message.guild;
if (!guild) return;
const member = await guild.members.fetch(user.id);
const role = guild.roles.cache.get(reactionRole.roleId);
if (!role) {
console.error("역할을 찾을 수 없습니다:", reactionRole.roleId);
return;
}
// 역할 부여
if (!member.roles.cache.has(role.id)) {
await member.roles.add(role);
console.log(`${user.tag}에게 ${role.name} 역할을 부여했습니다.`);
}
} catch (error) {
console.error("반응 역할 부여 오류:", error);
}
},
};
src/events/messageReactionRemove.ts
import { Events } from "discord.js";
import { Event } from "../types";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const messageReactionRemove: Event = {
name: Events.MessageReactionRemove,
async execute(reaction, user) {
if (reaction.partial) {
try {
await reaction.fetch();
} catch (error) {
console.error("반응 페치 오류:", error);
return;
}
}
if (user.bot) return;
try {
const reactionRole = await prisma.reactionRole.findFirst({
where: {
messageId: reaction.message.id,
emoji: reaction.emoji.name || reaction.emoji.toString(),
},
});
if (!reactionRole) return;
const guild = reaction.message.guild;
if (!guild) return;
const member = await guild.members.fetch(user.id);
const role = guild.roles.cache.get(reactionRole.roleId);
if (!role) return;
// 역할 제거
if (member.roles.cache.has(role.id)) {
await member.roles.remove(role);
console.log(`${user.tag}에게서 ${role.name} 역할을 제거했습니다.`);
}
} catch (error) {
console.error("반응 역할 제거 오류:", error);
}
},
};
간단한 반응 게임 만들기
마지막으로 반응을 활용한 간단한 게임을 만들어보겠습니다.
src/commands/reactiongame.ts
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { Command } from "../types";
export const reactiongame: Command = {
data: new SlashCommandBuilder()
.setName("반응게임")
.setDescription("빠른 반응 속도를 겨루는 게임을 시작합니다"),
async execute(interaction) {
const embed = new EmbedBuilder()
.setTitle("🎮 빠른 반응 게임")
.setDescription(
"곧 이모지가 나타납니다!\n가장 먼저 클릭하는 사람이 승리합니다!"
)
.setColor(0xffff00)
.setFooter({ text: "잠시만 기다려주세요..." });
const message = await interaction.reply({
embeds: [embed],
fetchReply: true,
});
// 랜덤 시간 후에 게임 시작 (3-10초 사이)
const delay = Math.random() * 7000 + 3000;
setTimeout(async () => {
const gameEmojis = ["🚀", "⚡", "🎯", "💎", "🏆", "🔥"];
const randomEmoji =
gameEmojis[Math.floor(Math.random() * gameEmojis.length)];
const gameEmbed = new EmbedBuilder()
.setTitle("🎮 지금!")
.setDescription(`${randomEmoji}를 클릭하세요!`)
.setColor(0x00ff00)
.setFooter({ text: "가장 먼저 클릭하는 사람이 승리!" });
await message.edit({ embeds: [gameEmbed] });
await message.react(randomEmoji);
const startTime = Date.now();
const filter = (reaction: any, user: any) => {
return reaction.emoji.name === randomEmoji && !user.bot;
};
const collector = message.createReactionCollector({
filter,
time: 30000,
max: 1,
});
collector.on("collect", async (reaction, user) => {
const reactionTime = Date.now() - startTime;
const winEmbed = new EmbedBuilder()
.setTitle("🏆 승리!")
.setDescription(`${user}님이 승리했습니다!`)
.addFields([
{ name: "반응 시간", value: `${reactionTime}ms`, inline: true },
{ name: "승리자", value: user.toString(), inline: true },
])
.setColor(0xffd700)
.setFooter({ text: "축하합니다!" });
await message.edit({ embeds: [winEmbed] });
});
collector.on("end", async (collected) => {
if (collected.size === 0) {
const timeoutEmbed = new EmbedBuilder()
.setTitle("⏰ 시간 초과")
.setDescription("아무도 반응하지 않았습니다!")
.setColor(0xff0000)
.setFooter({ text: "다음에 다시 도전해보세요!" });
await message.edit({ embeds: [timeoutEmbed] });
}
});
}, delay);
},
};
Prisma 스키마 업데이트
반응 역할 기능을 위해 데이터베이스 스키마에 새로운 모델을 추가해야 합니다.
prisma/schema.prisma
// ...기존 코드...
model ReactionRole {
id String @id @default(cuid())
guildId String
channelId String
messageId String
roleId String
emoji String
createdAt DateTime @default(now())
@@unique([messageId, emoji])
@@map("reaction_roles")
}
스키마를 업데이트했다면 마이그레이션을 실행해주세요:
npx prisma migrate dev --name add-reaction-roles
마무리하며
오늘은 Discord의 반응 시스템을 활용해서 다양한 인터랙티브 기능들을 만들어봤습니다. 투표부터 역할 부여, 게임까지 정말 다양한 활용법이 있죠?
반응 기반 기능의 가장 큰 장점은 사용자가 별도의 명령어를 기억할 필요 없이 직관적으로 이모지만 클릭하면 된다는 점입니다. 특히 모바일 환경에서 타이핑보다 훨씬 편리하고, 시각적으로도 더 매력적이에요.
다음 시간에는 봇의 다국어 지원 시스템을 구축해서 전 세계 사용자들이 편리하게 사용할 수 있도록 만들어보겠습니다. 국제적인 봇을 만들기 위해서는 꼭 필요한 기능이니까 기대해주세요!
반응 기반 기능들을 잘 활용하면 사용자 경험을 크게 향상시킬 수 있습니다. 여러분만의 창의적인 아이디어로 더 재미있는 기능들을 만들어보세요!
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
[DiscordJS 봇 개발 튜토리얼] 12. 다국어 지원 시스템 만들기 (i18n): 전 세계와 소통하자! (0) | 2025.06.13 |
---|---|
[DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기 (6) | 2025.06.11 |
[DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자! (2) | 2025.06.11 |
[DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기 (2) | 2025.06.10 |
[DiscordJS 봇 개발 튜토리얼] 7. 역할과 권한 체크 구현하기: 봇에게 질서를 부여하자! (1) | 2025.06.09 |