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

디스코드 봇 만들기#28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제)

목차12개 항목

안녕하세요! Mojangcam입니다 😎

지난 편(#27 티켓 담당자 배정·우선순위)에서는 문의 티켓을 스태프에게 자동으로 배정하는 시스템을 만들었어요. 이번 #28편에서는 분위기를 확 바꿔서, 서버를 시끌벅적하게 만드는 레벨·경험치(XP) 시스템을 만들어 볼게요!

채팅을 칠 때마다 경험치가 쌓이고, 일정량이 모이면 레벨업 축하 메시지가 뜨고, /랭킹 명령어로 서버 상위 10명을 보여주는 것까지 한 번에 완성합니다. MEE6 같은 유명 봇이 돈 받고 파는 기능인데, 우리는 직접 만들어서 공짜로 쓰는 거죠 🙌

🧠 왜 레벨 시스템이 필요할까요?

서버를 만들어 놓으면 제일 무서운 게 채팅이 죽는 것이에요. 레벨 시스템은 이 문제를 아주 단순한 방법으로 해결해요.

  • 말을 할수록 숫자가 오르니까 눈에 보이는 보상이 생겨요
  • 랭킹판이 있으면 경쟁심이 생겨서 자연스럽게 대화가 늘어나요
  • 레벨 10 달성 시 자동으로 역할 부여 → 고인물 인증 뱃지처럼 쓸 수 있어요

실제로 게임 서버 커뮤니티, 스터디방, 팬 서버에서 가장 많이 쓰는 기능 TOP 3 안에 드는 게 바로 이 레벨 시스템이에요.

📌 준비물

  • Python 3.10 이상
  • discord.py 2.x (이 글은 2.7.1 기준) — pip install -U discord.py
  • aiosqlite (0.22.x 기준) — pip install aiosqlite (비동기 DB, #23편에서 다뤘어요)
  • python-dotenv — pip install python-dotenv
  • 디스코드 개발자 포털에서 MESSAGE CONTENT INTENT 활성화 (필수!)

한 줄로 설치하려면 이렇게 하세요.

터미널
pip install -U discord.py aiosqlite python-dotenv

그리고 프로젝트 폴더에 .env 파일을 만들어 주세요.

터미널
token=여기에_봇_토큰_붙여넣기

💡 팁: .env는 절대 깃허브에 올라가면 안 돼요! .gitignore에 .env 한 줄을 꼭 추가해 주세요. 토큰이 유출되면 봇이 그대로 탈취됩니다.

📌 1단계 - DB 테이블 만들기

경험치는 봇이 꺼져도 남아 있어야 하니까 파일 DB에 저장할게요.

Python
import aiosqlite

DB_PATH = "levels.db"

async def init_db():
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS levels (
                guild_id INTEGER NOT NULL,
                user_id  INTEGER NOT NULL,
                xp       INTEGER NOT NULL DEFAULT 0,
                level    INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY (guild_id, user_id)
            )
        """)
        await db.commit()

하나씩 뜯어볼게요 💡

  • guild_id : 서버 ID예요. 봇을 여러 서버에 넣어도 경험치가 섞이지 않게 막아줘요.
  • PRIMARY KEY (guild_id, user_id) : 두 값을 묶어서 기본키로 잡았어요. "A서버의 홍길동"이 딱 한 줄만 존재하게 해주는 장치예요.
  • aiosqlite : sqlite3의 비동기 버전이에요. 봇은 비동기로 돌아가니까 동기 DB를 쓰면 DB 작업 중에 봇 전체가 멈춰버려요.

📌 2단계 - 레벨 공식 정하기

레벨이 오를수록 필요한 경험치가 늘어나야 재미있겠죠? 계단식 공식을 쓸게요.

Python
def xp_for_next(level: int) -> int:
    """현재 레벨에서 다음 레벨까지 필요한 XP"""
    return 5 * (level ** 2) + 50 * level + 100

이 공식대로면 이렇게 올라가요.

레벨 다음 레벨까지 필요한 XP
0 → 1 100
1 → 2 155
5 → 6 475
10 → 11 1,100

초반엔 쭉쭉 오르다가 뒤로 갈수록 느려지는, 딱 게임스러운 곡선이에요 👍

📌 3단계 - 메시지마다 경험치 주기 (쿨다운 필수!)

여기가 핵심이에요. 쿨다운을 안 걸면 도배로 레벨을 올려버립니다.

Python
import random, time

XP_COOLDOWN = 60          # 초 단위
last_xp_time: dict[tuple[int, int], float] = {}

async def add_xp(guild_id: int, user_id: int, amount: int):
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT INTO levels (guild_id, user_id, xp) VALUES (?, ?, ?) "
            "ON CONFLICT(guild_id, user_id) DO UPDATE SET xp = xp + ?",
            (guild_id, user_id, amount, amount),
        )
        await db.commit()

        cur = await db.execute(
            "SELECT xp, level FROM levels WHERE guild_id = ? AND user_id = ?",
            (guild_id, user_id),
        )
        xp, level = await cur.fetchone()

    # 레벨업 판정 (한 번에 여러 레벨 오를 수도 있어요)
    leveled_up = False
    while xp >= xp_for_next(level):
        xp -= xp_for_next(level)
        level += 1
        leveled_up = True

    if leveled_up:
        async with aiosqlite.connect(DB_PATH) as db:
            await db.execute(
                "UPDATE levels SET xp = ?, level = ? WHERE guild_id = ? AND user_id = ?",
                (xp, level, guild_id, user_id),
            )
            await db.commit()
    return level if leveled_up else None

하나씩 뜯어볼게요 💡

  • ON CONFLICT ... DO UPDATE : SQLite의 UPSERT 문법이에요. 행이 없으면 새로 만들고, 있으면 XP를 더해줘요. SELECT 후 INSERT를 나눠 하면 동시에 메시지가 들어올 때 값이 깨질 수 있는데, 이걸 한 방에 막아줍니다.
  • while 반복문 : 한 번에 큰 XP를 받아도 레벨이 여러 칸 오르도록 처리했어요.
  • last_xp_time : 메모리에 마지막 지급 시각을 담아두는 딕셔너리예요. 봇을 껐다 켜면 초기화되지만, 쿨다운은 그래도 무방해요.

📌 4단계 - 슬래시 명령어로 레벨·랭킹 보여주기

Python
@bot.tree.command(name="레벨", description="내 레벨과 경험치를 확인해요")
async def level_cmd(interaction: discord.Interaction, 유저: discord.Member = None):
    target = 유저 or interaction.user
    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "SELECT xp, level FROM levels WHERE guild_id = ? AND user_id = ?",
            (interaction.guild_id, target.id),
        )
        row = await cur.fetchone()

    xp, level = row if row else (0, 0)
    need = xp_for_next(level)
    filled = int(20 * xp / need)
    bar = "█" * filled + "░" * (20 - filled)

    embed = discord.Embed(title=f"{target.display_name} 님의 레벨", color=0x5865F2)
    embed.add_field(name="레벨", value=f"**{level}**", inline=True)
    embed.add_field(name="경험치", value=f"{xp} / {need}", inline=True)
    embed.add_field(name="진행도", value=f"`{bar}`", inline=False)
    embed.set_thumbnail(url=target.display_avatar.url)
    await interaction.response.send_message(embed=embed)

💡 진행도 막대는 이미지가 아니라 유니코드 블록 문자(█ / ░)로 그렸어요. 이미지 생성 라이브러리 없이도 꽤 그럴듯해 보입니다!

📌 전체 코드 (복붙 OK)

Python
# 디스코드 봇 만들기 #28 - 레벨·경험치(XP) 시스템
# discord.py 2.x (검증 버전 2.7.1) / Python 3.10+
# pip install -U discord.py aiosqlite python-dotenv
import os
import random
import time

import aiosqlite
import discord
from discord.ext import commands
from dotenv import load_dotenv

load_dotenv()
token = os.getenv("token")

DB_PATH = "levels.db"
XP_COOLDOWN = 60           # 경험치 지급 쿨다운(초)
XP_MIN, XP_MAX = 15, 25    # 메시지당 지급 XP 범위
LEVEL_UP_CHANNEL = None    # 특정 채널 ID를 넣으면 그곳에만 알림

intents = discord.Intents.default()
intents.message_content = True   # 개발자 포털에서도 켜야 해요!
intents.members = True

bot = commands.Bot(command_prefix="!", intents=intents)
last_xp_time: dict[tuple[int, int], float] = {}

def xp_for_next(level: int) -> int:
    return 5 * (level ** 2) + 50 * level + 100

async def init_db():
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            """
            CREATE TABLE IF NOT EXISTS levels (
                guild_id INTEGER NOT NULL,
                user_id  INTEGER NOT NULL,
                xp       INTEGER NOT NULL DEFAULT 0,
                level    INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY (guild_id, user_id)
            )
            """
        )
        await db.commit()

async def add_xp(guild_id: int, user_id: int, amount: int):
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT INTO levels (guild_id, user_id, xp) VALUES (?, ?, ?) "
            "ON CONFLICT(guild_id, user_id) DO UPDATE SET xp = xp + ?",
            (guild_id, user_id, amount, amount),
        )
        await db.commit()
        cur = await db.execute(
            "SELECT xp, level FROM levels WHERE guild_id = ? AND user_id = ?",
            (guild_id, user_id),
        )
        xp, level = await cur.fetchone()

    leveled_up = False
    while xp >= xp_for_next(level):
        xp -= xp_for_next(level)
        level += 1
        leveled_up = True

    if leveled_up:
        async with aiosqlite.connect(DB_PATH) as db:
            await db.execute(
                "UPDATE levels SET xp = ?, level = ? WHERE guild_id = ? AND user_id = ?",
                (xp, level, guild_id, user_id),
            )
            await db.commit()
        return level
    return None

@bot.event
async def on_ready():
    await init_db()
    await bot.tree.sync()
    print(f"로그인 성공: {bot.user}")

@bot.event
async def on_message(message: discord.Message):
    if message.author.bot or message.guild is None:
        await bot.process_commands(message)
        return

    key = (message.guild.id, message.author.id)
    now = time.time()
    if now - last_xp_time.get(key, 0) >= XP_COOLDOWN:
        last_xp_time[key] = now
        new_level = await add_xp(*key, random.randint(XP_MIN, XP_MAX))
        if new_level:
            channel = (
                bot.get_channel(LEVEL_UP_CHANNEL) if LEVEL_UP_CHANNEL else message.channel
            )
            await channel.send(
                f"🎉 {message.author.mention} 님이 **레벨 {new_level}** 달성!"
            )

    await bot.process_commands(message)

@bot.tree.command(name="레벨", description="내 레벨과 경험치를 확인해요")
async def level_cmd(interaction: discord.Interaction, 유저: discord.Member = None):
    target = 유저 or interaction.user
    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "SELECT xp, level FROM levels WHERE guild_id = ? AND user_id = ?",
            (interaction.guild_id, target.id),
        )
        row = await cur.fetchone()

    xp, level = row if row else (0, 0)
    need = xp_for_next(level)
    filled = int(20 * xp / need)
    bar = "█" * filled + "░" * (20 - filled)

    embed = discord.Embed(title=f"{target.display_name} 님의 레벨", color=0x5865F2)
    embed.add_field(name="레벨", value=f"**{level}**", inline=True)
    embed.add_field(name="경험치", value=f"{xp} / {need}", inline=True)
    embed.add_field(name="진행도", value=f"`{bar}`", inline=False)
    embed.set_thumbnail(url=target.display_avatar.url)
    await interaction.response.send_message(embed=embed)

@bot.tree.command(name="랭킹", description="서버 상위 10명을 보여줘요")
async def rank_cmd(interaction: discord.Interaction):
    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "SELECT user_id, level, xp FROM levels WHERE guild_id = ? "
            "ORDER BY level DESC, xp DESC LIMIT 10",
            (interaction.guild_id,),
        )
        rows = await cur.fetchall()

    if not rows:
        await interaction.response.send_message("아직 기록이 없어요! 채팅을 쳐보세요 😄")
        return

    medals = ["🥇", "🥈", "🥉"]
    lines = []
    for i, (user_id, level, xp) in enumerate(rows):
        member = interaction.guild.get_member(user_id)
        name = member.display_name if member else f"(탈퇴한 유저 {user_id})"
        prefix = medals[i] if i < 3 else f"**{i + 1}.**"
        lines.append(f"{prefix} {name} — Lv.{level} ({xp} XP)")

    embed = discord.Embed(
        title=f"🏆 {interaction.guild.name} 랭킹 TOP 10",
        description="\n".join(lines),
        color=0x5865F2,
    )
    await interaction.response.send_message(embed=embed)

bot.run(token)

📌 실행 결과

봇을 켜고 채팅을 몇 번 치면 이렇게 동작해요.

  1. 메시지를 보낼 때마다 조용히 15~25 XP가 쌓여요 (60초 쿨다운)
  2. 100 XP를 넘기면 🎉 @홍길동 님이 레벨 1 달성! 메시지가 뜹니다
  3. /레벨 → 내 레벨과 진행도 막대가 임베드로 나와요
  4. /랭킹 → 금·은·동메달이 붙은 TOP 10 목록이 나와요

💡 보너스 - 레벨 달성 시 자동 역할 주기

add_xp가 새 레벨을 돌려줄 때 역할을 붙여주면 끝이에요.

Python
LEVEL_ROLES = {5: "수다쟁이", 10: "고인물", 20: "터줏대감"}

async def grant_role(member: discord.Member, level: int):
    role_name = LEVEL_ROLES.get(level)
    if not role_name:
        return
    role = discord.utils.get(member.guild.roles, name=role_name)
    if role:
        await member.add_roles(role, reason=f"레벨 {level} 달성")

⚠️ 봇 역할이 부여할 역할보다 위에 있어야 작동해요. 서버 설정 → 역할에서 순서를 꼭 확인하세요!

📌 자주 나는 에러

1. message.content가 항상 빈 문자열이에요

  • 원인 : MESSAGE CONTENT INTENT가 꺼져 있어요.
  • 해결 : 디스코드 개발자 포털 → 내 앱 → Bot → Privileged Gateway Intents에서 MESSAGE CONTENT INTENT를 켜고, 코드에도 intents.message_content = True를 넣어주세요.

2. sqlite3.OperationalError: database is locked

  • 원인 : 여러 곳에서 동시에 DB에 쓰기를 시도했어요.
  • 해결 : 예제처럼 async with로 커넥션을 짧게 열고 닫으세요. 트래픽이 많은 서버라면 커넥션을 하나만 만들어 전역으로 재사용하는 방식으로 바꾸는 게 좋아요.

3. 슬래시 명령어가 목록에 안 떠요

  • 원인 : bot.tree.sync()를 호출하지 않았거나, 전역 동기화가 반영되기까지 시간이 걸려요.
  • 해결 : 테스트 중이라면 await bot.tree.sync(guild=discord.Object(id=서버ID))로 특정 서버에만 동기화하세요. 즉시 반영됩니다.

4. 명령어는 실행되는데 경험치가 안 올라요

  • 원인 : on_message를 덮어쓰면 기존 명령어 처리가 끊겨요.
  • 해결 : 예제처럼 함수 끝에 await bot.process_commands(message)를 반드시 호출해 주세요.

💬 마무리

오늘은 채팅으로 경험치를 쌓고, 레벨업 알림을 띄우고, 랭킹판까지 보여주는 레벨 시스템을 만들어 봤어요. 핵심은 세 가지였죠.

  • UPSERT로 동시 접근에도 안전하게 XP 누적하기
  • 쿨다운으로 도배 파밍 막기
  • aiosqlite로 봇을 꺼도 기록이 남게 하기

다음 편에서는 이 경험치를 화폐로 바꿔서 상점·아이템 시스템을 만들어 볼 예정이에요. 레벨만 올리면 심심하니까, 이제 쓸 곳을 만들어 줘야죠 😎 기대해주세요 🙌

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

  • #23 - aiosqlite로 비동기 DB 제대로 설계하기
  • #24 - 채널 난독화 대응하기
  • #25 - 에러 핸들링·로깅 체계 잡기
  • #26 - 티켓(Ticket) 문의 시스템 만들기
  • #27 - 티켓 담당자 배정·우선순위 만들기
  • #28 - 레벨·경험치(XP) 시스템 만들기 ← 지금 이 글!

연재 · 32편 중 28번째

디스코드 봇 만들기

  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 실전 기록 블로그