클라우드 Mac에서 iOS SQLite WAL 충돌 복구 검증하기

클라우드 Mac에서 iOS SQLite WAL 충돌 복구 검증하기

한 iOS 앱이 오프라인 초안을 SQLite에 저장한다. 사용자에게는 ‘저장 성공’이 표시됐지만, 프로세스는 다음 쓰기 작업 중 충돌했다. 앱을 다시 실행하면 초안은 남아 있을까? 클라우드 Mac의 격리된 시뮬레이터에서는 이 질문을 재현 가능한 실험으로 바꿀 수 있다. 먼저 기준 레코드 하나를 커밋하고, 두 번째 트랜잭션이 아직 커밋되지 않은 상태에서 의도적으로 충돌을 일으킨 뒤, 데이터베이스를 직접 확인한다. 여기서 검증하는 것은 트랜잭션 경계이지 빌드 속도가 아니다. ‘앱이 다시 열린다’는 사실로 데이터 검사를 대신하지도 않는다.

먼저 통과 조건 정의하기

id와 phase 두 열만 있는 테스트 테이블을 준비한다. 1|committed 레코드는 충돌 전에 반드시 커밋을 마쳐야 한다. 2|pending 레코드는 새로 시작한 트랜잭션에서 쓰되 커밋해서는 안 된다. 복구 후 무결성 검사 결과는 ok여야 하고, 쿼리에서는 첫 번째 레코드만 나와야 한다. 두 번째 레코드도 있다면 충돌을 일으키는 지점이 커밋 뒤에 놓이지 않았는지 먼저 살핀다. 첫 번째 레코드가 사라졌다면 실제로 같은 데이터베이스 파일에 기록됐는지 확인한다.

WAL이 ‘모든 쓰기 작업의 데이터가 유실되지 않는다’고 보장하는 것은 아니다. 이 테스트는 정상적으로 커밋된 트랜잭션을 이번 프로세스 충돌 이후에도 읽을 수 있고, 커밋되지 않은 트랜잭션이 저장된 데이터로 나타나지 않는지만 확인한다. 정전, 저장 장치 손상, 애플리케이션 수준의 동기화 테스트를 대신할 수는 없다.

실험에는 일회용 시뮬레이터와 테스트용 번들 ID를 사용한다. 먼저 Xcode 버전과 시뮬레이터 런타임을 고정하고 xcodebuild -version 결과와 시뮬레이터 식별자를 기록한다. 매번 동일한 조건에서 실행한다. 평소 개발에 쓰는 시뮬레이터나 실제 사용자 데이터베이스에서 장애를 주입하지 않는다.

커밋 경계에 장애 주입하기

장애 주입 진입점은 디버그 빌드로 제한하고, 일반 실행 중 실수로 호출되지 않도록 명시적인 실행 인자를 요구한다. 아래 함수는 호출 측에서 이미 Documents/Recovery.sqlite를 열었고, 각 SQL 호출의 반환값을 확인한다고 가정한다. 예제에서는 준비 과정의 실패를 precondition으로 즉시 드러내 잘못된 결론을 유도하는 쿼리 결과가 나오지 않게 한다.

#if DEBUG
import Darwin
import SQLite3

func runRecoveryProbe(_ db: OpaquePointer) {
    func execute(_ sql: String) {
        precondition(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK)
    }

    execute("PRAGMA journal_mode=WAL")
    execute("CREATE TABLE IF NOT EXISTS probe (id INTEGER PRIMARY KEY, phase TEXT NOT NULL)")
    execute("INSERT OR REPLACE INTO probe VALUES (1, 'committed')")
    execute("BEGIN IMMEDIATE")
    execute("INSERT OR REPLACE INTO probe VALUES (2, 'pending')")
    abort()
}
#endif

전용 테스트 플래그가 실행 인자에 있을 때만 이 함수를 호출한다. 테스트 진입점에서는 실험용 데이터베이스를 사용 중인지, 이전에 시작해 끝나지 않은 트랜잭션이 없는지 먼저 확인해야 한다. PRAGMA journal_mode=WAL은 트랜잭션 밖에서 실행한다. abort()가 이번 프로세스를 비정상 종료시키므로 뒤에는 의도적으로 COMMIT을 두지 않았다. 실제 프로젝트에서 데이터베이스 래퍼 계층을 사용한다면 그 계층을 통해 동일한 커밋 경계를 만들어야 한다. 래퍼 계층을 우회하고도 프로덕션 쓰기 경로를 검증했다고 주장해서는 안 된다.

이전 레코드가 결과에 섞이지 않게 하기

매번 일회용 시뮬레이터의 테스트 앱 데이터를 초기화하거나 새로 만든 전용 시뮬레이터에 설치한다. 다른 비즈니스 테이블과 파일을 남겨 둔 채 DELETE FROM probe만 실행해서는 안 된다. 첫 실행 전 데이터베이스가 실제로 앱 데이터 컨테이너 안에 있는지 확인한다. 장애 주입 전후에 데이터베이스 경로, 빌드 버전, 시뮬레이터 식별자를 기록하되 실제 프로젝트 데이터를 실험용 컨테이너에 복사하지 않는다.

충돌 후 데이터베이스 원본 상태 확인하기

앱을 실행한 뒤에는 예정된 장애로 비정상 종료됐는지 먼저 확인하고 쿼리한다. 실행 명령이 반환됐다는 사실만으로는 장애가 발생했다고 볼 수 없다. 앱이 아직 테스트 진입점에 도달하지 않았을 수도 있다. 시뮬레이터의 프로세스 상태와 충돌 보고서를 대조해 확인할 수 있다. 충돌 증거가 없는 실행은 ‘테스트 미완료’로 판정하고 통과로 처리하지 않는다.

프로세스가 종료됐음을 확인한 다음 클라우드 Mac 터미널에서 앱 데이터 컨테이너를 읽는다. 변수에는 이번 실행에 사용한 시뮬레이터 식별자와 테스트용 번들 ID를 넣는다.

UDID="your-simulator-udid"
BUNDLE_ID="com.example.RecoveryProbe"
DATA="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
sqlite3 "$DATA/Documents/Recovery.sqlite" \
  'PRAGMA integrity_check; SELECT id, phase FROM probe ORDER BY id;'

예상 출력은 순서대로 ok와 1|committed다. 검사 전에 데이터베이스 옆의 -wal, -shm 파일을 삭제하거나 따로 옮기지 않는다. SQLite가 파일을 원래 상태 그대로 열게 한다. 검사 중에는 다시 쓰기를 수행할 앱 프로세스도 실행하지 않는다. 프로젝트에 원래 데이터베이스가 여러 개라면 먼저 파일명과 컨테이너 경로를 대조해 ‘다른 파일을 조회한 것’을 복구 실패로 오인하지 않도록 한다.

실패 원인별로 구분하기

출력에 2|pending이 있다면 자동 커밋이 발생했는지, 래퍼 계층이 트랜잭션을 미리 끝냈는지, 장애 주입 플래그로 인해 다른 코드 경로에 들어갔는지부터 살핀다. 1|committed가 없다면 첫 번째 쓰기가 성공을 반환했는지 확인하고, 테스트가 동일한 연결과 동일한 파일에서 실행됐는지 대조한다. 쿼리에서 ‘테이블이 없다’는 오류가 나오면 테스트 진입점이 포함된 빌드를 설치했는지, 컨테이너 경로가 이전 시뮬레이터를 가리키지 않는지 먼저 확인한다.

integrity_check는 비즈니스 요구사항에 대한 단언이 아니다. ok가 반환돼도 레코드를 하나씩 확인해야 한다. 반대로 충돌 보고서는 프로세스가 비정상 종료됐다는 사실만 증명할 뿐, 트랜잭션이 올바르게 복구됐음을 증명하지는 못한다. 이 두 종류의 증거를 Xcode 버전, 시뮬레이터 식별자, 테스트 패키지의 빌드 식별자와 함께 보관해야 실패를 재현했을 때 조건을 비교할 수 있다.

CI에 연결할 때도 경계 유지하기

이 테스트가 시뮬레이터를 단독으로 사용하도록 한다. 장애 주입을 마치고 프로세스 종료를 확인한 뒤 읽기 전용 검사 단계를 실행한다. 일반 UI 테스트와 동일한 앱 데이터 컨테이너를 공유하지 않는다. 병렬 쓰기가 관찰 결과를 바꿀 수 있기 때문이다. 장애 주입 진입점은 디버그 빌드에만 존재하고 릴리스 빌드에는 포함되지 않아야 한다. CI에서 이 점을 명시적으로 확인한다.

마지막으로 충돌을 주입하지 않는 대조 실험을 한 번 더 한다. 정상 경로에서 두 번째 레코드를 커밋하고 읽을 수 있는지 확인한다. 이를 통해 ‘커밋되지 않은 쓰기가 롤백된다’는 점뿐 아니라 테스트 자체가 모든 쓰기를 막고 있지 않다는 점도 검증할 수 있다. 두 결과를 모두 확보해야 우연히 재시작한 뒤 관찰한 현상을 재검증 가능한 영속성 인수 테스트로 만들 수 있다.

자주 묻는 질문

정상적인 앱 종료로 충돌 테스트를 대신할 수 있나요?

없습니다. 정상 종료 과정에서 트랜잭션이 커밋되거나 정리 코드가 실행될 수 있으므로 커밋 전에 비정상 종료되었는지 확인해야 합니다.

검사 전에 -wal과 -shm 파일을 지워야 하나요?

아니요. 관련 파일을 그대로 둔 채 SQLite로 데이터베이스를 열어 무결성과 기록을 검사해야 합니다.

UDPMac 클라우드 Mac

다음 Xcode 빌드는 전용 물리 서버에서 실행하세요.

Apple Silicon 모델, 노드, 이용 기간을 선택하고 원격으로 연결해 원하는 빌드 워크플로를 실행하세요. 실제 이용 가능 여부는 주문 시 콘솔에 표시되는 정보를 기준으로 합니다.

클라우드 Mac 이용 플랜 보기