디스코드 봇 만들기#25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제)
목차11개 항목
안녕하세요! Mojangcam입니다 😎
지난 #24편에서는 채널 난독화(Channel Obfuscation)에 대응하는 법을 봤어요. 봇이 볼 수 없는 채널이 생겨도 안 터지게 만드는 작업이었죠.
이번 #25편은 그 연장선이에요. 봇이 에러 하나 때문에 조용히 멈추는 일을 없애는 편입니다. 슬래시 명령어에서 에러가 나면 사용자한테는 "애플리케이션이 응답하지 않습니다"만 뜨고, 개발자인 나는 무슨 일이 있었는지 전혀 모르죠. 터미널을 껐다면 로그도 없고요.
오늘은 세 가지를 만들어요. 전역 에러 핸들러로 모든 명령어 에러를 한 곳에서 받고, logging 모듈로 파일에 자동 기록하고, 심각한 에러는 관리자 채널에 임베드로 알림을 보냅니다. 실제 서버에 올려둔 봇이라면 없으면 안 되는 부분이에요!
📌 준비물
- Python 3.10+ (python-dotenv 최신 버전 기준)
- discord.py 2.x (2.7 기준) —
pip install -U discord.py - python-dotenv —
pip install python-dotenv - #19편에서 만든 Cog 구조 (
bot.py+cogs/폴더) - 에러 알림을 받을 관리자 전용 채널 ID 1개 (개발자 모드 켜고 채널 우클릭 → ID 복사)
.env 파일은 이렇게 준비해주세요.
DISCORD_TOKEN=여기에_봇_토큰
ERROR_LOG_CHANNEL_ID=123456789012345678💡
.env는 꼭 .gitignore에 넣어주세요. 깃허브에 토큰이 올라가면 디스코드가 자동으로 토큰을 무효화해버려요.
🧠 왜 전역 핸들러가 필요할까요?
명령어마다 try / except를 붙이는 방법도 있어요. 근데 명령어가 20개가 되는 순간 같은 코드를 20번 복붙하게 됩니다. 게다가 내가 예상 못 한 에러는 어차피 못 잡아요.
discord.py 2.x는 슬래시 명령어 전용 에러 통로를 따로 줍니다.
- CommandTree.on_error : 슬래시 명령어(app_commands)에서 난 에러가 전부 여기로 모임
- on_command_error :
!명령어형태의 접두사 명령어용 (우리는 슬래시 위주라 보조로만) - on_error : 이벤트 리스너(on_message 등)에서 난 에러
이 세 개만 잡아두면 봇에서 나는 에러는 사실상 다 걸러집니다.
📌 1단계 - logging 설정부터 잡기 (utils/logger.py)
print()는 터미널을 닫는 순간 사라져요. 파이썬 기본 내장 logging을 쓰면 파일로 남고, 날짜·레벨까지 자동으로 붙습니다.
# utils/logger.py
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
def setup_logger() -> None:
fmt = logging.Formatter(
"[{asctime}] [{levelname:<8}] {name}: {message}",
"%Y-%m-%d %H:%M:%S",
style="{",
)
# 1) 파일 저장 (5MB씩 최대 5개 보관 후 오래된 것부터 삭제)
file_handler = RotatingFileHandler(
LOG_DIR / "bot.log",
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(fmt)
# 2) 터미널 출력
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(fmt)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(file_handler)
root.addHandler(stream_handler)
# discord 라이브러리 내부 로그는 조금 조용히
logging.getLogger("discord.http").setLevel(logging.WARNING)하나씩 뜯어볼게요 💡
- RotatingFileHandler : 로그 파일이 무한정 커지는 걸 막아줘요.
maxBytes를 넘으면bot.log.1,bot.log.2로 넘기고,backupCount개를 넘으면 오래된 것부터 지웁니다. 이거 안 하면 몇 달 뒤 로그가 수 GB가 돼요. - encoding="utf-8" : 윈도우에서 한글 로그가 깨지는 걸 막는 필수 옵션이에요.
- style="{" : 포맷 문자열을
{message}형태로 쓰겠다는 뜻이에요.%(message)s보다 읽기 편하죠. - discord.http 레벨 조정 : 이걸 안 하면 API 요청 로그가 화면을 다 덮어버려요.
📌 2단계 - 전역 에러 핸들러 Cog 만들기 (cogs/error_handler.py)
이제 본체예요. 에러 종류를 사용자 잘못과 내 코드 잘못으로 나누는 게 핵심입니다.
# cogs/error_handler.py
import logging
import os
import traceback
import discord
from discord import app_commands
from discord.ext import commands
log = logging.getLogger(__name__)
class ErrorHandler(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
# 기존 핸들러를 백업해두고 우리 것으로 교체
self._old_tree_error = bot.tree.on_error
bot.tree.on_error = self.on_app_command_error
async def cog_unload(self) -> None:
# Cog를 언로드하면 원래 핸들러로 되돌려요 (핫리로드 대비)
self.bot.tree.on_error = self._old_tree_error
async def reply(self, interaction: discord.Interaction, msg: str, color: discord.Color):
embed = discord.Embed(description=msg, color=color)
# 이미 응답했거나 defer 했으면 followup으로 보내야 해요
if interaction.response.is_done():
await interaction.followup.send(embed=embed, ephemeral=True)
else:
await interaction.response.send_message(embed=embed, ephemeral=True)
async def on_app_command_error(
self,
interaction: discord.Interaction,
error: app_commands.AppCommandError,
) -> None:
# ── ① 사용자 잘못: 친절하게 안내만 하고 끝 ──
if isinstance(error, app_commands.CommandOnCooldown):
await self.reply(
interaction,
f"⏳ 너무 자주 쓰고 있어요! **{error.retry_after:.1f}초** 뒤에 다시 시도해주세요.",
discord.Color.orange(),
)
return
if isinstance(error, app_commands.MissingPermissions):
perms = ", ".join(error.missing_permissions)
await self.reply(
interaction,
f"🚫 이 명령어를 쓰려면 **{perms}** 권한이 필요해요.",
discord.Color.orange(),
)
return
if isinstance(error, app_commands.BotMissingPermissions):
perms = ", ".join(error.missing_permissions)
await self.reply(
interaction,
f"🤖 봇에게 **{perms}** 권한이 없어요. 서버 관리자에게 요청해주세요!",
discord.Color.orange(),
)
return
if isinstance(error, app_commands.CheckFailure):
await self.reply(
interaction,
"🚫 이 명령어를 사용할 수 있는 조건이 아니에요.",
discord.Color.orange(),
)
return
# ── ② 내 코드 잘못: 사용자에겐 사과, 나에겐 상세 리포트 ──
await self.reply(
interaction,
"⚠️ 처리 중 문제가 생겼어요. 관리자에게 자동으로 전달했습니다!",
discord.Color.red(),
)
command_name = interaction.command.name if interaction.command else "알 수 없음"
log.exception("슬래시 명령어 '%s' 실행 중 에러", command_name, exc_info=error)
await self.report_to_admin(interaction, error, command_name)
async def report_to_admin(self, interaction, error, command_name: str) -> None:
channel_id = os.getenv("ERROR_LOG_CHANNEL_ID")
if not channel_id:
return
channel = self.bot.get_channel(int(channel_id))
if channel is None:
return
tb = "".join(
traceback.format_exception(type(error), error, error.__traceback__)
)
# 디스코드 메시지는 2000자 제한! 코드블록 여백까지 감안해 잘라줍니다
tb = tb[-1800:]
embed = discord.Embed(
title="🔥 명령어 실행 에러",
description=f"```py\n{tb}\n```",
color=discord.Color.red(),
timestamp=discord.utils.utcnow(),
)
embed.add_field(name="명령어", value=f"`/{command_name}`", inline=True)
embed.add_field(name="사용자", value=f"{interaction.user} (`{interaction.user.id}`)", inline=True)
guild_name = interaction.guild.name if interaction.guild else "DM"
embed.add_field(name="서버", value=guild_name, inline=True)
await channel.send(embed=embed)
# ── ③ 이벤트 리스너에서 난 에러도 잡기 ──
@commands.Cog.listener()
async def on_error(self, event_method: str, *args, **kwargs) -> None:
log.exception("이벤트 '%s' 처리 중 에러", event_method)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(ErrorHandler(bot))하나씩 뜯어볼게요 💡
- bot.tree.on_error 교체 :
@bot.tree.error데코레이터를 쓸 수도 있지만, Cog 안에서는 이렇게 직접 바꿔 끼우는 게 깔끔해요.cog_unload에서 원래대로 돌려놔야 #19편의 핫리로드를 반복해도 핸들러가 꼬이지 않습니다. - interaction.response.is_done() : 이미
defer()나send_message()를 한 뒤라면 같은 방법으로 또 보낼 수 없어요. 반드시followup으로 보내야 합니다. 에러 핸들러가 조용히 실패하는 원인 1위예요. - ephemeral=True : 에러 메시지는 명령어를 쓴 사람에게만 보이게 해요. 채널이 지저분해지는 걸 막습니다.
- log.exception() :
log.error()와 달리 스택 트레이스 전체를 자동으로 같이 남겨줘요. 에러 로그는 무조건 이걸 쓰세요. - traceback 1800자 자르기 : 디스코드 임베드 description은 4096자, 메시지는 2000자 제한이 있어요. 긴 트레이스백은 뒷부분이 중요하니까
[-1800:]로 뒤에서 자릅니다.
📌 3단계 - bot.py에 연결하기
#19편에서 만든 진입점에 로거 설정 한 줄과 Cog 로드만 추가하면 끝이에요.
# bot.py — discord.py 2.x (2.7 기준) / Python 3.10+
import asyncio
import logging
import os
import discord
from discord.ext import commands
from dotenv import load_dotenv
from utils.logger import setup_logger
load_dotenv()
setup_logger()
log = logging.getLogger(__name__)
INITIAL_COGS = [
"cogs.error_handler", # 가장 먼저 올려두는 게 안전해요
# "cogs.poll",
# "cogs.automod",
]
class ServerBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
super().__init__(command_prefix="!", intents=intents)
async def setup_hook(self) -> None:
for cog in INITIAL_COGS:
try:
await self.load_extension(cog)
log.info("Cog 로드 완료: %s", cog)
except Exception:
log.exception("Cog 로드 실패: %s", cog)
await self.tree.sync()
async def on_ready(self):
log.info("로그인 성공: %s (ID: %s)", self.user, self.user.id)
async def main():
token = os.getenv("DISCORD_TOKEN")
if not token:
raise RuntimeError(".env에 DISCORD_TOKEN이 없어요!")
async with ServerBot() as bot:
await bot.start(token)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
log.info("봇을 종료합니다.")에러 핸들러 Cog를 가장 먼저 로드하는 게 포인트예요. 다른 Cog가 로드되다 터져도 로그는 남습니다.
🛠️ 테스트용 명령어 (일부러 터뜨려보기)
제대로 도는지 확인하려면 일부러 터지는 명령어를 하나 만들어보세요.
# cogs/debug.py
from discord import app_commands
from discord.ext import commands
class Debug(commands.Cog):
def __init__(self, bot):
self.bot = bot
@app_commands.command(name="에러테스트", description="일부러 에러를 발생시켜요")
async def error_test(self, interaction):
raise ValueError("이건 테스트용 에러예요!")
@app_commands.command(name="쿨다운테스트", description="쿨다운을 확인해요")
@app_commands.checks.cooldown(1, 10.0)
async def cooldown_test(self, interaction):
await interaction.response.send_message("성공! 10초 뒤에 다시 써보세요.")
async def setup(bot):
await bot.add_cog(Debug(bot))📌 실행 결과
/에러테스트를 실행하면 이렇게 동작해요.
- 사용자에게는 나만 보이는 빨간 임베드로 "⚠️ 처리 중 문제가 생겼어요" 안내
logs/bot.log에 시간·명령어·스택 트레이스가 기록- 관리자 채널에 트레이스백 + 사용자 + 서버명이 담긴 🔥 임베드 전송
/쿨다운테스트를 10초 안에 두 번 누르면 "⏳ 너무 자주 쓰고 있어요! 8.3초 뒤에 다시 시도해주세요." 가 뜹니다. 봇은 멈추지 않아요. 이게 핵심이에요!


📌 자주 나는 에러
1. InteractionResponded: This interaction has already been responded to before
- 원인: 에러 핸들러에서
interaction.response.send_message()를 호출했는데, 명령어 안에서 이미 응답하거나defer()를 한 상태예요. - 해결: 위 코드처럼
interaction.response.is_done()으로 분기해서followup.send()를 쓰세요.
2. NotFound: 404 Not Found (error code: 10062): Unknown interaction
- 원인: 상호작용은 3초 안에 첫 응답을 해야 해요. 오래 걸리는 작업 도중 에러가 나면 이미 만료된 상태예요.
- 해결: 무거운 명령어는 시작하자마자
await interaction.response.defer(ephemeral=True)를 호출해 15분으로 늘려두세요.
3. 로그 파일에 한글이 ????로 깨져요
- 원인: 윈도우 기본 인코딩(cp949) 때문이에요.
- 해결:
RotatingFileHandler(..., encoding="utf-8")을 꼭 넣으세요. 이미 위 코드에 들어가 있어요.
4. 에러가 나도 관리자 채널에 아무것도 안 와요
- 원인:
ERROR_LOG_CHANNEL_ID가.env에 없거나, 봇이 그 채널을 못 보거나(권한·#24편 난독화),get_channel()이 캐시에 없어None을 반환한 경우예요. - 해결: 채널 ID를 다시 확인하고, 캐시 문제라면
await self.bot.fetch_channel(int(channel_id))로 바꿔보세요.
5. Cog 'ErrorHandler' 리로드 후 핸들러가 두 번 실행돼요
- 원인:
cog_unload에서 원래 핸들러를 복구하지 않았을 때 생겨요. - 해결: 위 코드의
_old_tree_error백업·복구 패턴을 그대로 쓰세요.
💡 보너스 - 로그 레벨 나눠 쓰기
- DEBUG : 개발 중에만 보는 상세 값 (변수, 쿼리 결과)
- INFO : 정상 동작 기록 (봇 로그인, Cog 로드, 명령어 사용)
- WARNING : 아직 안 죽었지만 이상함 (권한 없음, API 재시도)
- ERROR : 기능 하나가 실패 (명령어 예외)
- CRITICAL : 봇 전체가 못 돌아감 (DB 연결 실패)
운영 중에는 root.setLevel(logging.INFO)로 두고, 문제 추적할 때만 DEBUG로 내리면 로그가 깔끔해요.
💬 마무리
오늘은 봇의 안전벨트를 달았어요. 정리하면 이렇습니다.
utils/logger.py— RotatingFileHandler로 파일 로그 + 용량 관리cogs/error_handler.py—tree.on_error교체로 모든 슬래시 명령어 에러를 한 곳에서 처리- 사용자 잘못은 친절한 안내, 내 코드 잘못은 관리자 채널 자동 리포트
이제 봇이 조용히 죽는 일은 없어요. 에러가 나도 어디서, 누가, 왜 났는지 바로 알 수 있으니까요 🙌
다음 #26편에서는 드디어 Docker로 봇 배포하기를 다뤄요. 내 PC를 꺼도 봇이 24시간 돌아가게 만드는 편입니다. 기대해주세요!
📚 디스코드 봇 만들기 시리즈
- #18 투표(Poll) 기능 만들기
- #19 Cog로 코드 쪼개기
- #20 자동 환영 메시지·온보딩
- #21 키워드 자동응답·금지어 필터
- #22 슬래시 명령어 자동완성
- #23 aiosqlite로 비동기 DB 설계
- #24 채널 난독화 대응하기
- #25 에러 핸들링·로깅 체계 잡기 ← 지금 이 글!
- #26 Docker로 봇 배포하기 (예정)
- #27 🏆 커뮤니티 통합 관리 봇 완성 (예정)
연재 · 32편 중 25번째
디스코드 봇 만들기
- #1디스코드 봇 만들기 - Slash Command 완전 정복!
- #2Slash Command에 옵션(Arguments) 추가하기!
- #3Slash Command에 서브 커맨드(Subcommand) 넣기!
- #4Slash Command로 임베드 전송하기!
- #5버튼(Button) 상호작용 구현하기!
- #6드롭다운(Select Menu) 메뉴 구현하기!
- #7모달(Modal)로 입력 받기 구현하기 (파이썬 예제)
- #8임베드(Embed) 이미지·필드로 꾸미기 (파이썬 예제)
- #9AI(Claude·ChatGPT API) 연동하기! (파이썬 예제)
- #10다국어(i18n) 지원하기! (파이썬 예제)
- #11플라스크(Flask)로 웹 대시보드 만들기! (파이썬 예제)
- #12명령어 쿨다운(Cooldown)·권한(Permission) 관리하기! (파이썬 예제)
- #13역할·채널 자동화로 서버 관리하기! (파이썬 예제)
- #14데이터 저장하기! JSON vs SQLite (파이썬 예제)
- #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제)
- #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제)
- #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제)
- #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제)
- #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제)
- #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제)
- #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제)
- #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제)
- #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제)
- #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제)
- #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제)지금 읽는 글
- #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제)
- #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제)
- #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제)
- #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제)
- #30투표·설문(Poll) 봇 만들기! 네이티브 투표 + 버튼으로 실시간 집계까지 (파이썬 예제) 🗳️
- #32서버 로그·감사(Audit Log) 채널 만들기! 메시지 삭제·수정·입퇴장·추방까지 자동 기록 (파이썬 예제) 🕵️
- #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
