디스코드 봇 만들기#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를 파일로 저장하는 함수.
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개(!출석, !포인트, !랭킹)를 얹으면 완성이에요. 이 코드는 이대로 실행 가능한 완성본입니다.
# 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 파일이 생겨요. 메모장으로 열면 이렇게 생겼습니다.
{
"123456789012345678": {
"points": 30,
"last_check": "2026-07-30"
}
}유저 10명 규모라면 이걸로 충분해요. 그런데 유저가 수백 명이 되고 기록이 쌓이면 문제가 생겨요. 출석 한 번에 파일 전체를 다시 쓰고, 랭킹 한 번에 전체를 다시 읽는 구조거든요. 게다가 저장 도중 봇이 강제 종료되면 파일이 통째로 깨질 수도 있어요. 이럴 때 필요한 게 SQLite입니다!
📌 3단계 - 같은 봇을 SQLite로 업그레이드
SQLite는 파이썬 내장 sqlite3 모듈로 바로 쓸 수 있어요. 먼저 DB 파일에 연결하고, 표(테이블)를 만들어요.
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()출석 명령어는 이렇게 바뀌어요.
@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 한 줄이면 끝나요.
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단계 코드를 그대로 쓰면 돼요.)
# 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)📌 실행 결과
봇을 켜고 서버에서 하나씩 확인해봐요.
!출석입력 → "✅ 출석 완료! +10P (총 10P)" 라고 답해요. 같은 날 또 입력하면 "오늘은 이미 출석했어요! 😅"!포인트입력 → 내 누적 포인트를 보여줘요.!랭킹입력 → 🥇🥈🥉 메달이 달린 TOP 5 임베드가 떠요.- 여기서 진짜 하이라이트! 봇을 껐다가 다시 켜보세요.
!포인트를 치면 포인트가 그대로 살아있어요. 봇 폴더의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)을 다뤄볼게요. 기대해주세요! 🙌
📚 디스코드 봇 만들기 시리즈
- #5 - 버튼(Button) 상호작용
- #6 - 드롭다운(Select Menu) 메뉴
- #7 - 모달(Modal)로 입력 받기
- #8 - 임베드(Embed) 꾸미기
- #9 - AI(Claude·ChatGPT API) 연동
- #10 - 다국어(i18n) 지원
- #11 - 플라스크(Flask) 웹 대시보드
- #12 - 명령어 쿨다운·권한 관리
- #13 - 역할·채널 자동화
- #14 - 데이터 저장 (JSON vs SQLite) ← 지금 이 글!
연재 · 32편 중 14번째
디스코드 봇 만들기
- #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임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
