조회 결과를 fetchmany로 나누어 저장하고 모든 ID를 검산하기

조회가 끝까지 진행됐다고 해서 저장된 CSV에도 같은 ID가 모두 들어 있다고 볼 수는 없습니다. 마지막 묶음을 빼먹거나 파일 하나를 다시 쓰면, 배치별 행 수는 그럴듯해도 전체 결과가 달라집니다. 이 실습은 가상 SQLite의 ID 1~23을 fetchmany(10)으로 꺼내 CSV 세 개에 저장한 뒤, 저장된 모든 ID를 원본 조회와 다시 대조합니다.

완료 조건은 10행·10행·3행, 총 23행입니다. 배치 크기를 7로 바꿔도 ID는 1~23 순서 그대로여야 합니다. 실제 조사·코드 실행 검증은 2026년 10월 10일 한국시간에 수행했습니다. Windows와 Microsoft Excel 앱 실행, 대용량 속도·메모리 벤치마크는 하지 않았습니다.

1. 입력 23행과 예상 결과

첨부 ZIP의 data/source.sqlite3는 실제 업무 자료가 아닌 가상 입력입니다. records 테이블은 id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER 세 열로 구성되며 item_01~item_23, quantity 1~23을 넣었습니다. DB 내용을 수정하거나 새로 만들지 않고 읽어 실습합니다.

파일데이터 행 수ID 범위
part_0001.csv101~10
part_0002.csv1011~20
part_0003.csv321~23
합계23ID 1~23 각각 한 번

세 파일에 id,item,quantity 머리글이 각각 있습니다. 23행은 머리글을 뺀 데이터 행의 합계입니다. CSV는 newline=""인 StringIO에서 행을 직렬화한 뒤, UTF-8 BOM과 UTF-8 바이트를 바이너리 파일에 기록합니다. 재검산할 때는 encoding="utf-8-sig", newline=""로 엽니다. BOM이 Excel의 인코딩 판별에 도움을 줄 수 있지만, 앱별 자동 열기 결과까지 이 실습에서 확인한 것은 아닙니다.

2. fetchmany는 같은 커서의 다음 묶음

핵심 조회는 SELECT id, item, quantity FROM records ORDER BY id입니다. 실행한 SELECT의 커서에서 fetchmany(10)을 반복하면 다음 10행, 다음 10행, 마지막 3행을 받습니다. 그 뒤 빈 목록이 반환되면 읽기가 끝났습니다. 마지막 묶음이 10행보다 작아도 버리면 안 됩니다.

이 코드가 SQL을 LIMIT 10 OFFSET 0, LIMIT 10 OFFSET 10으로 다시 실행하는 것은 아닙니다. fetchmany는 이미 실행한 한 조회 결과를 나누어 받는 방법입니다. 웹 화면의 페이지 번호·재개용 체크포인트 기능은 제공하지 않습니다. 중단한 출력 폴더는 보존하고, 새 폴더 이름으로 처음부터 다시 실행합니다.

ORDER BY는 코드에 명시합니다. id는 PRIMARY KEY이므로 동률이 없는 정렬 키입니다. ORDER BY를 빼고 이번에 1~23으로 보였다는 이유로 다음 실행의 순서를 보장할 수는 없습니다. 일반 테이블에서 날짜처럼 중복되는 열로 정렬한다면 ORDER BY 날짜, 고유ID처럼 동률을 해소할 키도 필요합니다.

Python이 한 번에 보유하는 결과 묶음의 행 수를 배치 크기로 제한하는 것이 목적입니다. SQLite 내부 정렬·캐시, 넓은 셀, CSV 인코딩 버퍼까지 포함한 전체 프로세스 메모리가 일정하다고 보장하지 않습니다. 이 예제의 고유 정수 기본키 순서를 다른 DB의 복잡한 조회 성능으로 일반화해서도 안 됩니다.

3. 같은 스냅샷에서 모든 ID를 재대조

같은 SQLite 읽기 스냅샷 안에서 ORDER BY id 결과를 저장 커서의 fetchmany(10)으로 세 CSV에 나누어 저장한 뒤, CSV에서 다시 읽은 전체 ID와 두 번째 정렬 커서의 ID 1~23을 대조하여 순서·누락·중복을 확인하는 설명용 도해.
설명용 도해: 세 CSV의 ID를 순서대로 다시 읽고, 같은 읽기 스냅샷의 두 번째 ORDER BY id 커서와 전체 ID를 대조합니다. 배치 크기를 바꿔도 비교 대상은 전체 23개 ID입니다. 그림을 누르면 원본 크기로 볼 수 있습니다.

원본 DB는 URI mode=ro로 열고 명시적 BEGIN 안에서 행 수 확인, 내보내기, 저장 CSV 재검산을 수행합니다. BEGIN만 호출한 순간이 아니라 첫 실제 SELECT가 읽기 스냅샷을 정합니다. 이후 같은 읽기 트랜잭션 안의 조회는 그 시점의 데이터를 봅니다.

검산은 두 단계입니다. 내보낼 때 이전 ID 하나를 기억해 ID가 엄격히 증가하는지 검사합니다. 저장 뒤에는 CSV를 행 단위로 다시 읽고, 같은 스냅샷에서 ORDER BY id로 조회한 두 번째 커서의 ID와 하나씩 비교합니다. 파일을 건너뛰거나, 행을 빼거나, ID를 바꿨으면 같은 위치에서 실패해야 합니다. 마지막에는 양쪽 모두 끝났는지도 확인합니다.

COUNT, MIN, MAX만 같으면 모든 ID가 같다고 결론 내릴 수 없습니다. 예를 들어 1·2·4·5와 1·3·4·5는 개수·최소·최대가 같습니다. 이 코드는 저장된 각 ID를 실제 원본 순서와 대조합니다. 전체 ID를 Python set이나 list로 모으지 않습니다. 검산 대상은 이 고정 SELECT의 ID 완전성과 순서이며, DB 전체의 데이터 품질을 인증하는 도구는 아닙니다.

mode=ro는 이 연결의 쓰기를 막습니다. 다른 프로그램의 쓰기까지 막는 것은 아닙니다. 정지된 제공 입력은 실행 전후 DB 파일의 SHA-256이 같은지 확인합니다. 운영 DB에서는 WAL 보조 파일과 동시 쓰기 때문에 주 DB 파일 해시만으로 전체 논리 상태나 스냅샷을 설명할 수 없습니다. 실습은 변경되지 않는 복사본에서 진행하고, DB가 사용 중이라면 관리자의 정상 백업 절차로 준비한 읽기용 사본을 사용하세요.

4. 빈 폴더에서 실행하기

실습 ZIP을 빈 연습 폴더에 풀고 그 폴더에서 터미널을 엽니다. Python 3.11 이상 API를 사용하는 코드이며, 실제 실행 검수 범위는 아래 3.12·3.13 환경입니다. Python 표준 라이브러리만 사용하므로 pandas나 openpyxl 설치는 필요 없습니다. data/source.sqlite3와 export_batches.py를 먼저 확인합니다.

python export_batches.py --database data/source.sqlite3 --output run_batch10 --batch-size 10

성공하면 새 출력 폴더 run_batch10 안에 part_0001.csv~part_0003.csv와 manifest.json, run.log가 생깁니다. 정상 결과의 정확한 콘솔 문구와 검산 필드는 아래 실제 실행 기록을 확인하세요.

배치 크기를 바꿔 새 폴더에 실행합니다.

python export_batches.py --database data/source.sqlite3 --output run_batch7 --batch-size 7

이번에는 7·7·7·2행, 총 23행입니다. 파일 수와 경계는 달라지지만, 파일명 순으로 읽은 모든 ID는 앞선 결과와 동일해야 합니다. 두 실행의 모든 ID를 스트리밍 비교하는 명령과 성공 출력은 7절을 참고하세요.

5. 실패를 고치고 다시 실행하기

  • 출력 폴더가 이미 있음: 기존 결과를 덮어쓰지 않습니다. run_batch10_second처럼 새 이름으로 실행하세요.
  • 배치 크기가 0·음수·상한 초과: 양의 정수이며 허용 상한 이내인 값을 지정하세요. Python 버전에 따른 음수 fetchmany 처리 차이에 의존하지 않습니다.
  • DB 경로가 잘못됨: 자동으로 빈 DB를 만들지 않고 실패합니다. 제공 입력 경로를 확인하세요.
  • 읽기·쓰기·검산 중 예외 또는 의도적 중단: 종료 코드를 확인하고 보존된 실패 기록을 읽습니다. CSV가 존재한다는 사실만으로 성공으로 판단하지 마세요.
  • 행 수·파일 수·행 바이트·출력 바이트 상한 초과: 제공 한도를 확인합니다. 실제 업무 입력에 적용할 때 무작정 상한을 높이기보다 저장 공간과 데이터 폭을 먼저 확인하세요.
  • 저장 CSV의 누락·중복·순서 오류: 실패한 폴더의 기록을 보존합니다. 원본을 고쳐 쓰거나 파일 하나를 임의로 합쳐 통과시키지 말고 새 폴더로 다시 실행합니다.

출력은 스스로 소유하는 연습 폴더에 만듭니다. 다른 사용자나 프로그램이 동시에 출력 파일을 바꾸는 환경의 보안·권한·충돌 해결까지 제공하지 않습니다. 성공 여부는 종료 코드 0과 manifest.json의 status="success"로 확인합니다. run.log는 읽기·ID 대조 단계 기록이며, manifest 작성 실패까지 포함한 최종 성공 표시는 아닙니다.

6. ZIP과 같은 전체 코드

export_batches.py입니다. ZIP의 같은 파일을 그대로 실었습니다.

export_batches.py 전체 코드 펼치기
"""한 SQLite 조회 커서를 fetchmany로 끝까지 읽고 모든 저장 ID를 검산한다.

Python 3.11+, 표준 라이브러리만 사용. 입력 SQL은 코드의 고정 상수다.
ID 검산은 전체 ID를 list/set에 쌓지 않고 같은 읽기 스냅샷과 스트리밍 비교한다.
"""
import argparse
import csv
import hashlib
import io
import json
from pathlib import Path
import platform
import signal
import sqlite3
import sys

COLUMNS = ("id", "item", "quantity")
SELECT_SQL = "SELECT id, item, quantity FROM records ORDER BY id"
ID_SQL = "SELECT id FROM records ORDER BY id"
COUNT_SQL = "SELECT COUNT(*), MIN(id), MAX(id) FROM records"
FAILURE_EVIDENCE_BYTES = 16384
HARD_LIMITS = {
    "batch_size": 1000,
    "max_rows": 1000000,
    "max_parts": 1000,
    "max_output_bytes": 50 * 1024 * 1024,
    "max_row_bytes": 65536,
}
MAX_SOURCE_BYTES = 64 * 1024 * 1024


class ExportError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


class SignalInterruption(Exception):
    def __init__(self, signum):
        self.signum = signum
        super().__init__(f"signal {signum}")


def sha256_file(path):
    digest = hashlib.sha256()
    with Path(path).open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def check_options(options):
    for key, maximum in HARD_LIMITS.items():
        value = options[key]
        if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
            raise ExportError("invalid_option", f"{key} must be an integer in 1..{maximum}")
    if options["max_row_bytes"] < 32:
        raise ExportError("invalid_option", "max_row_bytes must be at least 32")


def open_readonly(database, max_row_bytes):
    path = Path(database).resolve(strict=True)
    # as_uri는 공백, ?, # 등을 인코딩한다. mode=ro이면 없는 DB를 만들지 않는다.
    connect_options = {"uri": True, "isolation_level": None, "timeout": 5.0}
    if hasattr(sqlite3, "LEGACY_TRANSACTION_CONTROL"):
        # 3.12+에서는 isolation_level 모드를 명시한다. 3.11에는 이 인자가 없다.
        connect_options["autocommit"] = sqlite3.LEGACY_TRANSACTION_CONTROL
    conn = sqlite3.connect(path.as_uri() + "?mode=ro", **connect_options)
    try:
        # 읽기 전용 URI와 함께 적용하는 방어. 이 설정은 원본 파일을 수정하지 않는다.
        conn.execute("PRAGMA query_only=ON")
        # SQLite가 한 값/행을 무제한으로 materialize하지 못하게 한다.
        # 스키마 문자열/레코드 오버헤드 여유와 CSV 행 상한은 구분한다.
        conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, max(max_row_bytes + 4096, 8192))
        conn.setlimit(sqlite3.SQLITE_LIMIT_COLUMN, 32)
        conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, 8192)
        return conn
    except BaseException:
        conn.close()
        raise


class OutputBudget:
    """CSV + 로그 + 성공 manifest 실제 UTF-8 바이트를 함께 센다."""
    def __init__(self, limit):
        self.limit = limit
        self.used = 0

    def write(self, handle, data):
        if isinstance(data, str):
            data = data.encode("utf-8")
        if self.used + len(data) > self.limit:
            raise ExportError("output_limit", f"output would exceed {self.limit} bytes")
        handle.write(data)
        self.used += len(data)


def csv_line(values):
    buffer = io.StringIO(newline="")
    csv.writer(buffer).writerow(values)
    return buffer.getvalue().encode("utf-8")


def check_source_schema(conn):
    schema_cursor = conn.execute("PRAGMA table_info(records)")
    try:
        fields = schema_cursor.fetchall()
    finally:
        schema_cursor.close()
    if len(fields) != 3 or tuple(f[1] for f in fields) != COLUMNS:
        raise ExportError("source_schema", "records must have exactly id,item,quantity columns")
    if fields[0][2].upper() != "INTEGER" or fields[0][5] != 1:
        raise ExportError("source_schema", "id must be the unique INTEGER PRIMARY KEY")
    if any(f[5] for f in fields[1:]):
        raise ExportError("source_schema", "id must be the sole primary key")


def validate_saved_ids(conn, paths, expected, max_row_bytes):
    """파일별 ID를 같은 스냅샷의 별도 ID 커서와 한 행씩 비교한다.

    COUNT/MIN/MAX는 보조 검산이다. 임의 ID 집합의 동등성을 이것만으로 주장하지 않는다.
    커서 행을 fetchone으로 소비하므로 ID 비교 상태는 O(1)이다.
    """
    reference = conn.execute(ID_SQL)
    previous = None
    minimum = None
    count = 0
    old_field_limit = csv.field_size_limit()
    csv.field_size_limit(max_row_bytes)
    try:
        for path in paths:
            with Path(path).open("r", encoding="utf-8-sig", newline="") as handle:
                reader = csv.reader(handle)
                if next(reader, None) != list(COLUMNS):
                    raise ExportError("csv_header", f"unexpected CSV header: {Path(path).name}")
                for row in reader:
                    if len(row) != len(COLUMNS):
                        raise ExportError("csv_columns", f"unexpected CSV column count: {Path(path).name}")
                    if len(csv_line(row)) > max_row_bytes:
                        raise ExportError("row_bytes_limit", "saved CSV row exceeds configured byte limit")
                    try:
                        current = int(row[0])
                    except ValueError as exc:
                        raise ExportError("id_type", f"non-integer saved ID at row {count + 1}") from exc
                    if str(current) != row[0]:
                        raise ExportError("id_type", f"non-canonical saved ID at row {count + 1}")
                    if previous is not None and current <= previous:
                        raise ExportError("id_order", f"duplicate or out-of-order ID at row {count + 1}: {current}")
                    wanted = reference.fetchone()
                    if wanted is None or type(wanted[0]) is not int or current != wanted[0]:
                        raise ExportError("id_mismatch", f"source/output ID mismatch at row {count + 1}: {current}")
                    if minimum is None:
                        minimum = current
                    previous = current
                    count += 1
                    if count > expected["count"]:
                        raise ExportError("id_count", "saved rows exceed snapshot count")
        if reference.fetchone() is not None:
            raise ExportError("id_missing", "source IDs remain after the last saved CSV row")
        if (count, minimum, previous) != (expected["count"], expected["min_id"], expected["max_id"]):
            raise ExportError("id_count", "saved count/min/max differ from the snapshot")
        return {"strictly_increasing": True, "independent_stream_equal": True, "count": count, "min_id": minimum, "max_id": previous}
    except csv.Error as exc:
        raise ExportError("csv_error", str(exc)) from exc
    finally:
        csv.field_size_limit(old_field_limit)
        reference.close()


def write_failure(output, error, before, after, exported, parts, used):
    # 실패 보고는 정상 출력 예산과 별도로 최대 16 KiB만 허용한다.
    # 디렉터리를 새로 만든 실행만 사용하고, existing failure.json은 절대 덮어쓰지 않는다.
    report = {
        "status": "failed", "code": error.code, "message": str(error)[:2000],
        "source_sha256_before": before, "source_sha256_after": after,
        "source_hash_scope": "main_database_file_only", "exported_rows_written": exported,
        "completed_parts": len(parts), "last_completed_files": [p["file"] for p in parts[-10:]],
        "normal_output_bytes": used,
        "warning": "CSV files are failure evidence, not a verified complete export. A current file may be partial. Use a new output directory when retrying.",
    }
    payload = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
    if len(payload) > FAILURE_EVIDENCE_BYTES:
        payload = (json.dumps({"status": "failed", "code": error.code, "message": "failure evidence truncated"}) + "\n").encode()
    with (output / "failure.json").open("xb") as handle:
        handle.write(payload)


def export_database(database, output, *, batch_size=10, max_rows=100000,
                    max_parts=1000, max_output_bytes=1024 * 1024, max_row_bytes=65536,
                    on_snapshot=None, on_batch=None, on_finished_read=None):
    """실행 API. on_*는 결정적 검수용 콜백이며 CLI에는 노출하지 않는다."""
    options = {"batch_size": batch_size, "max_rows": max_rows, "max_parts": max_parts,
               "max_output_bytes": max_output_bytes, "max_row_bytes": max_row_bytes}
    check_options(options)
    if sys.version_info < (3, 11):
        raise ExportError("python_version", "Python 3.11 or later is required")
    database = Path(database)
    output = Path(output)
    if not database.is_file():
        raise ExportError("source_missing", "source database must be an existing regular file")
    database = database.resolve(strict=True)
    source_bytes = database.stat().st_size
    for suffix in ("-wal", "-shm"):
        sidecar = Path(str(database) + suffix)
        if sidecar.exists():
            source_bytes += sidecar.stat().st_size
    if source_bytes > MAX_SOURCE_BYTES:
        raise ExportError("source_limit", f"database and WAL/SHM exceed {MAX_SOURCE_BYTES} bytes")
    if output.exists() or output.is_symlink():
        raise ExportError("output_exists", "output path already exists; choose a new directory")
    if not output.parent.is_dir():
        raise ExportError("output_parent", "output parent directory must already exist")
    try:
        output.mkdir(exist_ok=False)
    except FileExistsError as exc:
        raise ExportError("output_exists", "output path was created concurrently; choose a new directory") from exc
    except OSError as exc:
        raise ExportError("io_error", str(exc)) from exc
    budget = OutputBudget(max_output_bytes)
    before = None
    after = None
    parts = []
    exported = 0
    conn = None
    log = None
    try:
        before = sha256_file(database)
        log = (output / "run.log").open("xb")
        budget.write(log, "READ_ONLY mode=ro transaction=BEGIN query=ORDER_BY_UNIQUE_ID\n")
        conn = open_readonly(database, max_row_bytes)
        conn.execute("BEGIN")
        # BEGIN만으로는 스냅샷이 고정되지 않는다. 이 첫 SELECT가 읽기를 시작한다.
        count_cursor = conn.execute(COUNT_SQL)
        try:
            count, min_id, max_id = count_cursor.fetchone()
        finally:
            count_cursor.close()
        check_source_schema(conn)
        snapshot = {"count": count, "min_id": min_id, "max_id": max_id}
        budget.write(log, f"SNAPSHOT count={count} min_id={min_id} max_id={max_id}\n")
        if count > max_rows:
            raise ExportError("row_limit", f"snapshot has {count} rows; max_rows={max_rows}")
        required_parts = (count + batch_size - 1) // batch_size
        if required_parts > max_parts:
            raise ExportError("part_limit", f"snapshot requires {required_parts} CSV files; max_parts={max_parts}")
        if on_snapshot is not None:
            on_snapshot(dict(snapshot))
        # 이 SELECT는 단 한 번 실행한다. LIMIT/OFFSET 페이지 조회가 아니다.
        cursor = conn.execute(SELECT_SQL)
        previous = None
        while True:
            rows = cursor.fetchmany(batch_size)
            # 결과가 소진되면 빈 list. 마지막 짧은 배치는 반드시 저장한다.
            if not rows:
                break
            index = len(parts) + 1
            if index > max_parts or exported + len(rows) > max_rows:
                raise ExportError("resource_limit", "stream exceeded configured row/part limit")
            filename = f"part_{index:04d}.csv"
            path = output / filename
            first_id = None
            last_id = None
            with path.open("xb") as handle:
                budget.write(handle, b"\xef\xbb\xbf")
                budget.write(handle, csv_line(COLUMNS))
                for row in rows:
                    if len(row) != 3 or type(row[0]) is not int:
                        raise ExportError("id_type", "source id must be an integer")
                    ident = row[0]
                    if previous is not None and ident <= previous:
                        raise ExportError("id_order", "source IDs must be strictly increasing")
                    if type(row[1]) is not str or type(row[2]) is not int:
                        raise ExportError("source_type", "item must be TEXT and quantity must be INTEGER")
                    # CSV 수식/Excel 실행 보안 대신 합성 item만 제공한다. 실제 데이터 사용 시 따로 검토한다.
                    line = csv_line(row)
                    if len(line) > max_row_bytes:
                        raise ExportError("row_bytes_limit", f"CSV row for id={ident} exceeds {max_row_bytes} bytes")
                    budget.write(handle, line)
                    if first_id is None:
                        first_id = ident
                    last_id = previous = ident
                    exported += 1
            parts.append({"file": filename, "rows": len(rows), "first_id": first_id,
                          "last_id": last_id, "bytes": path.stat().st_size, "sha256": sha256_file(path)})
            budget.write(log, f"BATCH {index:04d} rows={len(rows)} first_id={first_id} last_id={last_id}\n")
            log.flush()
            if on_batch is not None:
                on_batch(index, path)
        cursor.close()
        id_validation = validate_saved_ids(conn, (output / p["file"] for p in parts), snapshot, max_row_bytes)
        if exported != count:
            raise ExportError("id_count", "exported rows differ from snapshot count")
        for part in parts:
            path = output / part["file"]
            if path.stat().st_size != part["bytes"] or sha256_file(path) != part["sha256"]:
                raise ExportError("output_changed", f"CSV changed after it was written: {part['file']}")
        budget.write(log, f"ID_COMPARE OK count={exported} min_id={min_id} max_id={max_id}\n")
        conn.rollback()  # 읽기 스냅샷 종료. 파일/레코드를 쓰지 않았다.
        conn.close()
        conn = None
        if on_finished_read is not None:
            on_finished_read()
        after = sha256_file(database)
        if before != after:
            raise ExportError("source_changed", "main database file SHA-256 changed during the run")
        budget.write(log, "SOURCE_SHA256 UNCHANGED scope=MAIN_DATABASE_FILE_ONLY\n")
        budget.write(log, f"READ_AND_ID_VERIFY_COMPLETE rows={exported} parts={len(parts)}\n")
        log.close()
        log = None
        report = {
            "status": "success", "query": SELECT_SQL, "batch_size": batch_size,
            "snapshot": snapshot, "exported_rows": exported, "parts": parts,
            "id_validation": id_validation,
            "source_sha256_before": before, "source_sha256_after": after,
            "source_hash_scope": "main_database_file_only",
            "source_hash_warning": "Main-file equality does not prove no concurrent database writer. WAL commits can change logical data without changing this file; this export is checked against its own read transaction snapshot.",
            "resource_limits": options, "normal_output_bytes_before_manifest": budget.used,
            "log_sha256": sha256_file(output / "run.log"),
            "versions": {"python": platform.python_version(), "sqlite": sqlite3.sqlite_version, "system": platform.platform()},
        }
        payload = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
        # 예산 초과라면 성공 marker 자체도 만들지 않는다.
        if budget.used + len(payload) > budget.limit:
            raise ExportError("output_limit", "manifest would exceed the combined output byte limit")
        with (output / "manifest.json").open("xb") as handle:
            budget.write(handle, payload)
        return report
    except (Exception, KeyboardInterrupt) as exc:
        if conn is not None:
            try:
                conn.rollback()
            finally:
                conn.close()
            conn = None
        if log is not None:
            log.close()
            log = None
        try:
            after = sha256_file(database)
        except OSError:
            after = None
        if isinstance(exc, ExportError):
            error = exc
        elif isinstance(exc, (KeyboardInterrupt, SignalInterruption)):
            error = ExportError("interrupted", "export interrupted; retained files are failure evidence")
        elif isinstance(exc, sqlite3.Error):
            error = ExportError("sqlite_error", str(exc))
        elif isinstance(exc, OSError):
            error = ExportError("io_error", str(exc))
        else:
            error = ExportError("unexpected_error", str(exc))
        try:
            write_failure(output, error, before, after, exported, parts, budget.used)
        except OSError as evidence_error:
            print(f"WARNING failure report could not be written: {evidence_error}", file=sys.stderr)
        raise error from exc


def main():
    parser = argparse.ArgumentParser(description="조회 결과를 fetchmany로 나누어 저장하고 모든 ID를 검산합니다.")
    parser.add_argument("--database", type=Path, default=Path("data/source.sqlite3"))
    parser.add_argument("--output", type=Path, required=True, help="아직 존재하지 않는 출력 디렉터리")
    parser.add_argument("--batch-size", type=int, default=10)
    parser.add_argument("--max-rows", type=int, default=100000)
    parser.add_argument("--max-parts", type=int, default=1000)
    parser.add_argument("--max-output-bytes", type=int, default=1024 * 1024)
    parser.add_argument("--max-row-bytes", type=int, default=65536)
    args = parser.parse_args()
    old_term = signal.getsignal(signal.SIGTERM)
    def stop(signum, frame):
        raise SignalInterruption(signum)
    signal.signal(signal.SIGTERM, stop)
    try:
        report = export_database(**vars(args))
    except (ExportError, OSError) as exc:
        code = exc.code if isinstance(exc, ExportError) else "io_error"
        print(f"ERROR {code}: {exc}", file=sys.stderr)
        return 130 if code == "interrupted" else 2
    finally:
        signal.signal(signal.SIGTERM, old_term)
    print(f"SUCCESS rows={report['exported_rows']} parts={len(report['parts'])} id_compare=OK source_sha=UNCHANGED")
    print(f"OUTPUT {args.output}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

compare_exports.py입니다. ZIP의 같은 파일을 그대로 실었습니다.

compare_exports.py 전체 코드 펼치기
"""두 검수 완료 출력의 전체 ID 순서를 bounded-memory로 비교한다."""
import argparse
import csv
import json
from pathlib import Path
import re
import sys

from export_batches import COLUMNS, ExportError, HARD_LIMITS, csv_line, sha256_file

MAX_MANIFEST_BYTES = 1024 * 1024


def load_manifest(directory):
    directory = Path(directory)
    path = directory / "manifest.json"
    if (directory / "failure.json").exists() or not path.is_file():
        raise ExportError("compare_incomplete", "both inputs need success manifest.json and no failure.json")
    if path.stat().st_size > MAX_MANIFEST_BYTES:
        raise ExportError("compare_manifest", "manifest exceeds 1 MiB")
    try:
        report = json.loads(path.read_text(encoding="utf-8"))
    except (ValueError, UnicodeError, RecursionError) as exc:
        raise ExportError("compare_manifest", "invalid manifest JSON") from exc
    if not isinstance(report, dict) or report.get("status") != "success":
        raise ExportError("compare_manifest", "manifest status must be success")
    parts = report.get("parts")
    snapshot = report.get("snapshot")
    if not isinstance(parts, list) or len(parts) > HARD_LIMITS["max_parts"] or not isinstance(snapshot, dict):
        raise ExportError("compare_manifest", "invalid or excessive declared parts/snapshot")
    count = snapshot.get("count")
    if type(count) is not int or not 0 <= count <= HARD_LIMITS["max_rows"]:
        raise ExportError("compare_manifest", "invalid snapshot count")
    if count == 0:
        if snapshot.get("min_id") is not None or snapshot.get("max_id") is not None or parts:
            raise ExportError("compare_manifest", "empty snapshot must have no parts/min/max")
    elif type(snapshot.get("min_id")) is not int or type(snapshot.get("max_id")) is not int:
        raise ExportError("compare_manifest", "snapshot min/max must be integers")
    validation = report.get("id_validation", {})
    if not isinstance(validation, dict) or validation.get("independent_stream_equal") is not True:
        raise ExportError("compare_manifest", "original snapshot ID validation is required")
    if report.get("exported_rows") != count:
        raise ExportError("compare_manifest", "exported_rows differs from snapshot")
    total_rows = 0
    total_bytes = 0
    for index, part in enumerate(parts, 1):
        if not isinstance(part, dict) or part.get("file") != f"part_{index:04d}.csv":
            raise ExportError("compare_manifest", "declared files must be the numbered local CSV sequence")
        rows = part.get("rows")
        size = part.get("bytes")
        digest = part.get("sha256")
        if type(rows) is not int or not 1 <= rows <= HARD_LIMITS["batch_size"]:
            raise ExportError("compare_manifest", "invalid part row count")
        if type(size) is not int or size < 1 or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
            raise ExportError("compare_manifest", "invalid part bytes/SHA-256")
        total_rows += rows
        total_bytes += size
        if total_rows > HARD_LIMITS["max_rows"] or total_bytes > HARD_LIMITS["max_output_bytes"]:
            raise ExportError("compare_manifest", "declared input rows/bytes exceed hard limits")
        csv_path = directory / part["file"]
        if not csv_path.is_file() or csv_path.is_symlink() or csv_path.stat().st_size != size:
            raise ExportError("compare_hash", "CSV file is missing, symlinked, or size differs")
        if sha256_file(csv_path) != digest:
            raise ExportError("compare_hash", "CSV SHA-256 differs from success manifest")
    if total_rows != count:
        raise ExportError("compare_manifest", "declared part rows differ from snapshot count")
    return report


def iter_ids(directory, report):
    """manifest에 적힌 순서를 쓴다. glob 결과 순서나 전체 ID 목록을 믿지 않는다."""
    previous = None
    count = 0
    for part in report["parts"]:
        rows_in_part = 0
        with (Path(directory) / part["file"]).open(encoding="utf-8-sig", newline="") as handle:
            reader = csv.reader(handle)
            if next(reader, None) != list(COLUMNS):
                raise ExportError("compare_csv", "invalid CSV header")
            for row in reader:
                if len(row) != len(COLUMNS) or len(csv_line(row)) > HARD_LIMITS["max_row_bytes"]:
                    raise ExportError("compare_csv", "invalid or excessive CSV row")
                try:
                    ident = int(row[0])
                except ValueError as exc:
                    raise ExportError("compare_csv", "saved ID must be an integer") from exc
                if str(ident) != row[0] or previous is not None and ident <= previous:
                    raise ExportError("compare_csv", "IDs must be canonical and strictly increasing")
                if rows_in_part == 0 and ident != part.get("first_id"):
                    raise ExportError("compare_manifest", "first ID differs from declared part")
                previous = ident
                rows_in_part += 1
                count += 1
                if count > report["snapshot"]["count"] or rows_in_part > part["rows"]:
                    raise ExportError("compare_csv", "CSV exceeds declared row count")
                yield ident
        if rows_in_part != part["rows"] or previous != part.get("last_id"):
            raise ExportError("compare_manifest", "part count/last ID differs from declared part")
    if count != report["snapshot"]["count"]:
        raise ExportError("compare_csv", "CSV count differs from snapshot")


def compare_exports(first, second):
    left_report = load_manifest(first)
    right_report = load_manifest(second)
    if left_report["snapshot"] != right_report["snapshot"]:
        raise ExportError("compare_snapshot", "snapshot count/min/max differ between exports")
    # 두 커서 ID를 한 개씩만 유지한다. 해시가 아닌 실제 ID 순서 동등성 검사다.
    old_limit = csv.field_size_limit()
    csv.field_size_limit(HARD_LIMITS["max_row_bytes"])
    left = iter_ids(first, left_report)
    right = iter_ids(second, right_report)
    sentinel = object()
    count = 0
    minimum = None
    maximum = None
    try:
        while True:
            a = next(left, sentinel)
            b = next(right, sentinel)
            if a is sentinel and b is sentinel:
                break
            if a is sentinel or b is sentinel or a != b:
                raise ExportError("compare_ids", f"ID sequences differ at row {count + 1}")
            if minimum is None:
                minimum = a
            maximum = a
            count += 1
        snapshot = left_report["snapshot"]
        if (count, minimum, maximum) != (snapshot["count"], snapshot["min_id"], snapshot["max_id"]):
            raise ExportError("compare_ids", "actual IDs differ from declared snapshot count/min/max")
        return {"count": count, "min_id": minimum, "max_id": maximum}
    except csv.Error as exc:
        raise ExportError("compare_csv", str(exc)) from exc
    finally:
        left.close()
        right.close()
        csv.field_size_limit(old_limit)


def main():
    parser = argparse.ArgumentParser(description="두 검수 완료 출력의 모든 ID를 한 행씩 비교합니다.")
    parser.add_argument("--first", type=Path, required=True)
    parser.add_argument("--second", type=Path, required=True)
    args = parser.parse_args()
    try:
        result = compare_exports(args.first, args.second)
    except (ExportError, OSError) as exc:
        code = exc.code if isinstance(exc, ExportError) else "io_error"
        print(f"ERROR {code}: {exc}", file=sys.stderr)
        return 2
    print(f"MATCH count={result['count']} min_id={result['min_id']} max_id={result['max_id']} all_ids_equal=YES")
    return 0


if __name__ == "__main__":
    sys.exit(main())

7. 실제 실행·반례 검수와 다운로드

실행 환경은 Linux의 Python 3.12.14 / SQLite 3.53.1과 Python 3.13.5 / SQLite 3.46.1입니다. 두 환경 각각에서 제공 테스트 32개와 별도로 작성한 독립 테스트 32개를 실행해 모두 통과했습니다. 정상 입력뿐 아니라 누락·중복·순서 역전, 개수·최소·최대가 같은 다른 ID, 기존 출력 충돌, 실제 SIGINT·SIGTERM 중단, 출력 예산 경계, 동시 WAL 쓰기를 확인했습니다. 최종 ZIP을 새 폴더에 풀어 다시 실행한 결과도 아래 검수 기록에 포함합니다.

배치 10 실행의 콘솔 결과입니다.

SUCCESS rows=23 parts=3 id_compare=OK source_sha=UNCHANGED
OUTPUT run_batch10

배치 7은 parts=4이며 7·7·7·2행입니다. 두 결과를 직접 대조합니다.

python compare_exports.py --first run_batch10 --second run_batch7
MATCH count=23 min_id=1 max_id=23 all_ids_equal=YES

실패를 확인하려면 아직 없는 이름을 사용합니다.

python export_batches.py --database data/source.sqlite3 --output fail_rows --batch-size 10 --max-rows 22
ERROR row_limit: snapshot has 23 rows; max_rows=22

종료 코드는 2입니다. fail_rows에는 실패 기록이 남고 CSV와 성공 manifest는 없습니다. 정상 run_batch10 이름으로 다시 실행하는 경우에도 종료 코드 2로 거부하며 기존 바이트를 보존합니다.

python export_batches.py --database data/source.sqlite3 --output fail_manifest --max-output-bytes 1200
ERROR output_limit: manifest would exceed the combined output byte limit

이 예산 경계에서는 CSV 23행이 있어도 실패입니다. failure.json이 남고 성공 manifest.json은 없습니다. run.log는 읽기·ID 검산까지 기록하지만 SUCCESS라고 쓰지 않습니다. 새 폴더 이름으로 적절한 예산을 사용해 다시 실행하세요.

기본 한도는 배치 최대 1,000행, 총 데이터 100,000행, CSV 1,000개, CSV 한 행 65,536바이트, 정상 출력 합계 1MiB입니다. 합계에는 CSV·run.log·manifest가 포함되며 실패 보고는 별도 최대 16KiB입니다. 주 DB와 기존 WAL·SHM 파일을 합친 입력 크기도 64MiB 이하로 제한합니다. SQLite 값·레코드 한도는 별도로 설정합니다. 이런 한도가 임의 입력의 실행 시간이나 전체 프로세스 최대 메모리 측정값을 뜻하지는 않습니다.

동시 WAL 쓰기 시험에서는 다른 연결이 원본을 바꾼 후 현재 원본은 24행이 되었지만, 진행 중인 읽기 스냅샷의 내보내기·재검산 결과는 원래 23행으로 일치했습니다. 이 시험은 스냅샷 일관성과 주 DB 파일 해시의 한계를 설명하는 합성 반례입니다. 운영 DB를 안전하게 백업했다는 뜻은 아닙니다.

검산은 ID 동일성·순서와 저장 파일의 SHA-256 재확인을 다룹니다. 업무 값의 의미, CSV 수식 실행 위험, 다른 프로그램이 악의적으로 결과를 바꾸는 경우는 별도 검토가 필요합니다.

실습 ZIP에는 가상 DB, 전체 실행 코드, 테스트, 예상 결과, 실제 실행 기록, README, 파일별 SHA-256 목록을 넣었습니다. 본문의 전체 코드와 다운로드 코드가 같은지, ZIP CRC가 정상인지 확인했습니다. 공개 업로드 뒤 내려받은 파일도 원본 ZIP의 SHA-256과 비교했습니다.

실습 ZIP 내려받기

ZIP SHA-256: 2ea77a405e19bc48570913d9be785dd84acd719738c3fb5f548f6aa3fb66eb1e

이 ZIP에는 비밀번호·API 키·실제 고객 데이터가 없습니다. 속도 측정값이나 Windows 앱 실행 경험도 넣지 않았습니다.

8. 적용 범위와 공식 자료

이 글은 파일 결합이나 그룹별 합계를 만드는 실습과 목적이 다릅니다. 그런 작업은 여러 엑셀 파일 병합과 검산과 pandas groupby 집계를 참고하세요. 여기서 다룬 것은 한 조회의 배치 경계를 바꾸면서도 저장된 ID의 순서·누락·중복을 놓치지 않는 방법입니다.

공식 자료는 실제 조사일에 다시 확인했습니다. 당시 현재 /3/ 문서는 Python 3.15.0, 본문에서 연결한 /3.12/ 문서는 3.12.15로 표시되었습니다. 실행 버전과 문서 판을 구분했고, 버전에 따른 음수 fetchmany 처리 차이에는 의존하지 않습니다. 표시 게시일과 실제 검증일을 구분하며, 코드 실행 버전과 시험하지 않은 환경은 위 기록에 명시했습니다.

작성자 소개