클라우드 Mac에서 검증하는 iOS SQLite 온라인 백업

클라우드 Mac에서 검증하는 iOS SQLite 온라인 백업

클라우드 Mac에서 검증하는 iOS SQLite 온라인 백업

고객이 납품용 앱에 “데이터베이스 내보내기” 기능을 추가하되, 주문 입력은 중단하지 말아 달라고 요청했다. 개발팀은 클라우드 Mac에서 파일이 정상적으로 다운로드되는 것을 확인했지만, 복원해 보니 일부 레코드가 빠져 있었다. 원인은 전송이 아니라 사용 중인 SQLite 주 데이터베이스 파일을 그대로 복사한 데 있을 수 있다. 먼저 일관성을 보장하는 백업 방식을 선택하고, 복원까지 검증해야 한다. “복사 성공”만으로 납품 검증을 마쳐서는 안 된다.

사용 중인 파일을 복사하지 말고 백업 API부터 선택하기

온라인 내보내기에는 sqlite3_backup이 적합하다. 데이터베이스 연결을 통해 원본을 읽어 대상 데이터베이스에 기록하며, 페이지를 나누어 복사할 수도 있다. 반면 사용 중인 데이터베이스에 일반 파일 복사를 적용하는 방식은 적합하지 않다. 특히 원본이 WAL 모드를 사용하면 이미 커밋된 내용이 로그 파일에 남아 있을 수 있다.

주 파일과 그 옆의 WAL, SHM 파일을 차례로 복사해도 세 파일이 같은 시점의 상태라는 보장은 없다. 클라우드 Mac에서 수행하는 명령줄 실험은 데이터베이스의 동작을 검증하기 위한 것이다. iOS 앱에 통합할 때는 앱에서 이미 사용 중인 데이터베이스 경로와 연결 관리 방식을 따라야 한다.

온라인 백업이 보장하는 것은 데이터베이스의 일관성이지, 업무 처리의 완료가 아니다. “주문과 상세 항목 추가”가 여러 트랜잭션에 걸쳐 이루어진다면, 두 트랜잭션 사이의 상태가 백업될 수 있다.

따라서 먼저 납품 요구사항을 명확히 해야 한다. 일관성만 충족하는 임의의 상태가 필요한지, 특정 배치가 완료된 뒤의 상태가 필요한지 구분해야 한다. 후자라면 복사 시점을 추측하지 말고, 업무 처리 배리어와 명시적인 스냅샷 전략을 함께 사용해야 한다.

Swift로 실행 시간에 상한이 있는 백업 도구 만들기

다음 예제는 main 데이터베이스만 백업한다. 대상 파일은 작업 전용 디렉터리에 두며, 실행 전에 존재해서는 안 된다. 원본 파일은 이미 존재해야 한다. 코드를 backup.swift로 저장한 뒤, 명령줄 도구가 설치된 클라우드 Mac에서 실행한다.

import Foundation
import SQLite3

enum BackupFailure: Error {
    case invalidArguments
    case sqlite(Int32)
    case timeout
}

func openDB(_ path: String, flags: Int32) throws -> OpaquePointer {
    var db: OpaquePointer?
    let rc = sqlite3_open_v2(path, &db, flags, nil)
    guard rc == SQLITE_OK, let handle = db else {
        if let db = db { sqlite3_close(db) }
        throw BackupFailure.sqlite(rc)
    }
    return handle
}

func backup(source: String, target: String) throws {
    guard !FileManager.default.fileExists(atPath: target) else {
        throw BackupFailure.invalidArguments
    }
    let src = try openDB(source, flags: SQLITE_OPEN_READONLY)
    defer { sqlite3_close(src) }
    let dst = try openDB(
        target,
        flags: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE
    )
    var complete = false
    defer {
        sqlite3_close(dst)
        if !complete {
            try? FileManager.default.removeItem(atPath: target)
        }
    }
    guard let job = sqlite3_backup_init(dst, "main", src, "main") else {
        throw BackupFailure.sqlite(sqlite3_errcode(dst))
    }
    let deadline = ProcessInfo.processInfo.systemUptime + 10
    var rc: Int32 = SQLITE_OK
    repeat {
        if ProcessInfo.processInfo.systemUptime >= deadline {
            rc = SQLITE_INTERRUPT
            break
        }
        rc = sqlite3_backup_step(job, 64)
        if rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED {
            Thread.sleep(forTimeInterval: 0.02)
        }
    } while rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED
    let finish = sqlite3_backup_finish(job)
    guard rc == SQLITE_DONE else {
        if rc == SQLITE_INTERRUPT { throw BackupFailure.timeout }
        throw BackupFailure.sqlite(rc)
    }
    guard finish == SQLITE_OK else {
        throw BackupFailure.sqlite(finish)
    }
    complete = true
}

guard CommandLine.arguments.count == 3 else {
    throw BackupFailure.invalidArguments
}
try backup(source: CommandLine.arguments[1], target: CommandLine.arguments[2])
xcrun swiftc backup.swift -o backup -lsqlite3
mkdir -m 700 backup-run
./backup source.sqlite backup-run/snapshot.sqlite
sqlite3 backup-run/snapshot.sqlite \
  'PRAGMA integrity_check; PRAGMA foreign_key_check;'

10초는 실험용 전체 시간 예산이지, 모든 서비스에 적용할 기준이 아니다. 대상 연결은 다른 스레드에서 동시에 사용해서는 안 된다. 이 예제는 busy 핸들러를 설정하지 않는다. 앱에서 별도로 핸들러를 설정했다면, 핸들러 내부의 대기 시간도 시간 예산에 포함해야 한다.

동시 쓰기로 트랜잭션 경계 검증하기

테이블 두 개를 준비한다. 배치 테이블에는 배치 번호와 예상 상세 항목 수를 기록하고, 상세 테이블은 배치 번호를 참조하게 한다. 쓰기 작업은 매번 하나의 트랜잭션 안에서 배치 레코드 하나와 정해진 수의 상세 항목을 삽입한 뒤 커밋한다. 다른 연결에서는 백업을 반복 실행하되, 매번 새로운 대상 경로를 사용한다.

각 백업에서 배치에 선언된 항목 수와 실제 상세 항목 수가 같은지, 고아 상세 항목이 없는지, 배치 번호가 중복되지 않는지 확인한다. 스냅샷마다 포함된 완전한 배치의 수는 달라도 된다. 하지만 트랜잭션의 일부만 포함되어서는 안 된다.

진행률을 고정된 스냅샷으로 해석하지 않기

단계별 백업 도중 다른 연결이 계속 쓰기를 수행하면 백업 진행이 처음부터 다시 시작될 수 있다. 따라서 페이지 수로 표시되는 진행률이 계속 증가한다고 보장할 수 없으며, 결과가 백업 호출 시작 시점의 상태라고 단정해서도 안 된다.

읽기 시점을 고정해야 한다면 원본 연결에서 명시적으로 읽기 트랜잭션을 시작하고, 읽기를 수행해 스냅샷을 확정한 다음 백업한다. 백업이 끝나면 트랜잭션을 신속히 종료한다. WAL 모드에서는 장시간 유지되는 읽기 트랜잭션이 체크포인트 진행을 막을 수 있으므로 로그 증가량도 함께 관찰해야 한다. 저널 모드별 잠금 영향도 따로 테스트해야 한다.

실패 테스트로 불완전한 결과의 납품 방지 확인하기

폐기 가능한 테스트 픽스처 디렉터리에서 세 가지 실패 상황을 시험한다. 대상 디렉터리에 쓰기 권한이 없는 경우, 다른 연결이 쓰기 잠금을 장시간 유지하는 경우, 백업 프로세스가 도중에 종료되는 경우다. 저장 공간 부족 테스트는 통제된 테스트 볼륨에서만 수행하고, 다른 고객 작업이 실행되는 디스크를 가득 채워서는 안 된다.

모든 경우에 성공이 아닌 종료 상태가 반환되어야 하며, 납품 디렉터리에 완전한 백업으로 오인할 만한 파일이 남아서는 안 된다. 예제는 오류를 반환하며 정상적인 제어 흐름으로 종료할 때만 대상 파일을 삭제한다. 프로세스가 강제 종료되면 정리 코드가 실행되지 않으므로, 실제 통합 시에는 먼저 임시 파일에 기록하고 검증에 성공한 뒤에만 최종 결과로 공개해야 한다.

integrity_check의 예상 결과는 ok 하나뿐이며, foreign_key_check는 결과 행을 반환하지 않아야 한다. 두 검사에 모두 통과해도 업무 데이터 집계와 user_version을 확인해야 한다. 구조가 정상이라고 업무 데이터까지 올바른 것은 아니다.

별도 경로에서 복원해야 검증 완료

백업 연결을 닫고 결과 파일을 새로운 작업 디렉터리로 복사한 다음, 원본 데이터베이스의 WAL, SHM 파일 없이 다시 연다. 먼저 읽기 전용 쿼리를 실행한다. 이어서 폐기 가능한 복사본에서 추가, 수정, 삭제와 재열기를 수행해 앱이 실제로 계속 사용할 수 있는지 확인한다.

납품 기록에는 소스 코드 커밋 ID, 데이터베이스 버전, 백업 소요 시간, 파일 해시와 검사 결과를 남기고, 고객의 평문 데이터는 남기지 않는다. 앱에서 복원할 때도 먼저 기존 연결을 모두 닫아야 한다. 그렇지 않으면 새 파일을 원래 위치에 배치한 뒤에도 일부 연결이 이전 데이터베이스를 계속 조작할 수 있다.

마지막으로 “동시 쓰기 없음”과 “지속적인 쓰기”를 모두 회귀 테스트에 포함한다. 검증 목표는 매번 동일한 파일을 얻는 것이 아니다. 매번 독립적으로 열 수 있고 트랜잭션 및 업무 제약조건을 충족하는 데이터베이스를 얻는 것이며, 실패하면 결과 공개를 명확히 거부해야 한다.

자주 묻는 질문

WAL 모드에서는 주 데이터베이스 파일만 복사해도 되나요?

신뢰할 수 있는 온라인 백업 방법으로 볼 수 없습니다. 커밋된 데이터가 WAL에 남아 있을 수 있으므로 SQLite 온라인 백업 API나 일관성이 검증된 내보내기 방식을 사용해야 합니다.

integrity_check를 통과하면 검증이 끝나나요?

아닙니다. foreign_key_check, 업무 데이터의 불변 조건, 예상 스키마 버전도 확인하고 별도 경로에서 데이터베이스를 열어 복원을 시험해야 합니다.

온라인 백업은 항상 호출 시작 시점의 상태인가요?

아닙니다. 단계별 백업 중 다른 연결이 쓰면 진행이 다시 시작될 수 있습니다. 고정 스냅샷이 필요하면 원본 연결의 읽기 트랜잭션을 설계하고 WAL 증가에 미치는 영향을 평가해야 합니다.

프로젝트 일정에 맞춰 필요한 기간만 이용하세요

임시 빌드, 납품 주간의 추가 용량, 여러 고객 프로젝트를 위한 장비 분리에 적합합니다. 팀의 빌드 작업이 연중 계속되고 이미 보유 장비가 있다면, 구매와 임대 비용을 먼저 비교해 보세요.

VMSSD 클라우드 Mac은 가상 머신이 아닌 독립형 물리 Mac mini를 제공합니다. 일·주·월·분기 단위로 이용 기간을 선택할 수 있으며, 만료 후 연장하지 않으면 서비스가 종료됩니다. 이용 가능한 모델과 노드는 콘솔에서 실시간으로 확인할 수 있습니다.

읽고 난 뒤, 다음 단계를 계획하세요

프로젝트 일정과 빌드 부하를 먼저 확인한 다음 모델과 이용 기간을 선택하세요. 처음 이용하신다면 주문 체크리스트부터 살펴보세요.