백업 작업은 성공으로 표시됐는데 복구해 보니 최근 레코드가 빠져 있는 경우가 있습니다. SQLite WAL 모드를 사용하는 파일 서비스에서는 이런 장애의 원인을 잘못 판단하기 쉽습니다. 파일 복사가 성공했다는 것은 바이트 전송이 끝났다는 뜻일 뿐, 데이터베이스의 일관성까지 보장하지는 않습니다. 파일 인덱스, 작업 상태, 백업 목록을 계속 기록하는 물리 서버라면 ‘사본 생성’과 ‘사본을 실제로 사용할 수 있는지 검증’을 별도 단계로 나눠야 합니다.
1. WAL과 백업의 역할부터 구분하기
WAL 모드에서는 커밋된 변경 사항이 먼저 -wal 파일에 저장되고, 이후 체크포인트를 거쳐 데이터베이스 본 파일에 반영될 수 있습니다. 서비스가 실행 중인 상태에서 cp service.db backup.db를 실행하면 이 변경 사항이 누락될 수 있습니다. 본 파일, WAL 파일, 공유 메모리 파일을 순서대로 복사하더라도 동일한 시점의 스냅샷이 만들어지는 것은 아닙니다.
온라인 백업 인터페이스는 계속 바뀌는 파일을 그대로 복사하는 대신, 데이터베이스 연결을 통해 일관된 내용을 읽습니다. 쓰기 작업을 중단할 수 없는 서비스에 적합하지만, 읽기와 대상 저장소 쓰기에 따른 부하는 여전히 발생합니다. 모든 연결을 닫을 수 있다면 오프라인 백업이 더 간단합니다. 다만 쓰기 중단 범위에는 백그라운드 작업도 포함돼야 하며, 사용자 요청만 멈춰서는 안 됩니다.
WAL은 원격지 백업이 아니며, 미러링도 백업이 아닙니다. 신뢰할 수 있는 사본은 원본 데이터베이스와 같은 장애에 함께 영향을 받지 않는 범위에 보관하고, 독립적인 읽기 검증을 거쳐야 합니다.
OnuCloud 물리 서버에는 1TB NVMe 2개가 기본으로 제공되며, 각각 직접 연결되고 하드웨어 RAID는 구성되어 있지 않습니다. 다른 드라이브에 사본을 쓰면 읽기·쓰기 부하를 일부 분산할 수 있습니다. 하지만 두 드라이브 모두 같은 서버 안에 있으므로 원격지 보관을 대신할 수는 없습니다.
2. 지속적으로 쓰는 재현 가능한 테스트 데이터 만들기
다음 실험은 Debian 12처럼 Python 3와 표준 라이브러리 모듈 sqlite3를 사용할 수 있는 환경에 적용할 수 있습니다. 먼저 모듈을 확인하고 별도의 임시 디렉터리를 만듭니다. 모든 작업은 실험용 파일만 대상으로 하므로 운영 데이터베이스에 접근하지 않으며, 시스템 서비스도 수정할 필요가 없습니다.
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
umask 077
export LAB_DIR="$(mktemp -d)"
printf '%s\n' "$LAB_DIR"
테스트 데이터는 두 테이블로 구성합니다. events는 상세 내역을, totals는 누적값을 저장합니다. 각 트랜잭션에서 상세 내역 한 건을 추가하는 동시에 누적값도 증가시키므로, 사본은 반드시 ‘상세 금액의 합계와 누적값이 같다’는 조건을 만족해야 합니다. 이 조건은 파일의 존재 여부만 확인하는 것보다 의미가 있으며, 구조적 무결성과 업무 데이터의 정확성이 서로 다른 검증 대상이라는 점도 보여 줍니다.
아래 스크립트에는 쓰기 스레드와 백업 과정이 함께 들어 있습니다. 디렉터리는 앞 단계에서 설정한 값을 사용합니다. 다시 시험할 때마다 디렉터리를 새로 만들어 이전 테스트 데이터와 섞이지 않게 해야 합니다. 운영 데이터베이스는 대개 더 복잡하므로 테이블 간 관계와 애플리케이션 버전 요구 사항을 별도로 정의해야 하며, 이 단순한 카운트 모델을 그대로 적용해서는 안 됩니다.
3. 온라인 인터페이스로 사본을 만들고 검증하기
스크립트는 먼저 한 차례 커밋이 완료될 때까지 기다린 뒤, 쓰기 스레드가 계속 동작하는 동안 온라인 백업을 실행합니다. 대상은 우선 임시 파일로 만들고, 무결성 검사와 외래 키 검사, 업무 불변 조건 검증을 모두 통과했을 때만 정식 실험 사본 이름으로 변경합니다. 어느 단계에서든 예외가 발생하면 스크립트는 실패하며 새 사본을 확정하지 않습니다.
python3 - <<'PY'
import os
import sqlite3
import threading
import time
from pathlib import Path
base = Path(os.environ["LAB_DIR"])
source = base / "source.db"
pending = base / "backup.pending.db"
published = base / "backup.db"
db = sqlite3.connect(source)
mode = db.execute("PRAGMA journal_mode=WAL").fetchone()[0]
if mode.lower() != "wal":
raise RuntimeError("WAL mode unavailable")
db.executescript("""
CREATE TABLE events (
n INTEGER PRIMARY KEY,
amount INTEGER NOT NULL
);
CREATE TABLE totals (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance INTEGER NOT NULL
);
INSERT INTO totals VALUES (1, 0);
""")
db.close()
stop = threading.Event()
ready = threading.Event()
errors = []
def writer():
conn = None
try:
conn = sqlite3.connect(source, timeout=5)
while not stop.is_set():
with conn:
conn.execute("INSERT INTO events(amount) VALUES (1)")
conn.execute(
"UPDATE totals SET balance = balance + 1 WHERE id = 1"
)
ready.set()
time.sleep(0.01)
except Exception as exc:
errors.append(exc)
ready.set()
finally:
if conn is not None:
conn.close()
worker = threading.Thread(target=writer)
worker.start()
try:
if not ready.wait(5):
raise RuntimeError("Writer did not become ready")
if errors:
raise RuntimeError("Writer failed") from errors[0]
src = sqlite3.connect(source, timeout=5)
dst = sqlite3.connect(pending, timeout=5)
try:
src.backup(dst, pages=128, sleep=0.01)
finally:
dst.close()
src.close()
finally:
stop.set()
worker.join()
if errors:
raise RuntimeError("Writer failed") from errors[0]
check = sqlite3.connect(pending)
try:
result = check.execute("PRAGMA integrity_check").fetchall()
if result != [("ok",)]:
raise RuntimeError("Integrity check failed")
if check.execute("PRAGMA foreign_key_check").fetchall():
raise RuntimeError("Foreign key check failed")
count, amount = check.execute(
"SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM events"
).fetchone()
balance = check.execute(
"SELECT balance FROM totals WHERE id = 1"
).fetchone()[0]
if not (count > 0 and count == amount == balance):
raise RuntimeError("Business invariant failed")
finally:
check.close()
os.replace(pending, published)
print(f"Verified rows: {count}")
print(published)
PY
pages=128은 한 번에 처리할 데이터베이스 페이지 수를 뜻하며, 바이트 수가 아닙니다. sleep은 주로 데이터베이스가 사용 중일 때 재시도하는 데 쓰이는 값이지, 처리량을 일정하게 제한하는 장치가 아닙니다. 운영 환경에서는 소요 시간과 실패 원인을 기록하고 작업 전체에 제한 시간을 설정해야 합니다. 높은 쓰기 부하가 지속되면 백업 시간이 길어질 수 있습니다. 제한 시간을 초과하면 실패로 처리해야 하며, 미완성 파일을 성공한 백업으로 표시해서는 안 됩니다.
여기서 이름을 바꾸는 것은 검증 전 후보 파일과 검증을 통과한 사본을 구분하기 위한 것이며, 정전 시 데이터가 영구 저장되어 있음을 보장하지는 않습니다. 실제 백업 절차에서는 파일 동기화, 디렉터리 동기화, 대상 저장소의 동작 특성을 함께 고려해 영속성 보장 범위를 설계해야 합니다.
4. 원본이 아닌 사본을 대상으로 격리 복구 검증하기
백업이 끝난 뒤에도 원본 데이터베이스에는 쓰기가 계속됐을 수 있습니다. 따라서 사본의 행 수가 나중에 조회한 원본의 행 수와 같아야 한다고 요구해서는 안 됩니다. 사본 자체의 일관성을 먼저 확인하고, 백업이 수행된 시간 구간을 기준으로 데이터의 최신성을 판단해야 합니다. 온라인 백업은 지속적인 복제가 아니며, 임의 시점으로 복구할 수 있는 기능을 자동으로 제공하지도 않습니다.
확정된 사본에서 복구 검증용 작업 사본 만들기
실험 사본은 이미 연결이 닫힌 상태이므로 다른 디렉터리로 오프라인 복사할 수 있습니다. 아래에서는 복구 검증용 작업 사본을 읽기 전용으로 열어 테이블 구조와 주요 관계를 확인합니다. 복구 테스트 연결이 source.db를 가리키지 않도록 주의해야 합니다. 그렇게 하면 사본이 아니라 원본 데이터베이스를 검증하게 됩니다.
mkdir "$LAB_DIR/restore"
cp "$LAB_DIR/backup.db" "$LAB_DIR/restore/service.db"
python3 - <<'PY'
import os
import sqlite3
from pathlib import Path
path = Path(os.environ["LAB_DIR"]) / "restore" / "service.db"
conn = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
try:
print(conn.execute("PRAGMA integrity_check").fetchall())
print(conn.execute("PRAGMA foreign_key_check").fetchall())
print(conn.execute("""
SELECT
(SELECT COALESCE(SUM(amount), 0) FROM events),
(SELECT balance FROM totals WHERE id = 1)
""").fetchone())
finally:
conn.close()
PY
통과 조건은 무결성 검사 결과가 ok이고, 외래 키 검사에서 반환된 행이 없으며, 두 누적값이 같은 것입니다. 이 테스트 데이터에는 외래 키가 없으므로 외래 키 검사 결과가 비어 있다는 사실은 검사 절차를 실행할 수 있다는 점만 보여 줍니다. 실제 업무 데이터를 검증할 때는 실제 제약 조건을 유지한 상태에서 검사해야 합니다.
운영 환경의 인수 검증에서는 버전이 맞는 애플리케이션으로 작업 사본을 열고 파일 인덱스나 작업 목록도 읽어야 합니다. 이때 메일 발송, 콜백, 자동 실행 작업은 비활성화해야 합니다. 데이터베이스가 열린다고 해서 외부 파일까지 모두 갖춰져 있다는 뜻은 아닙니다.
5. 일회성 실험을 백업 승인 기준으로 정착시키기
운영에 적용하기 전에는 최소한 네 가지 증빙을 남겨야 합니다. 원본 데이터베이스 경로와 애플리케이션 버전, 백업 시작·종료 시각, 검사 결과, 격리된 환경에서의 읽기 검증 결과입니다. 시간 정보는 데이터의 최신성을 판단하기 위한 것이며, 정확한 트랜잭션 경계 시점으로 해석해서는 안 됩니다. 명확한 복구 시점 요구 사항이 있는 업무라면 커밋 순번과 복구 목표를 별도로 설계해야 합니다.
백업 디렉터리는 접근 권한을 제한해 데이터베이스의 토큰, 사용자 정보, 작업 매개변수가 사본을 통해 노출되지 않도록 해야 합니다. 사본을 원격지로 전송한 뒤에는 파일 체크섬을 대조하고 복구 테스트도 다시 수행해야 합니다. 체크섬 일치는 전송 과정의 무결성만 입증할 뿐, 데이터베이스 인수 검증을 대신하지는 못합니다.
실패한 경우에는 마지막으로 검증을 마친 사본을 유지하고 후보 파일은 별도로 처리해야 합니다. 성공한 백업을 빈 파일로 덮어써서는 안 됩니다. 데이터베이스가 첨부 파일, 인덱스 샤드, 다른 데이터베이스를 참조한다면 단일 데이터베이스의 온라인 백업만으로는 여러 리소스에 걸친 원자성을 보장할 수 없습니다. 애플리케이션의 쓰기를 중단하거나 버전 매니페스트로 각 리소스의 상태를 조율해야 합니다.
최종 승인 기준은 ‘복사 명령이 성공했다’가 아니라 ‘사본이 독립적으로 존재하고, 구조 검사를 통과했으며, 업무 관계가 성립하고, 애플리케이션이 격리된 환경에서 읽을 수 있다’여야 합니다. 이 네 조건을 모두 충족해야 백업에 검증 가능한 복구 가치가 생깁니다.
자주 묻는 질문
WAL 모드에서 주 데이터베이스 파일만 복사해도 되나요?
신뢰할 수 있는 온라인 백업 방식이 아닙니다. 커밋된 트랜잭션이 WAL에 남아 있을 수 있고, 파일을 따로 복사하면 같은 시점의 상태라는 보장이 없습니다. 온라인 백업 API를 사용하거나 모든 연결을 닫은 뒤 오프라인으로 복사해야 합니다.
integrity_check가 ok이면 복구 검증이 끝나나요?
아닙니다. 내부 구조 검사 외에 foreign_key_check를 별도로 실행하고, 격리된 사본에서 주요 테이블과 업무 불변 조건, 애플리케이션의 읽기 경로까지 확인해야 합니다.
AMD EPYC 7543P 전용 서버
월 $472부터