자동화 스크립트의 실행 기록이나 수집한 데이터를 CSV에 한 줄씩 붙여 두는 경우가 많습니다. 처음엔 편하지만 쌓일수록 불편해집니다. 조회할 때마다 파일 전체를 읽어야 하고, 스크립트가 두 번 돌면 같은 기록이 두 줄 들어가고, 두 스크립트가 동시에 쓰면 꼬입니다. 그래서 파이썬 sqlite 사용법을 실제 측정으로 정리해 봤습니다. sqlite3는 파이썬에 기본으로 들어 있어서 설치할 것이 없습니다. 실행 기록 20만 건으로 CSV와 비교해 보니, 처음 짠 대로 한 건마다 commit 하면 2,000건 저장에 16.958초가 걸렸고, 한 트랜잭션으로 묶자 20만 건이 0.803초에 끝났습니다. 이 글에서는 바로 가져다 쓸 수 있는 저장 모듈, 저장·조회 속도와 파일 크기 실측값, 측정하다 직접 걸린 함정 네 가지를 정리합니다. 환경은 윈도우 11, Python 3.12.10, 내장 SQLite 3.49.1입니다.
CSV와 SQLite, 실제로 재 본 결과
job 12개가 30~150초 간격으로 돌면서 남긴 실행 기록 200,000건(약 7개월치, 실패 3%)을 만들어 두 방식에 똑같이 저장하고 조회했습니다. “특정 하루 기록 찾기”는 반복 측정 중앙값(CSV 5회, SQLite 20회)이고, 나머지는 한 번 실행한 값입니다.
| 항목 | CSV | SQLite |
|---|---|---|
| 20만 건 저장 | 0.152초 | 0.803초 (한 트랜잭션) |
| 파일 크기 | 7.7MB | 24.0MB (인덱스 2개 포함) |
| 특정 하루 기록 찾기 | 322.89ms (전체 읽기) | 0.98ms (인덱스) |
| 최근 7일 실패 job별 집계 | 0.244초 | 0.021초 |
| 같은 데이터를 한 번 더 저장 | 줄 수가 두 배 (1,000건 → 2,000줄) | 행 수 그대로 (200,000 → 200,000) |
쓰기 자체는 CSV가 더 빠르고 파일도 작습니다. 대신 조회는 수십~수백 배 차이가 났고, 무엇보다 같은 기록이 두 번 들어가지 않습니다. 매일 쌓이고 나중에 조건으로 꺼내 보는 데이터라면 SQLite 쪽이 맞습니다. 한 번 쓰고 엑셀로 열어 볼 데이터라면 CSV로 충분합니다.
$ python bench.py
기록 200,000건, 기간 2026-01-01 00:01:51 ~ 2026-07-28 06:26:46
CSV 쓰기: 0.152초
SQLite 한 건마다 commit (2,000건): 16.958초
SQLite 한 번에 저장 (200,000건): 0.803초
파일 크기: CSV 7.7MB, SQLite 24.0MB
조회 CSV 전체 읽기: 0.244초
조회 SQLite (인덱스 있음): 0.021초
최근 7일(>= 2026-07-21 06:26:46) 실패 상위 3개:
job-05 실패 25건 평균 22.8초
job-11 실패 23건 평균 21.0초
job-09 실패 19건 평균 21.6초
CSV 결과와 일치: True
SQLite 같은 데이터 재저장: 0.323초
재저장 전후 행 수: 200,000 -> 200,000
저장 모듈 전체 코드 (store.py)
측정에 쓴 모듈 그대로입니다. 실행 기록을 저장하는 runs 테이블 하나와 함수 세 개뿐입니다.
"""자동화 실행 기록을 SQLite에 저장하는 작은 모듈 (표준 라이브러리만)."""
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
job TEXT NOT NULL,
started_at TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
seconds REAL,
status TEXT NOT NULL, -- ok / fail
message TEXT,
UNIQUE (job, started_at) -- 같은 실행을 두 번 넣지 않음
);
CREATE INDEX IF NOT EXISTS idx_runs_started ON runs (started_at);
"""
UPSERT = """
INSERT INTO runs (job, started_at, seconds, status, message)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (job, started_at) DO UPDATE SET
seconds = excluded.seconds,
status = excluded.status,
message = excluded.message
"""
def connect(path="runs.db"):
con = sqlite3.connect(path, timeout=10)
con.row_factory = sqlite3.Row # 결과를 row["job"]처럼 이름으로 읽기
con.execute("PRAGMA journal_mode=WAL") # 읽는 쪽과 쓰는 쪽이 덜 막힘
con.executescript(SCHEMA)
return con
def save_runs(con, rows):
"""rows: (job, started_at, seconds, status, message) 튜플 목록. 한 트랜잭션으로 저장."""
with con: # 끝나면 commit, 예외가 나면 rollback
con.executemany(UPSERT, rows)
return con.total_changes
def failures_since(con, since):
return con.execute(
"""SELECT job, COUNT(*) AS fails, ROUND(AVG(seconds), 1) AS avg_sec
FROM runs
WHERE started_at >= ? AND status = 'fail'
GROUP BY job ORDER BY fails DESC, job""",
(since,),
).fetchall()
핵심은 세 가지입니다.
- UNIQUE (job, started_at): 같은 job이 같은 시각에 시작한 기록은 한 줄만 허용합니다. 여기에
ON CONFLICT ... DO UPDATE를 붙이면 이미 있는 기록은 새 값으로 덮어쓰고, 없으면 새로 넣습니다. 스크립트를 몇 번 다시 돌려도 중복이 생기지 않습니다. - with con: 블록이 끝나면 commit, 중간에 예외가 나면 rollback 합니다.
executemany와 같이 쓰면 여러 건을 한 트랜잭션으로 저장합니다. - 시각은 문자열로:
YYYY-MM-DD HH:MM:SS형식은 문자열 순서와 시간 순서가 같아서started_at >= ?비교와 인덱스가 그대로 동작합니다.
자동화 스크립트에 붙이기
기존 스크립트의 마지막에 기록을 남기는 예입니다. 작업이 실패해도 finally에서 기록하므로 실패 원인이 같이 남습니다.
"""자동화 스크립트 끝에 실행 기록을 남기는 예. python daily_job.py [job이름]"""
import sys, time
from datetime import datetime
import store
sys.stdout.reconfigure(encoding="utf-8")
job = sys.argv[1] if len(sys.argv) > 1 else "backup"
started = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
t0 = time.perf_counter()
status, message = "ok", ""
try:
time.sleep(0.3) # 실제 작업 자리
if job == "crawl":
raise TimeoutError("응답 없음")
except Exception as e:
status, message = "fail", f"{type(e).__name__}: {e}"
finally:
con = store.connect("daily.db")
store.save_runs(con, [(job, started, round(time.perf_counter() - t0, 2), status, message)])
for r in con.execute("SELECT job, started_at, seconds, status, message FROM runs ORDER BY id DESC LIMIT 3"):
print(dict(r))
con.close()
backup과, 일부러 예외를 내게 한 crawl을 차례로 실행한 결과입니다. 두 번째 실행 뒤 최근 기록입니다.
$ python daily_job.py backup
$ python daily_job.py crawl
{'job': 'crawl', 'started_at': '2026-10-26 10:05:05', 'seconds': 0.3, 'status': 'fail', 'message': 'TimeoutError: 응답 없음'}
{'job': 'backup', 'started_at': '2026-10-26 10:05:04', 'seconds': 0.33, 'status': 'ok', 'message': ''}
이렇게 쌓아 두면 “지난주에 실패한 작업이 뭐였지?”를 SQL 한 줄로 바로 꺼낼 수 있습니다. store.py의 failures_since()가 그 쿼리입니다.
commit 한 번에 8.5ms — 저장이 느릴 때
처음 측정에서 가장 크게 차이 난 부분입니다. 한 건 넣을 때마다 commit()을 부르면 2,000건에 16.958초가 걸렸습니다. 건당 약 8.5ms입니다. 같은 속도라면 20만 건은 계산상 약 28분이 걸립니다(직접 돌려 보지는 않았습니다). 같은 20만 건을 한 트랜잭션으로 묶으면 0.803초였습니다.
# 느린 방법: 한 건 넣을 때마다 commit
for r in rows:
con.execute(UPSERT, r)
con.commit()
# 빠른 방법: 한 트랜잭션에 몰아서
with con:
con.executemany(UPSERT, rows)
commit 할 때마다 SQLite는 데이터가 디스크에 실제로 기록될 때까지 기다립니다. 그래서 건수보다 commit 횟수가 시간을 좌우합니다. 스크립트 구조상 한 건씩 commit 할 수밖에 없다면 설정으로 줄일 수 있습니다. 같은 2,000건을 설정별로 다시 재 봤습니다.
| 설정 | 2,000건 | 건당 |
|---|---|---|
| 기본 (rollback 저널, synchronous=FULL) | 17.076초 | 8.54ms |
| WAL (synchronous=FULL) | 4.660초 | 2.33ms |
| WAL + synchronous=NORMAL | 0.213초 | 0.11ms |
con = sqlite3.connect("runs.db")
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA synchronous=NORMAL")
synchronous=NORMAL은 WAL 모드에서 commit마다 디스크 동기화를 기다리지 않는 설정입니다. 정전처럼 갑자기 꺼지면 마지막 몇 건이 사라질 수 있지만, 실행 기록 같은 데이터에는 대개 문제가 되지 않습니다. 잃으면 안 되는 데이터라면 기본값을 두고 한 트랜잭션으로 묶는 쪽을 권합니다.
인덱스는 공짜가 아니다 — 크기와 효과
SQLite 파일이 CSV의 3배(24.0MB)인 게 의외여서 테이블 구성을 바꿔 가며 크기를 쟀습니다.
| 구성 | 파일 크기 | VACUUM 뒤 |
|---|---|---|
| 테이블만 | 9.9MB | 9.9MB |
| + UNIQUE(job, started_at) | 17.6MB | 16.9MB |
| + UNIQUE + started_at 인덱스 | 24.0MB | 22.5MB |
데이터 자체는 9.9MB로 CSV와 비슷했고, 늘어난 14MB는 인덱스 두 개였습니다. 그럼 인덱스가 그만큼 일을 하는지 조회별로 20회씩 재 봤습니다.
| 조회 | 인덱스 있음 | 인덱스 없음 |
|---|---|---|
| 특정 하루 기록 전부 | 0.98ms | 20.05ms |
| 최근 7일 실패를 job별 집계 | 22.75ms | 20.54ms |
| job-03 마지막 실행 1건 | 0.01ms | 0.01ms |
좁은 범위를 꺼낼 때는 20배 빨랐지만, 7일치를 모아 집계할 때는 인덱스가 있는 쪽이 오히려 조금 느렸습니다. “마지막 실행 1건”은 started_at 인덱스를 지워도 UNIQUE가 자동으로 만든 인덱스(sqlite_autoindex_runs_1)를 써서 똑같이 빨랐습니다. 인덱스는 자주 하는 조회를 정해 놓고 실제로 재 본 다음 붙이는 게 맞습니다. 20만 건 정도면 인덱스 없이 전체를 훑어도 20ms 안팎이었습니다.
측정하다 직접 걸린 함정 네 가지
1. 결과가 다르다고 나왔는데 사실 같았다. 첫 실행에서 CSV와 SQLite 집계를 비교하니 “일치: False”가 나왔습니다. 확인해 보니 값은 모두 같고, 실패 건수가 같은 job(15건인 job-02와 job-03 등)의 순서만 달랐습니다. ORDER BY fails DESC만으로는 동점 순서가 정해지지 않습니다. ORDER BY fails DESC, job처럼 두 번째 기준을 넣어 해결했습니다.
2. 인덱스를 지웠는데 실행 계획이 그대로였다. 인덱스 효과를 재려고 DROP INDEX 뒤 EXPLAIN QUERY PLAN을 다시 실행했는데, 지우기 전 계획이 그대로 나왔습니다.
계획(지우기 전): ['SEARCH runs USING INDEX idx_runs_started (started_at>?)']
계획(지운 뒤, 같은 문장): ['SEARCH runs USING INDEX idx_runs_started (started_at>?)']
계획(지운 뒤, 다른 문장): ['SCAN runs']
rollback 뒤 인덱스 목록: [('sqlite_autoindex_runs_1',)]
글자 하나 다르지 않은 같은 문장을 같은 연결에서 다시 실행하면 예전 계획이 나왔고, 끝에 공백 하나만 붙여도 SCAN runs로 바뀌었습니다. 파이썬 sqlite3가 준비해 둔 문장을 재사용하기 때문으로 보입니다. 인덱스 전후를 비교할 때는 새 연결을 여는 게 안전합니다.
3. DROP INDEX는 rollback으로 되돌아오지 않았다. 위 출력의 마지막 줄입니다. rollback()을 불렀는데도 인덱스가 사라진 상태였습니다. 이 환경의 기본 트랜잭션 모드에서는 DROP INDEX 같은 구조 변경이 바로 확정됐습니다. 실험용 DB가 아니라면 구조를 바꾸기 전에 파일을 복사해 두세요.
4. 동시 쓰기 시험이 UNIQUE 오류로 무효가 됐다. 다른 스크립트가 쓰는 중인 상황을 흉내 내려고 3초 동안 쓰기 잠금을 잡는 writer.py를 두 번 돌렸는데, 두 번째에 바로 죽었습니다.
Traceback (most recent call last):
File "...\writer.py", line 5, in <module>
con.execute("INSERT INTO runs (...) VALUES ('job-98', '2026-12-31 00:00:00', 1, 'ok', '')")
sqlite3.IntegrityError: UNIQUE constraint failed: runs.job, runs.started_at
첫 회차에 넣은 행이 남아 있어서 같은 키로 다시 INSERT 하다가 막힌 것입니다. 잠금을 잡기도 전에 끝났으니 그 회차 결과(“0.01초 기다림”)는 버리고, INSERT OR REPLACE로 바꿔 다시 쟀습니다. UNIQUE 제약이 중복을 확실히 막는다는 걸 의도치 않게 확인한 셈입니다.
두 스크립트가 동시에 쓸 때 (database is locked)
다시 잰 결과입니다. 한 스크립트가 쓰기 잠금을 3초 동안 잡고 있고, 0.5초 뒤 다른 스크립트가 저장을 시도합니다.
$ python lock.py
timeout=1: OperationalError: database is locked (1.17초 만에)
writer 종료 코드: 0
timeout=10: 성공, 2.55초 기다림
writer 종료 코드: 0
timeout=1이면 1초 기다리다 database is locked로 실패했고, timeout=10이면 앞 스크립트가 끝날 때까지 2.55초 기다렸다가 저장에 성공했습니다. 파이썬 sqlite3.connect()의 timeout 기본값은 5초입니다. 정기 실행 스크립트 여러 개가 같은 DB에 쓴다면 store.py처럼 넉넉하게 잡고, 쓰기 트랜잭션은 짧게 유지하는 게 좋습니다.
주의할 점
- datetime 객체를 그대로 넣지 마세요. Python 3.12에서 그대로 넣어 보니 값은 들어갔지만 아래 경고가 나왔습니다. 문자열로 바꿔 저장하는 편이 깔끔합니다.
DeprecationWarning: The default datetime adapter is deprecated as of Python 3.12;
see the sqlite3 documentation for suggested replacement recipes
('2026-10-26 09:00:00', 'text')
- commit은 몰아서 하세요. 이번 측정에서 저장 시간을 가장 크게 좌우한 건 데이터 양이 아니라 commit 횟수였습니다.
- 인덱스는 재 보고 붙이세요. 인덱스 두 개가 파일 크기를 9.9MB에서 24.0MB로 늘렸고, 집계 조회에서는 오히려 약간 느렸습니다.
- 동점 정렬 순서를 정하세요. 결과를 다른 프로그램과 비교하거나 리포트로 낼 때 순서가 매번 같아야 합니다.
- WAL 모드에서는 -wal, -shm 파일이 같이 생깁니다. DB를 복사·백업할 때는 연결을 닫은 뒤 세 파일을 함께 옮기세요.
마무리
CSV는 쓰기 쉽고, SQLite는 꺼내 쓰기 쉽습니다. 다만 commit 한 줄을 어디에 두느냐에 따라 저장 속도가 수천 배 달라졌습니다.
가장 작은 첫걸음은 이렇습니다.
- 위 store.py를 스크립트 옆에 저장하고, 기존 스크립트 끝에서
save_runs()로 실행 기록 한 줄을 남겨 봅니다. - 며칠 쌓인 뒤
failures_since()로 실패한 작업만 꺼내 봅니다. - 저장이 느리다면 commit 횟수부터 확인하고, 조회가 느리다면 그 조회에 맞는 인덱스를 붙인 뒤 전후 시간을 재 봅니다.
다음 글에서는 AI 문서 검색(RAG)이 어떤 원리로 관련 문서를 찾는지, TF-IDF 검색기를 파이썬으로 직접 만들어 돌려 보겠습니다.