본문 바로가기

지속 저장과 안전한 재시도의 경계

효과와 완료 기록을 함께 커밋하고 같은 ID의 동시 요청·다른 입력·기록 만료를 작은 실행으로 추적합니다.

부분 장애와 메모리 상태에서 응답이 없다고 작업이 실행되지 않았음을 단정할 수 없다는 점을 봤습니다. 이번에는 프로세스가 종료돼도 남는 파일 DB를 사용합니다. 목표는 “ID를 붙이면 한 번만 실행된다”는 믿음을 어느 효과와 어느 기록이 함께 저장되는가라는 조건으로 바꾸는 것입니다. Python 함수·dict·예외, SQL 기본 키와 COMMIT/ROLLBACK을 먼저 익혀 둡니다.

효과와 완료 기록의 한 경계

이 예제의 효과는 jobs에 한 행을 넣는 것입니다. 이메일 전송이나 외부 결제는 같은 SQLite 트랜잭션 안에 들어가지 않습니다. 요청은 (호출자 scope,request_id)로 구별하고 같은 키의 요청 내용(payload)도 비교합니다. scope는 여기서 고정된 합성 문자열이며 실제 사용자 인증 구현은 아닙니다.

실패 지점지속 저장 상태같은 요청 재시도
효과 INSERT 뒤, 완료 기록/COMMIT 전트랜잭션이 확정되지 않아 둘 다 안 남음처음부터 한 번 저장
효과만 먼저 COMMIT 뒤, 완료 기록 전효과 1·기록 0완료 여부를 못 찾아 효과를 다시 더할 수 있음
효과+완료 기록을 함께 COMMIT 뒤, 응답 전효과 1·기록 1저장된 결과 재사용
기록의 보관 만료 뒤효과는 남고 기록은 없음오래된 재시도가 새 요청처럼 처리될 수 있음

아래 DB 트랜잭션은 두 로컬 표를 하나의 확정 경계에 둡니다. 이것을 실제 분산 2PC나 SQLite XA PREPARE라고 부르지 않습니다. SQLite의 BEGIN은 준비 투표가 아니며 COMMIT이 단일 DB의 변경을 확정합니다. SQLite transaction

실행 가능한 최소 예제

Python 3의 표준 라이브러리만 씁니다. 쓰기 가능한 자신 전용 작업 폴더에서 새 파일 durable-example.py에 저장하고 python durable-example.py로 실행합니다. 현재 폴더 아래 TemporaryDirectory가 새 디렉터리와 DB를 만들고 정상 종료 때 그 디렉터리만 정리합니다. 시스템 임시 폴더의 접근 권한을 가정하지 않습니다.

durable-example.py
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory

def open_db(path):
    db = sqlite3.connect(path, timeout=10, isolation_level=None)
    db.execute("PRAGMA synchronous=FULL")
    return db

def submit(path, scope, request_id, payload):
    db = open_db(path)
    try:
        db.execute("BEGIN IMMEDIATE")
        old = db.execute(
            "SELECT payload,result FROM requests WHERE scope=? AND rid=?",
            (scope, request_id)
        ).fetchone()
        if old:
            if old[0] != payload:
                raise ValueError("PAYLOAD_CONFLICT")
            db.execute("COMMIT")
            return "REPLAY", old[1]
        result = db.execute(
            "INSERT INTO jobs(payload) VALUES(?)", (payload,)
        ).lastrowid
        db.execute("INSERT INTO requests VALUES(?,?,?,?)",
                   (scope, request_id, payload, result))
        db.execute("COMMIT")
        return "NEW", result
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise
    finally:
        db.close()

with TemporaryDirectory(prefix="cs-durable-", dir=Path.cwd()) as folder:
    path = Path(folder) / "lab.sqlite"
    db = open_db(path)
    db.execute("PRAGMA journal_mode=WAL")
    db.executescript("""
      CREATE TABLE jobs(id INTEGER PRIMARY KEY,payload TEXT NOT NULL);
      CREATE TABLE requests(scope TEXT NOT NULL,rid TEXT NOT NULL,
                            payload TEXT NOT NULL,result INTEGER NOT NULL,
                            PRIMARY KEY(scope,rid));
    """)
    db.close()
    print(submit(path, "reader-1", "request-7", "report"))
    print(submit(path, "reader-1", "request-7", "report"))
    try:
        submit(path, "reader-1", "request-7", "different")
    except ValueError as error:
        print(error)
    db = open_db(path)
    print("jobs", db.execute("SELECT COUNT(*) FROM jobs").fetchone()[0])
    db.close()

순서대로 ('NEW',1), ('REPLAY',1), PAYLOAD_CONFLICT, jobs 1이 나옵니다. 첫 호출은 효과와 결과를 저장합니다. 두 번째는 같은 요청의 완료 기록을 읽습니다. 세 번째는 같은 키로 다른 일을 요청했으므로 기존 결과를 조용히 돌려주거나 새 일을 추가하지 않고 거절합니다. 매 호출이 새 연결을 열어도 DB 파일의 기록은 남습니다.

동시 요청을 막는 실제 자원

두 프로세스가 동시에 같은 키를 보더라도 SQLite의 BEGIN IMMEDIATE는 이 파일의 writer를 먼저 확보합니다. 두 번째 writer는 기다렸다가 첫 커밋 이후의 완료 기록을 읽습니다. 따라서 두 결과는 순서와 관계없이 NEW 하나·REPLAY 하나, 두 결과의 ID는 모두 1이고, 효과는 1개입니다. timeout까지 writer를 얻지 못하면 database is locked 오류가 날 수 있습니다. 이 오류를 작업 완료라고 해석하지 않습니다. SQLite 격리와 writer 직렬화

한 번 SELECT한 뒤 트랜잭션 밖에서 효과를 더하면 둘 다 “기록 없음”을 읽을 수 있습니다. 메모리 dict나 ID 생성만으로 이 경쟁을 막지 못합니다. 이 설계는 한 로컬 DB의 직렬 writer에 의존합니다. 다른 저장소나 분산 노드에는 해당 저장소의 unique 제약·트랜잭션·충돌 처리와 요청 키 정책을 따로 설계해야 합니다.

프로세스 종료와 재시작을 확인하기

저장소의 docs/cs-textbooks/db-os-labs/durable_retry.py는 위 원리를 별도 worker 프로세스로 실행합니다. 표준 라이브러리만 사용하며 포트를 열지 않습니다. 새 결과 폴더를 지정하고 기존 DB가 있는 폴더에는 실행하지 않습니다.

저장소 루트에서 새 격리 실험
python docs/cs-textbooks/db-os-labs/durable_retry.py --out ./retry-lab-new
실행 시나리오종료 전 (효과,기록)재시도 후 (효과,기록)
두 표 INSERT 후 COMMIT 전 worker 종료(0,0) — 다른 연결에서 관측(1,1)
두 표 함께 COMMIT 뒤 응답 전 종료(1,1)(1,1)
잘못된 효과 단독 COMMIT 뒤 종료(1,0)(2,1)

worker는 정해진 저장 경계에서 자체 PID와 준비 표시를 남기고 기다립니다. 제어자는 이 PID가 자신이 만든 worker와 같은지 확인하고 그 프로세스만 종료합니다. DB 파일과 제어자는 살아 있습니다. 원시 *-before-kill.json과 results.json에 입력·예상·관측을 남깁니다. 함수 예외를 실제 프로세스 종료라고 부르지 않습니다. 전원 손실이나 실제 네트워크 응답 유실을 재현하는 실험도 아닙니다.

보관 기간은 보장의 기간이다

실험의 마지막은 완료 기록을 삭제하고 효과를 보존한 뒤 오래된 동일 요청을 재시도합니다. 결과는 효과 2·기록 1입니다. ID 문자열이 같아도 조회할 기록이 없으면 이미 한 일인지 알 수 없습니다.

서비스는 재시도 허용 기간, 키 재사용 가능 시점, 완료 결과/최소 처리 기록(tombstone)의 보관 기간을 맞춰야 합니다. 예를 들어 24시간까지 재시도를 허용하면서 기록을 1시간 뒤 버리면 남은 23시간에는 이 중복 억제 근거가 사라집니다. 실제 기간은 업무와 저장 비용에 따라 정합니다. 기록 만료 뒤에는 오래된 요청을 거절하거나 새 작업으로 취급한다는 계약을 명시해야 합니다. 이 예제의 DELETE는 결함을 관찰하기 위한 것이며 권장 만료 정책이 아닙니다.

외부 결제를 이미 수행한 뒤 로컬 완료 기록이 없으면 이 DB 트랜잭션만으로 결제를 되돌리거나 중복을 막지 못합니다. 외부 서비스의 idempotency key와 조회·대사, outbox/업무 상태 등 별도 경계를 검토해야 합니다. 어떤 효과까지 같은 확정 경계 안에 있는지가 재시도 보장의 범위입니다.

앞 절의 메모리 결과 재사용은 정상 경로를, 이번 절은 지속 저장과 경쟁·충돌·만료를 다룹니다. 여러 DB의 최종 결정은 분산 트랜잭션과 XA 복구로 이어집니다.

작은 실패 추적

효과와 완료 기록을 함께 COMMIT한 뒤 응답 전에 worker가 종료됐습니다. 같은 payload의 동일 요청을 재시도하면 효과 수는 얼마인가요? 이후 완료 기록만 삭제하고 다시 같은 요청을 보내면 무엇이 바뀌나요?

힌트

재시작한 함수의 메모리와 DB 파일의 기록을 나누고, 두 번째 재시도 때 조회할 기록이 있는지 확인합니다.

풀이

첫 재시도는 DB의 완료 결과를 재사용하므로 효과는 1개입니다. 기록만 삭제한 뒤에는 이미 한 작업을 찾을 근거가 없어 새 효과를 넣어 2개가 됩니다. 프로세스 재시작만으로 저장 기록은 없어지지 않으며, 기록 보관 기간은 이 중복 억제 보장의 기간과 맞아야 합니다.