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

디스코드 봇 만들기#14데이터 저장하기! JSON vs SQLite (파이썬 예제)

목차11개 항목

안녕하세요! Mojangcam입니다 😎

지난 #13편에서는 역할·채널 자동화로 서버 관리를 봇에게 통째로 맡겨봤어요. 그런데 한 가지 아쉬운 점이 있었죠. #13의 temp_channels처럼 변수에만 담아둔 데이터는 봇을 재시작하는 순간 전부 사라진다는 거예요.

그래서 이번 #14편에서는 봇에게 기억력을 만들어줄 거예요. 파이썬에 기본 내장된 JSON과 SQLite 두 가지 방법으로 출석 체크 + 포인트 + 랭킹 봇을 직접 만들면서, 언제 뭘 쓰면 되는지 확실히 정리해볼게요.

출석 체크, 포인트(경제) 시스템, 레벨, 경고 누적… 커뮤니티 서버의 인기 기능은 전부 "데이터를 기억하는 봇"이 있어야 가능해요. 오늘 이 관문만 넘으면 만들 수 있는 기능이 확 늘어납니다!

🧠 왜 저장이 필요할까요?

파이썬 변수는 전부 메모리(RAM)에만 존재해요. 봇을 재시작하거나, VPS가 재부팅되거나, 오류로 봇이 꺼지는 순간 변수 속 포인트·기록은 흔적도 없이 리셋됩니다. 그래서 데이터를 파일로 남겨두고, 봇이 켜질 때 다시 읽어오는 과정이 필요해요. 이걸 어려운 말로 영속성(Persistence)이라고 부릅니다.

🧩 JSON vs SQLite, 뭐가 다를까?

결론부터 말씀드릴게요! 설정처럼 작고 단순한 데이터는 JSON, 계속 쌓이고 검색·랭킹이 필요한 데이터는 SQLite예요.

구분 JSON SQLite
정체 텍스트 파일 (dict를 그대로 저장) 파일 하나짜리 진짜 데이터베이스
설치 필요 없음 (내장 json 모듈) 필요 없음 (내장 sqlite3 모듈)
읽고 쓰기 매번 파일 전체를 통째로 필요한 행만 골라서
검색·정렬 파이썬 코드로 직접 구현 SQL 한 줄 (ORDER BY 등)
데이터가 많아지면 점점 느려짐 인덱스 덕분에 빠름
저장 중 사고 나면 파일이 깨질 위험 트랜잭션으로 안전
어울리는 곳 봇 설정값, 소규모 기록 포인트·경제·로그처럼 계속 쌓이는 데이터

JSON은 파이썬 dict를 그대로 파일로 옮긴 거라 직관적이고, 메모장으로 열어서 눈으로 확인할 수 있다는 게 최고 장점이에요. 대신 저장할 때마다 파일 전체를 다시 쓰기 때문에 데이터가 커질수록 느려져요. SQLite는 서버 설치 없이 파일 하나로 돌아가는 진짜 데이터베이스라서, 필요한 데이터만 골라 읽고 정렬·집계까지 SQL에게 맡길 수 있어요.

📌 준비물

  • Python 3.8 이상
  • discord.py 2.x (최신 2.x 버전이면 돼요)
  • json·sqlite3 모듈 — 파이썬에 기본 내장이라 따로 설치할 게 없어요!
터미널
pip install -U discord.py

접두사 명령어(!출석)를 쓸 거라서 디스코드 개발자 포털 → Bot 탭 → Message Content Intent를 켜두셔야 해요. (이 설정이 처음이라면 시리즈 앞편을 참고해주세요!)

📌 1단계 - JSON 저장의 기본기: load & save 함수

JSON 방식의 핵심은 딱 두 함수예요. 파일에서 dict를 읽어오는 함수와 dict를 파일로 저장하는 함수.

Python
import json
import os

DATA_FILE = "points.json"

def load_data():
    """파일이 있으면 읽고, 없으면 빈 dict를 돌려줘요."""
    if not os.path.exists(DATA_FILE):
        return {}
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)

def save_data(data):
    """dict를 통째로 JSON 파일에 저장해요."""
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

하나씩 뜯어볼게요 💡

  • json.load(f) : 파일 내용을 파이썬 dict로 바꿔서 읽어와요.
  • json.dump(data, f) : dict를 JSON 형식 텍스트로 바꿔 파일에 써요.
  • encoding="utf-8" + ensure_ascii=False : 한글 깨짐 방지 세트예요. 둘 중 하나라도 빼면 한글이 \uD55C\uAE00 같은 외계어로 저장돼요.
  • indent=2 : 들여쓰기를 넣어 사람이 읽기 좋게 저장해요.

한 가지 주의! JSON의 키(key)는 문자열만 허용돼요. 유저 ID는 원래 숫자지만, 저장할 때는 str(ctx.author.id)처럼 문자열로 통일해서 쓰는 게 안전합니다.

📌 2단계 - JSON 버전 출석·포인트 봇 완성

위 두 함수에 명령어 3개(!출석, !포인트, !랭킹)를 얹으면 완성이에요. 이 코드는 이대로 실행 가능한 완성본입니다.

Python
# discord.py 2.x / Python 3.8+ / json은 파이썬 내장
import json
import os
from datetime import date

import discord
from discord.ext import commands

TOKEN = "여기에_봇_토큰"
DATA_FILE = "points.json"

def load_data():
    if not os.path.exists(DATA_FILE):
        return {}
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)

def save_data(data):
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

intents = discord.Intents.default()
intents.message_content = True  # 접두사 명령어용

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

@bot.event
async def on_ready():
    print(f"✅ {bot.user} 로 로그인했어요!")

@bot.command(name="출석")
async def attend(ctx: commands.Context):
    data = load_data()
    uid = str(ctx.author.id)
    today = date.today().isoformat()   # 예: '2026-07-30'

    user = data.get(uid, {"points": 0, "last_check": ""})
    if user["last_check"] == today:
        await ctx.send(f"{ctx.author.mention} 오늘은 이미 출석했어요! 😅")
        return

    user["points"] += 10
    user["last_check"] = today
    data[uid] = user
    save_data(data)
    await ctx.send(f"✅ {ctx.author.mention} 출석 완료! +10P (총 {user['points']}P)")

@bot.command(name="포인트")
async def my_points(ctx: commands.Context):
    data = load_data()
    user = data.get(str(ctx.author.id))
    total = user["points"] if user else 0
    await ctx.send(f"💰 {ctx.author.mention} 님의 포인트: {total}P")

@bot.command(name="랭킹")
async def ranking(ctx: commands.Context):
    data = load_data()
    if not data:
        await ctx.send("아직 아무도 출석하지 않았어요. 1등 기회! 🏃")
        return

    top = sorted(data.items(), key=lambda x: x[1]["points"], reverse=True)[:5]
    medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
    lines = [f"{medals[i]} <@{uid}> — {info['points']}P"
             for i, (uid, info) in enumerate(top)]
    embed = discord.Embed(title="🏆 포인트 랭킹 TOP 5",
                          description="\n".join(lines),
                          color=0x5865F2)
    await ctx.send(embed=embed)

bot.run(TOKEN)

하나씩 뜯어볼게요 💡

  • date.today().isoformat() : 오늘 날짜를 2026-07-30 형식 문자열로 만들어요. last_check와 비교해서 하루 1회 출석을 막는 핵심이에요.
  • data.get(uid, 기본값) : 처음 출석하는 유저라면 포인트 0으로 시작하는 기본 dict를 만들어줘요.
  • sorted(..., reverse=True)[:5] : 랭킹을 뽑으려고 전체 데이터를 파이썬으로 다 불러와서 직접 정렬했어요. 이게 JSON 방식의 한계 포인트! 뒤에서 SQLite와 비교해보세요.
  • 임베드 속 <@유저ID> 멘션 : 임베드 안의 멘션은 알림(핑)이 가지 않아서 랭킹 표시용으로 안성맞춤이에요.

실행하고 !출석을 입력하면 봇 폴더에 points.json 파일이 생겨요. 메모장으로 열면 이렇게 생겼습니다.

JSON
{
  "123456789012345678": {
    "points": 30,
    "last_check": "2026-07-30"
  }
}

유저 10명 규모라면 이걸로 충분해요. 그런데 유저가 수백 명이 되고 기록이 쌓이면 문제가 생겨요. 출석 한 번에 파일 전체를 다시 쓰고, 랭킹 한 번에 전체를 다시 읽는 구조거든요. 게다가 저장 도중 봇이 강제 종료되면 파일이 통째로 깨질 수도 있어요. 이럴 때 필요한 게 SQLite입니다!

📌 3단계 - 같은 봇을 SQLite로 업그레이드

SQLite는 파이썬 내장 sqlite3 모듈로 바로 쓸 수 있어요. 먼저 DB 파일에 연결하고, 표(테이블)를 만들어요.

Python
import sqlite3

con = sqlite3.connect("points.db")   # 파일이 없으면 자동 생성!
con.execute("""
CREATE TABLE IF NOT EXISTS points (
    user_id    TEXT PRIMARY KEY,
    points     INTEGER NOT NULL DEFAULT 0,
    last_check TEXT    NOT NULL DEFAULT ''
)
""")
con.commit()

출석 명령어는 이렇게 바뀌어요.

Python
@bot.command(name="출석")
async def attend(ctx: commands.Context):
    uid = str(ctx.author.id)
    today = date.today().isoformat()

    row = con.execute(
        "SELECT last_check FROM points WHERE user_id = ?", (uid,)
    ).fetchone()
    if row is not None and row[0] == today:
        await ctx.send(f"{ctx.author.mention} 오늘은 이미 출석했어요! 😅")
        return

    con.execute("INSERT OR IGNORE INTO points (user_id) VALUES (?)", (uid,))
    con.execute(
        "UPDATE points SET points = points + 10, last_check = ? WHERE user_id = ?",
        (today, uid),
    )
    con.commit()

하나씩 뜯어볼게요 💡

  • sqlite3.connect("points.db") : DB 파일에 연결해요. 파일이 없으면 알아서 만들어줘요.
  • CREATE TABLE IF NOT EXISTS : 표가 없을 때만 만들어요. 봇을 몇 번을 재시작해도 안전한 이유예요.
  • PRIMARY KEY : user_id가 중복되지 않게 막아주는 대표 열쇠예요.
  • 물음표(?) 플레이스홀더 : 값을 SQL 문장에 직접 붙이지 않고 안전하게 전달해요. f-string으로 SQL을 조립하면 SQL 인젝션 공격에 뚫릴 수 있으니 꼭 ?를 쓰세요!
  • INSERT OR IGNORE → UPDATE : "없으면 만들고, 있으면 그대로" 다음에 포인트를 더해요. 초보자에게 가장 실수 없는 패턴이에요.
  • con.commit() : 변경사항을 파일에 확정해요. 이걸 빼먹으면 저장이 안 돼요!

랭킹은 어떨까요? JSON에서는 전체를 불러와 파이썬으로 정렬했지만, SQLite는 SQL 한 줄이면 끝나요.

Python
rows = con.execute(
    "SELECT user_id, points FROM points ORDER BY points DESC LIMIT 5"
).fetchall()

ORDER BY points DESC LIMIT 5 — "포인트 내림차순으로 위에서 5명만"이라는 뜻이에요. 유저가 10만 명이어도 DB가 알아서 골라줍니다. 이게 데이터베이스의 힘이에요! 💪

📌 전체 코드 (복붙 OK)

SQLite 버전 완성본이에요. TOKEN만 바꾸면 바로 돌아갑니다. (JSON 버전 완성본은 위 2단계 코드를 그대로 쓰면 돼요.)

Python
# discord.py 2.x / Python 3.8+ / sqlite3는 파이썬 내장
import sqlite3
from datetime import date

import discord
from discord.ext import commands

TOKEN = "여기에_봇_토큰"
DB_FILE = "points.db"

# ── DB 준비 ──────────────────────────
con = sqlite3.connect(DB_FILE)
con.execute("""
CREATE TABLE IF NOT EXISTS points (
    user_id    TEXT PRIMARY KEY,
    points     INTEGER NOT NULL DEFAULT 0,
    last_check TEXT    NOT NULL DEFAULT ''
)
""")
con.commit()

intents = discord.Intents.default()
intents.message_content = True  # 접두사 명령어용

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

@bot.event
async def on_ready():
    print(f"✅ {bot.user} 로 로그인했어요!")

@bot.command(name="출석")
async def attend(ctx: commands.Context):
    uid = str(ctx.author.id)
    today = date.today().isoformat()

    row = con.execute(
        "SELECT last_check FROM points WHERE user_id = ?", (uid,)
    ).fetchone()
    if row is not None and row[0] == today:
        await ctx.send(f"{ctx.author.mention} 오늘은 이미 출석했어요! 😅")
        return

    con.execute("INSERT OR IGNORE INTO points (user_id) VALUES (?)", (uid,))
    con.execute(
        "UPDATE points SET points = points + 10, last_check = ? WHERE user_id = ?",
        (today, uid),
    )
    con.commit()

    total = con.execute(
        "SELECT points FROM points WHERE user_id = ?", (uid,)
    ).fetchone()[0]
    await ctx.send(f"✅ {ctx.author.mention} 출석 완료! +10P (총 {total}P)")

@bot.command(name="포인트")
async def my_points(ctx: commands.Context):
    row = con.execute(
        "SELECT points FROM points WHERE user_id = ?", (str(ctx.author.id),)
    ).fetchone()
    total = row[0] if row else 0
    await ctx.send(f"💰 {ctx.author.mention} 님의 포인트: {total}P")

@bot.command(name="랭킹")
async def ranking(ctx: commands.Context):
    rows = con.execute(
        "SELECT user_id, points FROM points ORDER BY points DESC LIMIT 5"
    ).fetchall()
    if not rows:
        await ctx.send("아직 아무도 출석하지 않았어요. 1등 기회! 🏃")
        return

    medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
    lines = [f"{medals[i]} <@{uid}> — {pts}P"
             for i, (uid, pts) in enumerate(rows)]
    embed = discord.Embed(title="🏆 포인트 랭킹 TOP 5",
                          description="\n".join(lines),
                          color=0x5865F2)
    await ctx.send(embed=embed)

bot.run(TOKEN)

📌 실행 결과

봇을 켜고 서버에서 하나씩 확인해봐요.

  1. !출석 입력 → "✅ 출석 완료! +10P (총 10P)" 라고 답해요. 같은 날 또 입력하면 "오늘은 이미 출석했어요! 😅"
  2. !포인트 입력 → 내 누적 포인트를 보여줘요.
  3. !랭킹 입력 → 🥇🥈🥉 메달이 달린 TOP 5 임베드가 떠요.
  4. 여기서 진짜 하이라이트! 봇을 껐다가 다시 켜보세요. !포인트를 치면 포인트가 그대로 살아있어요. 봇 폴더의 points.db(또는 points.json) 파일에 기억이 저장된 덕분이에요. 🎉

📌 자주 나는 에러

1. json.decoder.JSONDecodeError: Expecting value: line 1 column 1 원인: points.json이 빈 파일이거나, 저장 도중 봇이 강제 종료돼 파일이 깨졌어요. 해결: 파일을 지우고 다시 실행하면 새로 만들어져요. 데이터가 아깝다면 메모장으로 열어 깨진 부분을 고쳐요. 예방하려면 봇을 끌 때 저장 중이 아닐 때 Ctrl+C 한 번으로 정상 종료하세요.

2. 한글이 \uc548\ub155 같은 외계어로 저장됨 원인: json.dump에 ensure_ascii=False를 빼먹었어요. 해결: json.dump(data, f, ensure_ascii=False, indent=2) — 열 때도 encoding="utf-8"을 꼭 붙이세요.

3. sqlite3.OperationalError: no such table: points 원인: CREATE TABLE 실행 전에 SELECT를 했거나, 다른 폴더에서 봇을 실행해서 빈 DB 파일이 엉뚱한 곳에 새로 생겼어요. 해결: DB 준비 코드(CREATE TABLE)가 bot.run()보다 먼저 실행되는지 확인하고, 터미널에서 cd로 봇 폴더에 들어간 뒤 실행하세요.

4. sqlite3.OperationalError: database is locked 원인: DB 뷰어 프로그램이 points.db를 잡고 있거나, commit() 안 한 다른 연결이 파일을 물고 있어요. 해결: DB 뷰어를 닫고, 데이터를 바꾸는 코드 뒤에는 항상 con.commit()을 붙이는 습관을 들이세요.

💡 보너스 팁

  • 출석 연타 방지 : 하루 1회 제한에 더해서 #12편의 쿨다운을 걸면 스팸까지 완벽 차단돼요.
  • 백업은 파일 복사 한 번 : points.db 파일만 복사해두면 백업 끝! VPS에서 24시간 돌리는 중이라면 출석 날짜가 서버 시간 기준이니, 해외 VPS는 시간대를 서울(Asia/Seoul)로 맞춰주세요.
  • 데이터가 정말 많아진다면 : sqlite3는 동기 방식이라 아주 큰 작업에서는 봇이 잠깐 멈출 수 있어요. 입문 규모에서는 체감이 없지만, 나중에 비동기 라이브러리 aiosqlite로 넘어가는 방법을 시리즈에서 다룰 예정이에요!

📌 마무리

오늘은 봇에게 기억력을 달아주는 데이터 저장을 배웠어요. 작고 단순한 데이터는 JSON, 계속 쌓이고 검색·랭킹이 필요한 데이터는 SQLite — 이 기준 하나만 기억하면 됩니다. 출석·포인트·랭킹 봇을 두 방식으로 모두 만들어봤으니, 이제 레벨 시스템이든 경고 누적이든 뭐든 응용할 수 있어요!

다음 편에서는 명령어가 많아진 봇을 깔끔하게 정리하는 슬래시 명령어 그룹(Group)을 다뤄볼게요. 기대해주세요! 🙌

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

연재 · 32편 중 14번째

디스코드 봇 만들기

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