- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — "Choose any three"에 붙은 각주
- WAL 되감기 — 이 버그가 사는 동네
- 장부 두 줄 — mxFrame과 nBackfill
- 손상은 6단계로 일어난다
- 필요조건 — 왜 대부분의 사람은 무사한가
- 재현할 수 없는 버그를 어떻게 "고쳤다"고 말하는가
- 얼마나 위험한가 — 문서가 직접 밝힌 확률
- 릴리스가 꼬인 2주 — 3.52.0의 철회
- 당신이 실제로 해야 할 일
- 이 사건에서 남는 것
- 마치며
- 참고 자료
들어가며 — "Choose any three"에 붙은 각주
SQLite 문서 페이지 상단에는 늘 같은 문장이 붙어 있습니다. "Small. Fast. Reliable. Choose any three." 그리고 SQLite는 대체로 이 약속을 지켜 왔습니다. SQLite는 어디에나 있다 편에서 정리했듯이 이 엔진은 여러분의 폰, 브라우저, 항공전자 장비 안에서 조용히 돌고 있고, 그 신뢰성은 거의 인프라 상수처럼 취급됩니다.
그래서 2026년 3월의 사건이 흥미롭습니다. 공식 문서의 표현으로 "SQLite 개발자 중 한 명(Dan)"이 2026년 3월 3일에 WAL 모드에서 드물게 데이터베이스를 손상시킬 수 있는 버그를 찾아 고쳤습니다. SQLite 팀이 직접 붙인 이름은 "WAL-reset bug"입니다. 그리고 이 버그는 WAL이 처음 등장한 3.7.0(2010-07-21)부터 3.51.2(2026-01-09)까지 살아 있었습니다. 릴리스 노트의 표현을 그대로 옮기면 "15년 묵은 데이터베이스 손상 버그가 며칠 전에 발견됐고 이제 고쳐졌다"입니다.
먼저 온도를 맞춰 두겠습니다. 이건 여러분이 오늘 밤 페이저를 받아야 하는 종류의 사건이 아닙니다. SQLite 문서 스스로 "긴급 상황은 아니다(this is not an emergency)"라고 못 박고 있고, 그 근거도 함께 제시합니다. 다만 이 버그가 어떻게 생겼고 어떻게 잡혔는지는, WAL을 쓰는 사람이라면 읽어 둘 값이 있습니다. 락 하나가 아니라 락 두 개가 필드 하나를 나눠 지키면 무슨 일이 벌어지는지에 대한, 아주 깔끔한 교과서적 사례이기 때문입니다.
WAL 되감기 — 이 버그가 사는 동네
버그를 보기 전에 무대를 알아야 합니다. WAL의 기본 구조는 SQLite 내부 구조 편에서 다뤘으니, 여기서는 이 버그에 직접 관련된 부분만 짚습니다.
WAL 모드에서 쓰기는 데이터베이스 파일이 아니라 WAL 파일 끝에 프레임(frame)으로 추가됩니다. 읽기는 데이터베이스 파일과 WAL을 함께 봅니다. 그런데 WAL이 무한히 커지면 읽기가 느려집니다 — 문서의 표현으로, 읽기 성능은 WAL 파일이 커질수록 나빠지고 WAL을 확인하는 시간은 WAL 크기에 비례하기 때문입니다.
그래서 체크포인트(checkpoint) 가 돕니다. 체크포인트는 WAL의 내용을 데이터베이스 파일로 되돌려 씁니다(backfill). 기본 전략은 WAL이 약 1000페이지가 될 때까지 키운 다음, WAL이 1000페이지 아래로 다시 줄어들 때까지 이후의 매 COMMIT마다 체크포인트를 도는 것입니다.
여기서 이 글의 핵심 개념이 나옵니다. WAL 전체가 데이터베이스로 옮겨졌고 동기화까지 끝났으며 어떤 리더도 WAL을 쓰고 있지 않다면, 다음 writer는 WAL을 처음으로 되감고(rewind) 새 트랜잭션을 WAL의 맨 앞에 쓰기 시작합니다. 이게 "WAL reset"입니다. 파일을 지우고 새로 만드는 게 아니라, 커서를 0으로 되돌리고 앞에서부터 덮어쓰는 것입니다. 이 되감기 덕분에 WAL 파일이 무한정 커지지 않습니다.
되감기는 순수한 최적화이고, 대부분의 시간 동안 완벽하게 동작합니다. 문제는 "되감아도 되는가"를 판단하는 회계 장부에 있었습니다.
장부 두 줄 — mxFrame과 nBackfill
그 장부는 wal-index(공유 메모리, 흔히 -shm 파일) 헤더에 있습니다. 이 글에 필요한 필드는 둘입니다.
mxFrame WAL 안의 유효한 프레임 개수.
프레임은 1부터 번호가 매겨지므로, 마지막 유효 커밋 프레임의 인덱스이기도 하다.
0이면 WAL이 비었다는 뜻 -> 모든 내용을 DB 파일에서 바로 읽으면 된다.
nBackfill WAL 프레임 중 이미 데이터베이스로 복사돼 들어간 개수.
항상 nBackfill <= mxFrame.
mxFrame == nBackfill -> WAL 내용이 전부 DB에 반영됨
-> WAL_READ_LOCK(N>0) 을 잡은 연결이 없다면
다음 writer는 WAL을 되감아도 된다.
즉 mxFrame == nBackfill 이 되감기의 허가증입니다. 그리고 nBackfill 은 체크포인터가 "여기까지는 이미 옮겼으니 다음 체크포인트는 이 지점부터 시작하면 된다"고 적어 두는 진행 표시이기도 합니다.
이제 이 글 전체에서 가장 중요한 두 문장이 나옵니다. SQLite 문서(walformat.html)를 그대로 옮기면 이렇습니다.
nBackfill은 WAL_CKPT_LOCK을 잡은 상태에서만 증가시킬 수 있다.- 그러나
nBackfill은 WAL reset 동안 0으로 바뀌며, 이 일은 WAL_WRITE_LOCK을 잡은 상태에서 일어난다.
읽고 넘어가기 쉬운 문장이지만, 여기에 버그가 통째로 들어 있습니다. 필드는 하나인데 그 필드를 만지는 두 경로가 서로 다른 락을 잡습니다. 체크포인터는 CKPT 락을 들고 nBackfill 을 올리고, 되감는 writer는 WRITE 락을 들고 nBackfill 을 0으로 내립니다. 둘은 서로를 배제하지 않습니다 — 다른 자물쇠를 쥐고 같은 방에 들어가는 셈입니다.
이 설계가 15년간 굴러간 데는 이유가 있습니다. 정상 경로에서는 체크포인터가 nBackfill 을 올리려는 시점과 writer가 그걸 0으로 만드는 시점이 다른 상태(state)에 속하고, 다른 검사들이 그 사이를 막아 줍니다. 문제는 "체크포인트가 막 시작되는 찰나"라는 아주 좁은 창문이었습니다.
손상은 6단계로 일어난다
SQLite 문서가 정리한 시나리오는 정확히 6단계입니다. 순서대로 보겠습니다.
1. 연결 A가 체크포인트를 완주한다.
반드시 "완전한" 체크포인트여야 한다 — WAL의 모든 내용을 DB로 옮기고,
WAL이 되감길 수 있는 상태로 남겨 둔다. (즉 mxFrame == nBackfill)
2. 그 직후, 두 번째 체크포인트가 시작된다.
3. 2번 체크포인트가 "시작하는 중"일 때, 또 다른 연결이 트랜잭션을 커밋한다.
이 커밋이 WAL을 되감고(reset), 새 내용을 WAL의 맨 앞에 쓴다.
4. 데이터 경합 발생. 2번 체크포인트는 WAL이 3번에서 되감겼다는 사실을 알지 못한다.
그래서 wal-index 헤더의 필드를 잘못된 값으로 남긴다 —
"WAL의 이 부분은 이미 체크포인트됐다"고 적어 두지만, 실제로는 아니다.
5. 추가 트랜잭션들이 커밋되어, WAL의 프레임 수가
1번 체크포인트 시점보다 많아진다.
6. 나중에 세 번째 체크포인트가 돌 때,
3번에서 쓴 트랜잭션의 전부 또는 일부를 건너뛴다.
-> 그 트랜잭션이 DB 파일에 영영 도달하지 못한다.
-> 데이터베이스 손상.
4단계가 사고의 순간입니다. 되감기가 일어나면 nBackfill 은 0이 되어야 맞습니다 — WAL이 처음부터 새로 쓰이고 있으니 "이미 옮긴 프레임"은 0개니까요. 그런데 막 시작하던 2번 체크포인트는 되감기 이전의 세계관을 들고 있습니다. 그 체크포인트가 남긴 nBackfill 은 "앞쪽 N개 프레임은 이미 처리했다"고 주장합니다. 하지만 되감기 이후 그 앞쪽 N개 프레임은 완전히 다른 데이터입니다. 3단계에서 새로 쓴 트랜잭션이죠.
그래서 6단계의 체크포인터는 그 프레임들을 "이미 했다"고 믿고 건너뜁니다. 건너뛴 데이터는 DB 파일에 도달하지 못하고, WAL은 결국 되감겨 그 위에 덮어써집니다. 커밋되었다고 응답받은 트랜잭션이 조용히 증발하는 것입니다. 이게 durability 위반이자 손상입니다.
주목할 점은 손상이 즉시 드러나지 않는다는 것입니다. 4단계에서 잘못된 값이 기록되고, 실제 피해는 6단계에서 발생합니다. 그 사이에 5단계(추가 커밋들)가 끼어야 합니다. 사고와 증상 사이에 시간과 트랜잭션이 놓여 있습니다 — 사후 분석을 지독하게 어렵게 만드는 종류의 버그입니다.
필요조건 — 왜 대부분의 사람은 무사한가
문서가 명시한 발동 조건은 이렇습니다.
- 데이터베이스가 WAL 모드여야 한다.
- 같은 파일에 두 개 이상의 연결이 열려 있어야 하고, 그 연결들이 서로 다른 스레드나 프로세스에 있어야 한다.
- 그 두 연결이 같은 순간에 쓰기 또는 체크포인트를 시도해야 한다.
여기서 첫 번째 정직한 관찰. 이 조건은 단일 프로세스 단일 스레드 앱 — 데스크톱 앱, 모바일 앱, CLI 도구, 테스트 픽스처 — 을 통째로 제외합니다. SQLite 배포량의 압도적 다수가 여기에 속합니다.
동시에 두 번째 정직한 관찰도 해야 공정합니다. 남는 조건 — "WAL 모드 + 여러 프로세스가 한 파일에 쓰기" — 은 하필 요즘 유행하는 배포 형태와 정확히 겹칩니다. 워커 프로세스 여러 개를 띄운 웹 앱이 로컬 SQLite 파일 하나를 공유하는 구성 말이죠. 즉 이 버그의 필요조건은 SQLite의 전통적 사용처가 아니라 최근에 늘어난 사용처를 겨냥합니다.
그렇다고 겁을 줄 생각은 없습니다. 조건을 만족한다는 것과 실제로 터진다는 것은 전혀 다른 이야기이고, 그 간극이 이 사건에서 가장 흥미로운 부분입니다.
재현할 수 없는 버그를 어떻게 "고쳤다"고 말하는가
여기가 이 사건의 진짜 볼거리입니다. SQLite 문서의 서술을 옮기면 이렇습니다.
개발자들은 이 버그를 실험실에서 자연적으로(organically) 재현하는 데 한 번도 성공하지 못했다.
버그가 터지려면 여러 세부 사항이 딱 맞는 순간에 정렬해야 합니다. 특히 3단계의 커밋이 2단계 체크포인트의 "시작하는 중"이라는 좁은 구간에 정확히 들어가야 합니다. 이건 나노초 단위의 창문입니다. 그래서 SQLite 팀이 택한 방법은 이렇습니다.
SQLite 소스 자체를 수정해서, sqlite3_test_control() 로 제어되는 콜백을 심었습니다. 그 콜백은 테스트 프로그램이 2번 체크포인트가 도는 도중 정확히 그 순간에 3단계의 쓰기 트랜잭션을 발동시킬 수 있게 해 줍니다. 즉 경합을 기다리는 대신, 경합을 명령으로 만들어 냅니다. 문서의 표현으로, 이 코드 해킹 없이는 개발 및 테스트 과정에서 이 문제가 관측된 적이 한 번도 없었습니다.
이 접근이 왜 옳은지 짚고 넘어갈 값이 있습니다. 확률적으로만 터지는 경합에 대해 "고쳤다"고 주장하는 방법은 두 가지뿐입니다.
- 증명한다 (모델 체킹, 형식 검증).
- 경합을 결정론적으로 만든 뒤 테스트한다.
SQLite는 2번을 골랐고, 그러기 위해 프로덕션 코드에 테스트 전용 훅을 넣는 비용을 지불했습니다. 이건 공짜가 아닙니다 — 검증 가능성을 위해 코드 표면적을 늘리는, 명시적인 트레이드오프입니다. 하지만 대안은 "고친 것 같다"이고, 데이터 손상 버그에서 "같다"는 답이 아닙니다.
여기에 실무적 교훈이 하나 있습니다. 여러분의 시스템에도 확률적 경합이 있다면, "부하를 많이 주면 언젠가 재현되겠지"는 전략이 아닙니다. SQLite조차 그렇게 하지 못했습니다. 결정론적으로 스케줄을 조종할 수 있는 훅을 심는 쪽이 — 지저분해 보여도 — 실제로 답을 주는 유일한 길인 경우가 많습니다. (같은 철학을 시스템 전체 규모로 밀어붙이면 결정론적 시뮬레이션 테스트가 됩니다.)
얼마나 위험한가 — 문서가 직접 밝힌 확률
이 부분은 SQLite 문서의 표현을 최대한 그대로 전하겠습니다. 제가 수치를 만들어 붙일 자리가 아닙니다.
문서는 먼저 심각성을 인정합니다. 이 버그는 드물지만 심각한 결과를 낳으므로, 애플리케이션 개발자는 문제가 고쳐진 버전으로 업그레이드해야 한다고 말합니다.
그리고 곧바로 온도를 낮춥니다. "그러나 이것은 긴급 상황이 아니다." 근거는 이렇습니다 — 가용한 텔레메트리에 기반할 때, 이 문제가 실제 환경에서 발생하는 비율은 SSD 오작동이나 우주 방사선 히트의 예상 발생률 이하로 보인다는 것입니다. 그래서 패치되지 않은 버전을 돌리고 있더라도, 정말 특이한 짓을 하고 있는 게 아니라면 이 문제를 만날 일은 없을 것이라고 씁니다.
이 비교를 제대로 읽는 법이 중요합니다. "SSD 고장률 이하"는 "0"이 아닙니다. 그건 이미 여러분이 감수하고 있는 하드웨어 실패 확률의 잡음에 묻힌다는 뜻입니다. 여러분이 SSD 고장이나 비트 반전에 대비해 백업과 무결성 검사를 이미 갖추고 있다면, 이 버그는 그 대비 안에 들어옵니다. 반대로 그런 대비가 없다면, 걱정해야 할 건 이 버그가 아니라 SSD입니다.
그리고 caveat를 하나 남겨 둡니다. 문서는 "가용한 텔레메트리에 기반할 때(based on available telemetry)"라고 조건을 답니다. 어떤 텔레메트리인지, 표본이 얼마인지는 공개돼 있지 않습니다. 또한 앞서 본 것처럼 이 손상은 원인과 증상이 시간적으로 분리돼 있어서, 만약 발생했더라도 "SQLite 버그"로 귀속되지 않고 "원인 미상의 손상"이나 "디스크 문제"로 분류됐을 가능성이 구조적으로 존재합니다. 즉 이 추정치는 낙관 쪽으로 편향될 여지가 있는 종류의 추정치입니다. SQLite 팀이 부정직하다는 뜻이 아니라, 이런 버그의 관측 자체가 원래 어렵다는 뜻입니다.
릴리스가 꼬인 2주 — 3.52.0의 철회
여기서 이야기가 조금 이상해집니다. 그리고 이 부분은 오해하기 딱 좋으므로 정확히 짚겠습니다.
타임라인은 이렇습니다.
2026-01-09 3.51.2 릴리스 (버그가 살아 있는 마지막 버전)
2026-03-03 Dan이 WAL-reset 버그를 발견하고 고침
2026-03-06 3.52.0 릴리스 (WAL-reset 수정 + CLI 대개편 포함)
2026-03-13 3.52.0 철회(Withdrawn)
같은 날, 3.51.3 릴리스 (WAL-reset 수정만 백포트)
2026-04-09 3.53.0 릴리스 (3.52.0의 재출시 + 추가 수정)
2026-05-05 3.53.1 / 2026-06-03 3.53.2 / 2026-06-26 3.53.3
발견 사흘 만에 메이저 릴리스가 나갔고, 일주일 뒤 그 릴리스가 철회됐습니다.
중요: 3.52.0이 철회된 이유는 WAL-reset 버그가 아닙니다. 이건 시간 순서상 착각하기 쉬운 지점이라 분명히 해 둡니다. 철회 사유는 전혀 다른 문제였습니다 — 3.52.0의 새 기능 일부가 이전 릴리스와 100% 호환되지 않았고, 그 기능과 관련 API를 재작업해야 했기 때문입니다.
구체적으로, 릴리스 노트는 범위를 이렇게 좁힙니다. 3.52.0은 데이터베이스에 표현식 인덱스(expression index) 나 VIRTUAL 계산 열에 대한 인덱스 가 없는 한 잘 동작하고 완전히 하위 호환됩니다. 문제가 되는 건 그 인덱스의 표현식이 텍스트나 JSONB 입력에서 파생된 부동소수점 값으로 평가되는 경우입니다. 그런 인덱스가 있으면 드문 경우에 3.52.0이 이전 릴리스와 제대로 상호운용되지 않을 수 있습니다.
참고로 같은 릴리스의 변경 로그에는 부동소수점과 텍스트 사이 변환을 다시 구현했고 반올림 기본값을 이전 모든 버전의 유효숫자 15자리에서 17자리로 바꿨다는 항목이 따로 있습니다. 두 항목이 같은 영역을 건드린다는 점은 눈에 띄지만, 공개된 문서는 이 변경이 위 호환성 문제의 원인이라고 명시하지 않습니다 — 그러니 여기서 인과로 단정하지는 않겠습니다.
그럼 팀은 어떻게 대응했을까요. 여기가 배울 대목입니다. 그들은 "3.53.0 나올 때까지 기다리세요"라고 하지 않았습니다. 대신 3.51 브랜치에 패치 릴리스 3.51.3을 냈습니다. 3.51.3은 WAL-reset 버그와 3.51.2 이후 드러난 사소한 문제들만 고칩니다. 새 기능은 없습니다.
이 선택의 의미를 풀어 보면 이렇습니다. 손상 버그 수정이 CLI 대개편, 새 SQL 문법, QRF 라이브러리, 부동소수점 변환 개편과 한 덩어리로 묶여 있었습니다. 그리고 그 덩어리에서 호환성 문제가 터졌습니다. 만약 3.52.0이 유일한 경로였다면, 사용자는 "손상 버그를 고치려면 호환성 리스크가 있는 기능 릴리스를 통째로 삼켜라"는 선택을 강요받았을 것입니다. 낡은 안정 브랜치에 백포트를 내는 것은 그 강요를 없애는 일입니다 — 리스크를 분리해서, 손상 수정만 원하는 사람이 손상 수정만 가져갈 수 있게 하는 것.
그리고 이건 이론이 아니었습니다. node-sqlite3 저장소에는 철회 당일인 2026-03-13에 "철회된 3.52.0을 안정 버전 3.51.3으로 교체하자"는 이슈가 올라왔고, 보고자는 자신이 피해를 입은 게 아니라 문제를 예방하려는 정리 작업이라고 명시했습니다. 백포트가 없었다면 이 저장소의 선택지는 훨씬 나빴을 것입니다.
3.53.0(2026-04-09)은 릴리스 노트의 표현으로 "3.52.0의 재출시(re-release)이며, 스테일 표현식 인덱스 문제를 다루는 개선이 추가된" 버전입니다. 그리고 WAL-reset 버그에 대해 "3.52.0을 치지 않는다면, 이 수정을 담은 첫 메이저 릴리스"라고 적으며 업그레이드를 권합니다.
덧붙일 각주가 하나 있습니다. 3.52.0 릴리스 노트에는 이런 문장도 있었습니다 — SQLite의 릴리스 주기를 6개월에 한 번 정도로 늦추려 노력 중이며, 이건 약속이 아니라 목표라고. 그 문장이 나온 릴리스가 일주일 만에 철회됐다는 건, 공감이 가는 종류의 아이러니입니다.
당신이 실제로 해야 할 일
정리하면 행동 목록은 짧습니다.
1. 버전을 확인한다. 애플리케이션이 실제로 링크하고 있는 SQLite 버전을 확인하십시오. 패키지 매니저가 보고하는 버전이 아니라, 런타임이 보고하는 버전입니다.
SELECT sqlite_version();
2. 기준선은 3.51.3(2026-03-13) 이상입니다. 그보다 낮은 3.7.0 이상의 모든 버전에 이 버그가 있습니다. 메이저 릴리스로 가고 싶다면 3.53.0 이상이고, 이 글을 쓰는 2026-07-16 기준 최신은 3.53.3(2026-06-26)입니다.
3. 올릴 수 없다면 백포트가 있습니다. SQLite 문서는 일부 이전 릴리스에 대한 백포트를 제공합니다 — 3.44.6과 3.50.7. 다만 이건 정식 릴리스 타르볼이 아니라 Fossil 체크인 형태로 제공된다는 점에 유의하십시오.
4. 그리고 이게 진짜 함정입니다 — 한 번 올렸다고 끝이 아닙니다.
이 마지막 항목을 위해 실제 사례를 하나 보겠습니다. OpenAI의 codex 저장소에는 2026년 6월에 "번들 SQLite를 WAL-reset이 고쳐진 버전으로 핀 고정"이라는 PR이 올라와 2026-06-14에 머지됐습니다. PR 설명에 적힌 사고 경위는 이렇습니다.
SQLx 0.9가 libsqlite3-sys 에 대해 넓은 버전 범위를 허용하고 있었습니다. 그래서 이 문제와 아무 상관 없는 락파일 갱신 한 번이 libsqlite3-sys 를 0.37.0에서 0.35.0으로 되돌렸고, 번들된 SQLite 런타임이 3.51.3에서 3.50.2로 조용히 다운그레이드됐습니다. 즉 이미 고쳐진 버전에 도달했던 프로젝트가, 무관한 의존성 정리 작업 하나에 의해 취약한 버전으로 되돌아간 것입니다.
이게 SQLite가 라이브러리라는 사실의 대가입니다. SQLite는 어디에나 있다 편에서 서버 없는 설계의 장점을 이야기했지만, 동전의 뒷면이 여기 있습니다. 서버가 없다는 건 패치할 중앙 지점이 없다는 뜻이기도 합니다. PostgreSQL이라면 서버 하나를 올리면 모든 클라이언트가 고쳐집니다. SQLite는 그 코드를 링크한 모든 바이너리가 각자 고쳐져야 하고, 그 버전은 대개 여러분이 직접 고른 게 아니라 의존성 트리 어딘가에서 전이적으로 결정됩니다. 그리고 전이적 의존성은 여러분이 보지 않는 사이에 움직입니다.
실무적으로: 번들 SQLite를 쓰는 프로젝트라면 버전을 상한/하한으로 고정하고, CI에서 SELECT sqlite_version() 의 결과를 실제로 단언(assert)하는 편이 낫습니다. 락파일 diff를 사람이 읽어서 잡기를 기대하지 마십시오 — codex 사례가 정확히 그게 실패한 사례입니다.
이 사건에서 남는 것
몇 가지를 정리하고 싶습니다.
첫째, 15년은 코드가 옳다는 증거가 아닙니다. 이 버그는 SQLite 역사상 가장 많이 테스트된 코드 경로 중 하나에, 업계에서 가장 편집증적인 테스트 스위트를 갖춘 프로젝트 안에, 15년간 살아 있었습니다. 이유는 단순합니다 — 테스트는 스케줄을 제어하지 못했고, 이 버그는 스케줄에만 존재했습니다. 커버리지 100%도 인터리빙 100%를 뜻하지 않습니다.
둘째, 불변식은 필드가 아니라 락에 붙습니다. 이 사건을 한 문장으로 줄이면 "nBackfill 을 증가시키는 경로와 0으로 만드는 경로가 서로 다른 락을 잡았다"입니다. 두 경로 각각은 자기 락을 정확히 지켰습니다. 각각은 옳았습니다. 틀린 건 "이 필드를 지키는 락은 무엇인가"라는 질문에 답이 두 개였다는 사실입니다. 여러분의 코드에서 공유 상태를 리뷰할 때 물어야 하는 건 "락을 잡았는가"가 아니라 "이 상태를 만지는 모든 경로가 같은 락을 잡는가"입니다.
셋째, 좋은 공시(disclosure)의 모범입니다. SQLite 팀이 한 일을 보십시오. 전용 문서 섹션을 만들고, 영향 버전 범위를 정확히 명시하고, 6단계 메커니즘을 공개하고, 재현하지 못했다는 사실을 인정하고, 재현을 위해 소스를 해킹해야 했다는 것까지 적고, 발생률 추정치와 그 추정의 근거를 밝히고, "긴급 상황은 아니다"라고 온도를 조절하고, How To Corrupt An SQLite Database File 문서의 손상 원인 목록에 자기 버그를 항목으로 추가했습니다. 마지막 항목이 특히 좋습니다 — 그 문서는 원래 "손상은 대개 당신 잘못이다"를 설명하는 페이지인데, 거기에 "혹은 우리 잘못일 수도 있다"를 스스로 써 넣은 것입니다.
넷째, 리스크를 묶지 마십시오. 3.52.0의 교훈은 호환성 문제 자체가 아니라, 손상 수정이 기능 릴리스와 한 덩어리로 묶여 있었다는 구조입니다. 낡은 브랜치에 백포트를 낼 수 있는 능력은 릴리스 엔지니어링의 사치품이 아니라 안전 장비입니다.
마치며
정직한 결론은 이렇습니다. WAL-reset 버그는 여러분이 겪었을 가능성이 매우 낮고, SQLite 문서 스스로 그 발생률이 SSD 고장률의 잡음에 묻힌다고 밝히고 있습니다. 이 글은 여러분을 놀라게 하려고 쓴 게 아닙니다.
하지만 조치는 간단합니다 — 3.51.3 이상으로 올리고, CI에서 그 버전이 유지되는지 단언하십시오. 비용이 거의 없는 일이고, 문서가 권장하는 일입니다.
그리고 이 사건을 읽는 더 나은 방법이 있다고 생각합니다. 이건 SQLite의 신뢰성이 과대평가였다는 이야기가 아닙니다. 오히려 반대입니다 — 15년간 실무에서 아무도 자연 발생시키지 못한 경합을 개발자가 코드를 읽다가 찾아냈고, 재현하기 위해 테스트 훅을 심었고, 사흘 만에 고쳤고, 릴리스가 꼬이자 낡은 브랜치에 백포트를 냈고, 전 과정을 문서로 공개했습니다. "Reliable"은 버그가 없다는 뜻이 아닙니다. 버그를 이렇게 다룬다는 뜻입니다.
참고 자료
- Write-Ahead Logging — 11. The WAL-Reset Bug (SQLite 공식 문서)
- WAL-index 파일 포맷 — mxFrame, nBackfill, WAL 락 매트릭스
- How To Corrupt An SQLite Database File — 8.1 Race condition when writing to a WAL-mode database
- Recent SQLite News — 3.52.0 철회 공지와 3.51.3, 3.53.0 릴리스 노트
- Release History Of SQLite — 전체 변경 로그
- SQLite Release 3.53.0 On 2026-04-09 — 변경 로그
- 백포트 체크인 — 3.44.6 / 3.50.7
- openai/codex PR #27992 — 번들 SQLite를 WAL-reset 수정 버전으로 핀 고정
- TryGhost/node-sqlite3 Issue #1859 — 철회된 3.52.0을 3.51.3으로 교체
- SQLite는 어디에나 있다 (관련 글)
- SQLite 내부 구조 — B-tree, WAL, VFS, 가상 테이블 (관련 글)