본문 바로가기
AI 튜토리얼

SQLite durable workflow 실전 가이드 — 상태 머신·트랜잭션·폴링 워커 (2026)

by 정부우르사 2026. 5. 30.
반응형

"재시도 안전한 작업 큐 하나 만들겠다고 Redis에 Temporal 서버까지 띄워야 하나요?"


📌 핵심 3줄 요약

  • durable workflow는 프로세스가 죽었다 살아나도 마지막으로 성공한 스텝부터 "이어서" 실행돼 정확히-한-번 효과를 내는 워크플로다.
  • 상태 테이블 + 트랜잭션 원자적 전이 + 폴링 워커 세 가지만 있으면 SQLite durable workflow를 파일 하나로 구현할 수 있다.
  • 단일 노드·중소 처리량에는 충분하지만, 고동시성·수평 확장이 필요하면 Postgres(DBOS) 같은 전용 엔진으로 옮겨야 한다.

1. durable workflow가 뭔가요

여러 스텝으로 이뤄진 긴 작업을, 각 스텝의 상태와 결과를 영속 저장해 두는 게 durable execution(영속 실행)의 핵심이다. 중간에 서버가 죽어도 처음부터 다시 돌리는 대신, 마지막으로 성공한 스텝 다음부터 재개한다.

  • 완료된 스텝은 로그에서 결과만 읽어 건너뛴다(체크포인트·멱등).
  • 실패한 스텝만 재시도하므로 결제·메일 발송 같은 작업이 중복 실행되지 않는다.
  • 이 "재개 가능 + 중복 방지" 조합이 흔히 말하는 정확히-한-번 효과를 만든다.

💡 핵심 한 줄

durable workflow의 본질은 마법이 아니라 "스텝마다 상태와 결과를 트랜잭션으로 기록하고, 재시작하면 그 기록을 replay한다"는 단순한 규칙이다.


2. 왜 SQLite 한 파일로 충분한가

durable execution은 결국 "상태를 안전하게 저장할 곳"이 필요하다. SQLite는 그 저장소를 운영 인프라 0으로 제공한다.

  • 단일 파일 임베디드 DB — 네트워크 홉도, 띄워둘 별도 서버 프로세스도 없다.
  • ACID 트랜잭션 — 상태 전이를 원자적으로 커밋해 "반쯤 처리된" 상태가 안 생긴다.
  • WAL 모드 — 읽기와 쓰기가 서로를 막지 않아 폴링 워커가 부담 없이 돌아간다.

Gunnar Morling은 SQLite로 만든 durable execution 엔진에서 워크플로 진행 상황을 단일 실행 로그 테이블에 PENDING·COMPLETE·WAITING 상태와 attempts(재시도 횟수), 직렬화된 파라미터·반환값으로 남긴다고 설명한다. 구조 자체가 의외로 단출하다.


3. 핵심 구조 1 — 상태 테이블과 원자적 전이

먼저 작업 상태를 담는 테이블을 만든다. 상태는 pending → running → done/failed로만 움직이고, 모든 전이는 트랜잭션 안에서 일어난다.

schema.sql · SQL

PRAGMA journal_mode = WAL;      -- 읽기/쓰기 동시성 확보
PRAGMA synchronous = NORMAL;    -- WAL과 함께 쓰는 표준 설정
PRAGMA busy_timeout = 5000;     -- 잠금 시 5초까지 대기

CREATE TABLE IF NOT EXISTS tasks (
  id          INTEGER PRIMARY KEY,
  kind        TEXT    NOT NULL,             -- 작업 종류
  payload     TEXT    NOT NULL,             -- JSON 입력
  status      TEXT    NOT NULL DEFAULT 'pending', -- pending/running/done/failed
  attempts    INTEGER NOT NULL DEFAULT 0,
  run_after   TEXT    NOT NULL DEFAULT (datetime('now')), -- 재시도 백오프용
  result      TEXT,                         -- 완료 스텝 결과(멱등 replay)
  updated_at  TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_tasks_claim
  ON tasks (status, run_after);

result 컬럼이 체크포인트 역할을 한다. 재시작 후 이미 done인 작업은 결과만 읽어 다음 스텝으로 넘어가므로, 같은 작업이 두 번 실행되지 않는다.


4. 핵심 구조 2 — BEGIN IMMEDIATE로 작업 잡기

워커 여러 개가 같은 작업을 동시에 집으면 중복 실행이 난다. SQLite의 WAL은 "동시 읽기 여럿 + 쓰기는 정확히 하나" 모델이라, 작업을 잡는 순간 쓰기 잠금을 선점하면 경쟁이 자연스럽게 정리된다. 이때 쓰는 게 BEGIN IMMEDIATE다.

worker.py · Python

import sqlite3, json, time

def claim_one(conn):
    # 트랜잭션 시작 즉시 쓰기 잠금을 선점 → 다른 워커는 대기
    conn.execute("BEGIN IMMEDIATE")
    row = conn.execute("""
        SELECT id, kind, payload FROM tasks
        WHERE status = 'pending' AND run_after <= datetime('now')
        ORDER BY id LIMIT 1
    """).fetchone()
    if row is None:
        conn.execute("COMMIT")
        return None
    conn.execute(
        "UPDATE tasks SET status='running', attempts=attempts+1,"
        " updated_at=datetime('now') WHERE id=?", (row[0],))
    conn.execute("COMMIT")     # 여기서 '내가 이 작업 잡았다'가 원자적으로 확정
    return row

def run_forever(db="workflow.db"):
    conn = sqlite3.connect(db, isolation_level=None)  # 수동 트랜잭션
    while True:
        task = claim_one(conn)
        if task is None:
            time.sleep(1)      # 폴링 간격
            continue
        tid, kind, payload = task
        try:
            result = handle(kind, json.loads(payload))   # 실제 스텝 실행
            conn.execute("BEGIN IMMEDIATE")
            conn.execute("UPDATE tasks SET status='done', result=?,"
                         " updated_at=datetime('now') WHERE id=?",
                         (json.dumps(result), tid))
            conn.execute("COMMIT")
        except Exception as e:
            conn.execute("BEGIN IMMEDIATE")
            conn.execute("UPDATE tasks SET status='pending',"   # 재시도로 되돌림
                         " run_after=datetime('now','+30 seconds'),"
                         " updated_at=datetime('now') WHERE id=?", (tid,))
            conn.execute("COMMIT")

핵심은 "작업을 잡는 UPDATE"와 "잡았다는 사실의 커밋"이 한 트랜잭션이라는 점이다. 그래서 워커가 잡은 직후 죽어도 작업은 running에 멈춰 있다가, 별도 청소 로직이 일정 시간 지난 running을 다시 pending으로 돌려 재개한다.

⚠️ 흔한 함정

busy_timeout을 설정하지 않으면 두 워커가 동시에 쓰려 할 때 두 번째가 기다리지 않고 즉시 SQLITE_BUSY로 터진다. WAL은 쓰기를 하나로 제한할 뿐 줄을 세워주지 않으므로, busy_timeout(예: 5000ms)으로 "잠금 풀릴 때까지 대기"를 켜야 한다.


5. SQLite vs Temporal vs Celery vs DBOS

SQLite 직접 구현이 항상 정답은 아니다. 운영 복잡도와 확장성을 같이 보고 골라야 한다.

방식 운영 복잡도 내구성 확장성 적합 규모
SQLite 직접 구현 매우 낮음 (서버 0) 높음 (ACID) 단일 노드 한정 소~중
DBOS (Postgres) 낮음 (라이브러리) 높음 중~대 중~대
Celery + Redis 중간 (브로커 운영) 중간 (별도 설계) 높음 중~대
Temporal 높음 (서비스 3종) 매우 높음 매우 높음 대규모

Temporal은 Frontend·History·Matching 세 서비스에 별도 영속 저장소까지 띄워야 한다. 반면 DBOS는 Postgres에 스텝 결과를 같은 트랜잭션으로 커밋하고 재시작 시 마지막 커밋 스텝부터 replay하는 라이브러리라, 별도 서버가 없다. SQLite 방식은 그 "Postgres"마저 단일 파일로 줄인 가장 가벼운 끝단이다.

"Postgres is all you need for durable execution."

— DBOS 블로그. SQLite는 이 주장을 단일 파일까지 밀어붙인 버전이다.


⚠️ 단점과 주의할 점

  • 고동시성에는 안 맞는다 — SQLite는 쓰기가 정확히 하나라, 워커를 많이 띄워 쓰기를 폭주시키면 단일 라이터가 병목이 된다.
  • 수평 확장 불가 — 여러 서버가 한 DB 파일을 동시에 쓰는 분산 구성에는 부적합하다. 단일 노드 전제다.
  • 이전 시점을 정해둬라 — 처리량이 커지거나 멀티 노드가 필요해지면 Postgres 기반(DBOS) 또는 Temporal로 옮겨야 한다. 표 구조가 비슷해 마이그레이션 부담은 크지 않다.

✅ 핵심 정리

  • 상태 테이블 하나로 작업의 pending·running·done·failed를 추적하고, 결과를 저장해 완료 스텝을 replay로 건너뛴다.
  • 상태 전이는 트랜잭션으로 묶고, 작업 선점은 BEGIN IMMEDIATE로 잠금을 먼저 잡아 중복 실행을 막는다.
  • WAL + busy_timeout으로 폴링 워커들의 SQLITE_BUSY 충돌을 흡수한다.
  • 단일 노드·중소 규모면 인프라 0으로 충분하고, 그 이상은 Postgres·Temporal로 졸업한다.

🚀 지금 바로 할 일

  1. workflow.db를 만들고 위 schema.sql을 실행해 tasks 테이블과 WAL·busy_timeout PRAGMA를 적용한다.
  2. worker.py의 claim_one 루프를 그대로 돌려보고, 워커 프로세스를 강제로 죽였다 다시 켜 작업이 재개되는지 확인한다.
  3. handle() 안에 의도적으로 예외를 던져 재시도 백오프(run_after)가 동작하는지 검증한다.

💬 의견

지금 작업 큐를 Redis나 Celery로 돌리고 계신가요? 그게 정말 필요한 규모인지, 아니면 SQLite 한 파일로 줄일 수 있는지 댓글로 의견 나눠 주세요.

참고 자료


작성자: AI·백엔드 도구를 실무에서 써보며 정리하는 개발 블로그

최종 업데이트: 2026-05-30

참고 환경: Python 3.11 / SQLite 3.45 (WAL 모드) 기준으로 코드를 정리했습니다. 동시성·재시도 동작은 단일 노드 환경 가정입니다.

반응형