728x90

1. 6개월, 19건. "에러 없이 성공한 쓰기"가 사라졌다

Tailscale이 제어 평면(control plane) 데이터베이스에서 겪은 손상 사고 이야기가 화제다. 요약하면 이렇다. 2024년 8월 S3 백업을 읽는 파이프라인에서 오류가 났고, PRAGMA integrity_check를 돌려보니 진짜 손상이었다. 그 뒤 6개월 동안 서로 무관해 보이는 손상 사고가 19번 터졌다. 결론은 SQLite의 체크포인트와 쓰기 트랜잭션 사이에 존재하던 데이터 경쟁, SQLite 개발진이 WAL-Reset 버그라 이름 붙인, 최소 16년 묵은 결함이었다.

구성부터 보자. Tailscale의 제어 평면은 여러 샤드로 나뉘고, 각 샤드는 자기 SQLite 파일을 하나 갖는다. 접근하는 프로세스는 Go 프로세스 하나뿐이다. 단일 작성자 — SQLite가 가장 잘하는 형태다. 백업은 몇 분마다 전체 스냅샷을 떠서 S3에 올린다. 2023년 초부터 사고 없이 잘 돌던 구성이다.

운영 담당자 입장에서 이 사건이 무서운 건 증상이 아니라 증상의 성질이다. 정리하면:

  • 재현 조건이 없다. 특정 샤드·고객·기능·시간대·부하와 상관관계가 안 나왔다.
  • 발생 간격이 몇 시간에서 몇 주까지 제멋대로다. 10월~12월엔 6주간 조용하다가 크리스마스 즈음 다시 터졌다.
  • 가장 결정적인 단서: 백업 복구용으로 만든 트랜잭션 로그를 재생했더니 깨끗하게 적용되지 않았다. 어떤 트랜잭션이 기록하고 커밋한 데이터가, 뒤이은 트랜잭션에서는 안 보였다. 애플리케이션에는 에러가 하나도 안 올라왔는데 쓰기가 증발한 것이다.
  • 지표도 이상했다. WAL에 10페이지가 있는데 체크포인트는 20페이지를 복사했다고 집계했다.

영향은 메타데이터 범위였다. 사용자 개인키나 트래픽은 DB에 없다. 다만 손상된 샤드를 복구·복원하는 동안 제어 평면 프로세스를 멈춰야 했고, 초기엔 1시간 넘는 중단이 났다. 이미 연결된 WireGuard 피어는 살아 있지만 새로 켠 장치는 피어 목록을 못 받아 연결을 못 만든다. 그리고 반복되는 상태 페이지 공지 자체가 신뢰를 갉아먹었다.

2. WAL과 체크포인트, 그리고 리셋 순간의 틈

복습부터. WAL 모드에서 쓰기는 메인 DB 파일에 바로 가지 않고 -wal 파일에 페이지 단위로 append된다. 읽기 트랜잭션은 자기 스냅샷 시점까지의 WAL 프레임과 메인 파일을 조합해 본다. WAL이 무한정 커질 수 없으니 주기적으로 체크포인트가 WAL 페이지를 메인 DB로 복사하고, 아무도 그 프레임을 안 읽고 있으면 WAL을 리셋(처음부터 다시 쓰기 시작)한다.

여기서 조율을 담당하는 게 -shm 파일에 mmap된 wal-index다. 일반 파일 잠금이 아니라 xShmLock 같은 공유 메모리 원시연산으로 조정한다. 그래서 이 경로는 연결이 둘 이상 열려 있어야만 밟힌다. Tailscale은 프로세스가 하나지만, 쓰기와 체크포인트를 서로 다른 스레드의 서로 다른 커넥션에서 수행했을 것으로 보인다(HN 토론의 해석). 실제로 SQLite 측 버그 설명도 다중 연결 조건을 든다.

버그의 모양은 이렇다. 체크포인트가 진행되는 특정 시점에 쓰기 트랜잭션이 끼어들어 WAL을 리셋하면, 체크포인트 로직은 실제로 메인 DB에 복사하지 않은 페이지를 복사했다고 착각한다. 복사 안 된 페이지는 메인 파일에 영원히 안 들어간다. 그런데 그 페이지를 참조하는 인덱스 등 다른 페이지는 정상적으로 기록된다. 결과는 두 가지가 한꺼번에 온다 — 데이터 소실 + 구조 불일치(= 손상).

비유하자면, 이삿짐 목록에 체크 표시를 하는 사람과 실제 박스를 옮기는 사람이 다른데, 중간에 누가 목록을 새 종이로 갈아끼웠고 체크 표시만 그대로 옮겨 적힌 상황이다. 목록상으론 다 옮겼는데 박스 몇 개가 옛날 집에 남아 있고, 그 박스를 가리키는 색인표는 새 집에 도착해 있다.

왜 16년이나 안 걸렸나. 이 코드 경로는 xShmMap/xShmLock/xShmBarrier가 실제로 동작하는 VFS에서만 밟힌다. 현실에서 쓰이는 구현은 Unix와 Windows 정도고, 나머지 대체 VFS(브라우저/WASM/OPFS 등)는 locking_mode=exclusive로 우회해 wal-index를 힙에 둔다. 즉 Unix에서, 체크포인트를 수동으로, 공격적으로 돌리는 누군가가 나타나야 발견되는 버그였다. Tailscale은 빠르고 일관된 백업을 위해 체크포인트를 직접 제어하고 있었고, 그래서 당첨됐다.

3. 재현 안 되는 버그를 어떻게 잡았나 — 관측을 프로덕션에 심는다

이 사건의 진짜 볼거리는 디버깅 방법론이다. 합성 환경 재현을 포기하고 프로덕션에 수동적 포렌식 텔레메트리를 심는 쪽으로 방향을 틀었다.

  1. 사고 대응 시간을 먼저 줄였다. 손상 감지 시 샤드를 즉시 강제 중지, 백업마다 PRAGMA integrity_check를 돌리는 자동 백업 모니터 배포, 런북과 온콜 교육 개선. 이걸로 대응을 1시간 미만으로 낮췄다. 원인 못 찾는 동안 서비스는 돌려야 하니까.
  2. 트랜잭션 로그 파이프라인. DB를 바꾸는 모든 SQL 문을 별도 로그로 스트리밍. 단일 작성자 + 직렬화 가능 트랜잭션이라 기록이 선형적·결정적이다(멀티 라이터인 Postgres/MySQL에선 이 성질이 안 성립한다). 마지막 정상 백업에 로그를 재생해 손상 파일을 우회하고 최신 상태를 복원했다. 그리고 이 재생이 실패한 두 번의 사고가 결정적 단서가 됐다.
  3. tmstmpvfs shim. SQLite 개발진이 기존 VFS를 감싸 DB 변경 추적 로그를 남기는 shim을 만들었고, Tailscale이 이걸 프로덕션에 배포해 다음 사고의 로그를 확보했다. 여기서 경쟁 상태가 잡혔다.
  4. 수정 검증도 관측으로. "6주간 조용했던 전례"가 있었으니 재발 없음만으로 해결 선언을 안 했다. 대신 쓰기 트랜잭션과 WAL 리셋이 겹치면 경고를 남기도록 드라이버를 고쳤다. 배포 두 달 뒤 실제로 그 경고가 떴고 — DB는 멀쩡했다. 조건은 성립하는데 손상은 안 난다, 즉 패치가 사고를 막고 있다는 능동적 증거다.

여기서 훔쳐올 만한 원칙 하나: "안 터졌다"는 수정의 증거가 아니다. 위험 조건이 발생했다는 사실 자체를 계측해서, 조건 발생 + 무사고를 함께 봐야 한다.

4. 실무 점검: 지금 우리 SQLite는 안전한가

SQLite는 국내에서도 엣지 노드, 모니터링 에이전트, 사이드카 캐시, 온디바이스 저장소에 넘치게 쓰인다. 당장 확인할 것부터.

4-1. 저널 모드와 체크포인트 운용 확인

$ sqlite3 /var/lib/agent/state.db \
  "PRAGMA journal_mode; PRAGMA wal_autocheckpoint; PRAGMA synchronous;"
wal
1000
2

# 수동 체크포인트를 돌린다면 반환값을 반드시 확인
$ sqlite3 /var/lib/agent/state.db "PRAGMA wal_checkpoint(TRUNCATE);"
0|10|10

세 컬럼은 순서대로 busy, WAL 프레임 수, 메인 DB로 복사된 프레임 수다. 첫 값이 1이면 체크포인트가 완료되지 못했다는 뜻인데, 이걸 무시하고 "백업 떴다"고 넘어가는 스크립트가 현장에 정말 많다. 그리고 이번 사건에서 2번째와 3번째 값이 안 맞는 것이 이상 신호였다는 점을 기억하자. 백업 잡에서 이 세 값을 로그·메트릭으로 남겨두면 공짜 조기경보다.

수동 체크포인트를 고빈도로 돌리고 있다면 그게 곧 비표준 운용이다. 문서화되고 지원되는 기능이라도, 일반 배포 경로보다 검증 범위가 좁다는 사실을 Tailscale이 비싸게 증명했다.

4-2. 백업은 파일 복사가 아니라 검증까지

#!/bin/bash
set -euo pipefail
DB=/var/lib/agent/state.db
OUT=/backup/state-$(date +%Y%m%d%H%M).db

# 실행 중 DB를 cp/tar로 긁는 것은 금물. 일관 스냅샷 API를 쓴다.
sqlite3 "$DB" "VACUUM INTO '$OUT';"       # 3.27+ 지원

# 업로드 전에 반드시 검증
if ! sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -qx "ok"; then
  echo "CORRUPT backup: $OUT" >&2
  exit 1
fi
aws s3 cp "$OUT" s3://backups/agent/ --only-show-errors

핵심은 마지막 두 단계다. 검증하지 않은 백업은 백업이 아니다. Tailscale도 손상을 처음 발견한 게 DB 자체가 아니라 S3 백업을 읽던 파이프라인이었다. 백업 모니터가 없었다면 19건이 아니라 훨씬 뒤에, 훨씬 크게 터졌을 것이다.

4-3. 흔한 함정과 실제로 보게 될 에러

손상이 나면 애플리케이션 로그에 이런 게 뜬다. 검색해서 여기 온 사람이 있다면 반갑다.

Error: database disk image is malformed (11)
# Go 드라이버 기준
sql: transaction: SQLITE_CORRUPT: database disk image is malformed

$ sqlite3 state.db "PRAGMA integrity_check;"
*** in database main ***
Page 4213: btreeInitPage() returns error code 11
row 90210 missing from index idx_nodes_tailnet
wrong # of entries in index sqlite_autoindex_devices_1

그리고 함정 하나 더. 위 출력에서 row N missing from index만 잔뜩 나오고 페이지 에러는 없다면, 진짜 손상이 아닐 수 있다. Tailscale은 3.52.0 배포 직후 13개 DB에서 손상 경고를 받았는데 실제 손상이 아니었다. 원인은 오래된 표현식 인덱스였다. 계산값 위에 인덱스를 만들어놓고 나중에 계산 방식이 바뀌면 인덱스 값과 실제 값이 어긋나고, integrity_check는 이걸 손상으로 판정한다. 이들은 고정밀 타임스탬프를 텍스트로 저장하고 VIRTUAL 생성 열에서 부동소수점으로 변환하고 있었는데, 3.52.0의 최적화가 텍스트→부동소수점 반올림 동작을 미세하게 바꿔버렸다. 카나리 샤드엔 해당 타임스탬프가 없어서 단계적 배포로도 못 걸렀다.

SQLite 개발진은 이 거짓 경고 때문에 3.52.0을 철회하고, WAL-Reset 수정만 담은 3.51.3을 냈다. 3.53.0에는 오래된 표현식 인덱스를 막는 자동 자체 복구 기능이 들어갔다. Tailscale은 타임스탬프 정밀도를 정수 초로 낮춰 모호하지 않은 텍스트-정수 변환으로 바꿨다.

여기서 실무 교훈 두 개. 첫째, 생성 열/표현식 인덱스는 "계산 방식이 언젠가 바뀔 수 있는가"를 기준으로 설계하라. 부동소수점 변환, 로케일·콜레이션 의존, 커스텀 함수 결과 위에 인덱스를 얹는 건 시한폭탄이다. 둘째, "온 김에" 다른 변경을 한 배포에 끼워 넣지 마라. 이번엔 크리티컬 버그 픽스와 최적화가 같은 릴리스에 있었고, 그 결과 픽스를 검증하려던 순간에 무관한 알람이 쏟아졌다.

대안도 짚어두자. 멀티 라이터가 필요하거나 쓰기 QPS가 계속 오른다면 애초에 SQLite가 답이 아니다(Postgres/MySQL). 반대로 SQLite를 유지하되 복제·백업을 자동화하고 싶다면 Litestream/rqlite/LiteFS 같은 선택지가 있는데, 이들도 결국 WAL과 체크포인트를 어떤 방식으로 다루는지가 안정성의 핵심이니 도입 전 체크포인트 운용 방식이 표준 경로인지 공식 문서로 확인하는 게 좋다.

5. 정리

한 줄 요약: SQLite에서 체크포인트를 직접, 공격적으로 제어하고 있다면 16년 된 WAL-Reset 레이스에 걸릴 수 있으니 3.51.3 이상으로 올리고, 백업마다 integrity_check를 돌려라.

누가 지금 확인해야 하나. (1) WAL 모드 + 수동 wal_checkpoint를 짧은 주기로 돌리는 서비스, (2) 한 프로세스 안에서 여러 커넥션을 열어 쓰기와 체크포인트를 나눠 처리하는 구조, (3) 백업을 뜨기만 하고 무결성 검증은 안 하는 파이프라인. 셋 다 해당한다면 오늘 안에 위 명령 두 개는 돌려보길 권한다.

마지막으로 개인적으로 가장 남는 대목. Tailscale은 오픈소스 유지보수자에게 공짜 수정을 요구하지 않고 SQLite 유료 지원 계약을 샀고, 그 과정에서 나온 tmstmpvfs shim은 오픈소스로 남아 다음 사람이 비슷한 버그를 잡는 데 쓰인다. "검증된 기술도 비표준으로 굴리면 위험하다"는 교훈과 함께, 문제 해결에 돈과 시간을 제대로 쓰는 방식의 좋은 사례다.

참고 자료

728x90
728x90

"DB 서버를 하나 더 붙이자"는 얘기는 많이 들어봤어도 "DB 서버를 아예 없애자"는 얘기는 드물다. lobste.rs가 정확히 후자를 했다. MariaDB VPS를 통째로 내리고 애플리케이션 서버 안의 SQLite 파일 하나로 옮겼다. 결과는 CPU·메모리 사용량 감소, 체감 응답속도 개선, 그리고 VPS 비용 절반.

이게 왜 재밌냐면, 우리가 인프라 최적화라고 하면 보통 "더 추가"하는 방향으로만 생각하기 때문이다. 캐시 레이어 추가, 리드 레플리카 추가, 커넥션 풀러 추가. lobste.rs는 반대로 "빼는" 결정으로 단순성과 비용을 동시에 잡았다. 이번 글에서 그 과정과, 우리가 실제로 따라 하려 할 때 밟게 될 지뢰를 정리한다.

1. 왜 MariaDB를 버렸나 — 동기와 배경

사실 이 마이그레이션은 갑자기 나온 게 아니다. 논의는 2019년 이슈 #539에서 시작됐다. 당시엔 MariaDB에서 벗어나자는 얘기였고, 대안으로 PostgreSQL이 유력했다. PostgreSQL은 대부분의 팀에서 "고민 없이 고르는 기본값"이니까 자연스러운 선택이었다.

그런데 2025년에 실제로 작업할 지원자가 나타났을 때, 그 사람이 SQLite로 가고 싶어 했다. 여기서 lobste.rs 팀의 판단이 흥미롭다. 원문 댓글에서 관리자가 직접 밝힌 이유는 이렇다.

PostgreSQL은 평소 기본 선택이지만 별도 서비스를 운영·조정·유지해야 하는 부담이 있음. 예상 수요보다 크고 복잡한 해법을 피하고 싶어 SQLite를 선택함.

이게 핵심이다. lobste.rs는 단일 서버로 충분히 돌아가는 규모의 커뮤니티 사이트다. 이런 서비스에 PostgreSQL을 붙인다는 건 곧 별도 프로세스, 별도 VPS, 커넥션 관리, 백업 파이프라인, 버전 업그레이드 운영을 짊어진다는 뜻이다. "우리 규모에서 그게 정말 필요한가?"라는 질문에 정직하게 아니라고 답한 결과가 SQLite였다.

실무에서 우리도 이 질문을 자주 회피한다. "일단 Postgres 깔자, 나중에 커지면 편하니까." 그런데 그 "나중"이 안 오는 서비스가 훨씬 많다. lobste.rs는 오지 않을 미래를 위해 지금의 운영 복잡도를 지불하기를 거부한 거다.

2. SQLite는 프로덕션에서 쓸 수 있나 — 오해와 실제

여기서 대부분의 엔지니어가 멈칫한다. "SQLite는 임베디드용 아냐? 동시성이 안 되잖아." 반은 맞고 반은 틀리다.

SQLite의 동시성 모델을 정확히 이해하는 게 중요하다. 원문 FAQ 논의를 정리하면 이렇다.

  • 읽기는 여러 프로세스에서 동시에 가능
  • 쓰기는 한 번에 하나만 가능 (파일 시스템 잠금으로 보장)
  • 쓰기가 겹치면 명시적 큐가 아니라 "잠깐 대기 후 재시도" 방식으로 처리된다

비유하자면 화장실이 하나인 카페다. 읽기(구경)는 여러 명이 동시에 해도 되지만, 쓰기(화장실)는 한 명씩 순서대로다. lobste.rs 같은 사이트는 읽기가 쓰기보다 압도적으로 많은 read-heavy 워크로드다. 게시물 하나 쓰면 수백 번 읽힌다. 이런 패턴에서는 "쓰기 하나만 가능"이 실질적 병목이 되지 않는다.

그리고 "SQLite는 단일 서버여야 한다"는 제약도 있다. NFS로 파일 공유하는 방식은 공식적으로 권장되지 않는다. 그래서 SQLite를 쓴다는 건 곧 "우리 서비스는 한 대로 돌린다"는 아키텍처 선언이기도 하다. 요즘 서버 한 대 스펙이면 상당한 규모를 감당하니, 생각보다 많은 서비스가 여기 해당한다.

실제 규모 감을 위해: lobste.rs의 SQLite 파일 크기는 원문 댓글 기준 약 3.8GB라고 언급됐다. 커뮤니티 사이트 전체 데이터가 4GB 미만이라는 얘기다. 이 정도면 파일 하나로 충분히 관리된다.

3. 마이그레이션 과정 — 스키마 변환과 첫 배포 실패

이번 사례에서 가장 배울 게 많은 부분이 여기다. 첫 배포는 실패했다.

첫 배포가 CPU 100% 찍고 롤백된 이유

2월 21일 첫 배포에서, 읽기 전용 트래픽만으로 모든 CPU가 100%에 도달했다. 사이트를 읽기 전용 상태로 열었는데도 서버가 못 버틴 거다. 원인을 못 찾아 롤백했다.

가장 뼈아팠던 조건은 이거다. 팀이 프로덕션 DB에 접근할 수 없었다. 실제 데이터로 성능을 미리 재현할 방법이 없었다는 뜻이다. 그래서 로컬에서 실제 데이터 절반 크기의 테스트 데이터를 생성했는데, 이걸 만드는 데만 일주일이 걸렸다.

원인은 결국 두 가지였다.

  1. 대형 테이블에서 두 개의 쿼리가 전체 테이블 스캔(full table scan)을 일으킴
  2. 별도의 N+1 문제

여기서 실무 교훈. MariaDB에서 잘 돌던 쿼리가 SQLite에서 똑같이 잘 돈다는 보장이 없다. 옵티마이저가 다르고, 인덱스 활용 방식이 다르다. MariaDB가 알아서 커버해주던 비효율 쿼리가 SQLite로 오면 그대로 풀스캔으로 터질 수 있다.

lobste.rs 팀이 회고에서 남긴 가장 실용적인 조언이 이거다. "테스트 중 전체 테이블 스캔이 발생하면 테스트가 실패하도록 설정할 수 있었다면 첫 배포 문제를 미리 잡았을 것." 이건 우리도 바로 적용할 수 있다. SQLite는 쿼리 플랜을 확인할 수 있다.

# 쿼리가 인덱스를 타는지 풀스캔하는지 확인
sqlite3 lobsters.db "EXPLAIN QUERY PLAN
  SELECT * FROM stories WHERE user_id = 42 ORDER BY created_at DESC;"

# 인덱스 잘 타는 경우 출력 예시
QUERY PLAN
`--SEARCH stories USING INDEX index_stories_on_user_id (user_id=?)

# 풀스캔 나는 경우 출력 예시 (이게 나오면 위험 신호)
QUERY PLAN
`--SCAN stories

출력에 SCAN이 뜨고 그 대상이 큰 테이블이면 인덱스를 검토해야 한다. SEARCH ... USING INDEX가 나오면 정상이다. 이걸 CI 테스트에 엮어서 "큰 테이블에 SCAN이 뜨면 빌드 실패"로 만들면 lobste.rs가 겪은 사고를 예방할 수 있다.

스키마 변환에서 걸린 호환성 문제

MariaDB와 SQLite는 타입 시스템과 함수가 다르다. lobste.rs가 실제로 부딪힌 것들:

  • unsigned bigint 미지원: MariaDB에서 일부 ID에 쓰던 unsigned bigint를 SQLite가 지원하지 않아 그냥 bigint로 변경했다.
  • 없는 함수 직접 구현: SQLite에 없던 regexp, if, stddev를 SQLite gem의 사용자 정의 함수(UDF)로 구현했다. (참고로 최신 SQLite에는 iif 함수가 이미 있어서, 이 부분은 버전 확인이 필요하다.)
  • 정렬/대소문자 규칙: MariaDB에서 utf8mb4_general_ci를 쓰던 걸 SQLite에서는 NOCASE로 바꿨는데, NOCASE는 ASCII 문자만 대소문자를 구분 없이 처리한다. 전체 UTF 대소문자 접기(case folding)는 안 된다.

이 마지막 항목은 한국어 서비스에 특히 중요하다. 한글은 대소문자 개념이 없어서 직접 영향은 적지만, 영문/유니코드가 섞인 검색·정렬에서 MariaDB와 다르게 동작할 수 있다. "왜 대문자로 검색하면 결과가 다르지?" 같은 버그가 여기서 나온다.

또 전문 검색(full-text search)은 SQLite FTS를 쓰는데, 순위 산정 알고리즘이 MariaDB와 달라서 검색 결과 순서가 바뀔 수 있다. lobste.rs 팀도 "검색 테스트는 있지만 순위 검증은 적어서 기능적 변화 가능성이 있다"고 인정했다. 마이그레이션 후 검색 결과가 미묘하게 달라지는 건 각오해야 한다.

4. 운영 결과 — 실제로 뭐가 좋아졌나

7월 11일 두 번째 배포는 성공했다. 결과를 원문 기준으로 정리하면:

  • 전환 후 사이트 정상 운영, CPU·메모리 사용량 안정
  • 월요일 트래픽 급증(주간 피크)에서도 문제 없음
  • CPU·메모리 사용량 감소, 체감 응답성 개선
  • MariaDB VPS 종료로 VPS 비용 절반으로 감소

여기서 짚어야 할 포인트. 관리자도 "놀랍게도 CPU와 RAM이 모두 감소했다"고 했다. 직관과 반대다. DB를 앱 서버 안으로 끌고 왔는데 왜 리소스가 줄었을까?

추측이지만 합리적인 설명은 이렇다. MariaDB 시절엔 별도 VPS와 앱 서버 사이에 네트워크 왕복, 커넥션 관리, 직렬화/역직렬화 오버헤드가 있었다. SQLite는 같은 프로세스 안에서 함수 호출로 데이터를 읽으니 이 오버헤드가 통째로 사라진다. 네트워크를 안 타는 게 생각보다 크다. (단, 이건 이번 사례에 대한 해석이고, 모든 워크로드에서 재현되는 일반 법칙은 아니다.)

주의할 점: 이 "비용 절반, 리소스 감소"는 lobste.rs 규모와 read-heavy 패턴에서 나온 결과다. 원문에 구체적 수치(전후 CPU %, 응답시간 ms)는 차트로만 언급됐고 텍스트로는 없다. 우리 서비스에 그대로 대입하지 말고 방향성만 참고하자.

5. SQLite 프로덕션 운영 시 주의할 점 — 흔한 함정

기본 설정으로 쓰면 안 된다

SQLite를 서버에서 제대로 쓰려면 PRAGMA 튜닝이 필수다. 관리자가 원문에 공개한 lobste.rs의 실제 설정이 좋은 출발점이다.

PRAGMA foreign_keys=ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 134217728;
PRAGMA journal_size_limit = 67108864;
PRAGMA cache_size = 2000;

핵심만 보면:

  • journal_mode = WAL: Write-Ahead Logging. 이게 없으면 읽기가 쓰기를 블록한다. 서버용이면 필수다.
  • busy_timeout = 5000: 쓰기가 겹쳐서 잠겼을 때 5초까지 재시도하고 기다린다. 이게 없으면 바로 에러가 터진다.
  • synchronous = NORMAL: WAL 모드와 조합하면 성능과 안정성의 균형점.

흔한 함정: SQLITE_BUSY

SQLite를 서버에서 처음 굴리면 거의 반드시 만나는 에러가 이거다.

SQLite3::BusyException: database is locked (SQLite3::BusyException)

또는 로그에 이렇게 뜬다.

Error: database is locked (5) (SQLITE_BUSY)

쓰기 트랜잭션이 겹쳤는데 대기 시간을 초과했을 때 발생한다. 원인은 보통 둘 중 하나다.

  1. busy_timeout이 설정 안 됐거나 너무 짧다
  2. 트랜잭션이 지연 모드(deferred)로 시작돼서 잠금 획득 시점이 꼬였다

관리자가 중요한 힌트를 남겼다. "busy_timeout이 제대로 작동하려면 즉시 트랜잭션 모드(immediate transaction)도 켜야 한다." 기본 deferred 트랜잭션은 첫 쓰기 순간에야 잠금을 잡으려 하는데, 이때 이미 다른 쓰기가 진행 중이면 busy_timeout이 기대대로 안 먹힐 수 있다. immediate 모드는 트랜잭션 시작 시점에 쓰기 잠금을 확보하러 가서 이 경합을 깔끔하게 처리한다.

Rails라면 database.yml에서 이렇게 걸 수 있다.

# config/database.yml
production:
  adapter: sqlite3
  database: storage/production.sqlite3
  timeout: 5000
  # 트랜잭션 기본 모드를 IMMEDIATE로
  default_transaction_mode: IMMEDIATE
  pragmas:
    journal_mode: WAL
    synchronous: NORMAL
    foreign_keys: true
    busy_timeout: 5000
    cache_size: 2000
    temp_store: MEMORY

흔한 함정: NVMe와 안 맞는 기본값

관리자가 희미하게 기억한다며 남긴 얘기가 하나 있다. SQLite의 디스크 쓰기 방식 일부가 HDD에는 유리하지만 NVMe에는 부적합했던 부분이 있었다는 거다. 요즘 서버는 대부분 NVMe SSD니까, 기본값을 무조건 신뢰하지 말고 워크로드로 벤치마크해보는 게 안전하다. (구체적으로 어떤 PRAGMA인지는 원문에 명시 안 됐다. 공식 문서 확인 필요.)

백업은 어떻게?

파일 하나라서 백업이 오히려 단순하다. lobste.rs는 매일 밤 실행되는 작업이 restic을 호출하는 방식을 쓴다. 다만 WAL 모드에서는 파일을 그냥 복사하면 WAL에 아직 반영 안 된 데이터가 누락될 수 있으니, 안전하게 하려면 SQLite의 백업 API나 온라인 백업 명령을 쓰는 게 좋다.

# WAL을 고려한 안전한 온라인 백업 (사이트 돌아가는 중에도 OK)
sqlite3 production.sqlite3 ".backup '/backups/production-$(date +%F).sqlite3'"

# 백업 파일 무결성 검증
sqlite3 /backups/production-2025-01-15.sqlite3 "PRAGMA integrity_check;"

# 정상이면 출력
ok

실시간 복제/PITR이 필요하면 원문 댓글에서도 언급된 litestream 같은 도구를 검토하면 된다. lobste.rs는 restic 야간 백업으로 충분하다고 판단한 케이스다.

6. 정리 — 언제 이 선택이 옳은가

한 줄 요약: read-heavy이고 단일 서버로 충분한 규모라면, SQLite로 옮기는 게 비용과 운영 복잡도를 동시에 줄이는 현실적 선택이 될 수 있다.

이 선택이 맞는 경우:

  • 읽기가 쓰기보다 압도적으로 많은 워크로드 (커뮤니티, 블로그, 문서 사이트, 대시보드)
  • 단일 서버 아키텍처로 감당되는 규모 (데이터 수 GB 수준, 앞으로도 수평 확장 계획 없음)
  • 별도 DB 서버 운영 부담과 비용을 줄이고 싶은 소규모 팀

이 선택이 위험한 경우:

  • 쓰기가 매우 많거나 여러 서버가 동시에 써야 하는 워크로드 → 쓰기 직렬화가 병목이 된다
  • 수평 확장이 전제인 서비스 → SQLite는 단일 서버 가정을 강제한다
  • MariaDB/MySQL의 특정 함수·타입·정렬 규칙에 깊게 의존하는 코드베이스 → 호환성 작업 비용이 크다

lobste.rs 사례의 진짜 교훈은 SQLite 자체보다 그 판단 과정에 있다고 본다. "기본값으로 PostgreSQL"이라는 관성을 의심하고, 우리 서비스의 실제 규모와 접근 패턴을 정직하게 본 다음, 오지 않을 미래를 위한 복잡도를 지불하지 않기로 한 결정. 그리고 프로덕션 DB 접근 없이 배포했다가 CPU 100%로 롤백한 실패 경험까지 투명하게 공개한 것. 이 두 가지가 이 글이 단순 릴리스 공지가 아닌 이유다.

덧붙여, 마이그레이션 전에 꼭 하고 넘어갈 것 하나만 고르라면 "실제 데이터 규모의 테스트 셋으로 EXPLAIN QUERY PLAN을 돌려 풀스캔을 사전에 잡는 것"이다. lobste.rs가 일주일 데이터 생성하고 CPU 100% 맞고 나서야 배운 걸, 우리는 미리 하면 된다.

참고 자료

728x90

SQLite에서 UUID를 기본 키로 쓰면 안 되는 이유: 클러스터드 인덱스와 B-tree 재균형 비용

왜 지금 이 얘기를 하나

마이크로서비스 한다고 PK를 죄다 UUID로 깔아두는 게 요즘 기본값처럼 굳어졌다. ID 충돌 안 나고, 클라이언트에서 미리 생성해도 되고, 분산 환경에서 시퀀스 조율 안 해도 되니까. 그래서 별 생각 없이 SQLite 테이블에도 id TEXT PRIMARY KEY로 UUID4를 박아 넣는다.

그런데 이게 SQLite, 그리고 정도 차이는 있지만 MySIQL/InnoDB 계열에서 의외로 비싼 선택이다. 작은 토이 프로젝트일 땐 안 보이다가, 데이터가 수십만~수백만 행 쌓이고 나서 "삽입이 왜 이렇게 느려졌지?" 하고 뒤늦게 깨닫는다. 오늘 hada.io에 올라온 "SQLite에서 UUID 기본 키의 위험성" 글이 딱 이 지점을 찌른다. 실무에서 자주 밟는 지뢰라 정리해둔다.

핵심: 클러스터드 인덱스에 랜덤 키를 박으면 생기는 일

먼저 SQLite 저장 구조부터. SQLite의 일반 테이블은 내부적으로 rowid라는 64비트 정수를 클러스터드 인덱스 키로 쓴다. 즉 데이터가 rowid 순서대로 B-tree에 물리적으로 정렬되어 저장된다는 뜻이다. INTEGER PRIMARY KEY로 선언하면 그 컬럼이 곧 rowid의 별칭이 된다.

여기서 핵심은 "물리적으로 정렬"이다. 정수 rowid는 보통 autoincrement처럼 단조 증가하니까, 새 행은 항상 B-tree의 맨 오른쪽 끝(가장 큰 키)에 붙는다. 페이지가 가득 차면 새 페이지 하나 만들어 끝에 이어 붙이면 끝이다. 디스크 입장에서도 순차 쓰기에 가깝다.

그런데 UUID4를 PK로 쓰면 어떻게 될까? UUID4는 이름 그대로 랜덤이다. 새로 생성한 값이 기존 키들 사이 어디에 끼어들지 예측이 안 된다. 그래서 삽입할 때마다:

  • B-tree에서 이 키가 들어갈 위치를 찾아 내려가고(랜덤 탐색)
  • 그 중간 페이지에 끼워 넣어야 하니, 페이지가 꽉 찼으면 페이지 분할(split)이 발생하고
  • 트리 균형을 맞추느라 재균형(rebalancing) 비용이 든다

비유하자면 정수 PK는 "맨 뒤에 책 한 권 꽂기"고, 랜덤 UUID는 "책장 중간 아무 데나 책 끼워 넣기"다. 후자는 옆 책들을 계속 밀어내야 한다. 게다가 매번 다른 페이지를 건드리니 페이지 캐시 적중률도 떨어진다.

원문에서 언급하는 수치(정수 rowid 기준 100만 행 삽입 시 대략 초당 100만 건 수준, UUID4는 그보다 현저히 느려짐)는 환경에 따라 달라지므로 그대로 믿기보다 본인 워크로드에서 직접 벤치 떠보는 걸 권한다. 다만 "랜덤 키가 순차 키보다 삽입이 느리다"는 방향성 자체는 B-tree 특성상 분명하다.

흔히 나오는 대안이 WITHOUT ROWID 테이블인데, 이건 오히려 함정이 될 수 있다:

-- rowid 안 쓰고 UUID 자체를 클러스터드 키로
CREATE TABLE events (
    id TEXT PRIMARY KEY,
    payload TEXT
) WITHOUT ROWID;

이렇게 하면 정수 rowid 오버헤드는 없앨 수 있지만, UUID(텍스트)가 그대로 클러스터드 인덱스 키가 되어 데이터 전체가 UUID 순으로 정렬 저장된다. 랜덤 UUID라면 삽입 시 재균형 문제가 더 직접적으로 터진다. 게다가 텍스트 UUID는 36바이트라 키 자체가 뚱뚱해서 페이지당 들어가는 행 수가 줄고 트리가 깊어진다.

실무 관점: 그럼 어떻게 해야 하나

정답은 워크로드에 따라 갈린다. 내가 실무에서 판단하는 기준은 이렇다.

1) 가장 무난한 선택: 정수 PK + UUID는 별도 유니크 컬럼

외부로 노출할 ID는 UUID로 두되, 물리 저장과 조인은 정수 rowid로 처리한다.

CREATE TABLE users (
    id        INTEGER PRIMARY KEY,   -- rowid, 내부 조인/저장용
    public_id TEXT NOT NULL UNIQUE,  -- 외부 노출용 UUID
    name      TEXT
);

삽입 성능은 순차 정수의 이점을 그대로 누리고, UUID는 보조 유니크 인덱스에만 영향을 준다. 다만 이 경우에도 public_id 유니크 인덱스는 랜덤이라 인덱스 갱신 비용은 든다. 그래도 데이터 본체가 흔들리는 것보단 훨씬 싸다.

2) 굳이 UUID를 PK로 써야겠다면: 시간 정렬형 UUID

UUID4 대신 UUIDv7처럼 앞부분에 타임스탬프가 들어간 정렬 가능한 ID를 쓰면 랜덤성이 크게 줄어든다. 시간순으로 거의 단조 증가하니 삽입이 다시 "맨 뒤에 붙기"에 가까워진다. UUID의 분산 친화성은 유지하면서 B-tree 재균형 문제를 완화하는 절충안이다. UUIDv7은 표준(RFC 9562)으로 확정됐고 라이브러리 지원도 늘었으니 신규 설계라면 충분히 검토할 만하다.

3) UUID를 굳이 TEXT로 저장하지 마라

이건 의외로 많이 놓친다. UUID를 TEXT(36자 문자열)로 저장하면 BLOB 16바이트 대비 두 배 이상 공간을 먹고, 비교 연산도 문자열 비교라 느리다. 정 UUID를 키로 쓸 거면 16바이트 BLOB으로 저장하는 걸 고려하라. 가독성은 떨어지지만 인덱스/저장 효율은 확실히 낫다. 디버깅 편의 때문에 TEXT를 고집하다 용량 폭증하는 경우를 봤다.

흔한 실수 정리

  • "UUID는 충돌 안 나니까 무조건 좋다"고 PK 박기 → 삽입 성능과 저장 효율을 같이 봐야 한다
  • 벤치를 작은 데이터로만 돌려보고 OK 판단 → 문제는 데이터가 수십만 행 넘어 페이지 분할이 빈번해질 때 드러난다
  • WITHOUT ROWID가 만능인 줄 알고 적용 → 랜덤 키라면 오히려 독이다. 작고 짧은 정렬형 키일 때 이득
  • UUID를 TEXT로 저장하고 인덱스까지 걸어두고 용량/속도 둘 다 손해

트레이드오프

UUID PK의 진짜 가치는 "분산 환경에서 중앙 조율 없이 ID 생성"이다. 이게 정말 필요한 설계가 아니면(예: 모놀리식 SQLite, 단일 라이터 임베디드 DB) 정수 PK가 거의 항상 유리하다. SQLite는 애초에 임베디드/단일 파일 DB라 분산 ID의 필요성이 약한 경우가 많다는 점도 같이 고려하자.

정리

한 줄 요약: SQLite처럼 클러스터드 인덱스 기반 DB에 랜덤 UUID4를 PK로 박으면 삽입마다 B-tree 재균형이 일어나 느려지고 뚱뚱해진다. 정수 PK를 쓰거나, 꼭 UUID여야 한다면 정렬형 UUIDv7 + BLOB 저장을 검토하라.

  • 정수 PK + UUID 유니크 컬럼: 대부분의 임베디드/단일 라이터 상황의 무난한 정답
  • UUIDv7 PK: 분산 ID가 꼭 필요하면서 삽입 성능도 챙기고 싶을 때
  • UUID4 PK: 정말 필요할 때만, 그리고 BLOB로 저장하고 벤치 떠보고

같은 원리가 InnoDB(MySQL)에도 적용된다. InnoDB도 PK가 클러스터드 인덱스라 랜덤 UUID PK는 똑같이 페이지 분할 지옥을 부른다. SQLite만의 얘기로 들리지만 사실 "클러스터드 인덱스 DB 공통의 함정"으로 이해하는 게 맞다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

+ Recent posts