Skip to content
Published on

Discord Bot 개발 완벽 가이드: Pycord로 슬래시 커맨드, 버튼, 모달까지

Authors
  • Name
    Twitter

Discord Bot 개발 준비

Discord 개발자 포털 설정

  1. Discord Developer Portal에서 New Application 생성
  2. Bot 탭에서 Token 복사 (절대 공개하지 마세요!)
  3. OAuth2 탭에서 봇 초대 URL 생성:
    • Scopes: bot, applications.commands
    • Permissions: 필요한 권한 선택

프로젝트 설정

# 가상 환경 생성
python -m venv venv
source venv/bin/activate

# Pycord 설치
pip install py-cord python-dotenv aiohttp

# 프로젝트 구조
# my-discord-bot/
# ├── bot.py              # 메인 봇 파일
# ├── cogs/
# │   ├── __init__.py
# │   ├── general.py      # 일반 명령어
# │   ├── moderation.py   # 관리 명령어
# │   └── fun.py          # 재미 명령어
# ├── utils/
# │   └── helpers.py
# ├── .env
# └── requirements.txt

.env 파일

DISCORD_TOKEN=your_bot_token_here
GUILD_IDS=123456789012345678

기본 봇 구조

# bot.py
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv

load_dotenv()

# Intents 설정
intents = discord.Intents.default()
intents.message_content = True
intents.members = True

bot = discord.Bot(intents=intents)

@bot.event
async def on_ready():
    print(f"✅ {bot.user} 로그인 완료!")
    print(f"📊 {len(bot.guilds)}개 서버에 연결됨")
    await bot.change_presence(
        activity=discord.Activity(
            type=discord.ActivityType.watching,
            name="서버를 관찰 중 👀"
        )
    )

# Cog 로드
for filename in os.listdir("./cogs"):
    if filename.endswith(".py") and not filename.startswith("_"):
        bot.load_extension(f"cogs.{filename[:-3]}")

bot.run(os.getenv("DISCORD_TOKEN"))

슬래시 커맨드

# cogs/general.py
import discord
from discord.ext import commands
from discord import option
import aiohttp
from datetime import datetime

class General(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @discord.slash_command(name="ping", description="봇의 응답 시간을 확인합니다")
    async def ping(self, ctx: discord.ApplicationContext):
        latency = round(self.bot.latency * 1000)
        embed = discord.Embed(
            title="🏓 Pong!",
            description=f"지연 시간: **{latency}ms**",
            color=discord.Color.green() if latency < 100 else discord.Color.red()
        )
        await ctx.respond(embed=embed)

    @discord.slash_command(name="userinfo", description="사용자 정보를 표시합니다")
    @option("user", description="정보를 볼 사용자", type=discord.Member, required=False)
    async def userinfo(self, ctx: discord.ApplicationContext, user: discord.Member = None):
        user = user or ctx.author

        embed = discord.Embed(
            title=f"👤 {user.display_name}",
            color=user.color
        )
        embed.set_thumbnail(url=user.display_avatar.url)
        embed.add_field(name="ID", value=user.id, inline=True)
        embed.add_field(name="가입일", value=user.joined_at.strftime("%Y-%m-%d"), inline=True)
        embed.add_field(name="계정 생성일", value=user.created_at.strftime("%Y-%m-%d"), inline=True)
        embed.add_field(
            name="역할",
            value=", ".join([r.mention for r in user.roles[1:]]) or "없음",
            inline=False
        )

        await ctx.respond(embed=embed)

    @discord.slash_command(name="weather", description="날씨 정보를 가져옵니다")
    @option("city", description="도시 이름", type=str, required=True)
    async def weather(self, ctx: discord.ApplicationContext, city: str):
        await ctx.defer()  # 응답 지연 표시

        async with aiohttp.ClientSession() as session:
            url = f"https://wttr.in/{city}?format=j1"
            async with session.get(url) as resp:
                if resp.status != 200:
                    await ctx.followup.send("❌ 도시를 찾을 수 없습니다.")
                    return

                data = await resp.json()
                current = data["current_condition"][0]

                embed = discord.Embed(
                    title=f"🌤 {city} 날씨",
                    color=discord.Color.blue()
                )
                embed.add_field(name="🌡 기온", value=f"{current['temp_C']}°C", inline=True)
                embed.add_field(name="💧 습도", value=f"{current['humidity']}%", inline=True)
                embed.add_field(name="💨 바람", value=f"{current['windspeedKmph']} km/h", inline=True)
                embed.add_field(name="상태", value=current["weatherDesc"][0]["value"], inline=False)

                await ctx.followup.send(embed=embed)

def setup(bot):
    bot.add_cog(General(bot))

버튼 인터랙션

# cogs/fun.py
import discord
from discord.ext import commands
import random

class RockPaperScissorsView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=30)

    @discord.ui.button(label="✊ 바위", style=discord.ButtonStyle.primary, custom_id="rock")
    async def rock(self, button: discord.ui.Button, interaction: discord.Interaction):
        await self.play(interaction, "rock")

    @discord.ui.button(label="✋ 보", style=discord.ButtonStyle.success, custom_id="paper")
    async def paper(self, button: discord.ui.Button, interaction: discord.Interaction):
        await self.play(interaction, "paper")

    @discord.ui.button(label="✌️ 가위", style=discord.ButtonStyle.danger, custom_id="scissors")
    async def scissors(self, button: discord.ui.Button, interaction: discord.Interaction):
        await self.play(interaction, "scissors")

    async def play(self, interaction: discord.Interaction, user_choice: str):
        choices = {"rock": "✊", "paper": "✋", "scissors": "✌️"}
        bot_choice = random.choice(list(choices.keys()))

        if user_choice == bot_choice:
            result = "🤝 무승부!"
            color = discord.Color.yellow()
        elif (user_choice == "rock" and bot_choice == "scissors") or \
             (user_choice == "paper" and bot_choice == "rock") or \
             (user_choice == "scissors" and bot_choice == "paper"):
            result = "🎉 승리!"
            color = discord.Color.green()
        else:
            result = "😢 패배!"
            color = discord.Color.red()

        embed = discord.Embed(title=result, color=color)
        embed.add_field(name="당신", value=choices[user_choice], inline=True)
        embed.add_field(name="봇", value=choices[bot_choice], inline=True)

        # 버튼 비활성화
        for child in self.children:
            child.disabled = True

        await interaction.response.edit_message(embed=embed, view=self)

class Fun(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @discord.slash_command(name="rps", description="가위바위보 게임!")
    async def rps(self, ctx: discord.ApplicationContext):
        embed = discord.Embed(
            title="✊✋✌️ 가위바위보!",
            description="버튼을 클릭해서 선택하세요!",
            color=discord.Color.blue()
        )
        await ctx.respond(embed=embed, view=RockPaperScissorsView())

def setup(bot):
    bot.add_cog(Fun(bot))

모달 (Modal) 폼

class FeedbackModal(discord.ui.Modal):
    def __init__(self):
        super().__init__(title="📋 피드백 제출")

        self.add_item(discord.ui.InputText(
            label="제목",
            placeholder="피드백 제목을 입력하세요",
            style=discord.InputTextStyle.short,
            required=True,
            max_length=100
        ))

        self.add_item(discord.ui.InputText(
            label="내용",
            placeholder="상세 내용을 입력하세요",
            style=discord.InputTextStyle.long,
            required=True,
            max_length=2000
        ))

        self.add_item(discord.ui.InputText(
            label="점수 (1-5)",
            placeholder="1",
            style=discord.InputTextStyle.short,
            required=False,
            max_length=1
        ))

    async def callback(self, interaction: discord.Interaction):
        title = self.children[0].value
        content = self.children[1].value
        rating = self.children[2].value or "미입력"

        embed = discord.Embed(
            title="📋 새 피드백",
            color=discord.Color.blue()
        )
        embed.add_field(name="제목", value=title, inline=False)
        embed.add_field(name="내용", value=content, inline=False)
        embed.add_field(name="점수", value=f"{'⭐' * int(rating)}" if rating.isdigit() else rating)
        embed.set_footer(text=f"작성자: {interaction.user.display_name}")

        # 피드백 채널에 전송
        feedback_channel = interaction.guild.get_channel(FEEDBACK_CHANNEL_ID)
        if feedback_channel:
            await feedback_channel.send(embed=embed)

        await interaction.response.send_message(
            "✅ 피드백이 제출되었습니다! 감사합니다.", ephemeral=True
        )

# 슬래시 커맨드로 모달 열기
@discord.slash_command(name="feedback", description="피드백을 제출합니다")
async def feedback(ctx: discord.ApplicationContext):
    await ctx.send_modal(FeedbackModal())

Select 메뉴

class RoleSelectView(discord.ui.View):
    @discord.ui.select(
        placeholder="역할을 선택하세요 (최대 3개)",
        min_values=1,
        max_values=3,
        options=[
            discord.SelectOption(label="개발자", emoji="💻", value="developer"),
            discord.SelectOption(label="디자이너", emoji="🎨", value="designer"),
            discord.SelectOption(label="기획자", emoji="📊", value="planner"),
            discord.SelectOption(label="마케터", emoji="📢", value="marketer"),
            discord.SelectOption(label="데이터 분석가", emoji="📈", value="analyst"),
        ]
    )
    async def select_callback(self, select: discord.ui.Select, interaction: discord.Interaction):
        selected = ", ".join(select.values)
        await interaction.response.send_message(
            f"✅ 선택한 역할: {selected}", ephemeral=True
        )

에러 처리

# bot.py에 글로벌 에러 핸들러 추가
@bot.event
async def on_application_command_error(ctx: discord.ApplicationContext, error):
    if isinstance(error, commands.MissingPermissions):
        await ctx.respond("❌ 권한이 부족합니다.", ephemeral=True)
    elif isinstance(error, commands.CommandOnCooldown):
        await ctx.respond(
            f"⏳ 쿨다운 중입니다. {error.retry_after:.1f}초 후 다시 시도하세요.",
            ephemeral=True
        )
    elif isinstance(error, commands.MemberNotFound):
        await ctx.respond("❌ 사용자를 찾을 수 없습니다.", ephemeral=True)
    else:
        # 로깅
        import traceback
        traceback.print_exception(type(error), error, error.__traceback__)
        await ctx.respond("❌ 오류가 발생했습니다.", ephemeral=True)

관리 명령어

# cogs/moderation.py
class Moderation(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @discord.slash_command(name="clear", description="메시지를 삭제합니다")
    @commands.has_permissions(manage_messages=True)
    @option("amount", description="삭제할 메시지 수", type=int, min_value=1, max_value=100)
    async def clear(self, ctx: discord.ApplicationContext, amount: int):
        deleted = await ctx.channel.purge(limit=amount)
        await ctx.respond(f"🗑️ {len(deleted)}개 메시지 삭제됨", ephemeral=True)

    @discord.slash_command(name="slowmode", description="슬로우모드를 설정합니다")
    @commands.has_permissions(manage_channels=True)
    @option("seconds", description="초 단위 (0=해제)", type=int, min_value=0, max_value=21600)
    async def slowmode(self, ctx: discord.ApplicationContext, seconds: int):
        await ctx.channel.edit(slowmode_delay=seconds)
        if seconds == 0:
            await ctx.respond("✅ 슬로우모드가 해제되었습니다.")
        else:
            await ctx.respond(f"✅ 슬로우모드: {seconds}초로 설정됨")

def setup(bot):
    bot.add_cog(Moderation(bot))

배포

systemd 서비스

# /etc/systemd/system/discord-bot.service
[Unit]
Description=Discord Bot
After=network.target

[Service]
Type=simple
User=bot
WorkingDirectory=/opt/discord-bot
ExecStart=/opt/discord-bot/venv/bin/python bot.py
Restart=always
RestartSec=10
EnvironmentFile=/opt/discord-bot/.env

[Install]
WantedBy=multi-user.target
sudo systemctl enable discord-bot
sudo systemctl start discord-bot
sudo journalctl -u discord-bot -f

📝 확인 퀴즈 (6문제)

Q1. Discord Bot의 Intents란?

봇이 수신할 이벤트 유형을 지정하는 설정입니다. Privileged Intents(message_content, members)는 Developer Portal에서 별도 활성화가 필요합니다.

Q2. 슬래시 커맨드에서 ctx.defer()는 언제 사용하나요?

응답이 3초 이상 걸릴 때 사용합니다. defer() 후 ctx.followup.send()로 실제 응답을 보냅니다.

Q3. ephemeral=True의 의미는?

해당 메시지가 명령어를 실행한 사용자에게만 보이게 합니다. 다른 사용자는 볼 수 없습니다.

Q4. Cog의 장점은?

명령어를 모듈별로 분리하여 관리하고, 동적으로 로드/언로드할 수 있습니다. 코드 구조화와 유지보수에 유리합니다.

Q5. View의 timeout 파라미터는 무엇을 제어하나요?

버튼/셀렉트 메뉴가 비활성화되기까지의 시간(초)입니다. None으로 설정하면 타임아웃 없음.

Q6. Modal과 일반 메시지의 차이점은?

Modal은 사용자에게 입력 폼을 표시하여 구조화된 데이터를 받을 수 있습니다. 일반 메시지는 텍스트만 주고받습니다.