ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 1인 로컬 환경에도 outbox 패턴 — NetworkX·Qdrant·파일 3-way 정합성
    IT 2026. 6. 1. 22:00
    1인 로컬 환경에도 outbox 패턴 — NetworkX·Qdrant·파일 3-way 정합성

    한 페이지를 만들었다고 끝이 아니다. deep-wiki의 한 페이지가 작성되면 같은 정보가 세 개의 저장소에 동시에 들어가야 한다. 그래프 DB(SQLite + NetworkX 메모리), 벡터 DB(Qdrant — 의미 기반 검색을 위해 페이지를 임베딩 벡터로 저장하는 DB), 파일 시스템(wiki-output 폴더 + git 커밋). 셋 중 하나라도 실패하면 정합성이 깨진다. SQLite에는 들어갔는데 Qdrant에는 안 들어가면 RAG 검색 결과가 stale(낡은 상태)이 되고, 파일에는 있는데 그래프에는 없으면 viewer가 노드를 찾지 못한다.

    그런데 그 전에 한 가지 의문이 들 수 있다 — "애초에 왜 저장소가 셋인가? 하나로 합치면 안 되나?" 그 답은 별도의 글에서 다뤘다. 짧게 말하면 한 페이지에 던지는 질문이 "관계"·"의미"·"읽기"로 본질이 달라, 그래프 순회·벡터 유사도·파일 읽기를 하나의 엔진으로 잘 답할 수 없기 때문이다. 이 글은 그렇게 셋으로 갈라진 저장소를 어떻게 안전하게 동기화하느냐에 집중한다.

    "이건 분산 시스템 문제 아닌가?" 싶다. 그렇다, 마이크로 버전이긴 하지만 분명한 분산 시스템 문제다. 로컬 1인 환경에서도 트랜잭션이 없는 여러 저장소를 동기화하면 같은 일이 일어난다. 여기서 트랜잭션이란 "여러 작업을 한 묶음으로 처리해 — 다 성공하거나 다 실패하거나"를 보장하는 메커니즘을 말한다. 하나의 DB 안에서는 자연스럽지만 서로 다른 시스템 사이에서는 보장되지 않는다.

    첫 구현에서부터 이 문제를 풀어보려고 했다. 답은 의외로 흔한 패턴 하나였다 — outbox 패턴. 메시지 큐를 중간에 끼워 "한 번에 다 적용하기"를 포기하고 "결국 다 적용되기"를 보장하는 설계다. 이름은 "바깥으로 나가는 우편함" — producer가 큐에 일감을 던져두면 worker가 차례대로 꺼내 각 저장소에 반영한다. 실패하면 다시 시도. 이 글은 그 outbox를 가벼운 Python 워커 한 파일로 어떻게 구현했는지의 기록이다.

    1. 배경 — 트랜잭션 없는 세 개의 저장소

    먼저 각 저장소의 특성을 짚어 보자.

    diagram

    세 저장소는 서로 다른 트랜잭션 경계를 갖는다. atomic이라는 표현은 "쪼개지지 않는다, 다 되거나 다 안 된다"는 뜻 — SQLite WAL은 그 WAL 안에서만 atomic이고, Qdrant 호출은 HTTP 응답으로 끝나며, 파일 쓰기는 파일 단위로 atomic이고 git commit은 또 별도 단계다. 이 셋을 묶어 한 번의 atomic operation으로 처리할 방법은 없다.

    이론적으로는 2PC(Two-Phase Commit, 분산 트랜잭션 프로토콜 — 모든 참가자가 prepare → 모두 OK면 commit, 하나라도 실패면 rollback)를 도입하면 가능하다. 그러나 Qdrant도 파일 시스템도 그런 의미의 prepare/commit을 지원하지 않고, 1인 로컬 환경에 그런 기계 장치를 두는 건 명백히 과잉이다.

    그래서 다른 답이 필요했다. "한 번에 안 되면, 보상으로 결국 맞춰지게 한다." 이게 outbox 패턴의 본질이다 — 강한 atomicity 대신 eventual consistency(시간이 지나면 결국 일관된 상태로 수렴)를 받아들이는 것.

    2. Outbox 흐름 — Redis 큐 + NACK + 백오프

    diagram

    흐름은 단순하다. 페이지를 만든 쪽(convert_docs.py)이 Redis 리스트에 job 한 줄을 RPUSH(리스트 오른쪽 끝에 push)한다. 별도 프로세스인 outbox 워커가 BLPOP(blocking left pop, 리스트가 빌 때까지 기다렸다가 왼쪽에서 하나 꺼내기)으로 그 job을 가져와서 3개 저장소에 순서대로 적용한다. 하나라도 실패하면 NACK(Negative Acknowledgement, "처리 실패" 시그널 — 반대는 ACK) — job의 retry 카운터를 +1해서 큐 끝으로 되돌리고, exponential backoff(지수적으로 늘어나는 재시도 간격 — 1초·3초·9초·27초)로 다음 시도까지 기다린다. 짧은 간격으로 무작정 재시도하면 일시 장애를 더 악화시킬 수 있으므로 점점 길게 기다리는 것이다. 최대 4회 시도해도 실패하면 DLQ(Dead-Letter Queue, "더 이상 처리 못 한 job들의 무덤" — 사람이 검토해야 할 메시지 보관소)로 옮기고 텔레그램 알림을 보낸다.

    핵심은 "producer는 RPUSH 한 번으로 끝, consumer가 결국 맞춘다"는 분리다. 페이지 생성 측은 outbox 큐만 신뢰하면 되고, 정합성 책임은 워커에 위임된다.

    3. 코드로 보면 — 120줄 워커

    scripts/outbox_worker.py 한 파일이 outbox 패턴의 전부다. 분산 시스템 책의 화려한 다이어그램과 달리, 1인 환경에서는 정말 단순한 코드 한 덩어리로 끝난다.

    # scripts/outbox_worker.py — 3-way 정합성 워커 (D1.5)
    
    OUTBOX_KEY = "deep-wiki:outbox"
    DLQ_KEY = "deep-wiki:outbox:dlq"
    BACKOFF = (1, 3, 9, 27)              # 초 단위 exponential backoff
    MAX_RETRIES = len(BACKOFF)           # 총 4회 시도
    
    
    @dataclass(slots=True)
    class Job:
        sha: str                # 페이지 본문 SHA — 캐시 키와 공용
        output_path: str        # wiki-output/private//.md
        repo: str
        commit_sha: str         # 페이지 생성 시점의 코드 commit
        retry: int              # 0부터 시작
    
    
    def enqueue(sha, output_path, repo, commit_sha):
        """페이지 생성 직후 producer가 한 번 호출."""
        payload = json.dumps({
            "sha": sha, "output_path": output_path,
            "repo": repo, "commit_sha": commit_sha, "retry": 0,
        })
        content_sha_cache._client().rpush(OUTBOX_KEY, payload)
    
    
    def _apply(job: Job) -> None:
        """3-way 적용. 한 단계라도 raise되면 위로 전파 → retry."""
        _apply_graph(job)        # SQLite upsert
        _apply_qdrant(job)       # vector upsert
        _apply_git_commit(job)   # local git commit
    
    
    def run(timeout_s: int = 5, max_jobs: int | None = None) -> dict:
        """워커 본체. blpop으로 큐가 빌 때까지 계속."""
        client = content_sha_cache._client()
        counts = {"processed": 0, "retried": 0, "dlq": 0}
        while True:
            item = client.blpop(OUTBOX_KEY, timeout=timeout_s)
            if item is None:
                break                          # 큐 비었음, 정상 종료
            _, raw = item
            job = Job(**json.loads(raw))
            try:
                _apply(job)
                counts["processed"] += 1
            except Exception as exc:
                if job.retry >= MAX_RETRIES - 1:
                    # 4회 모두 실패 → DLQ로 격리, 텔레그램 알림
                    client.rpush(DLQ_KEY, raw)
                    counts["dlq"] += 1
                    _alert_terminal(job, exc)
                else:
                    # 재시도 — backoff 대기 후 큐 끝으로 되돌림
                    time.sleep(BACKOFF[job.retry])
                    job.retry += 1
                    client.rpush(OUTBOX_KEY, json.dumps(asdict(job)))
                    counts["retried"] += 1
        return counts
    

    이 코드의 미덕은 무엇이 없는지가 분명하다는 점이다. 분산 락 없음, distributed coordinator 없음, 2PC 없음. Redis 리스트의 LPOP·RPUSH 두 명령만으로 큐를 만들고, Python 예외를 retry 시그널로 쓴다. 단일 워커 프로세스라서 동시성 문제가 없다 — 워커를 여러 개 띄울 수도 있지만 BLPOP이 자동으로 단 하나의 워커에만 job을 건넨다.

    4. 멱등성과 retry — 같은 job을 두 번 적용해도 안전한가

    backoff retry가 의미를 가지려면 각 적용 단계가 멱등(idempotent)해야 한다. 멱등성이란 같은 연산을 몇 번 적용하든 결과가 동일하다는 성질 — 수학에서 f(f(x)) = f(x)로 표기하는 그 성질이다. 같은 job을 두 번 적용해도 결과가 같아야 안전하게 재시도할 수 있다. 그렇지 않으면 retry가 데이터를 망친다(예: 같은 행이 두 번 INSERT되어 중복이 생기는 식). deep-wiki에서 세 단계 모두 멱등하게 설계됐다.

    • SQLite upsertINSERT OR REPLACE 또는 ON CONFLICT DO UPDATE. 같은 노드 id로 두 번 들어와도 마지막 값으로 덮어쓴다.
    • Qdrant upsert — point id가 동일하면 vector가 갱신될 뿐 중복 row가 생기지 않는다. point id는 repo@commit_sha::lang::symbol_path 형태로 미리 결정됨.
    • 파일 쓰기 — atomic write(임시 파일 → rename). 같은 본문이면 SHA 비교로 no-op으로 끝남(content SHA 캐시).
    • git commit — staged 변경 없으면 자동 skip. 두 번 호출해도 한 번만 commit.

    이 멱등성 덕분에 worker는 "실패하면 다시"를 안전하게 반복할 수 있다. retry counter는 단지 무한 루프 방지용이지 데이터 안전을 위한 게 아니다. 데이터 안전은 각 단계의 멱등성이 책임진다.

    5. 운영 결과 — DLQ(Dead Letter Queue)가 비어 있는 시스템의 가치

    이 패턴을 도입한 직후 가장 만족스러운 결과는 "DLQ가 거의 항상 비어 있다"는 사실이었다. 일시적 실패는 1초·3초·9초·27초 backoff 사이에 거의 모두 자동 회복됐다. Qdrant를 재시작하는 동안 RPUSH된 job이 27초 백오프 안에 자연스럽게 처리됐다. 텔레그램 알림이 발동된 진짜 실패는 한 달에 0-1회 수준이었다.

    그리고 더 중요한 부수 효과 — producer 측 코드가 깨끗해졌다. convert_docs.py가 페이지 생성 후 "그래프에도 넣어줘, Qdrant에도 넣어줘, git commit도 해줘"를 직접 호출하지 않는다. enqueue() 한 줄이면 끝이다. 책임 분리가 자연스럽게 따라왔다.

    마무리 — 분산이 아니라도 분산 패턴이 필요할 때가 있다

    처음에 "1인 로컬 환경에 outbox 패턴이 진짜 필요한가?"라는 의심이 잠깐 들었다. "그냥 try/except로 묶어서 처리하면 되지 않나?" 그런데 같은 정보를 여러 곳에 동기화하는 순간, 그 시스템은 이미 분산 시스템이다. 마이크로 버전일 뿐. SQLite와 Qdrant 사이에 트랜잭션이 없는 것은 분산 시스템에서 다른 호스트 사이에 트랜잭션이 없는 것과 본질적으로 같다.

    그래서 outbox 같은 단순 패턴이 1인 환경에도 의외로 잘 어울린다. 화려한 인프라가 필요한 게 아니다. Redis 리스트 한 줄, exponential backoff 4개 숫자, 멱등성 등. 이 세 가지면 정합성 깨짐의 90%가 자동 회복되고, 나머지 10%는 사람에게 정확하게 도달한다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.