디스코드 봇 만들기#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에 저장할게요. 테이블은 두 개예요.
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가 올라가요.
상점에 진열할 아이템은 코드 상수로 정의할게요.
CURRENCY = "🪙"
SHOP_ITEMS = {
"coffee": {"name": "☕ 커피 한 잔", "price": 50, "desc": "기분 전환용. 효과는 없어요"},
"ticket": {"name": "🎟️ 이벤트 응모권", "price": 300, "desc": "주간 추첨 이벤트 참가권"},
"vip": {"name": "👑 VIP 역할권", "price": 1000, "desc": "VIP 역할을 받을 수 있어요"},
}📌 2단계 - 출석 체크로 코인 벌기 (중복 수령 막기)
출석 보상은 하루에 딱 한 번이어야 해요. 그런데 이걸 이렇게 짜면 큰일 나요 🚨
# ❌ 이렇게 하면 안 돼요
row = await select_last_daily(...) # 1) 마지막 출석일을 읽고
if row[0] != today: # 2) 오늘이 아니면
await add_coins(...) # 3) 코인을 준다1번과 3번 사이에 틈이 있어요. /출석을 아주 빠르게 두 번 누르면 두 요청이 동시에 2번을 통과해서 코인을 두 번 받아가요. 실제로 자주 터지는 버그예요.
해결책은 읽기와 쓰기를 SQL 한 문장으로 합치는 것이에요.
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단계 - 구매 로직 (코인이 마이너스로 안 내려가게)
구매도 똑같은 함정이 있어요. "잔액 확인 → 차감"을 나눠서 하면 빠르게 두 번 클릭했을 때 잔액이 마이너스가 돼요.
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로 써볼게요.
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)
# 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)📌 실행 결과
봇을 켜고 슬래시 명령어를 하나씩 눌러보면 이렇게 동작해요.
/출석→출석 완료! +200 🪙 (현재 200 🪙)— 같은 날 또 누르면 나만 보이는 안내 메시지가 떠요/상점→ 아이템 3개가 적힌 임베드 + 드롭다운이 나와요- 드롭다운에서 커피 한 잔을 고르면 →
☕ 커피 한 잔 구매 완료! 남은 코인 150 🪙 - 코인이 모자란 상태로 VIP 역할권을 고르면 →
코인이 모자라요 😢 (필요 1000 / 보유 150) /지갑→ 보유 코인과☕ 커피 한 잔 × 1인벤토리가 임베드로 나와요- 60초가 지나면 드롭다운이 회색으로 잠겨요


💡 보너스 - 관리자가 코인을 지급·회수하기
이벤트 보상을 줄 때 필요해요. 권한 체크는 #12편의 권한 관리 방식 그대로예요.
@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으로 죽은 버튼을 잠그게
돈을 다루는 기능은 버그 하나가 곧 서버 신뢰도 하락이라, 이번 편은 다른 편보다 방어 코드에 힘을 줬어요 🛡️
다음 편에서는 이 코인을 걸고 노는 미니게임을 만들어 볼 예정이에요. 슬롯머신과 가위바위보로 코인을 따고 잃는 시스템인데, 확률 설계와 "인플레이션 관리"까지 같이 다뤄볼게요 😎 기대해주세요 🙌
📚 디스코드 봇 만들기 시리즈
- #23 - aiosqlite로 비동기 DB 제대로 설계하기
- #24 - 채널 난독화 대응하기
- #25 - 에러 핸들링·로깅 체계 잡기
- #26 - 티켓(Ticket) 문의 시스템 만들기
- #27 - 티켓 담당자 배정·우선순위 만들기
- #28 - 레벨·경험치(XP) 시스템 만들기
- #29 - 화폐·상점(Economy) 시스템 만들기 ← 지금 이 글!
- 📖 시리즈 총정리 로드맵 (#1~#24)
연재 · 32편 중 29번째
디스코드 봇 만들기
- #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임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
