본문 바로가기

지속 저장과 안전한 재시도

SQLite 파일 데이터베이스에 작업과 완료 기록을 한 트랜잭션으로 저장해, 같은 요청의 재시도와 다른 내용의 충돌, 기록 만료 뒤의 중복을 실행으로 확인합니다. 실제로 worker 프로세스를 강제 종료해 커밋 전, 커밋 뒤, 효과만 먼저 저장한 경우에 무엇이 남는지 비교하고, 동시 요청과 외부 효과의 한계를 정리합니다.

앞 글에서는 응답이 오지 않았다고 해서 작업이 실행되지 않았다고 단정할 수 없고, 메모리에만 기록한 완료 정보는 프로세스와 함께 사라진다는 것을 보았습니다. 이번에는 프로세스가 죽어도 남는 파일 데이터베이스를 씁니다. 목표는 "요청 번호를 붙이면 한 번만 실행된다"는 막연한 믿음을, 어떤 효과와 어떤 기록이 함께 저장되느냐는 구체적인 조건으로 바꾸는 것입니다.

예제는 파이썬 표준 라이브러리의 sqlite3만 씁니다. SQL의 기본 키와 COMMIT, ROLLBACK을 알고 있으면 읽기 쉽습니다.

무엇과 무엇을 함께 저장하나

예제에서 효과는 jobs 표에 작업 한 행을 넣는 것이고, 완료 기록은 requests 표에 요청 번호와 그 결과를 남기는 것입니다. 요청은 호출한 쪽을 나타내는 scope와 요청 번호 rid의 짝으로 구별하고, 같은 번호에 다른 내용이 오는지 확인하려고 요청 내용도 함께 기록합니다. 어디에서 실패하느냐에 따라 남는 것이 다릅니다.

실패한 지점저장소에 남는 것같은 요청을 다시 보내면
두 표에 넣은 뒤, COMMIT 전트랜잭션이 확정되지 않아 둘 다 없다처음 처리하듯 한 번 저장한다
두 표를 함께 COMMIT한 뒤, 응답 전작업 1개, 기록 1개저장된 결과를 다시 돌려준다
작업만 먼저 확정하고, 기록 전작업 1개, 기록 없음처리 여부를 알 수 없어 작업을 또 넣는다
기록의 보관 기간이 지나 지운 뒤작업은 남고 기록은 없다오래된 재시도가 새 요청처럼 처리된다

두 표를 한 트랜잭션에 넣는 것이 핵심입니다. 이것은 하나의 데이터베이스 안에서 두 표의 변경을 함께 확정하는 보통의 트랜잭션이며, 여러 데이터베이스를 함께 확정하는 분산 커밋과는 다릅니다. SQLite의 트랜잭션 문서에 따르면 COMMIT 전에 연결이 닫힌 트랜잭션은 되돌려지고, 원자적 커밋 문서에 따르면 커밋하기 전에 프로세스가 죽거나 전원이 꺼진 트랜잭션도 다음에 파일을 열 때 저널로 되돌려져 변경이 남지 않습니다.

효과와 완료 기록을 한 트랜잭션으로

import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory

SCHEMA = """
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));
"""

def submit(path, scope, rid, payload):
    db = sqlite3.connect(path, timeout=10, isolation_level=None)
    try:
        db.execute("BEGIN IMMEDIATE")                     # 쓰기 잠금부터 잡는다
        old = db.execute("SELECT payload, result FROM requests WHERE scope=? AND rid=?",
                         (scope, rid)).fetchone()
        if old:                                           # 이미 처리한 요청이다
            db.execute("COMMIT")
            return ("REPLAY", old[1]) if old[0] == payload else ("PAYLOAD_CONFLICT", None)
        result = db.execute("INSERT INTO jobs(payload) VALUES(?)", (payload,)).lastrowid
        db.execute("INSERT INTO requests VALUES(?, ?, ?, ?)", (scope, rid, payload, result))
        db.execute("COMMIT")                              # 효과와 완료 기록을 함께 확정한다
        return "NEW", result
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise
    finally:
        db.close()

def counts(path):
    db = sqlite3.connect(path)
    try:
        return db.execute("SELECT (SELECT COUNT(*) FROM jobs), (SELECT COUNT(*) FROM requests)").fetchone()
    finally:
        db.close()

with TemporaryDirectory() as folder:
    path = Path(folder) / "lab.sqlite"
    db = sqlite3.connect(path)
    db.executescript(SCHEMA)
    db.close()

    print("첫 요청:", submit(path, "reader-1", "request-7", "report"))
    print("같은 요청 재시도:", submit(path, "reader-1", "request-7", "report"))
    print("같은 번호, 다른 내용:", submit(path, "reader-1", "request-7", "different"))
    print("(작업, 완료 기록):", counts(path))

    db = sqlite3.connect(path)                            # 완료 기록을 보관 기간이 지나 지웠다
    db.execute("DELETE FROM requests")
    db.commit()
    db.close()
    print("기록이 지워진 뒤 재시도:", submit(path, "reader-1", "request-7", "report"))
    print("(작업, 완료 기록):", counts(path))
첫 요청: ('NEW', 1)
같은 요청 재시도: ('REPLAY', 1)
같은 번호, 다른 내용: ('PAYLOAD_CONFLICT', None)
(작업, 완료 기록): (1, 1)
기록이 지워진 뒤 재시도: ('NEW', 2)
(작업, 완료 기록): (2, 1)

첫 요청은 작업과 완료 기록을 함께 저장하고, 같은 요청의 재시도는 저장된 결과 1을 다시 돌려받습니다. 같은 번호로 다른 내용을 보내면 기존 결과를 조용히 돌려주지도, 새 작업을 넣지도 않고 거절합니다. 클라이언트가 번호를 잘못 재사용한 것이므로, 어느 쪽으로 처리해도 클라이언트의 기대와 어긋나기 때문입니다. 호출할 때마다 연결을 새로 열고 닫는데도 결과가 이어지는 것은 기록이 파일에 남아 있기 때문입니다.

마지막 두 줄은 완료 기록을 지운 뒤의 일입니다. 같은 번호의 요청인데도 기록이 없으니 이미 처리했는지 알 수 없어, 작업이 하나 더 생겼습니다. 이 문제는 아래의 보관 기간에서 다시 다룹니다.

같은 요청이 동시에 오면

클라이언트가 타임아웃 직후 재시도했는데 원래 요청도 늦게 도착해, 같은 번호의 요청 두 개가 동시에 처리된다고 합시다. 둘이 모두 "기록이 없다"고 읽은 뒤 각자 작업을 넣으면 중복이 생깁니다. 이처럼 확인한 뒤 행동하는 사이에 다른 쪽이 끼어드는 것은 6장에서 본 경쟁 조건과 같은 문제입니다.

위 코드는 이를 BEGIN IMMEDIATE로 막습니다. 이 명령은 트랜잭션을 시작하자마자 그 데이터베이스 파일의 쓰기 권한을 확보하고, SQLite는 한 번에 하나의 쓰기 트랜잭션만 허락합니다(SQLite의 격리 문서). 두 번째 요청은 첫 요청이 커밋할 때까지 기다렸다가 기록을 읽으므로, 둘의 결과는 언제나 NEW 하나와 REPLAY 하나이고 작업은 한 개입니다. 기다린 시간이 timeout을 넘으면 database is locked 오류가 나는데, 이는 작업이 처리되었다는 뜻이 아니므로 다시 시도해야 합니다.

requests 표의 기본 키도 마지막 안전장치가 됩니다. 두 요청이 모두 같은 번호를 넣으려 하면 두 번째는 기본 키 위반으로 실패합니다. SQLite는 이때 실패한 문장만 되돌리고 트랜잭션은 열어 두므로, 같은 트랜잭션에서 먼저 넣은 작업은 위 코드의 except에서 ROLLBACK할 때 함께 취소됩니다. 이 방법은 한 데이터베이스가 쓰기를 차례로 처리해 준다는 데 기대고 있으므로, 다른 저장소나 여러 노드에 걸친 시스템에서는 그 저장소의 고유 제약과 트랜잭션으로 같은 일을 설계해야 합니다. 메모리의 사전이나 번호를 만드는 것만으로는 이 경쟁을 막을 수 없습니다.

프로세스가 죽으면 무엇이 남나

이제 함수의 예외가 아니라 실제 프로세스를 죽여 봅니다. 다음 코드는 별도의 worker 프로세스가 저장 도중의 정해진 지점까지 진행하면 표시 파일을 남기고 기다리게 하고, 그 순간 worker를 강제로 종료합니다. 그런 다음 남은 것을 세고, 같은 요청을 다시 보냅니다.

import sqlite3
import subprocess
import sys
import time
from pathlib import Path
from tempfile import TemporaryDirectory

WORKER = """
import pathlib, sqlite3, sys, time
path, mode, ready = sys.argv[1:4]
db = sqlite3.connect(path, isolation_level=None)
if mode == "effect-only":                   # 잘못된 설계: 효과만 먼저 확정한다
    db.execute("INSERT INTO jobs(payload) VALUES('report')")
else:
    db.execute("BEGIN IMMEDIATE")
    job = db.execute("INSERT INTO jobs(payload) VALUES('report')").lastrowid
    db.execute("INSERT INTO requests VALUES('request-7', ?)", (job,))
    if mode == "after-commit":
        db.execute("COMMIT")                # 두 표를 함께 확정했다
pathlib.Path(ready).write_text("ready")     # 이 지점까지 왔다고 알리고
time.sleep(60)                              # 응답하기 전에 종료되기를 기다린다
"""

def counts(path):
    db = sqlite3.connect(path)
    try:
        return db.execute("SELECT (SELECT COUNT(*) FROM jobs), (SELECT COUNT(*) FROM requests)").fetchone()
    finally:
        db.close()

def retry(path):                            # 완료 기록이 없을 때만 두 표를 함께 저장한다
    db = sqlite3.connect(path, isolation_level=None)
    try:
        db.execute("BEGIN IMMEDIATE")
        if db.execute("SELECT 1 FROM requests WHERE rid='request-7'").fetchone() is None:
            job = db.execute("INSERT INTO jobs(payload) VALUES('report')").lastrowid
            db.execute("INSERT INTO requests VALUES('request-7', ?)", (job,))
        db.execute("COMMIT")
    finally:
        db.close()

with TemporaryDirectory() as folder:
    for mode in ("before-commit", "after-commit", "effect-only"):
        path, ready = Path(folder) / f"{mode}.sqlite", Path(folder) / f"{mode}.ready"
        db = sqlite3.connect(path)
        db.executescript("CREATE TABLE jobs(id INTEGER PRIMARY KEY, payload TEXT);"
                         "CREATE TABLE requests(rid TEXT PRIMARY KEY, result INTEGER);")
        db.close()
        worker = subprocess.Popen([sys.executable, "-c", WORKER, str(path), mode, str(ready)])
        deadline = time.monotonic() + 15
        while not ready.exists() and time.monotonic() < deadline:
            time.sleep(0.01)
        worker.kill()                       # 응답을 보내기 전에 worker를 강제로 끝낸다
        worker.wait()
        before = counts(path)
        retry(path)
        print(f"{mode}: 종료 직후 (작업, 기록) = {before}, 재시도 뒤 = {counts(path)}")
before-commit: 종료 직후 (작업, 기록) = (0, 0), 재시도 뒤 = (1, 1)
after-commit: 종료 직후 (작업, 기록) = (1, 1), 재시도 뒤 = (1, 1)
effect-only: 종료 직후 (작업, 기록) = (1, 0), 재시도 뒤 = (2, 1)

worker.kill()은 Linux에서는 SIGKILL을, Windows에서는 프로세스 강제 종료를 보내므로 worker는 정리할 기회 없이 끝납니다. 커밋 전에 죽은 worker의 변경은 SQLite가 다음에 파일을 열 때 저널로 되돌려 아무것도 남지 않았고, 재시도가 처음부터 한 번 저장했습니다. 두 표를 함께 커밋한 뒤 죽은 경우에는 둘이 모두 남아 있어, 재시도가 아무것도 더하지 않았습니다. 앞 글의 메모리 모델과 달리 프로세스가 죽어도 완료 기록이 사라지지 않은 것입니다. 효과만 먼저 확정한 잘못된 설계에서는 기록이 없어 재시도가 작업을 또 넣었습니다.

이 실험은 프로세스의 강제 종료를 재현한 것이지, 전원이 꺼지거나 디스크가 망가지는 경우를 재현한 것은 아닙니다. 전원이 꺼져도 커밋한 내용이 남으려면 10장 4편에서 본 것처럼 데이터베이스가 커밋할 때 fsync로 저장 장치까지 기록을 내려보내야 하며, SQLite는 PRAGMA synchronous 설정으로 이를 조절합니다.

더 깊이: 전원이 꺼진 뒤에도 커밋이 남으려면

SQLite의 PRAGMA 문서에 따르면 synchronous는 OFF(0), NORMAL(1), FULL(2), EXTRA(3)의 네 단계이고, 롤백 저널을 쓸 때의 기본값은 FULL입니다. 문서는 응용 프로그램이 죽는 경우에는 이 설정이나 저널 방식과 상관없이 커밋한 트랜잭션이 남는다고 밝힙니다. 그러니 앞의 실험 결과는 이 설정과 무관하고, 설정이 차이를 만드는 것은 운영체제가 멈추거나 전원이 꺼지는 경우입니다.

롤백 저널의 기본인 DELETE 모드에서는 저널 파일을 지우는 것으로 커밋을 마칩니다. 그런데 문서에 따르면 FULL에서도 파일 시스템에 따라서는 전원이 꺼지기 직전에 커밋한 트랜잭션 하나가 재부팅 뒤 되돌려질 수 있습니다. 데이터베이스가 망가지지는 않지만 지속성은 깨지는 셈이며, EXTRA는 저널을 지운 뒤 그 저널이 있던 디렉터리까지 동기화해 이를 막습니다. 10장 4편에서 이름을 바꾼 뒤 디렉터리도 fsync()해야 했던 것과 같은 이유입니다.

WAL 모드에서는 사정이 다릅니다. WAL 모드의 NORMAL은 대부분의 트랜잭션에서 동기화를 하지 않으므로, 데이터베이스는 늘 일관되게 남지만 전원이 꺼지면 커밋한 트랜잭션이 되돌려질 수 있습니다. FULL은 커밋할 때마다 WAL 파일을 한 번 더 동기화해 전원이 꺼져도 트랜잭션이 남게 하며, WAL 모드에서 EXTRA는 FULL과 다르지 않습니다.

보관 기간이 곧 보장 기간이다

첫 예제의 마지막 부분처럼, 완료 기록이 지워지면 같은 번호의 요청이라도 이미 처리했는지 알 길이 없습니다. 완료 기록을 영원히 쌓아 둘 수는 없으므로 언젠가는 지워야 하는데, 그 순간부터 중복을 막는 장치도 사라집니다.

그래서 서비스는 클라이언트가 얼마 동안 재시도할 수 있는지, 같은 요청 번호를 언제 다시 써도 되는지, 완료 기록을 얼마 동안 보관하는지를 서로 맞춰야 합니다. 클라이언트에게 24시간 동안 재시도해도 된다고 약속하면서 기록을 1시간 만에 지우면, 남은 23시간 동안은 재시도가 중복을 만들 수 있습니다. 결제 서비스의 API들이 요청 번호(idempotency key)를 얼마 동안 기억하는지 문서에 밝혀 두는 것도 이 때문입니다. 보관 기간이 지난 요청 번호가 다시 오면 새 요청으로 처리할지 거절할지도 정해서 알려야 합니다.

같은 트랜잭션에 넣을 수 없는 효과

지금까지의 효과는 같은 데이터베이스에 행을 넣는 것이어서 완료 기록과 한 트랜잭션에 넣을 수 있었습니다. 하지만 이메일을 보내거나 외부 결제 서비스를 부르는 일은 데이터베이스의 트랜잭션에 넣을 수 없습니다. 결제를 요청한 뒤 완료 기록을 커밋하기 전에 프로세스가 죽으면, 결제는 되었는데 기록은 없는 상태가 되고, 데이터베이스의 ROLLBACK으로 결제를 되돌릴 수도 없습니다.

이런 효과에는 다른 방법을 씁니다. 외부 서비스가 요청 번호를 지원한다면 같은 번호를 그대로 넘겨 외부 서비스가 중복을 걸러 내게 하고, 나중에 외부 서비스의 기록과 내 기록을 맞춰 보는 대사를 합니다. 아니면 보낼 메시지를 업무 데이터와 같은 트랜잭션으로 아웃박스(outbox) 표에 먼저 기록하고, 별도의 프로세스가 그 표를 읽어 실제로 보내면서 보낸 것을 표시하는 방법도 있습니다. 여러 데이터베이스의 변경을 한꺼번에 확정해야 하는 경우는 데이터베이스 교재의 분산 트랜잭션과 XA 복구에서 다룹니다. 어떤 방법이든 재시도가 안전한 범위는, 어떤 효과까지 같은 확정 단위 안에 들어 있느냐로 정해집니다.

정리

  • 요청 번호로 중복을 막으려면 효과와 완료 기록이 한 트랜잭션으로 함께 확정되어야 합니다.
  • 같은 번호에 다른 내용이 오면 기존 결과를 돌려주거나 새 작업을 넣지 말고 거절합니다.
  • 같은 요청이 동시에 오는 경쟁은 확인과 저장을 한 쓰기 트랜잭션 안에서 하고, 기본 키 같은 고유 제약을 함께 두어 막습니다.
  • 커밋 전에 죽은 프로세스의 변경은 남지 않고, 커밋 뒤에 죽은 프로세스의 변경은 모두 남으므로, 함께 커밋한 설계에서는 프로세스가 죽어도 재시도가 안전합니다.
  • 완료 기록의 보관 기간이 중복을 막아 주는 기간이며, 데이터베이스 밖의 효과는 외부의 요청 번호나 아웃박스 같은 별도의 장치가 필요합니다.

연습 문제

1. 커밋 뒤의 종료와 기록 삭제

worker가 작업과 완료 기록을 함께 커밋한 뒤, 응답을 보내기 전에 강제로 종료되었습니다. 클라이언트가 같은 내용으로 같은 요청을 다시 보내면 작업은 몇 개가 되나요? 그 뒤 운영자가 완료 기록만 지우고 같은 요청이 한 번 더 온다면 무엇이 달라지나요?

해설 보기

worker는 죽었지만 커밋한 두 기록은 데이터베이스 파일에 남아 있으므로, 재시도는 완료 기록을 찾아 저장된 결과를 돌려주고 작업은 1개로 그대로입니다. 프로세스가 다시 시작되어도 파일의 기록은 사라지지 않습니다. 완료 기록만 지운 뒤에는 이 요청을 이미 처리했다는 것을 알려 줄 기록이 없으므로, 같은 요청이 새 요청으로 처리되어 작업이 2개가 됩니다. 기록을 보관하는 동안만 중복을 막을 수 있다는 뜻입니다.

2. 트랜잭션 밖에서 확인하면

어떤 개발자가 submit()을 고쳐, 완료 기록이 있는지는 트랜잭션 밖에서 먼저 SELECT로 확인하고, 없을 때만 트랜잭션을 열어 작업과 기록을 넣도록 했습니다. requests 표에는 기본 키가 없습니다. 같은 번호의 요청 두 개가 동시에 오면 어떻게 되나요? 기본 키가 있었다면 결과가 어떻게 달라지나요?

해설 보기

두 요청이 모두 트랜잭션 밖에서 "기록이 없다"고 읽은 뒤, 각자 트랜잭션을 열어 작업과 기록을 넣으므로 작업이 2개, 기록도 2개가 됩니다. 확인과 저장 사이에 다른 요청이 끼어들 수 있는 전형적인 경쟁 조건입니다. (scope, rid)에 기본 키가 있었다면 두 번째 트랜잭션은 기록을 넣는 순간 기본 키 위반으로 실패하고, 그 트랜잭션을 ROLLBACK하면 먼저 넣은 작업도 함께 취소되므로 작업은 1개로 남습니다. 다만 두 번째 요청은 오류를 받게 되므로, 그 오류를 잡으면 먼저 ROLLBACK한 뒤 기록을 다시 읽고 저장된 결과를 돌려주도록 처리해야 합니다. 오류를 잡은 채 같은 트랜잭션을 COMMIT하면 실패한 문장만 되돌려진 상태여서 작업이 2개가 됩니다. 처음부터 확인과 저장을 BEGIN IMMEDIATE 트랜잭션 안에서 하는 편이 단순합니다.

3. 확인 메일을 보내는 위치

작업을 접수하면 사용자에게 확인 메일을 보내야 합니다. 첫 예제의 submit()에서 COMMIT 직전에 메일을 보내는 경우와 COMMIT 직후에 보내는 경우, 각각 프로세스가 죽거나 재시도가 일어나면 어떤 문제가 생길 수 있나요? 더 나은 방법은 무엇인가요?

해설 보기

COMMIT 직전에 보내면, 메일을 보낸 뒤 커밋 전에 프로세스가 죽었을 때 작업과 기록은 되돌려지는데 메일은 이미 나간 상태가 됩니다. 재시도가 작업을 다시 저장하면서 메일도 다시 보내, 사용자는 메일을 두 통 받고 그중 하나는 실제로 없던 접수에 대한 것입니다. COMMIT 직후에 보내면, 커밋 뒤 메일을 보내기 전에 프로세스가 죽었을 때 재시도는 저장된 결과만 돌려주므로 메일이 한 통도 나가지 않습니다. 더 나은 방법은 보낼 메일을 아웃박스 표에 작업, 완료 기록과 같은 트랜잭션으로 기록하고, 별도의 프로세스가 아웃박스를 읽어 메일을 보낸 뒤 보냈다고 표시하는 것입니다. 이때도 보낸 뒤 표시하기 전에 죽으면 다시 보낼 수 있으므로, 메일 서비스가 요청 번호로 중복을 걸러 주거나 같은 메일이 두 번 가도 괜찮도록 설계해야 합니다.

이것으로 운영체제 교재의 본문을 마칩니다. 처음부터 다시 훑어보고 싶다면 운영체제 목차로 돌아가, 각 장의 개념이 서로 어떻게 이어지는지 확인해 보세요.