사용 중인 SQLite 파일을 backup API로 복사해 검산하기

SQLite 파일을 복사한 뒤 파일 크기만 확인하면, 다섯 행이 같은 키와 금액으로 들어갔는지 알 수 없습니다. 이 실습에서는 원본 연결을 열어 둔 채 Connection.backup()으로 새 DB를 만들고, 행 수 5개·주문 키 1001~1005·금액 합계 100000·integrity_check의 ok를 함께 확인합니다.

첨부는 독자가 새 연습 폴더에서 만드는 가상 주문 DB 전용입니다. 원본을 읽기 전용으로 열고 기존 출력은 덮어쓰지 않습니다. 실제 업무 DB를 받는 범용 백업 도구가 아니므로 다른 DB 경로를 넣지 마세요. 사이트나 서버의 전체 백업과는 별개인 작은 로컬 Python 실습입니다.

파일 복사와 SQLite 백업의 차이부터 확인하세요

사용 중인 DB는 파일 이름 하나만으로 상태를 설명하기 어렵습니다. WAL 모드에서는 커밋된 내용이 -wal 파일에도 있을 수 있습니다. 메인 .db 파일만 shutil.copy()로 복사했다고 일관된 백업이 보장되지는 않습니다. SQLite WAL 문서는 WAL 파일이 DB의 지속 상태에 포함된다는 점을 설명합니다.

Python sqlite3 공식 문서의 backup()은 대상 Connection으로 DB 내용을 복사하는 API입니다. pages는 한 단계에서 복사할 페이지 수, progress는 상태·남은 페이지·전체 페이지를 받는 콜백입니다. pages=1은 한 번 호출할 때 DB 전체에서 한 페이지만 백업한다는 뜻이 아니라, 내부 반복의 각 단계 크기입니다.

이 글의 정상 시험은 DELETE 저널 모드, 다른 쓰기 작업이 없는 가상 DB입니다. 원본 연결을 유지했다는 사실과 동시에 다른 프로그램이 내용을 썼다는 사실은 다릅니다. 동시 writer의 변경 반영이나 WAL 백업을 실제 시험했다고 읽으면 안 됩니다.

기존 출력 파일이 있으면 중단한 뒤 SQLite 원본 DB를 새 backup.db로 복사하고 행 수·키·합계 일치와 integrity_check ok를 확인하는 복사·검산·확정 도해
설명용 도해. 기존 출력 파일이 있으면 중단하고, 새 파일 복사 후 행 수·키·합계 일치 및 integrity_check = ok를 확인한다. 예제 데이터 검산 절차를 요약한 이미지다. 그림을 누르면 원본 크기로 볼 수 있습니다.

준비 환경과 완료 기준

Python 3.12 이상과 sqlite3 표준 모듈을 사용합니다. 외부 패키지를 설치하지 않습니다. 작업 폴더는 동기화·공유 중이지 않은 개인 로컬 연습 폴더를 권합니다. 이 실습의 최종 확정 단계는 같은 파일시스템의 하드링크 생성을 이용해 기존 파일을 덮어쓰지 않습니다. 하드링크를 지원하지 않는 위치에서는 안전하게 실패하며 다른 복사 방식으로 자동 전환하지 않습니다.

명령의 python은 이 글을 실행할 Python을 가리킵니다. 설치 환경에서 python3을 쓰고 있다면 아래 모든 명령에서 같은 이름을 사용하세요. Windows의 py -3을 사용하는 경우에도 전체 명령을 같은 인터프리터로 실행해야 합니다. 실제 검수 환경은 아래에 별도로 적었으며 다른 OS에서 성공했다고 단정하지 않습니다.

완료는 파일이 생긴 것만으로 판단하지 않습니다. 원본과 대상의 정렬된 전체 5행이 같아야 하고, 키·합계·구조 검사도 통과해야 합니다. 원본 파일 SHA-256의 전후 일치는 다른 writer가 없는 이 작은 실습에서만 원본 바이트 보존을 확인하는 항목입니다. 운영 중인 DB의 메인 파일 해시만으로 전체 DB 상태를 증명할 수는 없습니다.

1. ZIP을 풀고 가상 원본을 새로 만들기

P52 SQLite 백업 검산 실습 ZIP 다운로드

ZIP SHA-256: 53d351dbd1004b05e75c0005da0875e71c4b782fa953c9e7b04f736ec61be9aa

ZIP에는 create_fixture.py, sqlite_backup.py, 자동 시험, 같은 가상 입력·기대 JSON·실제 검수 로그·README가 있습니다. DB에는 reader-a 같은 가상 식별자와 계산용 정수가 들어 있습니다. amount_cents는 소수 계산을 피하려고 쓴 정수 열 이름이며 실제 매출이나 통화 가격을 뜻하지 않습니다.

압축을 새 폴더에 풀고 그 폴더에서 다음을 실행합니다. demo 폴더나 source.db가 이미 있으면 먼저 기존 파일을 보존한 채 다른 새 연습 폴더를 선택하세요.

python --version
python -c "import sqlite3; print(sqlite3.sqlite_version)"
mkdir demo
python create_fixture.py demo/source.db

생성 스크립트는 이 실습의 application_id와 user_version을 붙이고 커밋한 뒤 연결을 닫습니다. 이 표시와 테이블·전체 행 내용은 백업 스크립트에서 다시 검사합니다. 표시 숫자만 같다고 임의의 DB를 허용하지 않습니다.

실습 orders의 가상 5행
order_idcustomeramount_cents
1001reader-a12000
1002reader-b23500
1003reader-c8000
1004reader-d41000
1005reader-e15500

다섯 금액 12000+23500+8000+41000+15500의 합계는 100000입니다. 행 수가 5여도 키나 금액이 달라질 수 있으므로 전체 행 대조를 생략하지 않습니다.

2. 원본 연결을 유지하며 새 DB로 백업하기

python sqlite_backup.py demo/source.db demo/backup.db

성공 종료 코드는 0입니다. JSON에서 status=ok, rows=5, keys=[1001,1002,1003,1004,1005], amount_cents_sum=100000, integrity_check=["ok"], source_sha256_unchanged=true를 확인합니다. scope는 synthetic_fixture_no_concurrent_writer로 표시됩니다. progress 호출 수나 페이지 수는 파일·엔진 조건에 따라 달라질 수 있어 이 값 자체를 정답으로 외우지 않습니다.

{
  "status": "ok",
  "source_file": "source.db",
  "backup_file": "backup.db",
  "rows": 5,
  "keys": [1001, 1002, 1003, 1004, 1005],
  "amount_cents_sum": 100000,
  "integrity_check": ["ok"],
  "journal_mode": "delete",
  "source_sha256_unchanged": true,
  "scope": "synthetic_fixture_no_concurrent_writer"
}

위 JSON은 주요 안정 필드의 기대값입니다. 실제 출력에는 원본 전후 SHA와 backup_progress가 추가됩니다.

코드는 읽기 전용 URI mode=ro로 source.db를 엽니다. 파일이 없는 오타 경로에 빈 DB가 만들어지는 일을 피합니다. isolation_level=None 상태에서 BEGIN과 첫 조회로 읽기 스냅샷을 정한 뒤, 같은 열린 원본 연결로 backup()을 호출합니다. 검수된 새 파일을 닫기 전까지 원본 연결도 닫지 않습니다.

연결을 with 문으로 감싼 것만으로 close()가 실행되는 것은 아닙니다. 연결의 with 문은 트랜잭션 처리에 관한 기능이므로 이 코드는 finally에서 원본과 대상 모두 명시적으로 닫습니다. 오류가 나도 열린 연결과 읽기 잠금이 남지 않도록 하기 위한 단계입니다.

3. 복사와 검산을 통과한 파일만 확정하기

작업은 최종 backup.db를 바로 열어 쓰지 않습니다. 출력 폴더 안에 이번 실행만 소유하는 임시 디렉터리를 만들고 그 안의 copy.db에 백업합니다. 검산을 통과하고 연결을 닫은 다음, 최종 경로가 여전히 새 경로일 때만 확정합니다.

확정에는 os.link()를 사용합니다. 대상이 그사이 생겼으면 실패하며 덮어쓰지 않습니다. os.replace()로 기존 백업을 바꾸는 방법은 이 실습의 출력 보존 정책에 맞지 않습니다. 같은 폴더의 파일이나 sidecar를 임의로 지우지도 않습니다. 실패한 실행에서 정리하는 것은 그 실행이 만든 전용 임시 디렉터리뿐입니다.

파일 fsync를 호출하지만 전원 장애 이후의 디렉터리 엔트리 유지까지 보장하는 운영 백업 설계는 아닙니다. 신뢰할 수 있는 개인 폴더, 작고 알려진 입력, 다른 writer 없음이 전제입니다. 악의적인 다른 프로세스가 경로와 sidecar를 계속 바꾸는 환경의 경쟁 상태까지 해결하는 도구로 쓰지 마세요.

4. 기존 출력이 있으면 실패하는지 직접 확인하기

정상 백업을 만든 뒤 같은 명령을 한 번 더 실행해 보세요.

python sqlite_backup.py demo/source.db demo/backup.db

두 번째 실행은 종료 코드 1과 status=error로 끝나야 합니다. FileExistsError와 refusing existing target or sidecar 메시지가 표시되며 첫 backup.db는 유지됩니다. 새 실습을 하려면 backup-2.db처럼 아직 없는 경로를 선택하세요. 이 글은 기존 백업을 삭제하거나 바꾸는 명령을 제공하지 않습니다.

출력의 -journal·-wal·-shm 이름이 이미 있는 경우, 끊어진 심볼릭 링크, 원본과 같은 경로, 원본을 가리키는 하드링크도 거부합니다. 업무 파일로 이 반례를 만들 필요는 없습니다. 제공 자동 시험이 임시 연습 폴더에서 이 조건들을 생성하고 원본·기존 파일 바이트를 비교합니다.

python -m unittest -v test_sqlite_backup

잠금과 재시도는 언제까지 기다릴까요?

SQLite의 Online Backup API는 단계 사이에 원본 변경을 만나면 다시 시작할 수 있습니다. 계속되는 쓰기 때문에 진행이 늦어질 수 있어, 호출했다고 반드시 짧은 시간 안에 끝나는 것은 아닙니다. SQLite Backup API 설명과 backup_step 동작에 잠금·재시작·반환 상태의 범위가 정리되어 있습니다.

이 예제는 snapshot 조회와 backup 사이에 다른 writer가 끼어드는 상황을 다루는 대신, 명시적 읽기 트랜잭션을 유지합니다. DELETE 모드에서 다른 writer의 커밋은 이 읽기 트랜잭션이 끝날 때까지 기다리거나 잠금 오류를 만날 수 있습니다. 따라서 실제 앱의 쓰기를 지연시키지 않는 무중단 백업으로 소개하지 않습니다. 운영 백업에는 앱의 쓰기 제어, 짧은 작업 구간과 별도 복구 검증이 필요합니다.

기본 기한은 5초입니다. progress 콜백은 SQLITE_BUSY·SQLITE_LOCKED 상태를 포함해 반복마다 단조 시계를 검사하고, 기한을 만나면 예외로 백업을 중단합니다. pages=1, sleep=0.05를 쓰며 connect의 짧은 timeout도 둡니다. connect(timeout=…)만 설정하면 backup 전체 호출의 재시도 상한이 되는 것으로 오해하지 마세요.

–deadline-seconds는 0보다 크고 60 이하인 유한한 값만 허용합니다. 아래는 기본값을 명시한 같은 명령입니다.

python sqlite_backup.py demo/source.db demo/backup-2.db --deadline-seconds 5

이 기한은 검사 지점에서 관측하는 best-effort 제한입니다. 한 번의 파일 읽기·fsync·OS 호출이 오래 멈추면 정확히 5초에서 강제 종료하는 장치가 아닙니다. SQL 조회의 progress handler도 별도로 두지만 하드 실시간 제한이 되지는 않습니다. 강제 중단이 필요한 환경은 별도 프로세스 감독·정리 정책을 설계해야 합니다.

용량 부족과 검산 오류가 나면

입력은 1MiB 이하의 이 실습 fixture로 제한하고, 출력 폴더의 여유 공간도 사전에 확인합니다. 그러나 사전 확인 뒤 다른 프로그램이 공간을 사용할 수 있고 파일시스템 할당량·권한·I/O 오류는 따로 발생할 수 있습니다. 미리 확인했다고 디스크 부족이 불가능해지는 것은 아닙니다. 확정 전 복사·검산 단계에서 SQLITE_FULL이나 OSError가 나오면 새 파일을 확정하지 않고 기존 파일을 보존합니다.

확정 후 임시 디렉터리 정리에서 오류가 나면 종료 코드 1이어도 이미 검산된 새 출력 파일과 임시 디렉터리가 남을 수 있습니다. 오류 메시지와 최종 경로를 확인하고 파일을 바로 지우지 마세요. 기존 파일 보존, 새 출력 확정, 임시 파일 정리의 상태를 구별해야 합니다. 이 후반 정리 오류는 검수에서 모의 예외로 시험했으며 실제 디스크를 가득 채워 시험한 결과는 아닙니다.

source.db를 찾지 못하면 경로와 현재 폴더를 확인하세요. 내용이 다르거나 application_id·모드 검사가 실패하면 제공 생성 스크립트로 새 가상 원본을 만듭니다. 임의의 DB에 표시값을 붙여 검사를 통과시키려 하지 마세요. 잠금은 다른 연습 프로세스가 끝났는지 먼저 확인하고, 기다림이 필요한 이유를 모른 채 재시도를 무한 반복하지 않습니다.

integrity_check의 ok는 DB 내부 구조 검사입니다. 올바른 주문이 들어 있다는 업무 검산까지 대신하지 않습니다. 이 예제는 그 이유로 전체 5행과 키·합계를 별도로 대조합니다. 외래키 위반은 integrity_check에서 확인하지 않는 항목이며, 외래키가 있는 다른 설계에는 foreign_key_check가 필요합니다. 이 fixture에는 외래키가 없습니다. SQLite integrity_check 문서

실제 검수 기록과 사용 범위

Linux Python 3.12.14 / SQLite 3.53.1에서 새 압축해제본의 fixture 생성과 CLI 백업, 제공 자동 시험 24개를 실행해 모두 통과했습니다. 정상 결과는 5행·키1001~1005·합계100000·integrity_check=["ok"]이며 원본 전후 SHA-256도 같았습니다. 별도 실제 SQLITE_BUSY 반례는 12회 콜백 후 약0.122초에 DeadlineExceeded로 중단했고 데이터 쓰기를 커밋하지 않았습니다. 기존 출력·sidecar·동일 경로·심볼릭 링크·하드링크·손상/누락/다른 DB 거부와 원본 보존도 시험했습니다. 공개 ZIP 다운로드와 별도 독립 검수 결과는 게시 검수 자료에 기록합니다.

실제 조사·실행 검수일은 2026-10-10 KST입니다. 글의 표시일과 실제 검수일은 다릅니다. 정상 실습은 열린 연결의 backup API 복사와 출력 검산을 확인했으며, 별도의 잠금 반례는 중단·보존 동작을 확인한 것입니다. 동시 writer가 바뀐 데이터를 백업에 반영하는 시험이나 WAL 시험, Windows/macOS 실행, 업무 DB 복원·애플리케이션 재가동은 수행하지 않았습니다.

일반 파일을 VBA로 보존하는 절차가 필요하면 FileSystemObject 파일 백업·검산 글을, CSV 적재의 성공·실패 되돌리기는 두 CSV와 외래키 트랜잭션 글을 참고하세요. 조회 결과를 CSV로 나누어 내보내는 방법은 fetchmany 결과 저장·ID 검산 글에서 다룹니다. 이 글의 결과물은 CSV가 아니라 검산된 새 SQLite DB입니다.

sqlite_backup.py 전체 코드

아래 전체 코드는 ZIP의 sqlite_backup.py와 동일합니다. 생성 코드·시험·기대 결과는 ZIP에 함께 있습니다. 긴 코드의 오른쪽 끝은 코드 영역 안에서 가로 스크롤해 확인하세요.

sqlite_backup.py 전체 코드 펼치기
#!/usr/bin/env python3
"""Back up only the supplied P52 synthetic fixture, without overwriting anything."""
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
import shutil
import sqlite3
import sys
import tempfile
import time

APPLICATION_ID = 0x50523532
MAX_BYTES = 1024 * 1024
FIXTURE_SCHEMA_SQL = (
    'CREATE TABLE orders(order_id INTEGER PRIMARY KEY, customer TEXT NOT NULL, '
    'amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0))'
)
FIXTURE_COLUMNS = [
    (0, 'order_id', 'INTEGER', 0, None, 1, 0),
    (1, 'customer', 'TEXT', 1, None, 0, 0),
    (2, 'amount_cents', 'INTEGER', 1, None, 0, 0),
]
ROWS = [(1001, 'reader-a', 12000), (1002, 'reader-b', 23500),
        (1003, 'reader-c', 8000), (1004, 'reader-d', 41000),
        (1005, 'reader-e', 15500)]

class DeadlineExceeded(TimeoutError):
    """Cooperative deadline observed at a check, not an OS wall-clock cutoff."""

class DeadlineProgress:
    def __init__(self, deadline):
        self.deadline = deadline
        self.calls = 0
        self.busy_calls = 0
        self.last_status = None
        self.total_pages = 0
    def check(self):
        if time.monotonic() >= self.deadline:
            raise DeadlineExceeded('best-effort deadline exceeded; no backup published')
    def __call__(self, status, remaining, total):
        self.calls += 1
        self.last_status = status
        self.total_pages = total
        if status in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED):
            self.busy_calls += 1
        self.check()  # Raising exits Python's backup retry loop, including BUSY.

def sha256(path, budget):
    digest = hashlib.sha256()
    with path.open('rb') as stream:
        while True:
            budget.check()
            chunk = stream.read(65536)
            if not chunk:
                return digest.hexdigest()
            digest.update(chunk)

def new_target(path):
    """Resolve the parent only; lexists must see even a dangling leaf symlink."""
    path = Path(path).expanduser()
    parent = path.parent.resolve(strict=True)
    if not parent.is_dir():
        raise NotADirectoryError(str(parent))
    target = parent / path.name
    for candidate in (target, *(Path(str(target)+s) for s in ('-journal', '-wal', '-shm'))):
        if os.path.lexists(candidate):
            raise FileExistsError(f'refusing existing target or sidecar: {candidate}')
    return target

def verify_fixture(con):
    if con.execute('PRAGMA application_id').fetchone()[0] != APPLICATION_ID:
        raise ValueError('only the P52 synthetic fixture is allowed')
    if con.execute('PRAGMA user_version').fetchone()[0] != 1:
        raise ValueError('unsupported fixture version')
    if con.execute('PRAGMA journal_mode').fetchone()[0] != 'delete':
        raise ValueError('this exercise accepts rollback-journal DELETE mode only')
    # Exact complete allow-list, with no LIKE/prefix filtering. This fixture has
    # no internal tables, views, indexes, triggers or hidden/unchecked columns.
    objects = con.execute('SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name').fetchall()
    if objects != [('table', 'orders', 'orders', FIXTURE_SCHEMA_SQL)]:
        raise ValueError('unexpected complete schema or constraints; use the supplied fixture')
    if con.execute('PRAGMA table_xinfo(orders)').fetchall() != FIXTURE_COLUMNS:
        raise ValueError('unexpected columns; use the supplied synthetic fixture')
    rows = con.execute('SELECT order_id, customer, amount_cents FROM orders ORDER BY order_id').fetchall()
    if rows != ROWS:
        raise ValueError('fixture rows differ from the expected five synthetic rows')
    count, total = con.execute('SELECT COUNT(*), SUM(amount_cents) FROM orders').fetchone()
    keys = [row[0] for row in rows]
    integrity = [row[0] for row in con.execute('PRAGMA integrity_check')]
    if count != 5 or total != 100000 or integrity != ['ok']:
        raise ValueError('row/sum/integrity verification failed')
    return {'rows': count, 'keys': keys, 'amount_cents_sum': total,
            'integrity_check': integrity, 'journal_mode': 'delete'}

def backup_verified(source, output, *, deadline_seconds=5.0, pages=1, sleep=0.05):
    if not math.isfinite(deadline_seconds) or not 0 < deadline_seconds <= 60:
        raise ValueError('deadline must be finite and in (0, 60] seconds')
    if not isinstance(pages, int) or not 1 <= pages <= 64:
        raise ValueError('pages must be an integer from 1 to 64')
    if not math.isfinite(sleep) or not 0 < sleep <= 0.25:
        raise ValueError('sleep must be finite and in (0, 0.25] seconds')
    budget = DeadlineProgress(time.monotonic() + deadline_seconds)
    source = Path(source).expanduser().resolve(strict=True)
    if not source.is_file() or source.stat().st_size > MAX_BYTES:
        raise ValueError('source must be a regular fixture file no larger than 1 MiB')
    output = new_target(output)  # Refuses files, same path, hardlinks and symlinks.
    if output == source:
        raise ValueError('source and output must differ')
    if shutil.disk_usage(output.parent).free < 3 * source.stat().st_size + MAX_BYTES:
        raise OSError('insufficient free space for this small exercise')
    before = sha256(source, budget)
    src = dst = None
    with tempfile.TemporaryDirectory(prefix='.p52-work-', dir=output.parent) as work:
        temporary = Path(work)/'copy.db'
        try:
            # No immutable=1: ordinary SQLite locks and the journal still apply.
            src = sqlite3.connect(source.as_uri()+'?mode=ro', uri=True,
                                  timeout=min(0.05, deadline_seconds), isolation_level=None)
            src.set_progress_handler(lambda: time.monotonic() >= budget.deadline, 1000)
            src.execute('BEGIN')
            source_check = verify_fixture(src)  # Establishes the read snapshot.
            budget.check()
            dst = sqlite3.connect(temporary, timeout=min(0.05, deadline_seconds), isolation_level=None)
            dst.set_progress_handler(lambda: time.monotonic() >= budget.deadline, 1000)
            # Source connection stays OPEN throughout Connection.backup().
            src.backup(dst, pages=pages, progress=budget, sleep=sleep)
            budget.check()
            backup_check = verify_fixture(dst)
            if backup_check != source_check:
                raise ValueError('source snapshot and copied database differ')
            after = sha256(source, budget)
            if after != before:
                raise ValueError('source SHA changed; no-writer fixture precondition violated')
        finally:
            # `with connection:` commits/rolls back; it does NOT close connections.
            if dst is not None:
                dst.close()
            if src is not None:
                src.close()  # Also releases the explicit read transaction.
        budget.check()
        # Flush the completed new file before publishing; no durability guarantee.
        with temporary.open('rb') as stream:
            os.fsync(stream.fileno())
        new_target(output)  # Recheck sidecars before publication.
        budget.check()
        # Same-filesystem atomic no-clobber: if target appeared, link fails.
        # Unsupported hard links fail safely; there is no replace/copy fallback.
        os.link(temporary, output)
        # TemporaryDirectory removes only this invocation's private work directory.
    return {'status': 'ok', 'source_file': source.name, 'backup_file': output.name,
            **backup_check, 'source_sha256_before': before,
            'source_sha256_after': after, 'source_sha256_unchanged': True,
            'backup_progress': {'calls': budget.calls, 'busy_calls': budget.busy_calls,
                                'last_status': budget.last_status,
                                'total_pages': budget.total_pages},
            'scope': 'synthetic_fixture_no_concurrent_writer'}

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('source', help='synthetic source.db made by create_fixture.py')
    parser.add_argument('output', help='a NEW backup filename in an existing trusted folder')
    parser.add_argument('--deadline-seconds', type=float, default=5.0)
    args = parser.parse_args()
    try:
        result = backup_verified(args.source, args.output, deadline_seconds=args.deadline_seconds)
    except (OSError, ValueError, sqlite3.Error) as error:
        print(json.dumps({'status':'error', 'error_type':type(error).__name__, 'message':str(error)}, ensure_ascii=False), file=sys.stderr)
        return 1
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0

if __name__ == '__main__':
    raise SystemExit(main())

작성자 소개