Skip to content
Published on

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

공유하기
Authors

Discord Bot 개발 준비

이 글은 Pycord v2.8.1(2026-07-25 릴리스, Python 3.10 이상 3.15 미만) 기준입니다. 버전을 못 박는 이유가 있습니다. 파이썬 Discord 라이브러리는 discord.py와 Pycord 두 갈래인데 임포트 경로가 둘 다 discord라, 검색으로 찾은 코드가 어느 쪽인지 겉으로는 구분되지 않습니다. 그런데 둘은 확장 로딩과 콜백 인자 순서라는 결정적인 지점에서 다릅니다. 뒤에서 하나씩 짚습니다.

Discord 개발자 포털 설정

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

Scopes에서 applications.commands를 빼먹는 일이 아주 흔합니다. bot만 체크해도 봇은 서버에 들어가고 온라인으로 보이지만 슬래시 커맨드를 등록할 권한이 없습니다.

특권 인텐트를 켜는 자리

Bot 탭 아래 Privileged Gateway Intents 항목에 SERVER MEMBERS, PRESENCE, MESSAGE CONTENT 세 스위치가 있습니다. 각각 코드의 Intents.members, Intents.presences, Intents.message_content에 대응합니다.

Pycord 문서가 여기에 경고를 답니다. 포털에서 인텐트를 켰더라도 코드에서도 켜야 한다는 것입니다. 두 스위치는 독립적이고 증상도 다릅니다. 코드만 켜면 봇이 로그인 자체를 못 하고 예외를 던지고, 포털만 켜면 예외 없이 이벤트만 오지 않습니다. 후자가 훨씬 찾기 어렵습니다.

고유 사용자 10,000명을 넘기면 특권 인텐트는 Discord 심사를 거칩니다. 공개할 생각이라면 처음부터 message_content 없이 설계해 두세요.

프로젝트 설정

# 가상 환경 생성
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"))

위 코드에서 두 줄만 봅시다. bot = discord.Bot(intents=intents)는 슬래시 커맨드만 쓰겠다는 선언이고, bot.load_extension(...) 앞에 await가 없는 것은 오타가 아닙니다. 둘 다 다음 절의 주제입니다.

discord.Bot과 commands.Bot, 그리고 확장 로딩의 함정

어느 Bot 클래스를 고를까

문서의 commands.Bot 설명은 이렇게 시작합니다. 이 클래스는 discord.Bot의 서브클래스이며, 따라서 discord.Bot으로 할 수 있는 것은 무엇이든 이 봇으로도 할 수 있다는 것입니다. 추가로 얹는 것은 GroupMixin, 즉 접두사 명령어 기능입니다. 그러니 기준은 단순합니다. 슬래시 커맨드만 쓰면 discord.Bot, !ping 같은 접두사 명령어도 쓰면 commands.Bot입니다.

숨은 비용이 있습니다. 접두사 명령어는 메시지 본문을 읽어야 하므로 MESSAGE CONTENT 특권 인텐트가 필요합니다. 새로 만드는 봇이라면 discord.Bot에서 시작하세요. 서브클래스 관계라 나중에 갈아 끼워도 기존 슬래시 커맨드는 그대로 동작합니다.

await 없는 load_extension

가장 자주 사람을 붙잡는 함정입니다. Pycord의 확장 로딩은 동기 함수입니다.

# Pycord v2.8.1 — 문서에 실린 시그니처
def load_extension(self, name, *, package=None, recursive=False, store=False)

async def가 아니라 그냥 def입니다. Cog 진입점도 동기 함수입니다.

# cogs/general.py 맨 아래 — Pycord 방식
def setup(bot):
    bot.add_cog(General(bot))

discord.py 2.x는 정확히 반대로 await bot.load_extension(...)async def setup(bot)을 요구합니다. Stack Overflow 답변 대부분이 discord.py 기준이라, 그대로 복사하면 조용히 어긋납니다.

증상이 애매해서 더 나쁩니다. Pycord는 setup을 동기 호출하는데, async def setup을 호출하면 파이썬은 본문을 실행하지 않고 코루틴 객체만 돌려줍니다. 즉 bot.add_cog(...)가 실행되지 않습니다. 예외 없이, 봇은 멀쩡히 로그인하고, 명령어만 목록에 안 나타납니다. 반대로 await를 붙이면 코루틴이 아닌 값을 기다려 TypeError가 납니다.

첫 실행: 터미널에 실제로 찍히는 것

python bot.py를 처음 눌렀을 때 나오는 화면은 대체로 네 갈래입니다.

성공했을 때

✅ MyBot#1234 로그인 완료!
📊 1개 서버에 연결됨

on_ready 안의 print가 그대로 나옵니다. 여기까지 왔다면 토큰과 게이트웨이 연결은 문제가 없습니다. 다만 on_ready가 프로세스당 한 번만 호출된다는 보장은 없습니다. 재연결 때 또 호출되므로, 마이그레이션이나 시작 공지처럼 한 번만 해야 하는 일은 별도 플래그로 막아 두세요.

토큰이 틀렸을 때

인증이 거부되어 로그인 단계에서 예외가 나고 프로세스가 종료됩니다. 토큰 문자열, .env 경로, load_dotenv() 호출 여부, 토큰 앞뒤의 따옴표와 공백을 순서대로 확인하세요. 포털에서 토큰을 Reset 하면 이전 토큰은 즉시 무효가 됩니다.

인텐트를 안 켰을 때

discord.PrivilegedIntentsRequired가 발생합니다. 문서 설명 그대로, 게이트웨이가 특권 인텐트를 요청하는데 개발자 페이지에서 아직 체크되지 않았을 때 나오는 예외입니다. shard_id 속성이 있어 어느 샤드에서 났는지 알 수 있습니다. 위 bot.py는 message_content와 members를 둘 다 켜므로 포털에서도 두 개를 켜야 합니다.

봇은 켜졌는데 message.content가 비어 있을 때

예외가 나지 않아서 가장 오래 헤매는 경우입니다. MESSAGE CONTENT 인텐트가 없으면 Discord는 사용자가 입력한 내용이 담기는 필드를 빈 값으로 내려보냅니다. 오류도 경고도 없이 그냥 빈 문자열입니다.

예외가 네 가지 있어서 더 헷갈립니다. 봇 자신이 보낸 메시지, 봇과의 DM, 봇을 멘션한 메시지, 그리고 메시지 컨텍스트 메뉴 명령어가 대상으로 삼은 메시지는 인텐트 없이도 내용이 옵니다. 그래서 DM으로 테스트하면 잘 되고 멘션해도 잘 됩니다. 서버 채널에서 멘션 없이 보낸 평범한 메시지로 테스트해야 이 문제가 드러납니다.

슬래시 커맨드

# 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))

option 데코레이터와 Option 타입

파라미터를 정의하는 방법은 두 가지입니다. 파이썬 타입 어노테이션을 쓰거나, @discord.option 데코레이터를 쓰는 것입니다. 문서는 후자를 Option을 타입 힌트로 쓰는 대신 사용할 수 있는 데코레이터라고 설명합니다.

여기서 문서 자체에 걸려 넘어지기 쉽습니다. 가이드 페이지 예제는 @discord.option("first", type=...)처럼 type=을 쓰는데, API 레퍼런스의 시그니처는 option(name, input_type=None, **kwargs)로 파라미터 이름이 input_type입니다. 두 표기가 섞인 예제가 함께 돌아다니니, 헷갈리면 input_type=을 쓰거나 타입 어노테이션만 쓰세요.

discord.Option을 직접 쓸 때는 Option(input_type=str, /, description=None, **kwargs)입니다. 슬래시 앞의 input_type은 위치 전용 인자라 키워드로 넘길 수 없습니다. UI 옵션 이름과 파이썬 파라미터 이름을 분리하려면 parameter_name을 씁니다.

from discord import optionfrom discord import slash_command도 유효합니다. discord/commands/__init__.py를 통해 재노출되기 때문에 @discord.slash_command(...)@slash_command(...)는 같은 것입니다. 예제마다 표기가 달라 다른 API처럼 보이니 하나로 통일해 두세요. 명령어가 늘어나면 discord.SlashCommandGroup(name, description=None, guild_ids=None, parent=None, cooldown=None, max_concurrency=None, **kwargs)으로 두 단계로 묶습니다.

슬래시 커맨드가 목록에 안 뜬다

봇은 온라인인데 슬래시를 눌러도 명령어가 안 보입니다. 원인이 네 가지쯤 되고, 확인 순서가 중요합니다.

1. 초대 URL에 applications.commands가 빠졌다

압도적으로 흔합니다. Scopes에 bot만 체크하면 봇은 서버에 들어가고 온라인으로 보이지만 슬래시 커맨드를 등록할 권한 자체가 없습니다. 두 스코프를 모두 포함한 URL로 같은 서버에 재초대하세요. 이미 들어와 있는 봇도 재초대가 되고 기존 설정은 유지됩니다.

2. 아직 동기화가 안 됐다

Pycord는 Bot.auto_sync_commands가 기본값 True이고 discord.on_connect 시점에 Bot.sync_commands를 호출합니다. 보통은 신경 쓸 필요가 없지만, 수동으로 부를 때를 위해 시그니처를 봐 둡시다.

async def sync_commands(
    self,
    commands=None,
    method: Literal["individual", "bulk", "auto"] = "bulk",
    force=False,
    guild_ids=None,
    register_guild_commands=True,
    check_guilds=[],
    delete_existing=True,
)

코루틴이므로 이쪽은 await가 필요합니다. 앞의 load_extension과 반대라 헷갈리기 쉽습니다. delete_existing=True가 기본값이라 코드에서 지운 명령어는 Discord에서도 사라집니다.

3. 길드 명령어와 글로벌 명령어를 헷갈렸다

Discord 문서의 안내는 이렇습니다. 길드 명령어는 즉시 갱신되며, 빠른 테스트에는 길드 명령어를, 공개 준비가 끝나면 글로벌 명령어를 쓰라는 것입니다. 글로벌 명령어에는 내부 버전 확인이 붙어 있어서, 오래된 정의로 실행하면 Discord가 그 명령어를 거부하고 다시 불러오도록 트리거합니다.

개발 중에는 debug_guilds가 가장 편합니다. 문서 설명은 명령어 테스트에 사용할 길드의 ID이며, 디버그 길드 ID가 전달되면 봇은 글로벌 명령어를 하나도 만들지 않는다는 것입니다. 뒷부분이 중요합니다. 이 값을 켠 채로 배포하면 그 서버 밖에서는 명령어가 하나도 안 보입니다. 로컬에서는 되는데 배포하면 안 되는 증상의 단골 원인입니다.

4. 하루치 등록 한도를 썼다

Discord에는 길드당 하루 200회의 애플리케이션 명령어 생성이라는 글로벌 레이트 리밋이 있습니다. 평소에는 닿을 일이 없지만, 크래시로 재시작 루프에 갇힌 봇이 매번 전체 동기화를 하면 도달할 수 있는 숫자입니다.

3초 규칙: defer와 인터랙션 토큰

반드시 외워야 하는 숫자가 두 개 있습니다. Discord 문서의 표현을 그대로 옮기면, 이벤트를 받은 뒤 3초 안에 최초 응답을 보내야 하며 3초 데드라인을 넘기면 토큰이 무효화됩니다. 그리고 인터랙션 토큰은 15분간 유효합니다. 3초는 최초 응답의 데드라인이고, 넘기면 나중에 결과가 나와도 보낼 곳이 없습니다. 15분은 최초 응답을 제때 보낸 다음 후속 메시지를 보내거나 응답을 수정할 수 있는 창입니다.

그래서 3초 안에 안 끝날 일은 먼저 defer로 자리를 잡아 두고, 작업이 끝난 뒤 후속 메시지를 보냅니다. 앞의 /weather가 정확히 이 패턴입니다. 외부 HTTP 호출은 언제든 3초를 넘길 수 있으니 await ctx.defer()를 먼저 부르고 결과가 오면 ctx.followup.send(...)로 보냅니다. 반대로 /ping은 지연 계산만 하므로 바로 respond합니다.

defer의 시그니처는 이렇습니다.

async def defer(self, *, ephemeral: bool = False, invisible: bool = True)

invisible이 실질적인 차이를 만듭니다. Discord 응답 타입으로 보면 하나는 DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE(타입 5)이고 문서 설명이 사용자에게 로딩 상태가 보인다는 것입니다. 다른 하나는 DEFERRED_UPDATE_MESSAGE(타입 6)로 로딩 상태가 보이지 않습니다. 버튼 클릭을 조용히 뒤에서만 처리하고 싶을 때는 후자가 맞습니다.

respond가 그냥 되는 이유

Pycord 예제가 대부분 ctx.respond 하나로 끝나는 데는 이유가 있습니다. respondself.response.is_done()이 아니면 self.response.send_message(...)를, 이미 응답한 뒤라면 self.followup.send(...)를 부릅니다. InteractionResponded 예외도 함께 잡아냅니다. 최초 응답인지 후속 응답인지를 알아서 갈라 준다는 뜻입니다.

덕분에 defer 뒤에도 ctx.respond를 그대로 쓸 수 있습니다. 반면 interaction.response.send_message를 직접 두 번 부르면 이런 오류가 납니다.

Interaction was already issued a response. Try using {type}.send_followup() instead.

두 번째 호출부터는 후속 메시지 경로를 써야 한다는 뜻입니다.

버튼 인터랙션

# 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))

콜백 인자 순서를 조심하세요

Pycord의 버튼 콜백 시그니처는 (self, button, interaction)으로 컴포넌트가 먼저입니다. 문서 설명을 그대로 옮기면, 데코레이트되는 함수는 세 개의 파라미터를 가져야 하며 각각 discord.ui.View를 나타내는 self, 눌린 discord.ui.Button, 그리고 받게 되는 discord.Interaction입니다. Select도 같은 순서로 (self, select, interaction)입니다. 모달만 (self, interaction)으로 컴포넌트 인자가 없습니다.

순서를 뒤집어도 파이썬은 아무 말도 하지 않습니다. 타입 힌트를 반대로 적은 것뿐이니까요. 런타임에 interaction이라는 변수에 버튼 객체가 들어간 채로 await interaction.response.send_message(...)를 부르게 되고, 버튼에는 그런 속성이 없으니 AttributeError가 납니다. 다른 라이브러리 예제를 옮겨 왔을 때 첫 클릭에서 터지는 전형적인 모습입니다.

View의 timeout과 지속형 View

discord.ui.Viewtimeout 기본값은 180.0초입니다. 지정하지 않으면 3분 뒤 View가 만료되고 on_timeout 코루틴이 호출됩니다. 문서 설명은 명시적인 중단 없이 View의 타임아웃이 경과했을 때 호출된다는 것입니다. 위 가위바위보 View는 timeout=30이라 30초 안에 안 누르면 끝납니다.

문제는 역할 선택 패널처럼 계속 살아 있어야 하는 버튼입니다. 조건이 두 가지입니다. 문서 표현 그대로, timeout을 None으로 두어야 하고 View의 모든 자식이 custom_id 속성을 가지고 있어야 합니다.

두 번째만 지키고 첫 번째를 빼먹는 일이 자주 생깁니다. 증상은 이렇습니다. 배포 직후에는 버튼이 잘 눌리는데, 봇을 재시작하고 나면 예전 메시지의 버튼이 죽어 있습니다. 봇 프로세스가 View 객체를 메모리에 들고 있다가 재시작으로 잃어버린 것입니다. 지속형 View는 시작 시점에 다시 등록해야 하고, 그러려면 timeout이 None이어야 합니다.

반대로 게임처럼 한 번 쓰고 버리는 View라면 custom_id를 고정하지 않는 편이 낫습니다. 같은 명령어를 여러 사용자가 동시에 실행하면 같은 custom_id를 가진 버튼이 채널에 여러 개 떠다니게 됩니다.

discord.ButtonStyle은 primary, secondary, success, danger, link 다섯 개이고 별칭이 붙어 있습니다. blurple은 primary, grey와 gray는 secondary, green은 success, red는 danger, url은 link와 같은 값입니다. 예제마다 표기가 갈리는 이유가 이것입니다.

모달 (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())

discord.InputTextStyle에도 별칭이 있습니다. 열거형 정의를 보면 shortsingleline이 둘 다 1이고 paragraph, multiline, long이 모두 2입니다. 위 예제의 longparagraph는 같은 값입니다.

모달을 여는 자리와 3초 규칙

모달은 슬래시 커맨드에서 await ctx.send_modal(modal)로 열거나, 버튼 콜백에서 await interaction.response.send_modal(...)로 엽니다. 중요한 제약은 모달이 최초 응답이어야 한다는 점입니다. defer를 먼저 부르고 나서 모달을 열 수는 없습니다. 최초 응답 슬롯을 이미 써 버렸기 때문입니다.

그래서 무거운 조회를 먼저 하고 그 결과를 채운 모달을 띄우는 설계는, 조회가 3초 안에 안 끝나면 성립하지 않습니다. 순서를 뒤집으세요. 모달을 먼저 띄워 입력을 받고, 제출된 뒤에 defer를 걸고 무거운 작업을 하는 것입니다.

위 예제의 FEEDBACK_CHANNEL_ID는 정의되지 않은 상수입니다. 환경 변수에서 읽어 정수로 변환해 두어야 합니다. self.children 인덱스로 값을 꺼내는 것도 위험합니다. 입력 필드를 추가하거나 순서를 바꾸면 인덱스가 조용히 밀립니다.

discord.ui.Modaldiscord.ui.InputText의 전체 생성자 인자는 버전에 따라 달라질 수 있습니다. labelstyle 외의 인자는 사용 중인 버전의 문서에서 확인하세요.

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
        )

Select 콜백도 버튼과 같은 순서, 즉 (self, select, interaction)입니다. select.values는 리스트라서 max_values가 1이어도 리스트로 옵니다. 원소를 꺼내는 것을 잊으면 화면에 대괄호가 그대로 찍힙니다.

위 예제는 선택 결과를 알려 주기만 합니다. 실제로 역할을 부여하려면 조건이 둘 더 필요합니다. 봇에게 역할 관리 권한이 있어야 하고, 봇의 최상위 역할이 부여하려는 역할보다 서버 역할 목록에서 위에 있어야 합니다. 두 번째 조건 때문에 권한은 다 줬는데도 HTTP 403 계열의 오류가 나는 일이 흔합니다.

에러 처리

# 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)

on_application_command_error는 슬래시 커맨드 콜백에서 올라온 예외만 받습니다. 버튼, Select, 모달 콜백의 예외는 여기로 오지 않습니다. View에는 별도의 오류 처리 지점이 있으니, 인터랙션이 많은 봇이라면 그쪽을 따로 두어야 합니다.

ctx.respond로 오류를 알릴 때도 주의가 필요합니다. 3초를 이미 넘겨 토큰이 무효화된 뒤라면 아무것도 보낼 수 없습니다. 사용자 화면에는 실패만 남고 흔적은 로그에만 남습니다. 그래서 오류 핸들러는 반드시 로깅을 먼저 하고 알림을 나중에 시도해야 합니다. 위 코드가 traceback.print_exception을 먼저 부르는 이유입니다. 순서를 바꾸면 알림 전송이 실패했을 때 원래 예외까지 사라집니다.

레이트 리밋: 429가 오면 무슨 일이 벌어지나

Discord API는 한도를 넘으면 HTTP 429를 돌려줍니다. 응답에는 X-RateLimit-Limit, Retry-After, X-RateLimit-Reset-After 헤더가 붙습니다. 문서가 명시하는 전역 한도는 이렇습니다. 모든 봇은 초당 50회까지 API 요청을 보낼 수 있습니다.

여기에 더해 무효 요청 한도가 따로 있습니다. 10분 동안 무효 요청 10,000회를 넘기면 차단됩니다. 무효 요청이란 401, 403, 429 응답을 말합니다. 토큰이 만료된 봇이 재시도 루프에 갇히면 이 한도에 닿습니다.

Pycord가 대신 해 주는 것

대부분의 경우 직접 처리할 필요가 없습니다. Pycord의 HTTPClient는 각 요청을 for tries in range(5): 루프로 감싸고, 429가 오면 응답 본문에서 재시도 대기 시간을 읽어 그만큼 잠들었다가 다시 시도합니다. 전역 한도인지 버킷별 한도인지도 응답의 global 플래그로 구분합니다.

즉 짧은 초과는 지연으로 흡수됩니다. 코드에서 보이는 증상은 오류가 아니라 명령어가 느려지는 것입니다. 대량 삭제나 대량 전송 명령어가 유독 느리다면 대개 여기입니다. 오류 로그를 뒤져도 안 나오니, 이 동작을 모르면 원인을 찾기 어렵습니다.

직접 잡아야 할 때

다섯 번의 재시도로도 안 되면 예외가 올라옵니다. 여기서 자주 나오는 실수가 있습니다. discord.RateLimited는 Pycord v2.8.1에 없습니다. discord.py에는 있어서 그 이름을 쓰는 예제가 돌아다니지만, Pycord에서 임포트하면 실패합니다.

Pycord에서는 discord.HTTPException을 잡고 .status가 429인지 확인하세요. 이 예외는 .status, .code, .text, .response 속성을 가집니다. .status는 HTTP 상태 코드, .code는 Discord 고유의 오류 코드입니다.

2500 길드를 넘으면

문서 표현 그대로, 각 샤드는 최대 2500개 길드까지 지원할 수 있고 2500개 이상의 길드에 들어간 앱은 반드시 샤딩을 활성화해야 합니다. 어느 길드가 어느 샤드로 가는지는 식이 정해져 있습니다.

shard_id = (guild_id >> 22) % num_shards

샤드 수를 잘못 잡으면 게이트웨이가 close code 4010 Invalid Shard로 연결을 끊습니다. Pycord에서는 discord.AutoShardedBot 또는 commands.AutoShardedBot을 쓰면 됩니다. 대부분의 봇에게는 없는 문제지만, 공개 봇을 계획한다면 프로세스가 결국 여러 개로 쪼개진다는 사실을 처음부터 전제해 두세요. 프로세스 메모리에 상태를 두는 설계는 그 시점에 전부 다시 써야 합니다.

관리 명령어

# 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))

purge의 함정

ctx.channel.purge(limit=amount)는 편해 보이지만 제약이 있습니다. Discord의 대량 삭제 엔드포인트는 오래된 메시지를 지우지 못합니다. 큰 숫자를 넣어도 최근 것만 지워지고 나머지는 조용히 남습니다. 정확한 기준 일수는 Discord API 문서에서 확인하세요.

응답 순서도 문제입니다. 위 코드는 purge를 먼저 하고 ctx.respond를 부르는데, 삭제가 오래 걸리면 3초를 넘길 수 있습니다. 그러면 삭제는 됐는데 사용자에게는 실패로 보입니다. await ctx.defer(ephemeral=True)를 먼저 걸고 후속 메시지를 보내는 쪽이 안전합니다.

권한 체크가 실제로 검사하는 것

@commands.has_permissions(manage_messages=True)는 명령어를 실행한 사용자의 권한을 봅니다. 봇 자신의 권한은 보지 않습니다. 사용자에게는 권한이 있는데 봇에게 없으면 체크는 통과하고 실제 삭제 단계에서 403이 납니다. 그러면 MissingPermissions 분기가 아니라 마지막 else로 떨어져서, 사용자는 권한 안내 대신 일반 오류 메시지를 보게 됩니다.

슬래시 커맨드 데코레이터의 default_member_permissions를 함께 쓰면 Discord UI 단계에서 권한 없는 사용자에게 명령어를 아예 숨길 수 있습니다. 다만 UI 필터일 뿐이라 코드의 권한 체크를 대신하지는 못합니다. 둘 다 두세요.

배포

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

Restart=alwaysRestartSec=10이 붙어 있는 데는 이유가 있습니다. 게이트웨이 연결은 상태를 가진 WebSocket이라, 네트워크가 끊기거나 Discord가 연결을 닫으면 프로세스가 종료될 수 있습니다.

주의할 것은 재시작 루프입니다. 토큰이 틀렸거나 인텐트가 꺼져 있으면 봇은 켜자마자 죽고 10초 뒤 다시 뜹니다. 밤새 방치하면 무효 요청 한도에 닿습니다. 배포 직후 몇 분은 journalctl -u discord-bot -f로 지켜보세요. 로그인 메시지가 한 번 뜨고 조용해지면 정상이고, 같은 로그가 10초 주기로 반복되면 루프입니다.

WorkingDirectory도 중요합니다. 위 bot.py./cogs를 상대 경로로 훑기 때문에, 작업 디렉터리가 다르면 Cog를 하나도 못 찾습니다. 로컬에서는 되던 것이 서비스로 띄우면 명령어가 사라지는 경우 이것도 후보입니다. .env는 토큰이 평문으로 들어 있는 파일이니 소유자만 읽게 두고, 실수로 커밋했다면 파일을 지우는 것으로 끝내지 말고 포털에서 토큰을 Reset 하세요.

실패 사례: 증상에서 원인으로

지금까지 나온 함정을 증상에서 출발해 다시 정렬했습니다. 봇이 이상할 때 위에서부터 훑으면 대부분 여기서 끝납니다.

  • 봇이 아예 안 켜진다 — 토큰 문자열, .env 경로와 load_dotenv() 호출 위치, 그리고 discord.PrivilegedIntentsRequired인지 순서로 확인하세요. 마지막이라면 포털의 Privileged Gateway Intents 스위치입니다.
  • 봇은 온라인인데 슬래시 커맨드가 안 보인다 — 초대 URL의 applications.commands 스코프, debug_guilds 설정, Cog가 실제로 로드됐는지 순서입니다. 세 번째가 가장 놓치기 쉽습니다. 진입점을 async def setup(bot)으로 써 두었다면 Pycord는 예외 없이 그 Cog를 건너뜁니다.
  • 명령어는 보이는데 눌러도 실패한다 — 3초를 넘겼을 가능성이 큽니다. 콜백에서 외부 API나 DB를 건드린다면 await ctx.defer()를 맨 앞에 넣으세요. 로그에 예외가 하나도 안 남았다면 특히 이쪽입니다.
  • message.content가 항상 빈 문자열이다 — MESSAGE CONTENT 인텐트입니다. DM이나 멘션으로 테스트하면 정상으로 보이니, 서버 채널에서 멘션 없이 테스트하세요.
  • 재시작하면 예전 버튼이 죽는다 — 지속형 View 조건입니다. timeout=None과 모든 자식의 custom_id가 함께 필요하고, 시작 시 View를 다시 등록해야 합니다.
  • 버튼 첫 클릭에서 AttributeError가 난다 — 콜백 인자 순서입니다. Pycord는 컴포넌트가 먼저입니다.
  • 명령어가 갑자기 느려졌다 — 레이트 리밋 지연입니다. Pycord가 429를 받고 대기했다가 재시도하는 중이라 오류로는 안 보입니다.
  • 로컬에서는 되던 게 배포하면 안 된다debug_guilds, WorkingDirectory, 인텐트 순서로 보세요.

언제 Discord 봇을 쓰지 않나

Discord 봇은 상태 있는 WebSocket 연결을 상시 유지하는 프로세스입니다. 이 구조가 맞지 않는 경우가 분명히 있습니다.

알림만 보내면 될 때는 웹훅

채널에 메시지 하나 던지는 것이 전부라면 봇이 필요 없습니다. 채널 설정에서 웹훅 URL을 만들고 POST 한 번 하면 끝입니다. 프로세스도, 토큰 수명 관리도, 재시작 정책도, 인텐트 심사도 없습니다. CI 결과, 배포 알림, 모니터링 경보가 전부 여기 해당합니다. 봇으로 만들면 상시 프로세스 하나와 게이트웨이 연결 하나가 유지비로 붙고, 그 프로세스가 죽으면 알림도 함께 죽습니다. 웹훅에는 죽을 프로세스가 없습니다.

슬래시 커맨드만 필요하면 게이트웨이가 없어도 된다

Discord는 인터랙션을 HTTP로 받는 방식도 지원합니다. 공개 엔드포인트를 등록해 두면 Discord가 그리로 요청을 보내고, 봇은 게이트웨이 연결 없이 서버리스 함수로도 동작할 수 있습니다. 대신 게이트웨이 이벤트를 전제로 하는 라이브러리의 편의는 포기해야 합니다. 메시지 수신, 멤버 입퇴장, 프레즌스는 애초에 받을 수 없습니다. 어느 쪽이든 3초 규칙은 똑같이 적용되고, 서버리스라면 콜드 스타트가 그 3초를 갉아먹습니다.

전달 보장이 필요한 일에는 맞지 않는다

봇이 재시작되는 동안 들어온 메시지는 사라집니다. 게이트웨이가 재연결 시 일부 이벤트를 다시 보내 주긴 하지만 큐처럼 믿어서는 안 됩니다. 반드시 처리해야 하는 이벤트가 있다면 봇은 접수 창구까지만 맡고 실제 처리는 별도의 큐와 워커에 넘기세요.

봇 안에서 긴 작업을 돌리는 것도 위험합니다. 이벤트 루프 하나가 모든 인터랙션을 처리하므로, 한 명령어가 오래 붙잡으면 다른 사용자의 3초 예산까지 갉아먹습니다. 같은 이유로 상태를 프로세스 메모리에 두면 안 됩니다. View 객체, 진행 중인 게임, 사용자별 설정이 전부 재시작으로 사라집니다. 앞서 본 지속형 View 문제가 이 원칙의 작은 사례입니다.

업무용 워크플로에는 과할 수 있다

폼을 받고 승인 단계를 거치고 기록을 남기는 일은 모달과 버튼으로 만들 수는 있습니다. 다만 감사 로그, 권한 위임, 데이터 보존을 전부 직접 구현해야 합니다. 판단 기준은 하나입니다. 사람들이 이미 Discord에 상주하고 있는가. 커뮤니티가 거기 산다면 봇은 강력한 선택지이고, 하루에 한 번 들어오는 채널이라면 만들어도 아무도 안 씁니다.

참고 자료

  • Pycord 공식 문서discord.Botcommands.Bot의 관계, load_extension 시그니처, slash_commandoption 인자, View timeout 기본값과 지속형 View 조건, sync_commandsdebug_guilds (2026-08-16 확인)
  • Pycord Intents 가이드 — 특권 인텐트 목록, 포털과 코드 양쪽에서 켜야 한다는 주의 (2026-08-16 확인)
  • Discord Docs: Receiving and Responding to Interactions — 3초 데드라인, 15분 토큰 유효 기간, 지연 응답 타입 5와 6 (2026-08-16 확인)
  • Discord Docs: Gateway — 인텐트별 이벤트, 샤드당 2500 길드 한도, 샤드 계산식 (2026-08-16 확인)
  • Discord Docs: Rate Limits — 초당 50 요청, 429 응답 헤더, 무효 요청 한도 (2026-08-16 확인)

이 글의 코드와 시그니처는 Pycord v2.8.1(2026-07-25 릴리스) 기준입니다. 여기에 없는 API는 사용 중인 버전의 문서에서 확인하세요.


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

퀴즈

Q1. Pycord에서 Cog 진입점 setup은 동기 함수인가요, 코루틴인가요?

동기 함수입니다. def setup(bot)으로 쓰고, bot.load_extension(...) 앞에도 await를 붙이지 않습니다. discord.py 2.x는 정반대로 async def setup(bot)await bot.load_extension(...)을 요구합니다. discord.py 예제를 Pycord로 옮기면 Cog가 예외 없이 조용히 로드되지 않고, 명령어만 목록에서 사라집니다.

Q2. 버튼 콜백의 인자 순서는 무엇인가요?

(self, button, interaction)으로 컴포넌트가 먼저입니다. Select도 (self, select, interaction)으로 같은 순서이고, 모달만 (self, interaction)으로 컴포넌트 인자가 없습니다. 순서를 뒤집어도 파이썬은 경고하지 않고, 첫 클릭 때 AttributeError로 드러납니다.

Q3. 인터랙션의 3초와 15분은 각각 무엇을 뜻하나요?

3초는 최초 응답의 데드라인입니다. 넘기면 인터랙션 토큰이 무효화됩니다. 15분은 토큰의 유효 기간으로, 최초 응답을 제때 보낸 뒤 그 안에서 후속 메시지를 보내거나 응답을 수정할 수 있습니다. 3초를 넘길 것 같으면 await ctx.defer()로 먼저 자리를 잡으세요.

Q4. 봇을 재시작해도 예전 메시지의 버튼이 동작하게 하려면?

지속형 View 조건 두 가지를 모두 만족해야 합니다. timeout을 None으로 두고, View의 모든 자식에 custom_id를 설정하는 것입니다. 여기에 더해 봇 시작 시점에 View를 다시 등록해야 합니다. custom_id만 붙이고 timeout을 그대로 두면 기본값 180.0초가 적용되어 지속형이 되지 않습니다.

Q5. Pycord에서 429를 직접 처리하려면 어떤 예외를 잡아야 하나요?

discord.HTTPException을 잡고 .status가 429인지 확인합니다. discord.RateLimited는 discord.py에는 있지만 Pycord v2.8.1에는 없습니다. 다만 Pycord의 HTTPClient가 최대 다섯 번까지 자동 재시도하므로, 대부분의 429는 예외가 아니라 명령어 지연으로 나타납니다.

Q6. message.content가 항상 빈 문자열인데 DM으로 테스트하면 잘 되는 이유는?

MESSAGE CONTENT 특권 인텐트가 없어서입니다. 봇과의 DM, 봇을 멘션한 메시지, 봇 자신이 보낸 메시지, 그리고 메시지 컨텍스트 메뉴 명령어가 대상으로 삼은 메시지는 이 인텐트 없이도 내용이 그대로 옵니다. 그래서 서버 채널에서 멘션 없이 보낸 평범한 메시지로 테스트해야 문제가 드러납니다.