SQLite 4분 소요

16년간 아무도 못 봤던 SQLite 버그가 Tailscale 데이터를 망가뜨린 사연

“SQLite는 세상에서 가장 많이 테스트된 소프트웨어 중 하나다.” 개발자라면 한 번쯤 들어본 말입니다. 실제로 SQLite는 코드 한 줄당 테스트 코드가 수백 줄에 달하고 항공기부터 스마트폰까지 안 들어간 곳을 찾기가 더 어렵습니다. 그런데 그 SQLite에서 16년 동안 아무도 발견하지 못한 데이터 손상 버그가 튀어나왔습니다. 그것도 Tailscale이라는 실제 서비스의 데이터가 조용히 깨진 뒤에요.

조용히 사라진 데이터

시작은 흔한 장애 리포트였습니다. Tailscale 내부에서 SQLite로 관리하던 데이터가 어느 순간 앞뒤가 안 맞기 시작했습니다. 크래시도 없었고 에러 로그도 깨끗했습니다. 데이터베이스는 “정상"이라는데 정작 안에 든 내용이 있어야 할 모습과 달랐습니다.

이런 버그가 제일 고약합니다. 프로세스가 죽으면 적어도 어디서 죽었는지는 남습니다. 조용히 잘못된 데이터를 돌려주는 시스템은 언제부터 잘못됐는지조차 모릅니다. 며칠 전인지 몇 달 전인지 짚을 방법이 없습니다.

WAL, 그리고 리셋이라는 함정

문제가 터진 자리는 SQLite의 WAL 모드였습니다. WAL은 Write-Ahead Logging의 약자입니다. 쉽게 말해 “본문을 바로 고치지 않고, 수정 사항을 별도 노트에 적어두는” 방식이죠.

데이터베이스 본체 파일은 그대로 두고 변경 내용은 WAL 파일에 차곡차곡 쌓습니다. 읽는 쪽은 본체와 노트를 합쳐서 최신 상태를 봅니다. 쓰는 쪽은 노트에만 적으면 되니까 읽기와 쓰기가 서로를 막지 않습니다. WAL 모드가 기본 저널 모드보다 빠른 이유가 여기 있습니다.

노트가 너무 길어지면 정리를 해야겠죠. 이걸 체크포인트라고 부릅니다. 노트에 쌓인 내용을 본체 파일에 반영하고 노트를 처음부터 다시 씁니다. 이 “처음부터 다시” 하는 동작이 바로 WAL 리셋입니다.

말썽은 이 리셋 순간의 경계 조건이었습니다. 특정 타이밍에 리셋이 일어나면 이미 커밋된 트랜잭션의 일부가 없던 일이 되어버렸습니다. 데이터베이스는 아무 문제 없다고 봤고요. 커밋 확인까지 받은 데이터가 조용히 증발한 셈입니다.

왜 16년 동안 아무도 몰랐을까

궁금해집니다. 그 많은 테스트를 뚫고 어떻게 16년을 버텼을까요.

이유는 하나입니다. 재현이 안 되기 때문입니다. 이런 종류의 버그는 쓰기가 특정 순서로 일어나고 특정 크기에서 체크포인트가 걸리고 하필 그 시점에 프로세스가 죽어야 나타납니다. 세 조건이 동시에 맞아떨어져야 하는데 실제 환경에서 그 조합이 나올 확률은 극히 낮습니다.

기존 테스트는 대부분 “정상적인 순서"를 가정합니다. 코드 커버리지 100%를 찍어도 소용없습니다. 커버리지는 각 줄이 실행됐는지를 잴 뿐, 그 줄이 어떤 순서와 타이밍으로 얽히는지는 재지 않으니까요. 실행 경로의 조합은 사실상 무한합니다.

게다가 이 버그는 피해가 눈에 안 띕니다. 대부분의 SQLite 사용처에서는 데이터 몇 건이 어긋나도 아무도 모르고 지나갑니다. 앱이 죽지 않으니 버그 리포트도 안 올라옵니다. Tailscale처럼 데이터 일관성이 곧 서비스 정합성인 곳에서야 비로소 티가 났습니다.

결정론적 시뮬레이션이 범인을 잡았다

범인을 특정한 건 Antithesis라는 결정론적 시뮬레이션 테스트 플랫폼이었습니다.

발상은 이렇습니다. 소프트웨어를 완전히 통제된 가상 환경에서 돌립니다. 시계, 스레드 스케줄링, 디스크 I/O 순서, 네트워크 지연까지 전부 플랫폼이 결정합니다. 그리고 그 안에서 온갖 험한 상황을 만듭니다. 프로세스를 아무 때나 죽이고 디스크 쓰기를 중간에 끊고 스레드 실행 순서를 뒤섞습니다.

여기서 “결정론적"이 핵심입니다. 같은 시드를 넣으면 정확히 같은 실행이 재현됩니다. 크래시가 났던 그 순간을 몇 번이고 똑같이 되돌릴 수 있습니다. 심지어 그 시점부터 거꾸로 되감아 원인을 추적할 수도 있습니다.

평소 디버깅과 견줘보면 차이가 확실합니다. 보통 이런 버그를 만나면 로그를 뒤지고 재현 조건을 추측하고 운 좋게 한 번 재현되기를 기다립니다. 몇 주가 걸릴 수도 있고 영영 못 잡을 수도 있습니다. 결정론적 시뮬레이션은 이 과정을 “버그를 찾는 문제"에서 “찾은 버그를 되돌려 보는 문제"로 바꿔놓습니다.

이 방식이 새롭지는 않습니다. FoundationDB가 10년 넘게 이런 식으로 분산 데이터베이스의 신뢰성을 검증해왔고 Antithesis 창업자들이 바로 그 FoundationDB 출신입니다. 최근에는 TigerBeetle 같은 프로젝트도 비슷한 철학을 내세웁니다.

우리가 가져갈 교훈

이 사건을 보면서 걸린 지점이 몇 개 있습니다.

“검증된 소프트웨어"라는 말을 곧이곧대로 믿을 건 아닙니다. SQLite는 여전히 훌륭한 소프트웨어입니다. 16년이나 걸렸다는 건 오히려 그만큼 튼튼했다는 뜻이기도 하고요. 다만 버그 제로인 소프트웨어는 없습니다. 내 스택 어딘가에는 아직 아무도 밟지 않은 지뢰가 있다고 가정하는 편이 현실적입니다.

조용한 데이터 손상이 크래시보다 위험합니다. 크래시는 알람을 울리지만 데이터 손상은 침묵합니다. 그래서 무결성 검사를 주기적으로 돌리고 데이터가 지켜야 할 불변 조건은 코드로 못 박아둬야 합니다. “이 값은 항상 저 값보다 커야 한다” 같은 규칙을 검증하는 배치 작업 하나가 몇 달치 손상을 막아줍니다.

테스트 커버리지와 신뢰성은 다른 축입니다. 커버리지는 “이 코드가 실행됐다"를 말할 뿐입니다. 동시성, 크래시, 부분 쓰기 같은 조건은 커버리지 숫자로 잡히지 않습니다. 상태를 다루는 시스템을 만든다면 “이상한 순서로 벌어지는 일"을 일부러 만드는 테스트가 필요합니다.

마무리

가장 많이 테스트된 코드에서 16년 묵은 버그가 나왔습니다. 찾아낸 방법은 유닛 테스트를 더 늘리는 쪽이 아니라 세상을 통째로 시뮬레이션해서 최악의 순서를 강제하는 쪽이었습니다.

여러분이 지금 쓰고 있는 데이터베이스나 큐, 캐시에도 비슷한 지뢰가 묻혀 있을 수 있습니다. 언제 터지느냐보다 터졌을 때 알아챌 수 있느냐가 문제입니다. 데이터가 조용히 깨졌을 때 그 사실을 알려줄 장치가 내 시스템에 있는지, 그것부터 확인해볼 일입니다.

SQLite Tailscale 데이터베이스 소프트웨어테스팅 Antithesis

댓글

    댓글을 불러오는 중...