디스코드 봇 만들기#23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제)
목차16개 항목
안녕하세요! Mojangcam입니다 😊
지난 #22편에서는 /금지어추가, /금지어삭제 를 자동완성으로 만들었죠. 편해지긴 했는데, 그 데이터가 지금 어디에 있죠? data/banned_words.json 이에요. #21의 경고 기록도 data/warns.json 이고요.
파일 하나에 다 들어 있습니다. 봇이 커지기 전까지는 이게 제일 편해요. 근데 오늘 제가 실제로 재본 숫자 하나만 먼저 보여드릴게요 😨
같은 유저에게 동시에 경고 100번 주기
JSON 결과: 1 (99번이 증발!)
SQLite 결과: 100 (정상)이게 오늘 글의 전부예요. 오늘은 aiosqlite로 봇의 데이터 계층을 제대로 다시 깝니다. #14편에서 "JSON vs SQLite" 개념만 비교했다면, 오늘은 실제로 갈아엎어요 🗄️
이번 편에서 배울 것
- JSON이 무너지는 정확한 지점 (실측 데이터로)
aiosqlite첫 연결과async with의 함정- 커넥션을 한 번만 열고 Cog 전체가 공유하는 구조 (
setup_hook) - WAL 모드·
row_factory·인덱스 같은 실전 설정 - UPSERT 한 방으로 경고 누적 처리하기
- 기존 JSON 데이터를 그대로 옮기는 마이그레이션 스크립트
- 자동완성(#22)을 DB 검색(LIKE)으로 바꾸기

🧠 JSON은 정확히 어디서 무너질까요?
"JSON 느리다"는 말, 사실 반은 틀렸어요. 제가 직접 재봤습니다.
유저 1,000명 → JSON 0.29ms / SQLite 4.11ms ← JSON이 14배 빠름 😳
유저 50,000명 → JSON 11.37ms / SQLite 3.91ms ← 역전!작을 땐 JSON이 훨씬 빨라요. 파일 통째로 읽어서 딕셔너리 하나 고치는 게 SQL 파싱보다 싸거든요. 그런데 보시면 JSON은 데이터가 커진 만큼 그대로 느려지고(0.29 → 11.37), SQLite는 거의 그대로예요(4.11 → 3.91). 유저 5만 명을 훑든 100명을 훑든 인덱스로 한 줄만 찾으니까요.
그런데 속도는 사실 두 번째 문제예요. 진짜 문제는 맨 위에서 보신 그거예요.
# 이 코드가 왜 위험할까요?
d = json.load(f) # ① 읽고
d["1"]["7"] += 1 # ② 고치고
json.dump(d, f) # ③ 쓴다① 과 ③ 사이에 await 가 하나라도 끼면 (디스코드 API 호출, 메시지 전송 등) 다른 코루틴이 끼어들어서 ①에서 읽은 옛날 값을 그대로 덮어써요. 봇은 온통 await 투성이잖아요. 이걸 Lost Update(갱신 손실) 라고 해요.

정리하면 JSON을 버려야 할 때는 이렇습니다.
- 동시에 여러 이벤트가 같은 파일을 건드릴 때 ← 오늘의 핵심
- 데이터가 수천 건을 넘어갈 때
- 검색·정렬·집계가 필요할 때 ("경고 3회 이상인 사람 뽑기" 같은 것)
- 쓰는 도중 봇이 죽으면 파일이 통째로 깨질 때
💡 반대로 길드 설정값 20개 저장하는 정도면 JSON이 정답이에요. "무조건 DB"가 아니라 "동시에 바뀌는 데이터는 DB" 로 기억하세요 🙂
📌 준비물
- Python 3.9+ ⚠️ 지금까지 3.8+ 였는데, aiosqlite는 0.21 버전부터 Python 3.9 이상이 필요해요 (0.20 까지만 3.8 지원)
- discord.py 2.x (2026년 8월 기준 최신 2.7.1)
- aiosqlite (최신 0.22.1) · python-dotenv
- #19편에서 만든
server-bot/폴더 — 오늘은database.py를 새로 만들고 Cog들을 여기에 연결해요 - 기존
data/warns.json,data/banned_words.json이 있다면 그대로 두세요. 마이그레이션으로 옮깁니다
pip install -U discord.py aiosqlite python-dotenv버전 확인은 이렇게 해요.
python -c "import aiosqlite, discord; print(aiosqlite.__version__, discord.__version__)"💡 sqlite3는 설치할 게 없어요. 파이썬에 기본으로 들어 있습니다.
aiosqlite는 그sqlite3를 별도 스레드에서 돌려서await로 쓸 수 있게 감싸주는 얇은 껍데기예요. 서버 설치도, 계정도, 포트도 필요 없습니다 🎉
📌 1단계 - aiosqlite 첫 연결
먼저 감을 잡아볼게요. 파일 하나로 끝나요.
import asyncio
import aiosqlite
async def main():
async with aiosqlite.connect("data/bot.db") as db:
await db.execute("CREATE TABLE IF NOT EXISTS memo (text TEXT)")
await db.execute("INSERT INTO memo VALUES (?)", ("첫 줄!",))
await db.commit()
async with db.execute("SELECT text FROM memo") as cursor:
async for row in cursor:
print(row[0])
asyncio.run(main())하나씩 뜯어볼게요 💡
aiosqlite.connect(...): 파일이 없으면 알아서 만들어줘요. 다만 폴더는 안 만들어줍니다 (data/가 없으면 에러)await db.commit(): ⚠️ 이걸 빼먹는 게 1등 실수예요. commit 없이 봇을 끄면 방금 넣은 데이터가 그냥 사라집니다?플레이스홀더 : 값은 절대 f-string으로 넣지 마세요.f"... VALUES ('{닉네임}')"로 쓰면 닉네임에 따옴표 하나만 들어가도 쿼리가 깨지고, 그게 곧 SQL 인젝션이에요("첫 줄!",): 값이 하나여도 튜플이어야 해요. 쉼표 빠뜨리면ValueError납니다async with db.execute(...): 커서도 비동기 컨텍스트예요.async for로 한 줄씩 돌 수 있어요
⚠️
async with aiosqlite.connect(...)는 블록을 나가면 커넥션을 닫아요. 예제로는 좋은데, 봇에서 이렇게 쓰면 메시지 하나 올 때마다 DB를 열었다 닫습니다. 다음 단계에서 이걸 고쳐요.
📌 2단계 - 커넥션은 한 번만 열기 (database.py)
봇의 정석은 켤 때 한 번 열고, 끌 때 닫는 것이에요. 커넥션을 담당하는 파일을 따로 만듭니다.
class Database:
def __init__(self, path: Path = DB_PATH):
self.path = path
self.conn: Optional[aiosqlite.Connection] = None
async def connect(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True) # data/ 폴더 보장
self.conn = await aiosqlite.connect(self.path)
self.conn.row_factory = aiosqlite.Row # 컬럼명으로 접근
await self.conn.execute("PRAGMA journal_mode=WAL")
await self.conn.execute("PRAGMA foreign_keys=ON")
await self.conn.executescript(SCHEMA) # 테이블 자동 생성
await self.conn.commit()
async def close(self) -> None:
if self.conn is not None:
await self.conn.close()
self.conn = None
db = Database() # 모듈 하나에 인스턴스 하나 (Cog 전체가 공유)하나씩 뜯어볼게요 💡
row_factory = aiosqlite.Row: 이거 하나로row[0]대신row["count"]로 쓸 수 있어요. 컬럼 순서 바뀌어도 안 깨지니까 무조건 켜세요PRAGMA journal_mode=WAL: Write-Ahead Logging 모드예요. 쓰는 중에도 읽기가 안 막혀요. 봇처럼 읽기가 훨씬 많은 프로그램에 딱이에요. DB 파일 옆에-wal,-shm파일이 생기는데 정상입니다 (.gitignore에 넣으세요)PRAGMA foreign_keys=ON: SQLite는 외래키 검사가 기본으로 꺼져 있어요. 커넥션마다 켜줘야 합니다executescript(SCHEMA):CREATE TABLE IF NOT EXISTS를 매번 실행해두면 처음 켜는 사람도 설정 없이 바로 돌아가요db = Database(): 파이썬 모듈은 한 번만 로드되니까, 어느 Cog에서from database import db를 해도 같은 객체를 씁니다
이제 봇에 연결해요. bot.py 의 setup_hook 이 자리예요.
class ServerBot(commands.Bot):
async def setup_hook(self):
await db.connect() # ① Cog보다 먼저!
for file in sorted(Path("cogs").glob("*.py")):
await self.load_extension(f"cogs.{file.stem}")
async def close(self):
await db.close() # ② 끌 때 정리
await super().close()- 왜
setup_hook인가요? :__init__은 아직 이벤트 루프가 없어서await를 못 써요.on_ready는 재연결 때마다 또 불려서 DB를 여러 번 엽니다.setup_hook은 로그인 직전 딱 한 번이에요 - 순서가 중요해요 : Cog가 올라올 때 DB를 쓸 수도 있으니
db.connect()를 먼저 부릅니다 close()오버라이드 :Ctrl+C로 껐을 때 WAL을 정리하고 깔끔히 닫아줘요
📌 3단계 - 테이블 설계
지금까지 JSON에 있던 걸 표 두 개로 옮겨요.
CREATE TABLE IF NOT EXISTS warns (
guild_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (guild_id, user_id)
);
CREATE TABLE IF NOT EXISTS banned_words (
guild_id INTEGER NOT NULL,
word TEXT NOT NULL,
category TEXT,
added_by INTEGER,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (guild_id, word)
);
CREATE INDEX IF NOT EXISTS idx_banned_guild ON banned_words (guild_id);하나씩 뜯어볼게요 💡
PRIMARY KEY (guild_id, user_id): 두 컬럼을 묶은 복합 기본키예요. "이 서버의 이 유저"는 한 줄만 존재하게 강제됩니다. JSON에서 중첩 딕셔너리로 하던 걸 DB가 대신 지켜줘요banned_words의PRIMARY KEY (guild_id, word): 중복 추가가 DB 차원에서 막혀요. #22에서 파이썬으로if 단어 in words:검사하던 걸 안 해도 됩니다INTEGER로 ID 저장 : 디스코드 ID는 19자리 숫자인데, SQLiteINTEGER는 8바이트(약 922경) 라 넉넉해요. JSON처럼 문자열로 바꿀 필요가 없습니다 🎉category·added_by: JSON 땐 못 넣던 정보예요. 누가 언제 왜 추가했는지가 남으면 #24 감사 로그에서 바로 써먹어요- 인덱스 :
WHERE guild_id = ?로 자주 찾으니까 미리 만들어둡니다. 없으면 전체를 훑어요
💡
updated_at TEXT DEFAULT (datetime('now'))는 UTC 기준이에요. 한국 시간으로 보려면 조회할 때datetime(updated_at, '+9 hours')를 쓰거나, 아예datetime('now', 'localtime')으로 저장하세요.
📌 4단계 - 경고 누적을 UPSERT 한 방으로
#21의 경고 누적 로직, 기억나시죠? "없으면 1, 있으면 +1". JSON에선 get() 하고 if 쓰고 저장했는데 SQL은 한 줄이에요.
async def add_warn(guild_id: int, user_id: int) -> int:
await db.execute(
"""
INSERT INTO warns (guild_id, user_id, count, updated_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT(guild_id, user_id)
DO UPDATE SET count = count + 1, updated_at = datetime('now')
""",
(guild_id, user_id),
)
row = await db.fetchone(
"SELECT count FROM warns WHERE guild_id = ? AND user_id = ?",
(guild_id, user_id),
)
return row["count"]하나씩 뜯어볼게요 💡
ON CONFLICT ... DO UPDATE: 이게 UPSERT예요. 넣어보고 기본키가 겹치면 UPDATE로 넘어갑니다. "조회 → 판단 → 저장" 3단계가 원자적 1단계로 줄어요count = count + 1: 파이썬이 아니라 DB가 직접 더해요. 그래서 아까 그 Lost Update가 생길 수 없습니다. 100번 동시에 불러도 정확히 100이에요 ✅ON CONFLICT(guild_id, user_id): 괄호 안은 기본키(또는 UNIQUE) 컬럼이어야 해요. 아무 컬럼이나 쓰면OperationalError가 납니다- 반환값 : 누적 횟수를 바로 알아야 "경고 3회 → 타임아웃" 을 판단하죠. SQLite도
RETURNING count를 쓸 수 있지만 버전을 타서, 안전하게SELECT한 번 더 했어요
#21의 automod.py 는 이제 이렇게 짧아져요.
# 변경 전 (#21): JSON 읽고 → 파이썬으로 +1 → 다시 쓰기
# 변경 후
from database import add_warn
count = await add_warn(message.guild.id, message.author.id)
if count >= 3:
await message.author.timeout(timedelta(minutes=10), reason="경고 3회 누적")📌 5단계 - 기존 JSON 데이터 옮기기
지금까지 쌓인 경고·금지어를 버릴 순 없죠. 한 번만 돌리는 스크립트를 만들어요.
async def main():
await db.connect()
wf = Path("data/warns.json")
if wf.exists():
data = json.loads(wf.read_text(encoding="utf-8"))
for guild_id, users in data.items():
for user_id, count in users.items():
await db.execute(
"INSERT OR REPLACE INTO warns (guild_id, user_id, count) VALUES (?, ?, ?)",
(int(guild_id), int(user_id), int(count)),
)
wf.rename(wf.with_suffix(".json.bak")) # 백업으로 이름만 바꿔둬요INSERT OR REPLACE: 스크립트를 두 번 돌려도 안전해요. 같은 키면 그냥 덮어씁니다 (이걸 멱등성이라고 해요)int(guild_id): JSON 키는 전부 문자열이었죠. DB는INTEGER컬럼이니 변환해서 넣어요. 이거 안 하면 나중에 숫자로 조회했을 때 하나도 안 잡힙니다 😵.json.bak으로 이름 변경 : 삭제하지 마세요. 잘못 옮겼을 때 되돌릴 유일한 방법이에요
📌 6단계 - 자동완성을 DB 검색으로 바꾸기
#22의 자동완성은 JSON 전체를 읽고 파이썬 리스트를 걸렀어요. 이제 DB가 대신 걸러줍니다.
async def registered_autocomplete(self, interaction, current: str):
rows = await db.fetchall(
"""
SELECT word FROM banned_words
WHERE guild_id = ? AND word LIKE ?
ORDER BY added_at LIMIT ?
""",
(interaction.guild_id or 0, f"%{current}%", 25),
)
return to_choices([row["word"] for row in rows])LIKE ?+f"%{current}%":%는 SQL의 아무 글자나 라는 뜻이에요. 앞뒤로 붙이면 포함 검색이 됩니다. 패턴은 값으로 넘기니까 인젝션 걱정 없어요LIMIT 25: #22에서 파이썬으로[:25]자르던 걸 DB에서 자릅니다. 금지어가 1만 개여도 25개만 꺼내와요ORDER BY added_at: 추가한 순서가 유지돼요. JSON 리스트 순서에 의존하던 걸 명시적으로 바꿨습니다- ⚠️ 유저가
%나_를 입력하면 와일드카드로 해석돼요. 정확히 하려면LIKE ? ESCAPE '\\'와 함께current.replace('%', '\\%')처리를 해주세요
💬 자동완성은 3초 안에 응답해야 하죠(#22). 인덱스 걸린
LIKE조회는 밀리초 단위라 전혀 문제없어요. 다만 자동완성 콜백 안에서commit하는 무거운 작업은 피하세요 ⏱️
📌 전체 코드 (복붙 OK)
1) database.py
# aiosqlite 0.22.x / discord.py 2.x / Python 3.9+
# 디스코드 봇 만들기 #23 - 비동기 DB 계층
import os
from pathlib import Path
from typing import Any, Iterable, List, Optional
import aiosqlite
from dotenv import load_dotenv
load_dotenv()
DB_PATH = Path(os.getenv("DB_PATH", "data/bot.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS warns (
guild_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (guild_id, user_id)
);
CREATE TABLE IF NOT EXISTS banned_words (
guild_id INTEGER NOT NULL,
word TEXT NOT NULL,
category TEXT,
added_by INTEGER,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (guild_id, word)
);
CREATE INDEX IF NOT EXISTS idx_banned_guild ON banned_words (guild_id);
"""
class Database:
"""커넥션 하나를 봇 전체가 공유하는 얇은 래퍼"""
def __init__(self, path: Path = DB_PATH):
self.path = path
self.conn: Optional[aiosqlite.Connection] = None
async def connect(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = await aiosqlite.connect(self.path)
self.conn.row_factory = aiosqlite.Row # row["컬럼명"] 으로 접근
await self.conn.execute("PRAGMA journal_mode=WAL")
await self.conn.execute("PRAGMA foreign_keys=ON")
await self.conn.executescript(SCHEMA)
await self.conn.commit()
async def close(self) -> None:
if self.conn is not None:
await self.conn.close()
self.conn = None
async def execute(self, sql: str, params: Iterable[Any] = ()) -> None:
await self.conn.execute(sql, tuple(params))
await self.conn.commit()
async def fetchone(self, sql: str, params: Iterable[Any] = ()):
async with self.conn.execute(sql, tuple(params)) as cursor:
return await cursor.fetchone()
async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> List[aiosqlite.Row]:
async with self.conn.execute(sql, tuple(params)) as cursor:
return await cursor.fetchall()
db = Database()
# ---------- 경고 (#21 automod 에서 사용) ----------
async def add_warn(guild_id: int, user_id: int) -> int:
"""경고를 1 올리고 누적 횟수를 돌려줘요. UPSERT라 동시 호출에도 안전해요."""
await db.execute(
"""
INSERT INTO warns (guild_id, user_id, count, updated_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT(guild_id, user_id)
DO UPDATE SET count = count + 1, updated_at = datetime('now')
""",
(guild_id, user_id),
)
row = await db.fetchone(
"SELECT count FROM warns WHERE guild_id = ? AND user_id = ?",
(guild_id, user_id),
)
return row["count"]
async def reset_warn(guild_id: int, user_id: int) -> None:
await db.execute(
"DELETE FROM warns WHERE guild_id = ? AND user_id = ?", (guild_id, user_id)
)
# ---------- 금지어 (#22 wordadmin 에서 사용) ----------
async def get_words(guild_id: int) -> List[str]:
rows = await db.fetchall(
"SELECT word FROM banned_words WHERE guild_id = ? ORDER BY added_at",
(guild_id,),
)
return [row["word"] for row in rows]
async def add_word(guild_id: int, word: str, category=None, added_by=None) -> bool:
"""추가 성공하면 True, 이미 있으면 False (중복 검사는 DB가 대신 해줘요)"""
try:
await db.execute(
"INSERT INTO banned_words (guild_id, word, category, added_by) "
"VALUES (?, ?, ?, ?)",
(guild_id, word, category, added_by),
)
return True
except aiosqlite.IntegrityError:
return False
async def remove_word(guild_id: int, word: str) -> bool:
before = await get_words(guild_id)
await db.execute(
"DELETE FROM banned_words WHERE guild_id = ? AND word = ?", (guild_id, word)
)
return word in before2) bot.py (setup_hook 연결)
# discord.py 2.x / aiosqlite 0.22.x / Python 3.9+
import os
from pathlib import Path
import discord
from discord.ext import commands
from dotenv import load_dotenv
from database import db
load_dotenv()
TOKEN = os.getenv("token") # .env 에서 봇 토큰 로딩 (하드코딩 금지!)
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
class ServerBot(commands.Bot):
def __init__(self):
super().__init__(command_prefix="!", intents=intents)
async def setup_hook(self):
await db.connect() # ① DB 먼저
print("🗄️ DB 연결 완료")
for file in sorted(Path("cogs").glob("*.py")):
await self.load_extension(f"cogs.{file.stem}")
print(f"✅ cogs.{file.stem} 불러옴")
async def close(self):
await db.close() # ② 끌 때 정리
print("🗄️ DB 연결 종료")
await super().close()
bot = ServerBot()
@bot.event
async def on_ready():
print(f"{bot.user} 로그인 성공!")
if __name__ == "__main__":
bot.run(TOKEN).env 파일은 이렇게 생겼어요.
token=여기에_봇_토큰
DB_PATH=data/bot.db3) migrate.py (한 번만 실행)
# 기존 JSON 데이터를 SQLite로 옮기는 1회용 스크립트
# 실행: python migrate.py
import asyncio
import json
from pathlib import Path
from database import db, add_word
async def main():
await db.connect()
# 1) 경고 기록 (#21)
warn_file = Path("data/warns.json")
if warn_file.exists():
data = json.loads(warn_file.read_text(encoding="utf-8"))
for guild_id, users in data.items():
for user_id, count in users.items():
await db.execute(
"INSERT OR REPLACE INTO warns (guild_id, user_id, count) "
"VALUES (?, ?, ?)",
(int(guild_id), int(user_id), int(count)),
)
warn_file.rename(warn_file.with_suffix(".json.bak"))
print("✅ warns.json 옮김")
# 2) 금지어 목록 (#22)
word_file = Path("data/banned_words.json")
if word_file.exists():
data = json.loads(word_file.read_text(encoding="utf-8"))
for guild_id, words in data.items():
for word in words:
await add_word(int(guild_id), word, category="이전됨")
word_file.rename(word_file.with_suffix(".json.bak"))
print("✅ banned_words.json 옮김")
rows = await db.fetchall("SELECT * FROM warns")
print(f"📊 경고 {len(rows)}건 이전 완료")
await db.close()
if __name__ == "__main__":
asyncio.run(main())4) 폴더 구조
server-bot/
├─ .env # token, DB_PATH
├─ .gitignore # data/*.db, *.db-wal, *.db-shm, .env
├─ bot.py # setup_hook 에서 DB 연결 (오늘 수정)
├─ database.py # ← 오늘 추가!
├─ migrate.py # ← 오늘 추가 (1회용)
├─ data/
│ ├─ bot.db # ← 자동 생성
│ ├─ warns.json.bak # 백업본
│ └─ banned_words.json.bak
└─ cogs/
├─ poll.py # #18
├─ onboarding.py # #20
├─ automod.py # #21 (add_warn 으로 교체)
└─ wordadmin.py # #22 (DB LIKE 검색으로 교체)💡
.gitignore에*.db,*.db-wal,*.db-shm세 개를 꼭 넣으세요. WAL 모드면 파일이 3개로 늘어납니다.
📌 실행 결과
먼저 마이그레이션이에요.
$ python migrate.py
✅ warns.json 옮김
✅ banned_words.json 옮김
📊 경고 1건 이전 완료그다음 봇을 켜면 DB가 Cog보다 먼저 올라와요.
$ python bot.py
🗄️ DB 연결 완료
✅ cogs.automod 불러옴
✅ cogs.onboarding 불러옴
✅ cogs.poll 불러옴
✅ cogs.wordadmin 불러옴
MojangBot#1234 로그인 성공!
겉보기엔 #22와 똑같이 동작해요. 그런데 이제 data/bot.db 를 DB Browser for SQLite 같은 무료 툴로 열어보면 표로 보입니다. 여기서 SQL 한 줄이면 JSON으로는 못 하던 걸 해요.

그리고 오늘의 하이라이트, 동시성 테스트예요. 같은 유저에게 경고를 동시에 100번 줘봤어요.
JSON 결과 (기대 100): 1
SQLite 결과 (기대 100): 100JSON은 99번이 증발했어요. 이게 오늘 DB로 갈아탄 진짜 이유예요 🛡️
📌 자주 나는 에러
1. sqlite3.OperationalError: unable to open database file
- 원인 : 폴더가 없어요.
aiosqlite는 파일은 만들어주지만 폴더는 안 만들어줍니다 - 해결 :
self.path.parent.mkdir(parents=True, exist_ok=True)를connect()앞에 두세요. 전체 코드에 이미 들어 있어요
2. 데이터를 넣었는데 봇을 껐다 켜면 사라져요
- 원인 :
await db.commit()을 안 했어요. 가장 흔한 실수 1위예요 - 해결 :
execute()뒤에 반드시 commit. 전체 코드처럼 래퍼 함수 안에서 자동으로 하게 만들면 실수가 없어요
3. AttributeError: 'NoneType' object has no attribute 'execute'
- 원인 :
db.conn이 아직None이에요.db.connect()보다 먼저 DB를 썼습니다 - 해결 :
setup_hook에서await db.connect()를load_extension보다 앞에 두세요. Cog의__init__에서 DB를 쓰지 말고, 명령어가 불릴 때 쓰세요
4. sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread
- 원인 :
sqlite3를 직접 쓰면서 비동기 코드에 섞었어요.aiosqlite를 쓰면 안 나는 에러예요 - 해결 :
import sqlite3가 아니라import aiosqlite인지,await를 빠뜨리지 않았는지 확인하세요
5. sqlite3.OperationalError: database is locked
- 원인 : 다른 프로그램(DB Browser 등)이 DB를 열어둔 채 쓰기 잠금을 잡고 있어요
- 해결 : 툴을 닫거나 WAL 모드를 켜세요. WAL이면 읽기가 쓰기를 안 막아요. 그래도 나면
aiosqlite.connect(path, timeout=10)으로 대기 시간을 늘리세요
6. sqlite3.IntegrityError: UNIQUE constraint failed
- 원인 : 이미 있는 기본키를 또 넣었어요 (같은 금지어 중복 추가 등)
- 해결 : 에러가 아니라 기능이에요.
try/except aiosqlite.IntegrityError로 잡아서 "이미 등록돼 있어요" 안내를 주거나,INSERT OR IGNORE를 쓰세요
7. 마이그레이션은 됐는데 조회하면 아무것도 안 나와요
- 원인 : JSON 키가 문자열이라
"123"으로 넣고, 조회는123(숫자)으로 했어요. SQLite는 타입이 다르면 다른 값으로 봅니다 - 해결 : 넣을 때
int(guild_id)로 변환하세요. 이미 잘못 넣었다면UPDATE warns SET guild_id = CAST(guild_id AS INTEGER)로 고칠 수 있어요
8. TypeError: 'sqlite3.Row' object does not support item assignment
- 원인 :
row_factory로 받은Row는 읽기 전용이에요 - 해결 : 값을 고치려면
dict(row)로 바꾸고 쓰세요
9. ModuleNotFoundError: No module named 'aiosqlite' / 설치가 안 돼요
- 원인 : 가상환경이 다르거나, Python 3.8을 쓰고 있어요. aiosqlite는 0.21 버전부터 Python 3.9 이상을 요구합니다
- 해결 :
python -V로 확인하고, 3.8이라면 파이썬을 올리세요. 당장 올리기 어려우면pip install "aiosqlite<0.21"(0.20.0, Python 3.8 지원) 로 낮춰 쓰면 돼요
📌 마무리
오늘은 봇의 데이터를 JSON에서 aiosqlite로 옮겼어요 🙌
오늘의 요약
- JSON이 무너지는 건 속도보다 동시 갱신(Lost Update) 때문이에요 — 100번 중 99번 증발
- 커넥션은
setup_hook에서 한 번만 열고,close()에서 닫아요 row_factory = aiosqlite.Row+ WAL 모드는 켜두고 시작하세요- UPSERT(
ON CONFLICT DO UPDATE) 로 "없으면 만들고 있으면 +1"을 한 방에 - 값은 무조건
?플레이스홀더 — f-string은 SQL 인젝션이에요 - 마이그레이션은
INSERT OR REPLACE+ 백업 남기기로 안전하게
다음 #24편에서는 서버 로그·감사(Audit) 채널을 만들어요. 메시지가 삭제·수정될 때, 누가 들어오고 나갈 때를 전부 임베드 로그로 남깁니다. 오늘 만든 DB에 로그 채널 설정을 저장하면서 바로 써먹을 거예요. 기대해주세요! 🙌
💬 오늘 만든
database.py는 #27 최종 프로젝트 "커뮤니티 통합 관리 봇"의 데이터 계층이에요. 앞으로 나올 로그 설정·에러 기록이 전부 이 파일 하나를 거쳐 갑니다 🗄️
📚 디스코드 봇 만들기 시리즈
- #1 ~ #12 — 봇 만들기 기초부터 쿨다운·권한까지
- #13 역할·채널 자동화
- #14 데이터 저장하기 (JSON vs SQLite)
- #15 슬래시 명령어 그룹(Group)
- #16 리마인더·알림 기능
- #17 RAG로 문서 기반 답변봇 만들기
- #18 투표(Poll) 기능 만들기
- #19 Cog로 코드 쪼개기
- #20 자동 환영 메시지·온보딩
- #21 키워드 자동응답·금지어 필터
- #22 슬래시 명령어 자동완성(Autocomplete)
- #23 aiosqlite로 비동기 DB 설계하기 ← 지금 이 글!
- #24 서버 로그·감사(Audit) 채널 만들기 (예정)
연재 · 32편 중 23번째
디스코드 봇 만들기
- #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임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
