SQLite 파일을 cp로 백업하면 깨지는 이유, VACUUM INTO와 백업 API로 해결하기

SQLite 파일을 cp로

“SQLite는 파일 하나짜리 DB니까 cp 명령으로 복사만 해두면 백업이 끝난다”고 생각하는 분이 많습니다. 하지만 SQLite 파일을 cp로 백업하면 깨지는 경우가 실제로 있고, 그 이유는 cp가 SQLite 엔진의 잠금 상태나 WAL(Write-Ahead Log) 파일을 전혀 알지 못하기 때문입니다. 결과만 먼저 말씀드리면, 쓰기 작업이 전혀 없는 완전히 닫힌 DB를 복사할 때는 cp로도 문제가 없지만, 서비스가 돌아가는 중이거나 체크포인트가 끝나지 않은 상태라면 VACUUM INTO나 공식 백업 API를 쓰는 쪽이 안전합니다.

cp는 SQLite의 잠금과 WAL을 전혀 모릅니다

cp는 파일시스템 레벨에서 바이트를 그대로 복사하는 명령입니다. SQLite가 어떤 잠금(lock)을 걸고 있는지, 지금 트랜잭션이 진행 중인지 같은 정보는 cp의 관심사가 아닙니다. SQLite는 기본 롤백 저널 모드에서는 파일명-journal 파일을, WAL 모드에서는 파일명-wal과 파일명-shm 파일을 메인 .db 파일과 함께 운용합니다.

WAL 모드로 운영 중인 DB라면 최근에 커밋된 변경 내용 중 일부가 메인 .db 파일에는 아직 반영되지 않고 -wal 파일에만 남아 있을 수 있습니다. 이 상태에서 메인 .db 파일만 cp로 복사하면, 복사된 파일은 최신 커밋이 빠진 과거 시점의 데이터베이스가 됩니다. -wal과 -shm까지 같이 복사해도 세 파일이 ‘같은 순간’에 찍힌 사진이어야 하는데, cp는 파일을 하나씩 순서대로 복사하므로 그 사이에 쓰기가 끼어들면 셋이 서로 어긋난 상태로 저장됩니다.

쓰기 도중 cp를 실행했을 때 벌어지는 일

cp는 파일을 처음부터 끝까지 순서대로 읽어갑니다. 그런데 그 사이에 다른 프로세스가 같은 .db 파일의 페이지를 덮어쓰고 있다면, cp가 읽는 시점에 따라 어떤 페이지는 변경 전 내용, 어떤 페이지는 변경 후 내용이 섞여서 복사됩니다. 이렇게 서로 다른 시점의 조각이 뒤섞인 복사본을 흔히 ‘torn read(찢어진 읽기)’라고 부릅니다.

SQLite 엔진 자신은 읽기 작업을 할 때 공유 잠금을 걸어서 이런 상황을 막습니다. 반면 cp는 이 잠금 체계에 전혀 참여하지 않는 외부 프로세스이기 때문에, 커널 입장에서는 그냥 평범한 read 호출의 연속일 뿐입니다. 결과물을 다시 열어보면 헤더는 정상인데 특정 테이블의 b-tree 구조가 깨져서 “database disk image is malformed” 같은 오류가 나는 경우가 바로 이 패턴입니다.

서버 하드 드라이브에 데이터베이스를 안전하게 백업하는 개념 이미지

VACUUM INTO로 깨지지 않는 백업 파일을 만들어보세요

VACUUM INTO는 SQLite 3.27.0(2019-02-07 릴리스)부터 추가된 SQL 문입니다. 현재 열려 있는 DB 내용을 하나의 읽기 트랜잭션 스냅샷으로 고정한 다음, 지정한 경로에 완전히 새로운 파일로 써 내려갑니다. 이 과정에서 결과 파일은 자동으로 VACUUM, 즉 조각모음까지 끝난 상태로 생성됩니다.

sqlite3 original.db "VACUUM INTO 'backup_20261012.db';"

명령이 끝나면 backup_20261012.db 하나만 있으면 되고, 별도의 -wal이나 -journal 파일을 신경 쓸 필요가 없습니다. 다만 대상 경로에 쓸 수 있는 디스크 여유 공간이 원본 DB 크기만큼 더 필요하고, 조각모음을 새로 하기 때문에 수십 GB짜리 DB에서는 단순 파일 복사보다 시간이 오래 걸릴 수 있습니다. 정확한 문법과 제약 사항은 VACUUM INTO 공식 문서에 정리돼 있습니다.

Backup API로 운영 중인 DB를 안전하게 복제하는 방법

VACUUM INTO가 한 번에 통째로 복사하는 방식이라면, SQLite의 온라인 백업 API는 페이지 단위로 조금씩 복사하면서 중간에 다른 작업에 양보할 수 있는 구조입니다. 운영 중인 서비스처럼 DB를 오래 잠가두면 안 되는 상황에 적합합니다. Python 표준 라이브러리 sqlite3 모듈은 3.7부터 Connection.backup() 메서드로 이 API를 그대로 노출합니다.

import sqlite3

src = sqlite3.connect("original.db")
dst = sqlite3.connect("backup.db")

def progress(status, remaining, total):
    print(f"{total - remaining}/{total} pages copied")

with dst:
    src.backup(dst, pages=50, progress=progress, sleep=0.1)

src.close()
dst.close()

터미널 명령어로 손상된 파일을 복구하는 장면

실행하면 아래와 비슷한 출력이 나옵니다.

50/120 pages copied
100/120 pages copied
120/120 pages copied

pages=50을 지정하면 50페이지를 복사할 때마다 잠깐 쉬었다가(sleep 간격만큼) 다음 묶음을 복사합니다. 이렇게 하면 긴 잠금 구간이 생기지 않아서 같은 DB에 쓰기 요청이 들어오는 서비스에서도 충돌 없이 백업을 진행할 수 있습니다. 세부 동작과 C API 원형은 공식 원자성이 보장되는 방식으로 설명돼 있는데, 쉽게 말해 복사 도중 원본에 쓰기가 들어와도 백업 쪽 트랜잭션이 일관된 상태를 유지하도록 SQLite가 내부적으로 재시도와 잠금을 처리해 준다는 뜻입니다.

방식 잠금 인지 WAL 반영 쓰기 중 실행 가능 여부 결과 파일
cp 전혀 모름 모름, 별도 처리 필요 위험(깨질 수 있음) 원본과 동일(손상 위험 포함)
VACUUM INTO SQLite가 트랜잭션으로 처리 체크포인트 후 반영 가능(스냅샷 기준) 조각모음 완료된 새 파일
Backup API 페이지 단위 잠금, 재시도 지원 체크포인트 포함 반영 가능(점진적 복사) 원본과 동일 구조의 복제본

그러면 cp를 써도 되는 경우는 없을까요?

있습니다. 애초에 DB 연결을 모두 닫아 두고, WAL 모드라면 PRAGMA wal_checkpoint(TRUNCATE);로 -wal 파일 내용을 메인 .db에 전부 합친 다음, 그 상태에서 아무도 접근하지 않는 동안 cp를 실행하면 결과물은 안전합니다. 배치 작업이 끝난 뒤 단일 프로세스로만 DB를 쓰는 환경, 예를 들어 정기 작업이 끝나고 다음 작업 시작 전까지 완전히 유휴 상태인 서버라면 이 조건을 맞추기 쉽습니다.

반대로 VACUUM INTO나 Backup API에도 트레이드오프는 있습니다. VACUUM INTO는 대상 파일이 이미 존재하면 실패하고, DB 크기가 클수록 조각모음 때문에 단순 복사보다 느립니다. Backup API는 코드를 직접 짜야 하고 pages와 sleep 값을 상황에 맞게 조정하지 않으면 오히려 백업이 끝나지 않고 계속 늘어지는 경우도 생깁니다. 결국 “완전히 멈춰 있는 DB”라는 전제를 확실히 보장할 수 있는지가 cp를 써도 되는지를 가르는 기준입니다.

운영 중인 서비스의 SQLite 파일이라면 cron으로 단순히 cp data.db backup.db를 돌리고 있던 설정을 먼저 점검해 보시길 권합니다. 지금 당장은 문제가 없어 보여도, 트래픽이 몰리는 시간에 백업 스크립트가 돌면 손상된 백업이 조용히 쌓이고, 정작 복구가 필요한 날 그 사실을 알게 됩니다. VACUUM INTO 한 줄로 교체하는 것만으로도 이 위험은 대부분 사라집니다.

행을 지웠는데 디스크가 그대로인 이유, 데드 튜플과 오토배큠 튜닝으로 해결하기

Leave a Comment