AlgoLab Blog · 운영 실무 · 체결 기록 DB · 2026

SQLite database is locked — WAL만 켜면 더 샌다

운영 · 트러블슈팅 2026-10-30 · 약 9분 읽기 · 알고랩 AlgoLab
한 줄 요약

sqlite3.OperationalError: database is locked는 매매 봇·텔레그램 봇·정산 스크립트처럼 두 개 이상의 프로세스가 같은 SQLite 파일에 쓸 때, 한쪽이 쓰기 잠금을 파이썬 기본 대기시간(timeout=5.0초)보다 오래 쥐면 납니다. 해결은 세 가지를 같이 하는 것입니다 — 쓰기 트랜잭션을 BEGIN IMMEDIATE로 시작하고, 트랜잭션 안에서 주문 API 응답을 기다리지 않고, 수량 계산은 파이썬이 아니라 SQL 안에서(qty = qty + ?) 합니다. 흔히 권하는 PRAGMA journal_mode=WAL만 켜는 것은 해결이 아닙니다. 실측(4개 프로세스 × 500건)에서 WAL + 기본 트랜잭션은 2,000건 중 1,500건이 즉시 실패했고, 파이썬 기본 설정으로 "읽고 계산해서 쓰는" 코드는 에러 한 줄 없이 포지션 업데이트 1,500건이 사라졌습니다. BEGIN IMMEDIATE로 바꾸자 같은 조건에서 2,000건이 전부 들어갔습니다. (Python 3.10.4 · SQLite 3.37.2 · Windows 11, 2026-10-30 실측)

목차

  1. 언제 나나 — 5.43초 뒤에 터지는 이유
  2. 실측: 설정 5가지로 2,000건을 써 보니
  3. WAL이 더 나빠지는 이유 — 승격 충돌
  4. 고치는 코드
  5. 그래도 안 되면 — SQLite를 그만 쓸 때
  6. 자주 묻는 질문

1. 언제 나나 — 5.43초 뒤에 터지는 이유

자동매매 프로그램을 몇 달 운영하면 프로세스가 하나가 아니게 됩니다. 장중에 키움 kt10000이나 KIS TTTC0012U로 주문을 내고 체결을 fills 테이블에 쌓는 매매 봇, 그 테이블을 읽어 잔고를 알려 주는 텔레그램 봇, 장 마감 뒤 손익을 정산하는 스크립트가 같은 trade.db를 엽니다. 별도 서버가 필요 없는 SQLite가 편해서 이렇게 시작하는데, 어느 날 로그에 아래 한 줄이 찍히고 그날의 체결 기록이 비어 있습니다.

Traceback (most recent call last):
  File "bot.py", line 88, in record_fill
    conn.execute("INSERT INTO fills(code,qty,price,ts) VALUES(?,?,?,?)", row)
sqlite3.OperationalError: database is locked

SQLite는 파일 하나에 동시에 한 연결만 쓸 수 있습니다. 다른 연결이 쓰기 잠금을 쥐고 있으면 기다리다가, 정해진 시간이 지나면 SQLITE_BUSY를 돌려주고 파이썬은 이것을 OperationalError: database is locked로 올립니다. 그 대기 시간이 sqlite3.connect()의 timeout이고 기본값은 5.0초입니다(PRAGMA busy_timeout으로 읽으면 5000). 한 프로세스가 쓰기 트랜잭션을 연 채 8초 동안 커밋하지 않게 해 두고, 다른 프로세스에서 INSERT를 해 본 결과입니다.

두 번째 연결의 timeoutDELETE(기본) 저널WAL 저널
지정 안 함 (기본 5.0초)5.43초 뒤 database is locked5.42초 뒤 database is locked
timeout=0.50.61초 뒤 실패0.60초 뒤 실패
timeout=108.04초 기다린 뒤 성공8.11초 기다린 뒤 성공
같은 순간 SELECT0.00초 · 성공0.00초 · 성공

표에서 두 가지를 읽을 수 있습니다. 첫째, 쓰기와 쓰기의 충돌은 WAL이어도 똑같이 막힙니다. WAL이 바꾸는 것은 읽기가 쓰기를 기다리지 않게 하는 부분입니다. 둘째, 8초를 쥐고 있던 이유가 문제입니다. 실제 봇에서 이런 시간은 대개 트랜잭션을 연 채 주문 API 응답을 기다릴 때 생깁니다. INSERT로 주문 기록을 먼저 넣고, requests.post()로 주문을 보내고, 응답의 주문번호(ord_no·ODNO)를 UPDATE한 다음에야 commit()하는 구조입니다. 이 사이에 ReadTimeout이 걸리면 그 시간 전체가 잠금 시간이 됩니다.

2. 실측: 설정 5가지로 2,000건을 써 보니

체결 1건이 들어올 때 봇이 하는 일을 흉내 냈습니다 — 현재 포지션을 읽고(SELECT qty FROM pos), 체결을 기록하고(INSERT INTO fills), 포지션을 1 늘립니다(UPDATE pos). 4개 프로세스(multiprocessing)가 각각 500번, 합계 2,000번 동시에 실행하고, 끝난 뒤 fills 행 수와 포지션 증가분을 셌습니다. 전부 timeout=5입니다. 표의 숫자는 3회 실행(1회 + 반복 2회) 값입니다.

설정실패(에러)실제 반영시간
DELETE + BEGIN(DEFERRED)458 · 495 · 403에러 난 만큼 빠짐19~23초
WAL + BEGIN(DEFERRED)1,500 · 1,504 · 1,500약 500건1.3~1.4초
DELETE + BEGIN IMMEDIATE3 · 5 · 31,995~1,99723~28초
WAL + BEGIN IMMEDIATE1 · 0 · 01,999~2,0005.0~7.6초

더 무서운 것은 따로 돌린 다섯 번째 설정입니다. 트랜잭션을 직접 열지 않고 파이썬 sqlite3 기본값(isolation_level='')으로, 많은 봇 코드가 실제로 쓰는 모양 그대로 "읽고 → 파이썬에서 +1 → 쓰기"를 했습니다.

qty = conn.execute("SELECT qty FROM pos WHERE code='005930'").fetchone()[0]
conn.execute("UPDATE pos SET qty=? WHERE code='005930'", (qty + 1,))
conn.commit()
python-default DELETE: 25.50s fails=2 expected+=1998 actual+=703
python-default WAL:     5.22s fails=0 expected+=2000 actual+=500

WAL에서는 에러가 0건인데 포지션은 2,000이 아니라 500만 늘었습니다. 파이썬 기본 모드는 SELECT 전에는 트랜잭션을 열지 않고 UPDATE 직전에야 엽니다. 그래서 네 프로세스가 같은 qty를 읽고 각자 +1을 써서 서로의 결과를 덮어씁니다(lost update). 잠금 에러는 적어도 로그에 남지만 이것은 아무 흔적이 없습니다. 실제 봇이라면 보유 수량이 틀어진 채로 다음 매도 수량을 계산합니다. 같은 일을 UPDATE pos SET qty = qty + 1처럼 계산을 SQL 안에 넣어 돌리면 WAL에서 2,000건 전부(5.65초), DELETE에서 1,997건이 반영됐습니다.

2,000건 중 실제로 반영된 건수 (4프로세스 × 500, timeout=5) 파이썬 기본값 + WAL 500 · 에러 0건 WAL + BEGIN 약 500 · 에러 1,500 DELETE + BEGIN 약 1,540 DELETE + IMMEDIATE 약 1,996 WAL + IMMEDIATE 2,000 · 5~7초
Python 3.10.4 · SQLite 3.37.2 · Windows 11 로컬 디스크 · 2026-10-30 실측(값은 3회 중 대표값)

3. WAL이 더 나빠지는 이유 — 승격 충돌

BEGIN은 기본이 DEFERRED입니다. 트랜잭션을 "읽기"로 시작했다가 첫 쓰기 문장에서 "쓰기"로 승격합니다. 그런데 읽은 뒤 승격하기 전에 다른 연결이 커밋해 버리면, WAL 모드의 이 트랜잭션은 이미 낡은 스냅숏을 보고 있으므로 쓰기를 허락받을 수 없습니다. SQLite는 이때 SQLITE_BUSY_SNAPSHOT을 돌려주는데, 기다려도 풀리지 않는 충돌이라 busy handler(timeout)를 타지 않고 즉시 실패합니다. 두 연결로 재현한 결과입니다.

== upgrade WAL
  other writer commit while A holds read txn: 0.01s -> OK
  deferred upgrade (timeout=10): 0.000s -> OperationalError: database is locked
== upgrade DELETE
  other writer commit while A holds read txn: 10.78s -> OperationalError: database is locked

WAL에서는 A가 읽는 동안 B의 커밋이 0.01초 만에 통과했고, 그 뒤 A의 UPDATE는 timeout=10을 줬는데도 0.000초에 거절됐습니다. 2장 표에서 WAL + DEFERRED가 1.3초 만에 끝난 이유가 이것입니다 — 기다리지 않고 바로 실패했기 때문에 빠른 것입니다. 기본 DELETE 저널에서는 반대로 A가 읽기 트랜잭션을 열어 둔 것만으로 B의 커밋이 10.78초 동안 막혔습니다. 대시보드나 텔레그램 봇이 SELECT 후 커밋을 안 하고 연결을 붙잡고 있으면 매매 봇의 체결 기록이 막히는 구조입니다.

BEGIN IMMEDIATE는 시작할 때 바로 쓰기 잠금을 잡습니다. 못 잡으면 그 자리에서 timeout만큼 기다리고, 잡은 뒤에는 아무도 끼어들 수 없으므로 승격 충돌이 생기지 않습니다. 그래서 읽기→쓰기 트랜잭션에는 IMMEDIATE가 정답이고, WAL은 그 위에서 읽기 쪽을 풀어 주는 보조 장치입니다. DELETE + IMMEDIATE에서 3~5건이 남은 것은 Windows 로컬 디스크의 동기화가 느려 일부 대기가 5초를 넘었기 때문으로 보이며, timeout을 늘리면 줄어듭니다.

4. 고치는 코드

체결 기록·포지션 갱신을 이 함수 하나로만 하게 만드는 것이 핵심입니다. 키·계좌번호 같은 비밀값은 DB가 아니라 환경변수에 둡니다(API 키 보관).

import sqlite3, time, contextlib

def connect(path="trade.db"):
    # isolation_level=None: 파이썬이 몰래 BEGIN을 끼우지 않게 하고 직접 연다
    conn = sqlite3.connect(path, timeout=15, isolation_level=None)
    conn.execute("PRAGMA journal_mode=WAL")      # 읽기가 쓰기를 막지 않게
    conn.execute("PRAGMA busy_timeout=15000")    # 연결마다 따로 걸린다
    conn.execute("PRAGMA synchronous=NORMAL")    # WAL에서 흔히 쓰는 조합
    return conn

@contextlib.contextmanager
def write_tx(conn, retries=3):
    for i in range(retries):
        try:
            conn.execute("BEGIN IMMEDIATE")      # 시작부터 쓰기 잠금
            break
        except sqlite3.OperationalError as e:
            if "locked" not in str(e) or i == retries - 1:
                raise
            time.sleep(0.2 * (i + 1))
    try:
        yield conn
        conn.execute("COMMIT")
    except Exception:
        conn.execute("ROLLBACK")
        raise

def record_fill(conn, code, qty, price):
    with write_tx(conn):
        conn.execute("INSERT INTO fills(code,qty,price,ts) VALUES(?,?,?,?)",
                     (code, qty, price, time.time()))
        conn.execute("UPDATE pos SET qty = qty + ? WHERE code = ?", (qty, code))  # 계산은 SQL 안에서
fix: 0.37s rows=2000 qty+=2000 exitcodes=[0, 0, 0, 0]

위 코드를 그대로 2장과 같은 조건(4프로세스 × 500건)으로 돌린 출력입니다. 에러 0건, 체결 2,000행, 포지션 +2,000이 0.37초에 끝났습니다. 2장의 WAL + IMMEDIATE(5~7초)보다 빠른 것은 synchronous=NORMAL 덕분인데, 이 설정은 전원이 갑자기 꺼지면 마지막 커밋 몇 건을 잃을 수 있는 대신 DB 파일 자체는 깨지지 않는 조합입니다. 정전 대비가 더 중요하면 이 줄을 빼면 됩니다.

주문과 기록의 순서도 바꿔야 합니다. 트랜잭션 안에서 네트워크를 기다리지 않는 것이 규칙입니다.

  1. 주문 전 — 짧은 트랜잭션으로 orders에 "보냄" 상태 1행을 넣고 커밋합니다(재시작 때 미확인 주문을 찾는 근거).
  2. 주문 — 트랜잭션 밖에서 kt10000·TTTC0012U를 호출합니다. 몇 초가 걸려도 DB는 잠기지 않습니다.
  3. 주문 후 — 다시 짧은 트랜잭션으로 주문번호와 상태를 UPDATE합니다. 체결은 실시간 체결 통보가 올 때 record_fill로 넣습니다.

읽기만 하는 텔레그램 봇·대시보드는 with 블록 안에서 SELECT만 하고 곧바로 연결을 닫거나, sqlite3.connect("file:trade.db?mode=ro", uri=True)로 읽기 전용으로 엽니다. 여러 스레드가 한 연결을 나눠 쓰려고 check_same_thread=False를 켜는 경우가 많은데, 그러면 한 스레드의 열린 트랜잭션에 다른 스레드의 문장이 섞입니다. 스레드마다 연결을 따로 만드는 편이 안전합니다.

하지 말 것 3가지 — ① 잠금이 안 풀린다고 trade.db-wal·trade.db-shm 파일을 지우기: 아직 본 파일로 옮겨지지 않은 커밋이 -wal에 있을 수 있습니다. 모든 프로세스를 끈 뒤 PRAGMA wal_checkpoint(TRUNCATE)로 정리합니다. ② DB 파일을 구글 드라이브·네트워크 드라이브에 두기: SQLite 공식 문서는 WAL이 네트워크 파일시스템에서 동작하지 않는다고 적고 있고, 동기화 클라이언트가 파일을 잡으면 잠금이 예측 불가능해집니다. 봇과 DB는 로컬 디스크(C:)에 둡니다. ③ except OperationalError: pass로 삼키기: 체결 1건이 조용히 사라집니다. 실패하면 다시 시도하고, 그래도 안 되면 로그에 남기고 알림을 보냅니다.

5. 그래도 안 되면 — SQLite를 그만 쓸 때

위 설정이면 개인 봇 규모에서는 충분합니다. 2장 실측에서 WAL + IMMEDIATE는 쓰기 2,000건을 5~7초에 처리했고, 하루 체결이 수백 건인 봇은 이 부하의 근처에도 가지 않습니다. 그래도 아래에 해당하면 구조를 바꿀 때입니다.

상황방법
쓰는 프로세스가 3개 이상이고 계속 늘어난다쓰기는 한 프로세스만 하게 하고, 나머지는 multiprocessing.Queue로 그 프로세스에 넘긴다(단일 기록자)
틱·호가를 초당 수십 건 저장한다건별 커밋 대신 모아서 executemany 한 번에 넣는다. 원본 틱은 Parquet 같은 파일로 분리
DB를 여러 PC·VPS가 같이 본다PostgreSQL 같은 서버형 DB. SQLite는 같은 PC 안의 프로세스용이다
같은 봇이 두 번 떠서 서로 쓴다DB 문제가 아니라 중복 실행 문제 — 중복 실행 잠금부터

운영 중에 잠금 에러가 처음 보였다면, 고치기 전에 그날 fills 행 수와 증권사 체결 내역(kt00009·TTTC0081R)을 맞대어 빠진 건부터 확인하세요. 기록이 빠진 상태로 다음 날 봇을 다시 켜면 보유 수량을 잘못 알고 시작합니다. 장애 대응 순서는 봇 장애 복구 플레이북에 정리해 두었습니다.

6. 자주 묻는 질문

sqlite3.OperationalError: database is locked는 왜 5초쯤 뒤에 나나요?

파이썬 sqlite3.connect의 timeout 기본값이 5.0초라서 다른 연결이 쓰기 잠금을 쥐고 있으면 5초 동안 기다렸다가 포기합니다. 실측에서 8초간 잠금을 쥔 프로세스 옆에서 기본값으로 쓰기를 시도하면 5.43초 뒤에 에러가 났습니다.

PRAGMA journal_mode=WAL만 켜면 database is locked가 해결되나요?

읽기와 쓰기가 서로 막지 않게 될 뿐 쓰기끼리는 여전히 한 번에 하나입니다. 실측에서 WAL에 BEGIN(DEFERRED)으로 읽고 쓰는 트랜잭션 2,000건 중 1,500건이 즉시 실패했고, 파이썬 기본 설정으로 읽고 계산해 쓰는 코드는 에러 없이 2,000건 중 1,500건의 업데이트가 사라졌습니다. 쓰기 트랜잭션은 BEGIN IMMEDIATE로 시작해야 합니다.

timeout을 크게 주면 해결되지 않나요?

잠금을 짧게 쥐는 경우에만 효과가 있습니다. 읽기 트랜잭션을 쓰기로 승격하다 충돌하는 경우는 timeout=10이어도 기다리지 않고 0초 만에 실패했습니다. 또 트랜잭션 안에서 주문 API 응답을 기다리면 그 시간만큼 다른 프로세스가 전부 멈춥니다.

-wal, -shm 파일을 지워도 되나요?

지우면 안 됩니다. 아직 본 파일에 옮겨지지 않은 커밋이 -wal 파일에 들어 있을 수 있습니다. 모든 프로세스를 끈 뒤 PRAGMA wal_checkpoint(TRUNCATE)로 정리하세요.

체결 기록이 새지 않는 봇, 만들어 드립니다

주문·체결·포지션을 잠금 충돌 없이 기록하고, 재시작해도 증권사 체결 내역과 맞춰 시작하는 자동매매 프로그램을 맞춤 제작합니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기