카테고리: 디스코드 봇 만들기

디스코드 봇 만들기#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을 쓰면 파일로 남고, 날짜·레벨까지 자동으로 붙습니다.

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

이제 본체예요. 에러 종류를 사용자 잘못과 내 코드 잘못으로 나누는 게 핵심입니다.

Python
# 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 로드만 추가하면 끝이에요.

Python
# 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가 로드되다 터져도 로그는 남습니다.

🛠️ 테스트용 명령어 (일부러 터뜨려보기)

제대로 도는지 확인하려면 일부러 터지는 명령어를 하나 만들어보세요.

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

📌 실행 결과

/에러테스트를 실행하면 이렇게 동작해요.

  1. 사용자에게는 나만 보이는 빨간 임베드로 "⚠️ 처리 중 문제가 생겼어요" 안내
  2. logs/bot.log에 시간·명령어·스택 트레이스가 기록
  3. 관리자 채널에 트레이스백 + 사용자 + 서버명이 담긴 🔥 임베드 전송

/쿨다운테스트를 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로 내리면 로그가 깔끔해요.

💬 마무리

오늘은 봇의 안전벨트를 달았어요. 정리하면 이렇습니다.

  1. utils/logger.py — RotatingFileHandler로 파일 로그 + 용량 관리
  2. cogs/error_handler.py — tree.on_error 교체로 모든 슬래시 명령어 에러를 한 곳에서 처리
  3. 사용자 잘못은 친절한 안내, 내 코드 잘못은 관리자 채널 자동 리포트

이제 봇이 조용히 죽는 일은 없어요. 에러가 나도 어디서, 누가, 왜 났는지 바로 알 수 있으니까요 🙌

다음 #26편에서는 드디어 Docker로 봇 배포하기를 다뤄요. 내 PC를 꺼도 봇이 24시간 돌아가게 만드는 편입니다. 기대해주세요!

📚 디스코드 봇 만들기 시리즈

  • #18 투표(Poll) 기능 만들기
  • #19 Cog로 코드 쪼개기
  • #20 자동 환영 메시지·온보딩
  • #21 키워드 자동응답·금지어 필터
  • #22 슬래시 명령어 자동완성
  • #23 aiosqlite로 비동기 DB 설계
  • #24 채널 난독화 대응하기
  • #25 에러 핸들링·로깅 체계 잡기 ← 지금 이 글!
  • #26 Docker로 봇 배포하기 (예정)
  • #27 🏆 커뮤니티 통합 관리 봇 완성 (예정)

연재 · 32편 중 25번째

디스코드 봇 만들기

  1. #1디스코드 봇 만들기 - Slash Command 완전 정복!
  2. #2Slash Command에 옵션(Arguments) 추가하기!
  3. #3Slash Command에 서브 커맨드(Subcommand) 넣기!
  4. #4Slash Command로 임베드 전송하기!
  5. #5버튼(Button) 상호작용 구현하기!
  6. #6드롭다운(Select Menu) 메뉴 구현하기!
  7. #7모달(Modal)로 입력 받기 구현하기 (파이썬 예제)
  8. #8임베드(Embed) 이미지·필드로 꾸미기 (파이썬 예제)
  9. #9AI(Claude·ChatGPT API) 연동하기! (파이썬 예제)
  10. #10다국어(i18n) 지원하기! (파이썬 예제)
  11. #11플라스크(Flask)로 웹 대시보드 만들기! (파이썬 예제)
  12. #12명령어 쿨다운(Cooldown)·권한(Permission) 관리하기! (파이썬 예제)
  13. #13역할·채널 자동화로 서버 관리하기! (파이썬 예제)
  14. #14데이터 저장하기! JSON vs SQLite (파이썬 예제)
  15. #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제)
  16. #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제)
  17. #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제)
  18. #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제)
  19. #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제)
  20. #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제)
  21. #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제)
  22. #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제)
  23. #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제)
  24. #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제)
  25. #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제)지금 읽는 글
  26. #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제)
  27. #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제)
  28. #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제)
  29. #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제)
  30. #30투표·설문(Poll) 봇 만들기! 네이티브 투표 + 버튼으로 실시간 집계까지 (파이썬 예제) 🗳️
  31. #32서버 로그·감사(Audit Log) 채널 만들기! 메시지 삭제·수정·입퇴장·추방까지 자동 기록 (파이썬 예제) 🕵️
  32. #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
전체 32편 보기접기

글쓴이

Mojangcam

직접 코드를 짜고, 서버를 돌리고, 막힌 에러를 해결하며 배운 것들을 기록하는 개발·IT 실전 기록 블로그