---
title: "SQLite를 프로덕션에서 쓰려면 WAL, 락, VFS를 제대로 알아야 한다"
published: 2026-07-29T07:18:24.000Z
canonical: https://jeff.news/article/5198
---
# SQLite를 프로덕션에서 쓰려면 WAL, 락, VFS를 제대로 알아야 한다

이 글은 SQLite를 단순한 로컬 임베디드 DB가 아니라 저지연 서버용 데이터베이스로 쓰는 방법을 정리한다. WAL 모드, busy timeout, BEGIN IMMEDIATE, cache_size, mmap_size, VFS 기반 복제까지 실전 설정이 꽤 구체적으로 나온다. 다만 분산 쓰기나 테라바이트급 데이터셋에는 여전히 PostgreSQL 같은 서버형 DB가 맞다는 선도 분명히 긋는다.

## SQLite는 로컬 장난감이 아니라, 조건 맞으면 꽤 진지한 서버 DB가 됨

- 글의 출발점은 단순함: 요즘 하드웨어에서는 DB 네트워크 왕복이 병목이 될 수 있음
  - NVMe SSD와 빠른 로컬 스토리지가 흔해지면서, 같은 서버 프로세스 안에서 SQLite를 직접 쓰면 네트워크 latency를 통째로 없앨 수 있음
  - 읽기는 메모리 매핑된 파일 접근에 가까워져서 sub-millisecond 쿼리도 가능하다는 주장임

- 하지만 SQLite를 프로덕션에 그냥 던져 넣으면 안 됨
  - 기본 설정은 호환성과 안전성을 넓게 잡은 쪽이지, 고동시성 앱 서버에 맞춘 설정이 아님
  - WAL, 체크포인트, 락, 캐시, VFS 같은 내부 동작을 알고 조정해야 함

## WAL 모드는 사실상 첫 번째 관문임

- 기본 rollback journal 방식은 쓰기 작업 중 읽기와 쓰기가 서로 막히기 쉬움
  - 쓰기 전에 원본 페이지를 rollback journal 파일에 복사하고, 성공하면 삭제하고, 실패하면 복구하는 방식임
  - 문제는 write가 read를 막고 read가 write를 막는 동시성 구조임

- 프로덕션 서버에서 SQLite를 쓰려면 WAL 모드를 켜는 게 핵심임
  - 설정은 `PRAGMA journal_mode = WAL;` 한 줄임
  - WAL 모드에서는 본 DB 파일을 바로 고치지 않고 `.sqlite-wal` 파일에 새 트랜잭션을 append함
  - reader는 main DB와 WAL의 필요한 페이지를 읽고, writer는 WAL 끝에 쓰기 때문에 읽기와 쓰기가 동시에 가능해짐

- WAL은 공짜가 아니라 checkpoint 관리가 따라옴
  - WAL 파일이 계속 커지면 읽기 때 WAL index를 더 많이 봐야 해서 느려질 수 있음
  - SQLite는 WAL 내용을 main DB로 합치는 checkpoint를 주기적으로 해야 함

- checkpoint 모드는 운영 지연 시간에 직접 영향을 줌
  - PASSIVE는 reader와 writer를 막지 않고 가능한 만큼만 병합함
  - FULL은 새 write를 막고 기존 read가 끝나길 기다린 뒤 전체 병합을 시도함
  - RESTART는 FULL과 비슷하지만 WAL을 다시 처음부터 쓰게 만듦
  - TRUNCATE는 RESTART에 더해 디스크 파일 크기까지 0바이트로 줄임

> [!TIP]
> write가 많은 서버에서 자동 checkpoint만 믿으면, 항상 active reader가 있는 상황에서 WAL 파일이 계속 커질 수 있음. 백그라운드 작업으로 `PRAGMA wal_checkpoint(PASSIVE);`나 RESTART 계열을 명시적으로 돌리는 설계가 낫다.

## SQLITE_BUSY는 버그가 아니라 설계 제약임

- WAL을 켜도 SQLite는 writer가 하나뿐임
  - 여러 reader와 하나의 writer는 괜찮지만, 동시에 두 writer가 쓰는 건 안 됨
  - 두 번째 writer가 들어오면 `SQLITE_BUSY`가 날 수 있음

- 그래서 `busy_timeout`은 프로덕션 기본값처럼 봐야 함
  - 예시는 `PRAGMA busy_timeout = 5000;`로 5초 동안 락 획득을 재시도하게 하는 설정임
  - SQLite가 내부적으로 sleep과 retry를 하면서 피크 시간대의 애플리케이션 레벨 오류를 줄여줌

- 쓰기가 포함된 트랜잭션은 `BEGIN IMMEDIATE`로 시작하는 편이 안전함
  - 기본 DEFERRED 트랜잭션은 처음엔 락을 안 잡고 읽기처럼 시작했다가, 실제 write 순간에 락을 올리려 함
  - 두 연결이 둘 다 읽고 있다가 나중에 동시에 쓰려고 하면 경합이 꼬일 수 있음
  - `BEGIN IMMEDIATE`는 시작할 때 reserved lock을 잡아서, 다른 write 트랜잭션과의 충돌을 초기에 정리함

## 캐시와 mmap은 읽기 성능에 바로 꽂히는 설정임

- SQLite 기본 cache size는 보통 서버 워킹셋 기준으로 작음
  - 글에서는 `PRAGMA cache_size = -64000;` 예시를 들며 약 64MB 캐시를 할당함
  - 음수 값은 페이지 수가 아니라 KiB 기준 크기를 뜻함

- mmap을 켜면 DB 파일을 애플리케이션 가상 주소 공간에 매핑할 수 있음
  - 예시는 `PRAGMA mmap_size = 2147483648;`로 최대 2GB까지 매핑하는 설정임
  - DB가 이보다 작으면 전체 DB가 메모리에 매핑될 수 있고, 읽기가 포인터 접근에 가까워짐
  - OS 커널의 페이지 캐시를 활용하므로 user-space 복사 비용도 줄어듦

## 클라우드에서 SQLite를 쓰려면 VFS와 복제를 같이 봐야 함

- SQLite의 VFS는 파일 작업을 갈아끼울 수 있는 강력한 추상화임
  - SQLite는 OS 파일 시스템에 직접 쓰는 대신 open, read, write, sync 같은 작업을 VFS 모듈에 맡김
  - 이 구조 덕분에 저장 위치나 복제 방식을 바꾸는 도구들이 나올 수 있음

- Litestream은 WAL 프레임을 object storage로 계속 흘려보내는 쪽에 가까움
  - 별도 프로세스로 돌면서 AWS S3 같은 저장소에 incremental WAL frame을 주기적으로 업로드함
  - 목적은 거의 0에 가까운 오버헤드로 point-in-time recovery를 확보하는 것임

- LiteFS는 FUSE 기반 VFS로 SQLite DB를 여러 노드에 복제하는 방식임
  - 파일 시스템 레벨에서 write를 가로채고 read replica로 트랜잭션을 실시간 복제함
  - Fly.io 같은 환경에서 globally distributed SQLite 배포를 가능하게 하는 쪽임

> [!WARNING]
> AWS ECS, Kubernetes, Fly.io처럼 로컬 디스크가 사라질 수 있는 환경에서 SQLite만 덜렁 쓰면 내구성이 깨질 수 있음. 이런 환경에서는 VFS 기반 복제나 WAL 백업 전략이 선택이 아니라 운영 조건에 가까움.

## 글에서 제안하는 기본 설정 묶음

- 앱 부트스트랩에서 커넥션을 열자마자 PRAGMA를 일관되게 적용하라는 게 핵심임
  - `journal_mode = WAL`로 읽기·쓰기 동시성 확보
  - `synchronous = NORMAL`로 WAL 환경에서 commit마다 디스크 sync하는 부담 완화
  - `busy_timeout = 5000`으로 write lock 경합 흡수
  - `cache_size = -64000`으로 약 64MB 캐시 할당
  - `mmap_size = 1073741824`로 최대 1GB memory-mapped I/O 활성화
  - `foreign_keys = ON`으로 외래 키 제약 적용
  - `journal_size_limit = 67108864`로 WAL 파일을 64MB 수준에서 제한
  - `auto_vacuum = INCREMENTAL`로 페이지 회수와 쿼리 계획 관리에 도움

- 결론은 꽤 현실적임
  - read-heavy 서비스, 수백 GB 안쪽 데이터셋, 단일 서버나 단일 tenant edge 배포라면 SQLite가 운영 단순성과 저지연 면에서 매력적일 수 있음
  - 반대로 여러 지역에서 복잡한 분산 write 트랜잭션이 필요하거나, 데이터가 수 TB를 넘으면 PostgreSQL 같은 서버형 DB가 맞음

---

## 기술 맥락

- SQLite를 고르는 이유는 단순히 가볍기 때문이 아니라 네트워크 왕복을 없앨 수 있기 때문이에요. 앱 프로세스와 DB가 같은 머신에 있으면 쿼리 하나마다 DB 서버까지 갔다 오는 비용이 사라지거든요.

- WAL을 켜는 이유는 읽기와 쓰기의 충돌을 줄이기 위해서예요. rollback journal은 안전하지만 동시성에서 손해를 보고, WAL은 writer가 로그에 append하는 동안 reader가 기존 DB 스냅샷을 계속 볼 수 있게 해줘요.

- 그래도 SQLite는 단일 writer라는 제약이 남아요. 그래서 busy_timeout과 BEGIN IMMEDIATE가 중요한데, 이 둘은 락 경합을 없애는 마법이 아니라 경합을 예측 가능한 실패나 대기로 바꿔주는 장치예요.

- mmap과 cache_size는 읽기 경로를 줄이려는 설정이에요. 워킹셋이 메모리에 들어오는 서비스라면 디스크 I/O를 줄이고 OS 페이지 캐시를 잘 활용하는 쪽이 지연 시간에 바로 영향을 줘요.

- 클라우드 배포에서는 내구성이 별도 문제예요. 로컬 디스크가 휘발적이면 SQLite 파일 하나가 빠른 건 장점이지만, 장애 때 복구할 복제본이나 WAL 백업이 없으면 운영 DB로는 위험하거든요.

## 핵심 포인트

- WAL 모드를 켜면 읽기와 쓰기가 서로 막히는 문제를 크게 줄일 수 있다
- SQLite는 여전히 단일 writer 모델이라 SQLITE_BUSY를 busy_timeout과 BEGIN IMMEDIATE로 다뤄야 한다
- cache_size와 mmap_size 설정으로 읽기 성능을 끌어올릴 수 있다
- 클라우드의 휘발성 로컬 디스크에서는 Litestream, LiteFS 같은 VFS·복제 도구가 사실상 필수다

## 인사이트

SQLite 프로덕션 논쟁은 '쓸 수 있냐 없냐'보다 '쓰기 패턴과 운영 모델이 맞냐'가 핵심이다. 읽기 많고 단일 서버·엣지 배포에 가까운 서비스라면 네트워크 왕복을 없애는 이득이 꽤 크지만, 락과 내구성 설정을 대충 두면 바로 발목 잡힌다.
