클라우드 Mac에서 검증하는 iOS SQLite 읽기 스냅샷과 체크포인트

클라우드 Mac에서 검증하는 iOS SQLite 읽기 스냅샷과 체크포인트

백그라운드 동기화에서 새 레코드를 커밋했는데도 목록을 새로 고치면 여전히 이전 값이 표시됩니다. 캐시를 다시 만들면 잠시 해결되지만, 다음 일괄 조회에서 같은 문제가 반복됩니다. 이럴 때는 먼저 읽기 작업이 여전히 같은 SQLite 트랜잭션 안에서 실행되고 있는지 확인해야 합니다. 클라우드 Mac에서 독립된 연결 두 개로 재현하면, 원격 데스크톱에서 같은 조작을 반복하는 것보다 데이터베이스의 동작과 UI 캐시 문제를 더 쉽게 구분할 수 있습니다.

먼저 읽기가 지켜야 할 조건 정의하기

이 글은 로컬 파일 데이터베이스의 WAL 모드만 다룹니다. 여기서 얻은 결론을 인메모리 데이터베이스나 공유 캐시 설정에 그대로 적용해서는 안 됩니다. Python 3와 SQLite 모듈을 사용할 수 있는 환경을 준비한 뒤, 먼저 python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'를 실행하고 출력을 테스트 기록과 함께 보관합니다.

검증할 조건은 다음과 같습니다. 읽기 연결에서 첫 쿼리를 실행한 뒤에도 쓰기 연결은 커밋할 수 있어야 합니다. 기존 읽기 트랜잭션에서는 계속 이전 값이 보여야 하며, 읽기 트랜잭션을 끝낸 뒤 다시 조회해야 새 값이 보여야 합니다. 여기서 ‘이전 값’이 보이는 것은 트랜잭션의 일관성 때문이지, 데이터가 유실되었기 때문이 아닙니다.

목록을 ‘더 실시간으로’ 갱신하려고, 일관된 뷰가 필요한 여러 테이블 조회 도중에 임의로 트랜잭션을 커밋하지 마세요. 먼저 업무 요건이 동일한 스냅샷을 요구하는지, 아니면 쿼리마다 가장 최근에 커밋된 데이터를 요구하는지 결정해야 합니다.

테스트에는 실제 임시 파일과 연결 두 개를 사용해야 합니다. 일반적인 :memory: 연결 두 개는 각각 별개의 데이터베이스를 가지므로, 이 상호작용을 검증할 수 없습니다.

두 연결로 최소 재현 실험 구성하기

아래 내용을 snapshot_check.py로 저장하고 python3 snapshot_check.py를 실행합니다. 이 스크립트는 서비스 데이터베이스에 연결하지 않으며, 임시 디렉터리는 사용이 끝나면 자동으로 삭제됩니다. 모든 커서는 결과를 즉시 읽고 닫아, 아직 끝나지 않은 SQL 문을 명시적 트랜잭션 문제로 오인하지 않도록 합니다.

import sqlite3
import tempfile
from pathlib import Path

def one(db, sql):
    cursor = db.execute(sql)
    try:
        return cursor.fetchone()
    finally:
        cursor.close()

with tempfile.TemporaryDirectory() as root:
    path = Path(root) / "snapshot.sqlite"
    writer = sqlite3.connect(path, isolation_level=None)
    reader = sqlite3.connect(path, isolation_level=None)
    try:
        assert one(writer, "PRAGMA journal_mode=WAL")[0] == "wal"
        writer.execute("PRAGMA wal_autocheckpoint=0")
        writer.execute("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)")
        writer.execute("INSERT INTO item VALUES(1, 'old')")
        one(writer, "PRAGMA wal_checkpoint(TRUNCATE)")

        reader.execute("BEGIN")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"

        writer.execute("BEGIN IMMEDIATE")
        writer.execute("UPDATE item SET value='new' WHERE id=1")
        writer.execute("COMMIT")

        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"
        held = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert held[1] > held[2], held

        reader.execute("COMMIT")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "new"

        released = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert released[1] == released[2], released
        print({"held": held, "released": released, "result": "passed"})
    finally:
        if reader.in_transaction:
            reader.rollback()
        if writer.in_transaction:
            writer.rollback()
        reader.close()
        writer.close()

코드에서 자동 체크포인트를 명시적으로 끈 것은 실험 과정을 관찰하기 위해서일 뿐, 운영 환경에 권장하는 설정은 아닙니다. 초기화 후 수행하는 WAL 절단도 이 테스트에서 독점적으로 사용하는 픽스처에만 해당합니다. 실제 작업을 처리 중인 데이터베이스에 그대로 적용하지 마세요.

BEGIN과 첫 읽기 시점 구분하기

일반적인 BEGIN은 지연 트랜잭션을 시작하므로, 이 문장을 실행한 시점을 곧바로 스냅샷 시점으로 간주해서는 안 됩니다. 위 코드에서는 첫 번째 쿼리가 읽기 뷰를 확정합니다.

별도의 변형 실험도 해 보세요. 쓰기와 커밋을 읽기 연결의 첫 번째 쿼리보다 앞에서 실행하도록 옮깁니다. 이 경우 첫 번째 쿼리와 같은 트랜잭션의 후속 쿼리 모두 new를 반환해야 하므로, 검증문도 그에 맞게 수정해야 합니다.

이 실험은 흔한 오해를 드러냅니다. 로그에 ‘트랜잭션 시작’이 기록되었다는 이유만으로 이후의 모든 읽기가 그 시점의 데이터를 본다고 생각하는 것입니다. 진단할 때는 최소한 연결 ID, 트랜잭션 ID, 첫 쿼리 실행 지점과 트랜잭션 종료 지점을 기록해야 합니다. 메서드 진입 시점만 기록해서는 부족합니다.

여러 쿼리가 반드시 일관된 결과를 반환해야 할까

예를 들어 주문 총건수를 먼저 조회하고 상세 내역을 조회할 때, 두 쿼리가 같은 업무 스냅샷을 사용해야 한다면 하나의 짧은 읽기 트랜잭션으로 묶어야 합니다. 반대로 스크롤로 데이터를 추가로 불러올 때마다 최신 데이터가 필요하다면, 페이지의 전체 생명주기를 하나의 트랜잭션으로 감싸서는 안 됩니다. 검증 기준은 어느 쪽이 더 빨리 갱신되는 것처럼 보이는지가 아니라, 업무상 필요한 범위에 따라 정해야 합니다.

파일 크기뿐 아니라 체크포인트 진행 상태 확인하기

wal_checkpoint(PASSIVE)는 정수 세 개를 반환하며, 순서대로 busy 상태, 로그 프레임 수, 체크포인트가 완료된 프레임 수를 나타냅니다. 이 픽스처에서는 기존 읽기 트랜잭션이 이전 시점의 뷰를 계속 필요로 하므로, 일부 새 프레임의 체크포인트를 완료할 수 없습니다. 읽기 트랜잭션을 종료한 뒤 다시 실행하면 체크포인트가 진행될 수 있습니다.

특정 실행에서 정확히 몇 개의 프레임이 생성되는지를 고정된 값으로 검증하지 마세요. 페이지 크기, 데이터베이스 레이아웃, 런타임 라이브러리 버전에 따라 개수가 달라질 수 있으므로, 테스트에서는 값 사이의 상대적인 관계를 확인해야 합니다.

마찬가지로 체크포인트가 완료된 프레임 수가 로그 프레임 수와 같아졌다고 해서 WAL 파일이 즉시 작아지는 것은 아닙니다. 파일은 나중에 재사용할 공간을 그대로 유지할 수 있습니다. 운영 환경에서는 읽기 트랜잭션의 지속 시간, 체크포인트 결과, 쓰기 추이를 함께 기록해야 합니다. 디렉터리에 큰 파일이 있다는 이유만으로 로그를 삭제해서는 안 됩니다.

두 번째 검증문이 실패하면 먼저 아직 열려 있는 읽기 트랜잭션, 커서 또는 다른 연결을 찾고, 그다음 체크포인트 전략을 분석하세요. 반복문으로 강제 절단부터 시도해 실제 원인인 장기 읽기 트랜잭션을 가려서는 안 됩니다.

픽스처를 iOS 데이터 계층 테스트로 옮기기

호스트 측 실험에서 검증한 대상은 현재 Python에 링크된 SQLite입니다. iOS로 옮긴 뒤에는 대상 시뮬레이터나 기기에서 실제 데이터 접근 계층을 통해 같은 검증을 다시 실행하고, 해당 환경의 sqlite3_libversion()을 기록해야 합니다. 호스트에서 얻은 결과를 모든 실행 환경의 동작을 입증하는 근거로 삼아서는 안 됩니다.

실제로 독립된 하위 연결 두 개를 유지하고, 명시적인 테스트 배리어로 ‘첫 읽기 완료, 쓰기 커밋, 재조회’의 세 단계를 제어하세요. 일정 시간 잠들어 기다리는 방식에 의존해서는 안 됩니다. 연결 풀이 두 작업을 같은 연결에 배정하면 실험의 의미가 없어집니다.

마지막으로 네 가지를 확인합니다. 기존 트랜잭션에서는 이전 값을 읽는지, 새 트랜잭션에서는 새 값을 읽는지, 예외 경로에서도 트랜잭션이 종료되는지, 장기 읽기 트랜잭션을 해제한 뒤 체크포인트가 진행될 수 있는지 확인하세요. 비동기 쿼리는 취소 후에도 커서와 트랜잭션이 모두 정리되는지 확인해야 합니다. 이 검증 항목들을 회귀 테스트에 남겨 두면, 다음에 목록에서 이전 데이터가 보일 때 정상적인 스냅샷 동작인지, 트랜잭션의 생명주기가 의도한 범위를 벗어난 것인지 먼저 판단할 수 있습니다.

자주 묻는 질문

BEGIN을 실행하면 읽기 스냅샷이 바로 고정되나요?

일반적인 지연 트랜잭션에서는 아닙니다. 이 WAL 실험에서는 첫 실제 읽기가 스냅샷을 만듭니다.

기존 읽기 트랜잭션이 새 커밋을 즉시 봐야 하나요?

아닙니다. 기존 트랜잭션은 원래 스냅샷을 유지합니다. 트랜잭션을 끝내고 다시 읽어야 커밋된 새 값을 봅니다.

체크포인트가 끝나면 WAL 파일이 반드시 작아지나요?

아닙니다. 페이지를 데이터베이스에 반영하는 작업과 파일을 자르는 작업은 다릅니다. 파일 크기만으로 진행 여부를 판단하면 안 됩니다.

전용 클라우드 Mac에 작업을 맡기세요

작업 일정에 맞춰 위치와 요금제를 선택하고 전용 물리 Mac mini를 대여하세요. 개통 후 콘솔에서 접속 정보를 확인할 수 있으며, 이용 중 문제가 생기면 콘솔에서 지원 티켓을 제출하세요.

클라우드 Mac 요금제 선택

작업 일정에 맞는 위치 선택

사양과 이용 기간을 확인한 뒤 나만의 전용 물리 Mac mini를 개통하세요.