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

디스코드 봇 만들기#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명을 훑든 인덱스로 한 줄만 찾으니까요.

그런데 속도는 사실 두 번째 문제예요. 진짜 문제는 맨 위에서 보신 그거예요.

Python
# 이 코드가 왜 위험할까요?
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 첫 연결

먼저 감을 잡아볼게요. 파일 하나로 끝나요.

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

봇의 정석은 켤 때 한 번 열고, 끌 때 닫는 것이에요. 커넥션을 담당하는 파일을 따로 만듭니다.

Python
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 이 자리예요.

Python
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에 있던 걸 표 두 개로 옮겨요.

SQL
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자리 숫자인데, SQLite INTEGER 는 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은 한 줄이에요.

Python
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 는 이제 이렇게 짧아져요.

Python
# 변경 전 (#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 데이터 옮기기

지금까지 쌓인 경고·금지어를 버릴 순 없죠. 한 번만 돌리는 스크립트를 만들어요.

Python
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가 대신 걸러줍니다.

Python
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

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

2) bot.py (setup_hook 연결)

Python
# 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.db

3) migrate.py (한 번만 실행)

Python
# 기존 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): 100

JSON은 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 최종 프로젝트 "커뮤니티 통합 관리 봇"의 데이터 계층이에요. 앞으로 나올 로그 설정·에러 기록이 전부 이 파일 하나를 거쳐 갑니다 🗄️


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

연재 · 32편 중 23번째

디스코드 봇 만들기

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