SQLite는 별도 서버 없이 애플리케이션에 포함할 수 있고 백업도 파일 단위로 다룰 수 있어 ‘지루하지만 믿을 만한 기술’로 자주 선택된다. 그러나 검증된 기술을 쓴다는 사실과 내 운영 방식까지 널리 검증됐다는 사실은 다르다.
GeekNews에 소개된 Tailscale의 SQLite WAL-Reset 장애 분석은 이 차이를 잘 보여준다. Tailscale은 정상적인 단일 writer 구조로 SQLite를 운영했지만 6개월 동안 19번의 데이터베이스 손상을 겪었다. 원인은 애플리케이션 코드의 단순한 실수가 아니라, 특정 체크포인트 방식에서 드물게 발생하는 SQLite 내부 경쟁 조건이었다.
단일 writer여도 손상은 발생했다
Tailscale의 제어 평면은 여러 shard로 나뉘고, 각 shard마다 하나의 Go 프로세스가 SQLite 데이터베이스 하나를 독점한다. 여러 프로세스가 같은 파일에 동시에 쓰는 구성이 아니다. SQLite가 권장하는 단일 writer 경계에 가깝다.
데이터베이스는 WAL(Write-Ahead Logging) 모드로 동작했다. 변경된 페이지는 먼저 WAL 파일에 기록되고, 체크포인트 과정에서 메인 데이터베이스 파일로 복사된다. Tailscale은 몇 분마다 전체 파일을 객체 저장소에 백업하기 위해 체크포인트 시점을 직접 제어하고 매우 자주 실행했다.
문제는 쓰기 트랜잭션과 WAL을 재설정하는 체크포인트가 아주 좁은 시점에 겹칠 때 발생했다. 체크포인트가 일부 페이지를 메인 파일에 복사했다고 잘못 판단하면서 실제 페이지는 사라졌고, 그 페이지를 참조하는 인덱스만 남아 데이터베이스가 손상됐다. SQLite 개발팀은 이 문제를 WAL-Reset 버그라고 이름 붙였고 최소 16년 동안 존재했을 가능성이 있다고 설명한다.
중요한 점은 SQLite 자체가 일반적으로 위험하다는 결론이 아니다. Tailscale도 수동 체크포인트를 공격적으로 실행한 비표준적인 운영 경로 때문에 희귀 조건을 반복해서 만날 가능성이 커졌다고 분석했다.
수정 버전 숫자도 단순하지 않았다
WAL-Reset 수정은 처음 SQLite 3.52.0에 포함됐다. Tailscale이 이 버전을 배포하자 무결성 모니터가 13개 데이터베이스에서 새로운 손상을 보고했다. 실제 추가 손상이 아니라, 고정밀도 문자열을 부동소수점으로 변환하는 방식이 바뀌면서 기존 expression index가 오래된 값으로 판단된 문제였다.
SQLite는 3.52.0을 철회했고 WAL-Reset 수정만 담은 3.51.3을 2026년 3월 13일 공개했다. 이후 3.53.0에는 오래된 expression index를 자동으로 복구하는 기능이 추가됐다. 따라서 ‘3.52.0 이상이면 된다’처럼 숫자만 비교하면 철회된 릴리스의 맥락을 놓친다. 운영에서는 현재 사용하는 바인딩이 어떤 SQLite 소스를 포함하는지와 프로젝트가 지원하는 최신 패치 버전을 함께 확인해야 한다.
시스템 CLI와 애플리케이션의 SQLite는 다를 수 있다
직접 검증한 환경에서는 다음처럼 버전이 달랐다.
시스템 sqlite3 CLI 3.50.1
Django Python 런타임 3.53.2
sqlite3 --version만 실행했다면 수정 전 버전인 3.50.1을 사용한다고 판단했을 것이다. 하지만 실제 Django 프로세스가 불러오는 Python sqlite3 모듈은 3.53.2를 사용하고 있었다.
애플리케이션이 링크한 버전은 해당 런타임 안에서 확인해야 한다.
python -c 'import sqlite3; print(sqlite3.sqlite_version)'
Go의 SQLite 드라이버, Python wheel, 운영체제 CLI, 컨테이너 이미지가 각각 다른 SQLite를 포함할 수 있다. 호스트 패키지만 업데이트하고 서비스 컨테이너를 다시 만들지 않았다면 취약한 라이브러리가 그대로 남을 수도 있다.
같은 환경의 운영 데이터베이스를 읽기 전용으로 검사했을 때는 다음 결과가 나왔다.
PRAGMA journal_mode; delete
PRAGMA integrity_check; ok
이 데이터베이스는 WAL 모드가 아니므로 Tailscale과 같은 조건을 그대로 갖지 않는다. integrity_check가 ok라는 결과도 현재 발견 가능한 구조 오류가 없다는 뜻이지 미래 손상이나 백업 복구 가능성을 보증하지는 않는다.
라이브 DB만 검사하면 백업 문제를 놓친다
Tailscale이 첫 손상을 발견한 곳은 운영 프로세스가 아니라 백업을 읽는 데이터 파이프라인이었다. 이후 백업마다 PRAGMA integrity_check를 계속 실행하는 모니터를 만들었다.
운영 DB가 정상이어도 복사 시점, WAL 포함 여부, 업로드 중단, 보존 정책 때문에 백업은 불완전할 수 있다. 반대로 백업 파일이 열려도 최신 트랜잭션이 포함됐는지는 별도 검증이 필요하다. 따라서 무결성 확인은 다음 세 단계로 나누는 편이 안전하다.
- 서비스가 사용하는 실제 런타임에서 SQLite 버전과 compile 옵션을 확인한다.
- 백업으로 생성된 파일 자체에
PRAGMA integrity_check를 실행한다. - 격리된 환경에서 백업을 복원하고 핵심 테이블의 행 수와 업무 쿼리를 재생한다.
Tailscale은 복구 시간을 줄이기 위해 변경 SQL을 별도 로그로 남기고 마지막 정상 백업 위에 재생하는 경로도 만들었다. 모든 서비스가 SQL 로그를 그대로 보관할 필요는 없지만, 백업 사이의 변경을 얼마나 잃을 수 있는지 나타내는 RPO와 실제 복원에 걸리는 RTO는 숫자로 확인해야 한다.
운영 점검은 버전·모드·복구를 함께 본다
SQLite 서비스를 관리한다면 다음 순서가 실용적이다.
- 프로세스별 embedded SQLite 버전을 수집한다.
journal_mode가 WAL인지 확인한다.- 자동 체크포인트를 바꿨거나 수동 체크포인트를 호출하는 코드를 찾는다.
- WAL을 사용한다면 SQLite 3.51.3 이후의 지원 버전으로 올린다.
- 업그레이드 전 expression index와 사용자 정의 함수 의존성을 점검한다.
- 라이브 데이터베이스와 백업 파일을 각각 무결성 검사한다.
- 정상 백업에서 새 인스턴스를 만드는 복원 훈련을 정기적으로 수행한다.
- canary에서 버전 변경과 데이터 표현 차이를 먼저 확인한다.
- 손상을 발견했을 때 쓰기를 즉시 멈추고 원본 사본을 보존하는 runbook을 만든다.
희귀 경쟁 조건은 평소 테스트에서 쉽게 재현되지 않는다. 그래서 ‘몇 주 동안 문제가 없었다’는 관찰만으로 수정됐다고 결론 내리기도 어렵다. Tailscale은 수정된 런타임이 실제 충돌 조건을 차단했을 때 경고하도록 계측했고, 두 달 뒤 그 경고를 관찰해 원인과 수정 효과를 확인했다.
결론: 지루한 기술에도 지루하지 않은 운영 경로가 있다
이번 사고가 주는 교훈은 SQLite를 피하라는 것이 아니다. 단일 writer와 작은 shard, 빠른 파일 백업은 여전히 합리적인 설계가 될 수 있다. 다만 기본 동작을 벗어나 체크포인트, VFS, 사용자 정의 함수나 특수 인덱스를 적극적으로 제어한다면 그 경로는 별도의 시스템처럼 검증해야 한다.
CLI 버전 하나가 아니라 실제 프로세스의 라이브러리 버전, 저널 모드, 백업 파일 무결성, 복원 결과를 함께 봐야 한다. 장애를 막는 마지막 증거는 ‘유명한 데이터베이스를 쓴다’가 아니라 내가 보관한 데이터로 서비스를 다시 세울 수 있다는 재현 결과다.