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

디스코드 봇 만들기#29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제)

한눈에 보기

출석 체크로 코인을 벌고 드롭다운 상점에서 아이템을 사는 화폐·상점 시스템을 aiosqlite로 만들어요. 잔액 검사와 차감을 SQL 한 문장으로 합쳐 중복 수령과 마이너스 잔액을 막는 게 핵심입니다.

  • Python 3.10 이상 · discord.py 2.7.1 · aiosqlite · python-dotenv 기준
  • 출석·구매·송금은 WHERE 조건 + cur.rowcount로 판단과 변경을 한 문장에 — 빠르게 두 번 눌러도 중복 지급·마이너스 잔액이 안 생겨요
  • 상점 드롭다운은 interaction_check로 남의 상점 창 조작을 막고, on_timeout으로 60초 뒤 잠가요
  • discord.py 2.x 콜백 순서는 (self, interaction, select) — Pycord 예제를 복사하면 순서가 반대라 터져요
목차13개 항목

안녕하세요! Mojangcam입니다 😎

지난 편(#28 레벨·경험치(XP) 시스템)에서는 채팅을 칠 때마다 경험치가 쌓이고 랭킹판까지 뜨는 레벨 시스템을 만들었어요. 그리고 마지막에 이렇게 예고했었죠. "다음엔 이 경험치를 쓸 곳을 만들어 주겠다"고요.

이번 #29편에서는 약속대로 화폐·상점(Economy) 시스템을 만들어 볼게요! 출석 체크로 코인을 벌고, /상점에서 드롭다운으로 아이템을 고르고, /지갑으로 내 인벤토리를 확인하는 것까지 한 번에 완성해 볼게요 🪙

예시 화면: 상점 드롭다운이 열린 디스코드 채널 화면

🧠 왜 화폐 시스템이 필요할까요?

레벨 시스템만 있으면 숫자가 오르는 재미는 있는데, 그 숫자로 할 수 있는 게 없어요. 그래서 한 달쯤 지나면 다들 시들해져요 😢

화폐를 붙이면 이야기가 달라져요.

  • 코인을 모으면 쓸 곳이 생기니까 매일 들어올 이유가 생겨요
  • 출석 체크 보상은 서버 재방문율을 올리는 가장 싸고 확실한 장치예요
  • VIP 역할권, 이벤트 응모권처럼 실제 혜택을 코인으로 팔 수 있어요
  • 나중에 미니게임·도박·경매까지 얹을 수 있는 기반 시스템이에요

실제로 유명한 봇들(MEE6, Dank Memer 같은)이 유료로 파는 기능이 바로 이 경제 시스템이에요. 우리는 직접 만들어서 공짜로 써요 🙌

📌 준비물

  • Python 3.10 이상 (discord.Member | None 같은 X | None 문법을 쓰기 때문이에요)
  • discord.py 2.x (2026년 9월 기준 최신은 2.7.1) — pip install -U discord.py
  • aiosqlite — pip install aiosqlite (비동기 DB, #23편에서 다뤘어요)
  • python-dotenv — pip install python-dotenv

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

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

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

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

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

💬 이번 편은 MESSAGE CONTENT INTENT가 필요 없어요. 메시지 내용을 읽지 않고 슬래시 명령어와 드롭다운만 쓰거든요. 대신 /지갑에서 다른 멤버 정보를 보려면 SERVER MEMBERS INTENT는 켜주셔야 해요.

🧩 이번 편에서 만들 것

명령어 하는 일
/출석 하루 한 번 200코인 지급 (KST 자정 기준)
/지갑 내 코인 + 인벤토리 확인
/상점 드롭다운으로 아이템 구매
/송금 다른 멤버에게 코인 보내기

📌 1단계 - 지갑·인벤토리 테이블 설계

코인은 봇을 껐다 켜도 남아야 하니까 DB에 저장할게요. 테이블은 두 개예요.

Python
import aiosqlite

DB_PATH = "economy.db"

async def init_db():
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS wallets (
                guild_id   INTEGER NOT NULL,
                user_id    INTEGER NOT NULL,
                coins      INTEGER NOT NULL DEFAULT 0,
                last_daily TEXT,
                PRIMARY KEY (guild_id, user_id)
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS inventory (
                guild_id INTEGER NOT NULL,
                user_id  INTEGER NOT NULL,
                item_id  TEXT    NOT NULL,
                amount   INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY (guild_id, user_id, item_id)
            )
        """)
        await db.commit()

하나씩 뜯어볼게요 💡

  • wallets : 한 사람의 코인 잔액을 담는 표예요. 컬럼 하나짜리 단순한 구조가 제일 안전해요.
  • last_daily : 마지막 출석 날짜를 "2026-09-17" 같은 문자열로 저장해요. 시각이 아니라 날짜만 저장하는 게 핵심이에요. 그래야 "오늘 받았나?"를 문자열 비교 한 번으로 끝낼 수 있어요.
  • PRIMARY KEY (guild_id, user_id) : 서버별로 지갑이 따로 놀게 만드는 장치예요. A서버에서 번 코인이 B서버에서 쓰이면 곤란하니까요.
  • inventory의 (guild_id, user_id, item_id) : "누가 / 어느 서버에서 / 어떤 아이템을" 몇 개 가졌는지를 한 줄로 만들어요. 같은 아이템을 두 번 사면 새 줄이 생기는 게 아니라 amount가 올라가요.

상점에 진열할 아이템은 코드 상수로 정의할게요.

Python
CURRENCY = "🪙"

SHOP_ITEMS = {
    "coffee": {"name": "☕ 커피 한 잔",      "price": 50,   "desc": "기분 전환용. 효과는 없어요"},
    "ticket": {"name": "🎟️ 이벤트 응모권",  "price": 300,  "desc": "주간 추첨 이벤트 참가권"},
    "vip":    {"name": "👑 VIP 역할권",     "price": 1000, "desc": "VIP 역할을 받을 수 있어요"},
}

📌 2단계 - 출석 체크로 코인 벌기 (중복 수령 막기)

출석 보상은 하루에 딱 한 번이어야 해요. 그런데 이걸 이렇게 짜면 큰일 나요 🚨

Python
# ❌ 이렇게 하면 안 돼요
row = await select_last_daily(...)      # 1) 마지막 출석일을 읽고
if row[0] != today:                     # 2) 오늘이 아니면
    await add_coins(...)                # 3) 코인을 준다

1번과 3번 사이에 틈이 있어요. /출석을 아주 빠르게 두 번 누르면 두 요청이 동시에 2번을 통과해서 코인을 두 번 받아가요. 실제로 자주 터지는 버그예요.

해결책은 읽기와 쓰기를 SQL 한 문장으로 합치는 것이에요.

Python
from datetime import datetime, timedelta, timezone

KST = timezone(timedelta(hours=9))
DAILY_REWARD = 200

async def claim_daily(guild_id: int, user_id: int) -> bool:
    today = datetime.now(KST).date().isoformat()   # '2026-09-17'
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT OR IGNORE INTO wallets (guild_id, user_id, coins) VALUES (?, ?, 0)",
            (guild_id, user_id),
        )
        cur = await db.execute(
            "UPDATE wallets SET coins = coins + ?, last_daily = ? "
            "WHERE guild_id = ? AND user_id = ? "
            "AND (last_daily IS NULL OR last_daily <> ?)",
            (DAILY_REWARD, today, guild_id, user_id, today),
        )
        changed = cur.rowcount
        await db.commit()
    return changed > 0

하나씩 뜯어볼게요 💡

  • timezone(timedelta(hours=9)) : 한국 시간(KST)이에요. ZoneInfo("Asia/Seoul")을 써도 되지만 윈도우에서는 tzdata 패키지를 따로 깔아야 해서, 의존성 없는 이 방법을 썼어요.
  • INSERT OR IGNORE : 지갑이 없으면 만들고, 이미 있으면 조용히 넘어가요. "있는지 확인 후 만들기"를 한 줄로 끝내는 SQLite 문법이에요.
  • WHERE ... AND last_daily <> ? : 여기가 핵심이에요. "오늘 이미 받았으면 이 UPDATE는 아무 줄도 안 건드린다"는 조건을 SQL 안에 넣었어요. 판단과 지급이 한 문장 안에서 원자적으로 일어나니까 동시에 눌러도 절대 두 번 안 나가요.
  • cur.rowcount : 방금 UPDATE가 몇 줄을 바꿨는지예요. 1이면 출석 성공, 0이면 오늘 이미 받은 거예요. 이걸 성공/실패 판정에 그대로 쓰면 돼요.

📌 3단계 - 구매 로직 (코인이 마이너스로 안 내려가게)

구매도 똑같은 함정이 있어요. "잔액 확인 → 차감"을 나눠서 하면 빠르게 두 번 클릭했을 때 잔액이 마이너스가 돼요.

Python
async def buy_item(guild_id: int, user_id: int, item_id: str) -> bool:
    price = SHOP_ITEMS[item_id]["price"]
    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "UPDATE wallets SET coins = coins - ? "
            "WHERE guild_id = ? AND user_id = ? AND coins >= ?",
            (price, guild_id, user_id, price),
        )
        if cur.rowcount == 0:        # 잔액 부족 → 아무 일도 일어나지 않았어요
            await db.rollback()
            return False
        await db.execute(
            "INSERT INTO inventory (guild_id, user_id, item_id, amount) VALUES (?, ?, ?, 1) "
            "ON CONFLICT(guild_id, user_id, item_id) DO UPDATE SET amount = amount + 1",
            (guild_id, user_id, item_id),
        )
        await db.commit()
    return True

하나씩 뜯어볼게요 💡

  • AND coins >= ? : 잔액 검사를 파이썬이 아니라 DB한테 시키는 것이 포인트예요. 돈이 모자라면 UPDATE가 0줄을 바꾸고 끝나서, 잔액이 음수가 될 수가 없어요.
  • rollback() : 코인이 모자랐을 때 여태 연 트랜잭션을 되돌려요. 어차피 바뀐 게 없지만, 습관으로 넣어두면 나중에 로직이 복잡해져도 안전해요.
  • ON CONFLICT ... DO UPDATE : SQLite의 UPSERT예요. 처음 사면 amount = 1로 넣고, 또 사면 amount + 1을 해줘요. #28편에서 XP 누적에 썼던 그 문법 그대로예요.
  • 차감과 지급이 하나의 커밋 : commit()이 끝에 한 번만 있어요. 중간에 봇이 죽어도 "코인만 빠지고 아이템은 안 들어온" 상태가 생기지 않아요.

📌 4단계 - 드롭다운 상점 만들기

#6편에서 배운 드롭다운(Select Menu)을 상점 UI로 써볼게요.

Python
import discord

class ShopView(discord.ui.View):
    def __init__(self, author_id: int):
        super().__init__(timeout=60)
        self.author_id = author_id
        self.message: discord.Message | None = None

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        if interaction.user.id != self.author_id:
            await interaction.response.send_message(
                "이 상점 창은 다른 분이 연 거예요! `/상점`으로 직접 열어주세요 😅",
                ephemeral=True,
            )
            return False
        return True

    async def on_timeout(self):
        for child in self.children:
            child.disabled = True
        if self.message:
            await self.message.edit(view=self)

    @discord.ui.select(
        placeholder="살 물건을 골라주세요",
        options=[
            discord.SelectOption(
                label=info["name"],
                value=item_id,
                description=f"{info['price']} 코인 · {info['desc']}",
            )
            for item_id, info in SHOP_ITEMS.items()
        ],
    )
    async def pick_item(self, interaction: discord.Interaction, select: discord.ui.Select):
        item_id = select.values[0]
        info = SHOP_ITEMS[item_id]
        ok = await buy_item(interaction.guild_id, interaction.user.id, item_id)
        coins = await get_coins(interaction.guild_id, interaction.user.id)
        if ok:
            msg = f"{info['name']} 구매 완료! 남은 코인 **{coins}** {CURRENCY}"
        else:
            msg = f"코인이 모자라요 😢 (필요 {info['price']} / 보유 {coins})"
        await interaction.response.send_message(msg, ephemeral=True)

하나씩 뜯어볼게요 💡

  • 콜백 순서는 (self, interaction, select) : discord.py 2.x는 interaction이 먼저예요. Pycord 같은 다른 포크는 순서가 반대라, 그쪽 예제를 그대로 복사하면 바로 터져요. 공식 문서에도 "self, the Interaction you receive and the chosen select class" 순서로 명시돼 있어요.
  • interaction_check : 이 View의 모든 조작 앞에서 먼저 실행되는 관문이에요. False를 돌려주면 콜백이 아예 안 불려요. 남이 연 상점 창을 옆 사람이 눌러서 남의 코인으로 결제되는 사고를 막아줘요.
  • timeout=60 : View의 기본 타임아웃은 180초인데, 상점은 짧게 잡는 게 좋아요. 시간이 지나면 on_timeout이 불려서 드롭다운을 회색으로 잠가줘요.
  • ephemeral=True : 구매 결과를 누른 사람에게만 보여줘요. 잔액이 채팅창에 공개되지 않아서 깔끔해요.
  • SelectOption의 제한 : label·value·description 모두 100자까지, 옵션은 최대 25개까지예요. 아이템이 25개를 넘어가면 카테고리별로 드롭다운을 나눠야 해요.

📌 전체 코드 (복붙 OK)

Python
# discord.py 2.x (2.7.1 기준) / Python 3.10+
# pip install -U discord.py aiosqlite python-dotenv
import os
from datetime import datetime, timedelta, timezone

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

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

DB_PATH = "economy.db"
KST = timezone(timedelta(hours=9))
DAILY_REWARD = 200          # 출석 보상 코인
CURRENCY = "🪙"

SHOP_ITEMS = {
    "coffee": {"name": "☕ 커피 한 잔",      "price": 50,   "desc": "기분 전환용. 효과는 없어요"},
    "ticket": {"name": "🎟️ 이벤트 응모권",  "price": 300,  "desc": "주간 추첨 이벤트 참가권"},
    "vip":    {"name": "👑 VIP 역할권",     "price": 1000, "desc": "VIP 역할을 받을 수 있어요"},
}

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

bot = commands.Bot(command_prefix="!", intents=intents)


# ---------------- DB ----------------
async def init_db():
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            """
            CREATE TABLE IF NOT EXISTS wallets (
                guild_id   INTEGER NOT NULL,
                user_id    INTEGER NOT NULL,
                coins      INTEGER NOT NULL DEFAULT 0,
                last_daily TEXT,
                PRIMARY KEY (guild_id, user_id)
            )
            """
        )
        await db.execute(
            """
            CREATE TABLE IF NOT EXISTS inventory (
                guild_id INTEGER NOT NULL,
                user_id  INTEGER NOT NULL,
                item_id  TEXT    NOT NULL,
                amount   INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY (guild_id, user_id, item_id)
            )
            """
        )
        await db.commit()


async def get_coins(guild_id: int, user_id: int) -> int:
    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "SELECT coins FROM wallets WHERE guild_id = ? AND user_id = ?",
            (guild_id, user_id),
        )
        row = await cur.fetchone()
    return row[0] if row else 0


async def claim_daily(guild_id: int, user_id: int) -> bool:
    today = datetime.now(KST).date().isoformat()
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT OR IGNORE INTO wallets (guild_id, user_id, coins) VALUES (?, ?, 0)",
            (guild_id, user_id),
        )
        cur = await db.execute(
            "UPDATE wallets SET coins = coins + ?, last_daily = ? "
            "WHERE guild_id = ? AND user_id = ? "
            "AND (last_daily IS NULL OR last_daily <> ?)",
            (DAILY_REWARD, today, guild_id, user_id, today),
        )
        changed = cur.rowcount
        await db.commit()
    return changed > 0


async def buy_item(guild_id: int, user_id: int, item_id: str) -> bool:
    price = SHOP_ITEMS[item_id]["price"]
    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "UPDATE wallets SET coins = coins - ? "
            "WHERE guild_id = ? AND user_id = ? AND coins >= ?",
            (price, guild_id, user_id, price),
        )
        if cur.rowcount == 0:
            await db.rollback()
            return False
        await db.execute(
            "INSERT INTO inventory (guild_id, user_id, item_id, amount) VALUES (?, ?, ?, 1) "
            "ON CONFLICT(guild_id, user_id, item_id) DO UPDATE SET amount = amount + 1",
            (guild_id, user_id, item_id),
        )
        await db.commit()
    return True


async def transfer(guild_id: int, sender_id: int, receiver_id: int, amount: int) -> bool:
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT OR IGNORE INTO wallets (guild_id, user_id, coins) VALUES (?, ?, 0)",
            (guild_id, receiver_id),
        )
        cur = await db.execute(
            "UPDATE wallets SET coins = coins - ? "
            "WHERE guild_id = ? AND user_id = ? AND coins >= ?",
            (amount, guild_id, sender_id, amount),
        )
        if cur.rowcount == 0:
            await db.rollback()
            return False
        await db.execute(
            "UPDATE wallets SET coins = coins + ? WHERE guild_id = ? AND user_id = ?",
            (amount, guild_id, receiver_id),
        )
        await db.commit()
    return True


# ---------------- 상점 UI ----------------
class ShopView(discord.ui.View):
    def __init__(self, author_id: int):
        super().__init__(timeout=60)
        self.author_id = author_id
        self.message: discord.Message | None = None

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        if interaction.user.id != self.author_id:
            await interaction.response.send_message(
                "이 상점 창은 다른 분이 연 거예요! `/상점`으로 직접 열어주세요 😅",
                ephemeral=True,
            )
            return False
        return True

    async def on_timeout(self):
        for child in self.children:
            child.disabled = True
        if self.message:
            await self.message.edit(view=self)

    @discord.ui.select(
        placeholder="살 물건을 골라주세요",
        options=[
            discord.SelectOption(
                label=info["name"],
                value=item_id,
                description=f"{info['price']} 코인 · {info['desc']}",
            )
            for item_id, info in SHOP_ITEMS.items()
        ],
    )
    async def pick_item(self, interaction: discord.Interaction, select: discord.ui.Select):
        item_id = select.values[0]
        info = SHOP_ITEMS[item_id]
        ok = await buy_item(interaction.guild_id, interaction.user.id, item_id)
        coins = await get_coins(interaction.guild_id, interaction.user.id)
        if ok:
            msg = f"{info['name']} 구매 완료! 남은 코인 **{coins}** {CURRENCY}"
        else:
            msg = f"코인이 모자라요 😢 (필요 {info['price']} / 보유 {coins})"
        await interaction.response.send_message(msg, ephemeral=True)


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


@bot.tree.command(name="출석", description="하루 한 번 코인을 받아요")
async def daily_cmd(interaction: discord.Interaction):
    ok = await claim_daily(interaction.guild_id, interaction.user.id)
    coins = await get_coins(interaction.guild_id, interaction.user.id)
    if ok:
        await interaction.response.send_message(
            f"출석 완료! **+{DAILY_REWARD}** {CURRENCY} (현재 {coins} {CURRENCY})"
        )
    else:
        await interaction.response.send_message(
            "오늘은 이미 출석했어요! 한국 시간 자정에 초기화돼요 ⏰", ephemeral=True
        )


@bot.tree.command(name="지갑", description="내 코인과 인벤토리를 확인해요")
@app_commands.describe(유저="다른 사람의 지갑도 볼 수 있어요")
async def wallet_cmd(interaction: discord.Interaction, 유저: discord.Member | None = None):
    target = 유저 or interaction.user
    coins = await get_coins(interaction.guild_id, target.id)

    async with aiosqlite.connect(DB_PATH) as db:
        cur = await db.execute(
            "SELECT item_id, amount FROM inventory "
            "WHERE guild_id = ? AND user_id = ? AND amount > 0",
            (interaction.guild_id, target.id),
        )
        rows = await cur.fetchall()

    lines = [
        f"{SHOP_ITEMS[item_id]['name']} × {amount}"
        for item_id, amount in rows
        if item_id in SHOP_ITEMS
    ]

    embed = discord.Embed(title=f"{target.display_name} 님의 지갑", color=0x5865F2)
    embed.add_field(name="보유 코인", value=f"**{coins}** {CURRENCY}", inline=False)
    embed.add_field(
        name="인벤토리",
        value="\n".join(lines) if lines else "아직 아무것도 없어요 🛒",
        inline=False,
    )
    embed.set_thumbnail(url=target.display_avatar.url)
    await interaction.response.send_message(embed=embed)


@bot.tree.command(name="상점", description="코인으로 아이템을 사요")
async def shop_cmd(interaction: discord.Interaction):
    coins = await get_coins(interaction.guild_id, interaction.user.id)
    embed = discord.Embed(
        title="🛒 상점",
        description=f"보유 코인: **{coins}** {CURRENCY}\n아래에서 살 물건을 골라주세요!",
        color=0x5865F2,
    )
    for info in SHOP_ITEMS.values():
        embed.add_field(
            name=f"{info['name']} — {info['price']} {CURRENCY}",
            value=info["desc"],
            inline=False,
        )

    view = ShopView(interaction.user.id)
    await interaction.response.send_message(embed=embed, view=view)
    view.message = await interaction.original_response()


@bot.tree.command(name="송금", description="다른 멤버에게 코인을 보내요")
@app_commands.describe(받는사람="코인을 받을 사람", 금액="보낼 코인 수")
async def pay_cmd(interaction: discord.Interaction, 받는사람: discord.Member, 금액: int):
    if 금액 < 1:
        await interaction.response.send_message("1코인 이상만 보낼 수 있어요!", ephemeral=True)
        return
    if 받는사람.bot or 받는사람.id == interaction.user.id:
        await interaction.response.send_message(
            "봇이나 자기 자신에게는 보낼 수 없어요 🙅", ephemeral=True
        )
        return

    ok = await transfer(interaction.guild_id, interaction.user.id, 받는사람.id, 금액)
    if ok:
        await interaction.response.send_message(
            f"{interaction.user.mention} → {받는사람.mention} **{금액}** {CURRENCY} 송금 완료!"
        )
    else:
        await interaction.response.send_message("코인이 모자라요 😢", ephemeral=True)


bot.run(token)

📌 실행 결과

봇을 켜고 슬래시 명령어를 하나씩 눌러보면 이렇게 동작해요.

  1. /출석 → 출석 완료! +200 🪙 (현재 200 🪙) — 같은 날 또 누르면 나만 보이는 안내 메시지가 떠요
  2. /상점 → 아이템 3개가 적힌 임베드 + 드롭다운이 나와요
  3. 드롭다운에서 커피 한 잔을 고르면 → ☕ 커피 한 잔 구매 완료! 남은 코인 150 🪙
  4. 코인이 모자란 상태로 VIP 역할권을 고르면 → 코인이 모자라요 😢 (필요 1000 / 보유 150)
  5. /지갑 → 보유 코인과 ☕ 커피 한 잔 × 1 인벤토리가 임베드로 나와요
  6. 60초가 지나면 드롭다운이 회색으로 잠겨요

예시 화면: /출석 실행 결과와 코인이 지급된 메시지

예시 화면: /지갑 임베드 - 보유 코인과 인벤토리 목록

💡 보너스 - 관리자가 코인을 지급·회수하기

이벤트 보상을 줄 때 필요해요. 권한 체크는 #12편의 권한 관리 방식 그대로예요.

Python
@bot.tree.command(name="코인지급", description="(관리자) 코인을 지급하거나 회수해요")
@app_commands.describe(대상="지급할 사람", 금액="음수를 넣으면 회수돼요")
@app_commands.checks.has_permissions(administrator=True)
async def grant_cmd(interaction: discord.Interaction, 대상: discord.Member, 금액: int):
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT OR IGNORE INTO wallets (guild_id, user_id, coins) VALUES (?, ?, 0)",
            (interaction.guild_id, 대상.id),
        )
        await db.execute(
            "UPDATE wallets SET coins = MAX(0, coins + ?) "
            "WHERE guild_id = ? AND user_id = ?",
            (금액, interaction.guild_id, 대상.id),
        )
        await db.commit()

    coins = await get_coins(interaction.guild_id, 대상.id)
    await interaction.response.send_message(
        f"{대상.display_name} 님의 코인을 **{금액:+d}** 조정했어요. (현재 {coins} {CURRENCY})"
    )

💡 MAX(0, coins + ?) 를 쓴 이유는 회수하다가 잔액이 음수로 내려가는 걸 DB 차원에서 막기 위해서예요. 관리자 명령어라도 안전장치는 넣어두는 게 좋아요!

📌 자주 나는 에러

1. 코인이 마이너스로 내려가요

  • 원인 : 잔액을 파이썬에서 if coins >= price: 로 확인한 뒤 따로 차감했어요. 두 동작 사이에 다른 요청이 끼어들 수 있어요.
  • 해결 : 예제처럼 WHERE ... AND coins >= ? 를 UPDATE 문 안에 넣고, cur.rowcount 로 성공 여부를 판정하세요. 검사와 차감이 한 문장에서 끝나야 안전해요.

2. sqlite3.OperationalError: database is locked

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

3. This interaction failed 가 떠요

  • 원인 : 디스코드는 상호작용에 3초 안에 응답해야 해요. DB 작업이 길어지면 시간이 초과돼요.
  • 해결 : 무거운 작업 앞에 await interaction.response.defer() 를 먼저 호출하고, 결과는 await interaction.followup.send(...) 로 보내세요.

4. 드롭다운을 눌러도 아무 반응이 없어요

  • 원인 : View의 타임아웃이 지났어요. 기본값은 180초, 예제에서는 60초로 잡았어요.
  • 해결 : /상점 을 다시 실행하면 돼요. 예제의 on_timeout 이 드롭다운을 회색으로 잠가주니까 "눌리는데 반응 없는" 답답한 상황은 안 생겨요.

5. AttributeError: 'Select' object has no attribute 'response'

  • 원인 : 콜백 파라미터 순서를 (self, select, interaction) 으로 썼어요. Pycord처럼 시그니처가 다른 포크의 예제를 복사하면 이렇게 돼요.
  • 해결 : discord.py 2.x에서는 (self, interaction, select) 순서예요. 버튼도 마찬가지로 (self, interaction, button) 이에요.

💬 마무리

오늘은 코인을 벌고 쓰는 화폐·상점 시스템을 만들어 봤어요. 기능보다 중요한 건 사실 아래 두 가지였어요.

  • 판단과 변경을 SQL 한 문장으로 — WHERE ... AND coins >= ? + rowcount 조합이면 중복 수령도, 마이너스 잔액도 원천 봉쇄돼요
  • UI에도 잠금장치를 — interaction_check 로 남의 상점 창을 못 만지게, on_timeout 으로 죽은 버튼을 잠그게

돈을 다루는 기능은 버그 하나가 곧 서버 신뢰도 하락이라, 이번 편은 다른 편보다 방어 코드에 힘을 줬어요 🛡️

다음 편에서는 이 코인을 걸고 노는 미니게임을 만들어 볼 예정이에요. 슬롯머신과 가위바위보로 코인을 따고 잃는 시스템인데, 확률 설계와 "인플레이션 관리"까지 같이 다뤄볼게요 😎 기대해주세요 🙌

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

연재 · 32편 중 29번째

디스코드 봇 만들기

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