읽기는 성공하는데 WAL은 계속 커진다: SQLite 에이전트 메모리의 체크포인트 함정
1. 장애는 쓰기 실패보다 오래 열린 읽기에서 시작될 수 있다
로컬 에이전트의 대화 기록이나 작업 상태를 SQLite에 저장한다고 가정해 보자. 기록을 읽고 모델 응답을 기다린 다음 결과를 저장하는 흐름은 자연스럽다. 그러나 이 전체 흐름을 데이터베이스 트랜잭션 하나로 감싸면, 네트워크 대기 시간이 데이터베이스의 오래된 읽기 상태를 붙잡는 시간이 될 수 있다. 쓰기 요청은 계속 성공하는데 옆의 -wal 파일만 커지는 현상을 만날 수 있는 이유다.
핵심은 WAL 모드가 읽기와 쓰기의 동시 실행을 허용한다는 사실과, 오래된 읽기가 아무 비용도 만들지 않는다는 주장은 다르다는 점이다. SQLite 공식 문서는 긴 읽기 트랜잭션이 체크포인트의 진행을 막을 수 있다고 설명한다. 이는 특정 에이전트 제품에서 발견한 사고 보고가 아니라, 공식 동작 원리를 에이전트의 메모리 저장 흐름에 적용한 운영 해설이다. 오늘 새로 발표된 기능이나 성능 순위로 소개하지 않는다.
문제를 찾을 때 연결 개수만 세는 것도 부족하다. 연결이 열려 있다는 사실과 그 연결이 읽기 스냅샷을 유지하고 있다는 사실은 다르다. 연결 풀에 아무 일도 하지 않는 연결이 남아 있는 경우와, 결과를 조금씩 소비하는 커서가 미완료 상태인 경우를 구별해야 한다. 관측해야 할 것은 연결의 나이만이 아니라 읽기 트랜잭션과 실행 중인 문장의 수명이다.
2. COMMIT, 체크포인트, 파일 축소는 서로 다른 사건이다
WAL 모드에서는 변경 내용을 먼저 별도 로그에 추가한다. 커밋을 나타내는 기록이 WAL에 들어가면 트랜잭션은 커밋될 수 있다. 모든 변경이 그 순간 본 데이터베이스 파일로 옮겨져야 하는 것은 아니다. 나중에 WAL 내용을 본 파일로 반영하는 작업이 체크포인트다. 따라서 커밋 성공을 체크포인트 완료나 WAL 파일 삭제의 증거로 읽으면 안 된다.
읽는 쪽은 자기 트랜잭션에서 사용할 끝 지점인 ‘end mark’를 기억한다. 뒤에서 새로운 커밋이 쌓이더라도 같은 읽기 트랜잭션은 앞서 보던 일관된 스냅샷을 유지한다. 체크포인트는 그 독자가 필요로 하는 내용을 덮어쓰지 않도록 안전한 지점까지만 진행한다. 동시 읽기·쓰기가 가능하다는 설명에는 이 보존 비용이 함께 들어 있다. 또한 WAL이라고 해서 여러 쓰기 트랜잭션이 동시에 실행되는 것은 아니다. SQLite의 쓰기는 한 번에 하나다.
커밋 성공은 본 파일 반영이나 WAL 축소 완료의 증거가 아니다. 출처: sqlite.org/wal.html.
공식 기본 자동 체크포인트 기준은 통상 1,000페이지다. 컴파일 설정이나 연결별 설정으로 달라질 수 있으며, 이는 시도 시점이지 파일 크기의 상한이 아니다. 독자가 계속 남아 있으면 그 기준을 넘어도 완료되지 않을 수 있다. 반대로 체크포인트가 완료됐는데 파일 크기가 줄지 않는 경우도 있다. SQLite는 보통 할당된 WAL 파일을 재사용하며, 매번 잘라내지 않는다. 파일 크기 한 항목만으로 ‘정리 실패’ 경보를 만들면 정상적인 재사용까지 장애로 분류할 수 있다.
3. 반환값 0 뒤에 남은 40프레임을 직접 확인했다
공식 설명을 확인하기 위해 운영 데이터와 분리된 임시 데이터베이스에서 작은 순서 제어 실험을 실행했다. 두 연결을 열고, 한쪽은 BEGIN 뒤 실제 SELECT를 수행해 읽기 스냅샷을 고정했다. 다른 쪽은 같은 행을 40번 갱신하고 매번 커밋했다. 자동 체크포인트는 이 실험에서만 꺼 두었다. 따라서 여기서 보이는 프레임 수를 일반적인 작업당 로그 크기나 처리량으로 일반화해서는 안 된다.
Python 3.12.11에 연결된 SQLite 3.49.1에서 세 번 반복한 결과, 독자가 남아 있을 때 PRAGMA wal_checkpoint(PASSIVE)는 세 열 (0, 40, 0)을 반환했다. 첫 열은 해당 체크포인트 호출의 상태, 다음 두 열은 WAL 프레임 수와 반영된 프레임 수를 해석하는 단서다. 첫 열이 0이어도 전체 40프레임 중 반영된 수는 0일 수 있었다. 여기서 프레임 수는 서로 다른 데이터베이스 페이지 수나 이 호출이 실제로 수행한 디스크 쓰기 횟수가 아니다. 같은 페이지의 여러 변경이 로그에 쌓일 수 있고, 체크포인트는 필요한 최신 내용을 반영한다. PASSIVE는 다른 독자가 끝나기를 기다리지 않고 가능한 만큼만 처리하므로, 부분 진행 자체를 오류로 간주하지 않는다.
독자의 트랜잭션을 끝낸 뒤에는 (0, 40, 40)이 나왔다. 이때 WAL 파일은 여전히 164,832바이트였다. 이어서 다른 독자가 없는 조건에서 TRUNCATE를 성공시키자 반환값은 (0, 0, 0), 파일 길이는 0바이트가 됐다. 같은 파일 크기라도 ‘아직 반영하지 못한 로그’와 ‘이미 반영했지만 재사용하려 남겨 둔 공간’은 전혀 다른 상태라는 뜻이다. 이 수치는 본 실험의 페이지 구성과 순서에서 얻은 값이지 권장 임계값이 아니다.
Python 3.12.11 / SQLite 3.49.1의 격리 실험. 운영 임계값이나 성능 벤치마크가 아니다. 출처: sqlite.org/pragma.html#pragma_wal_checkpoint.
오래된 런타임에만 기대지 않기 위해 공식 SQLite 3.51.3 소스를 별도로 내려받아, 릴리스 페이지의 SHA3-256과 일치함을 확인하고 C 프로그램으로도 같은 체크포인트 순서를 실행했다. PASSIVE의 미완료 성공과 독자 종료 뒤 전체 반영을 다시 확인했다. 이것은 문서의 의미를 확인하는 작은 기능 실험이다. 대규모 동시성, 전원 장애, 장기 내구성, 에이전트 성능을 검증한 벤치마크는 아니다. 시스템에 묶인 3.49.1을 운영 권장 버전으로 제시하는 것도 아니다.
4. 강한 체크포인트나 긴 타임아웃이 만능 해결책은 아니다
PASSIVE는 독자나 작성자가 끝나기를 기다리지 않으며 busy handler를 호출하지 않는다. 따라서 busy timeout을 늘렸다는 이유만으로 PASSIVE가 끝까지 반영할 것이라고 기대하면 안 된다. FULL은 작성자가 없고 독자가 최신 스냅샷을 읽는 조건을 기다리며 전체 반영을 시도한다. RESTART는 다음 작성자가 로그 앞부분부터 다시 시작할 수 있도록 WAL을 쓰는 독자들의 종료까지 기다린다. TRUNCATE는 성공할 때 로그 파일 길이까지 0으로 만든다.
여기서 중요한 단어는 ‘성공할 때’다. busy handler가 더 기다리지 않기로 하거나 필요한 잠금을 얻지 못하면 강한 모드도 완료되지 못할 수 있다. 실제 작은 실험에서도 독자를 붙잡아 둔 TRUNCATE는 첫 열 1을 반환했다. 매 요청마다 강한 체크포인트를 넣는 방식은 백그라운드 정리 문제를 요청 지연과 작성자 대기로 옮길 수 있다. 운영 정책에는 실행 빈도뿐 아니라 대기 한도, 실패 기록, 독자 수명을 줄이는 경로가 함께 필요하다.
또 다른 함정은 읽기에서 쓰기로 전환하는 순간이다. 오래된 스냅샷을 가진 연결이 다른 연결의 커밋 이후 쓰기로 승격하려 하면 SQLITE_BUSY_SNAPSHOT이 날 수 있다. 실험에서는 확장 오류 코드 517로 확인했다. 이 경우 단순히 같은 오래된 트랜잭션에서 기다리는 것으로 최신 스냅샷을 얻지는 않는다. 읽기를 끝내고 새 트랜잭션에서 상태를 다시 확인해야 한다. 처음부터 짧은 BEGIN IMMEDIATE로 쓰기 권한을 확보할 수는 있지만, 모델 응답을 기다리는 긴 구간까지 넣으면 다른 작성자를 막는 대가가 생긴다.
5. 에이전트의 네트워크 대기를 트랜잭션 밖으로 꺼내라
실무적인 출발점은 기억을 읽고 필요한 데이터를 애플리케이션 메모리에 담은 뒤, 커서를 정리하고 명시적 읽기 트랜잭션을 종료하는 것이다. 모델 호출이나 사용자 입력 대기는 그 밖에서 수행한다. 응답이 오면 짧은 새 트랜잭션을 열어 현재 상태와 버전을 확인하고 결과를 저장한다. 이 순서는 SQLite가 모든 애플리케이션에 강제하는 정답이 아니라, 긴 외부 대기가 스냅샷을 붙잡지 않도록 하는 설계 제안이다.
트랜잭션을 쪼개면 그 사이 데이터가 바뀔 수 있으므로 일관성 요구를 버리면 안 된다. 예를 들어 읽었던 상태의 버전을 저장하고, 결과를 쓰기 전에 그 버전이 여전히 유효한지 확인하는 방식을 고려할 수 있다. 충돌 시에는 재독해, 병합, 작업 취소 중 무엇을 할지 업무 규칙으로 정한다. ‘모든 과정을 한 트랜잭션에 넣기’와 ‘아무 검증 없이 분리하기’ 사이에 명시적인 상태 확인 절차가 필요하다.
외부 대기를 분리하되, 다시 쓸 때 현재 상태와 충돌 처리 규칙을 확인한다. 출처: sqlite.org/lang_transaction.html.
관측도 세 층으로 나누자. 첫째, 읽기 트랜잭션과 미완료 커서가 외부 대기를 가로지르는지 추적한다. 둘째, 체크포인트의 반환 상태와 전체·반영 프레임을 함께 기록한다. 셋째, 실제 WAL 파일 크기와 디스크 여유를 별도로 본다. PASSIVE를 호출해 수치를 읽는 행위 자체가 가능한 체크포인트 작업을 수행한다는 점도 운영 문서에 명시한다. 단순 읽기 전용 모니터링과 같은 것으로 취급하지 않는다.
연결이 비어 있는 경우, BEGIN만 하고 아직 데이터에 접근하지 않은 경우, 읽기를 끝낸 경우도 대조했다. 작은 Python 실험에서는 이 조건들이 전체 반영을 허용한 반면, 명시적 스냅샷이나 끝나지 않은 커서는 반영을 붙잡았다. 자동 기준을 5프레임으로 낮춰도 후자의 로그는 30프레임까지 늘었다. 이는 낮은 숫자를 운영에 적용하라는 권장이 아니라, 기준이 상한이 아님을 확인하기 위한 대조 실험이다.
6. 줄여야 할 것은 파일만이 아니라 오래된 상태의 수명이다
WAL이 크다고 파일을 직접 지우는 것은 해결책이 아니다. 공식 문서는 WAL을 데이터베이스의 영속 상태 일부로 취급한다. 본 데이터베이스 파일만 복사하거나 WAL을 분리하면 이미 커밋한 변경을 잃거나 손상을 일으킬 수 있다. 살아 있는 데이터베이스의 백업은 공식 Online Backup API 같은 지원 경로를 검토해야 한다. 정리와 백업은 서로 다른 작업이다.
버전 관리도 별도다. 공식 문서는 특정 동시 쓰기·체크포인트 순서에서 발생하는 WAL-reset 손상 버그와 수정 버전을 안내한다. 이번 글의 체크포인트 기아는 오래된 독자를 보존하기 위한 정상 동작에 관한 것이며, 그 경쟁 조건 버그를 재현하거나 그 안전성을 입증한 것이 아니다. 실제 배포 런타임은 공식 수정 안내를 따로 확인해야 한다. Python 버전 문자열만 보고 내장 SQLite 버전까지 같다고 가정하지 말자.
결론은 SQLite를 포기하라는 것이 아니다. WAL이 제공하는 읽기·쓰기 병행성에는 체크포인트라는 세 번째 작업이 있고, 그 작업은 독자의 수명과 연결되어 있다. 에이전트가 모델을 기다리는 동안 무엇을 붙잡고 있는지 먼저 살펴보자. 쓰기 성공률 하나보다 스냅샷 종료, 반영 진행, 공간 재사용을 구분하는 운영 계약이 이 문제를 훨씬 정확하게 드러낸다.
참고 자료
- SQLite Write-Ahead Logging: 동시성, 체크포인트, 큰 WAL 파일과 버전 주의사항
- SQLite Isolation: 스냅샷과 읽기에서 쓰기로의 승격
- SQLite Transaction: 명시적·암묵적 트랜잭션과 문장 종료
- SQLite wal_checkpoint PRAGMA: 모드와 세 반환 열
- SQLite 체크포인트 C API: 대기·반영·축소의 정확한 차이
- SQLite 3.51.3 공식 릴리스와 소스 해시
- SQLite Online Backup API
공식 문서, 해시로 고정한 소스 구현, 격리된 로컬 기능 실험을 교차 확인했다. 그림은 해당 의미와 실제 로컬 결과를 코드로 그린 원본 도식이다. 특정 서비스의 장애율, 지연 개선율, 데이터 손실 확률을 추정하지 않았다.



댓글
댓글 쓰기