Skip to content
Published on

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

Authors

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