Huny's Dev Blog
huny.dev

SQLite WAL에서 SQLITE_BUSY_SNAPSHOT 해결하기

WAL 읽기 스냅샷이 오래되면 쓰기 전환이 실패하는 이유를 재현하고, 트랜잭션 재시도와 BEGIN IMMEDIATE의 차이를 살펴본다.

Hun Jang
Hun Jang Nov 12, 2026

SQLite WAL 모드에서 읽은 값을 바탕으로 업데이트하다가 SQLITE_BUSY_SNAPSHOT이 발생하면, 현재 트랜잭션을 롤백하고 새 트랜잭션에서 읽기부터 다시 시작한다. 쓰기가 예정된 짧은 트랜잭션은 첫 조회 전에 BEGIN IMMEDIATE로 시작할 수 있다. Python의 로컬 연결 두 개로 오래된 읽기 스냅샷과 일반적인 쓰기 경합을 재현한다.

겹쳐진 청록색 데이터 판 중 새 버전 옆에 주황색 쓰기 표시가 있는 일러스트

읽기에 성공했는데 쓰기가 실패하는 이유

WAL에서 읽기 트랜잭션은 읽기를 시작했을 때의 일관된 스냅샷을 본다. 연결 A가 값 0을 읽고 있는 동안 연결 B가 10으로 바꾸고 커밋하면, A는 같은 트랜잭션에서 계속 0을 본다. 이때 A가 쓰기를 시도하면 SQLite는 이미 오래된 스냅샷에서 분기하는 쓰기를 허용하지 않고 SQLITE_BUSY_SNAPSHOT을 반환한다. B의 쓰기가 끝나도 A의 스냅샷은 그대로이므로, 대기 시간을 늘리는 것으로 해결할 수 없다. SQLite 격리 문서 · 확장 오류 코드

두 연결로 오류와 해결 절차 재현하기

아래 코드를 wal_snapshot.py로 저장하고 python3 wal_snapshot.py를 실행한다. Python 3.12의 autocommit=True를 사용하고, 여러 문장을 묶는 트랜잭션은 SQL의 BEGIN, ROLLBACK, COMMIT으로 제어한다. B의 단독 업데이트는 문장이 끝날 때 커밋된다. 두 연결은 같은 임시 파일을 사용한다. Python sqlite3 트랜잭션 문서

실행 결과에서 두 오류 구분하기

2026년 11월 12일 Linux에서 Python 3.12.14와 SQLite 3.53.1로 실행했다. A가 여전히 0을 볼 때 B가 10을 보는지, A의 쓰기 전환이 예상한 확장 오류 코드를 반환하는지 확인했다. 이후 A가 쓰기 트랜잭션을 먼저 시작하자 B의 BEGIN IMMEDIATE는 SQLITE_BUSY로 실패했다. A는 현재 값 10을 읽고 1을 더해 커밋했고, B가 새 값 11을 읽었다.

SQLITE_BUSY_SNAPSHOT에서는 롤백 후 업무 로직 전체를 다시 실행한다. 실패한 UPDATE만 반복하거나, 트랜잭션을 새로 열더라도 예전 조회값으로 업데이트하면 데이터가 틀어질 수 있다. 일반 SQLITE_BUSY는 이 예제에서 다른 쓰기 트랜잭션이 진행 중이라는 뜻이다. 쓰기 시작 시점에 경합을 처리할 수 있지만, 기다리는 시간에는 상한을 둔다. SQLite 트랜잭션 문서

쓰기를 먼저 확보할 때의 비용

BEGIN IMMEDIATE는 첫 조회 전에 쓰기 트랜잭션을 시작한다. WAL에서는 다른 연결이 계속 읽을 수 있지만 다른 쓰기는 기다리거나 실패할 수 있으므로, 트랜잭션 안에서 느린 네트워크 호출을 기다리지 않는다. 단순 증가처럼 단일 SQL 문으로 표현할 수 있다면 UPDATE counter SET value = value + 1로 별도 조회와 계산을 줄일 수 있다. 외부 API 호출처럼 롤백되지 않는 부작용이 포함된 작업은 트랜잭션 재시도와 별도로 중복 실행을 관리한다. 예제는 로컬 파일에서 연결 두 개의 실행 순서를 검증했다. 실제 부하나 재시도 지연은 측정하지 않았다. SQLite 격리 문서 · 트랜잭션 모드

English version · Fix SQLITE_BUSY_SNAPSHOT in SQLite WAL ModeFix SQLITE_BUSY_SNAPSHOT in SQLite WAL Mode

추천 글