
AI 에이전트 환경에서의 Saga 패턴 — 오케스트레이션(Orchestration), 멱등성(Idempotency), 그리고 트랜잭션
요약
AI 에이전트 환경에서 발생할 수 있는 네트워크 오류와 중복 실행 문제를 해결하기 위한 Saga 패턴과 멱등성 설계 방안을 다룹니다. 오케스트레이션, 멱등성 키, 트랜잭션 아웃박스 패턴을 통해 분산 시스템의 데이터 일관성을 유지하는 방법을 설명합니다.
핵심 포인트
- 네트워크 응답 유실 시 발생하는 중복 실행 및 데이터 불일치 문제 해결
- 오케스트레이션 기반 Saga 패턴을 통한 분산 트랜잭션 관리
- 멱등성 키(Idempotency keys)를 활용한 중복 요청 방지
- 트랜잭션 아웃박스 패턴을 통한 이벤트 발행의 신뢰성 확보
대출 과정은 하나의 saga입니다. 이는 여러 서비스에 걸쳐 실행되는 명령의 시퀀스이며, 각 명령은 개별적으로는 트랜잭션(transactional)이지만, 전체가 공동으로 트랜잭션인 것은 아닙니다. 네트워크가 응답을 삼켜버렸을 때, 오케스트레이션된 saga가 어떻게 정확성을 유지하는지 설명합니다 — 중복 쓰기를 방지하는 재시도(retries), 이벤트를 놓치지 않는 아웃박스(outbox), 그리고 동일한 이벤트를 두 번 적용하지 않는 컨슈머(consumer)에 대하여.
연습용 분산 시스템과 실제 운영 환경의 시스템을 가르는 실패 사례는 정확히 단 하나 존재하며, 이는 한 문장으로 요약됩니다:
쓰기는 커밋되었으나, 응답이 유실되었다.
오케스트레이터(orchestrator)가 대출 실행을 위해 loans-api를 호출합니다. loans-api는 커밋을 완료합니다. 하지만 200 응답이 도착하기 전에 연결이 리셋됩니다. 대출 실행이 이루어졌을까요? 호출자는 알 수 없습니다. 맹목적으로 재시도하면 대출이 두 번 실행될 것이고, 재시도를 전혀 하지 않으면 모든 네트워크 장애가 고객을 여정 중간에 고립시킬 것입니다.
AI 에이전트 환경에서 이러한 실패는 더 심각합니다. 재시도 압박이 대화형(conversational)으로 발생하기 때문입니다. 고객이 "다시 시도해줘"라고 말하면, LLM은 기쁘게 도구(tool)를 다시 호출하며, 이제 당신의 중복 제거(dedup) 로직은 언어 모델에 의해 테스트됩니다. 이 글에서는 이를 안전하게 만드는 전체 메커니즘을 살펴봅: 오케스트레이션 기반의 saga, 제대로 구현된 멱등성 키(idempotency keys), 트랜잭션 아웃박스(transactional outbox), 그리고 멱등적 프로젝션(idempotent projection) — 이 모든 구성 요소는 카오스 테스트(chaos tests)를 통해 검증되었습니다.

다이어그램을 순서대로 읽어보면:
- 명령 (The command) — 오케스트레이터(Orchestrator)가 여정을 주도하며 네트워크를 절대 신뢰하지 않습니다. 오케스트레이터는 하나의 멱등성 키(Idempotency key)를 담은 하나의 명령을 보냅니다. 기록 시스템(System of record)은 쓰기(Write), 이벤트(Event), 그리고 영수증(Receipt)을 단일 트랜잭션(Single transaction) 내에서 커밋합니다.
- 경로 1 — 응답 (Road 1 — the reply) — 동기식 200 응답이 결과를 영구적인 상태(Durable state)로 전달합니다. 이것이 해피 패스(Happy path)이지만, 사라질 수도 있는 경로입니다. 즉, 쓰기는 커밋되었으나 응답이 도착하기 전에 연결이 재설정(Connection reset)되는 경우입니다.
- 경로 2 — 아웃박스 (Road 2 — the outbox) — 이벤트가 동일한 트랜잭션 내에 기록되었기 때문에, 이벤트가 존재하지 않을 수 없습니다. 아웃박스 릴레이(Outbox relay)가 이를 이벤트 버스(Event bus)에 다시 게시(Republish)하면, 프로젝션(Projection)이 이를 적용합니다. 이는 정확히 동일한 영구적 상태로 향하는 두 번째 독립적인 경로입니다.
- 수렴 (Convergence) — 두 경로 모두 동일한 키를 적용하므로, 중복은 병합(Collapse)되고 유실된 응답은 재실행(Replay)됩니다. 진실(Truth)이 아무리 여러 번 도착하더라도, 결과는 정확히 한 번만 반영됩니다. 응답은 사라질 수 있지만, 결과는 사라질 수 없습니다.
원문 기사에는 읽음에 따라 각 단계를 밝혀주는 대화형 스크롤 기반 다이어그램 버전이 있습니다.
전체 아키텍처가 바로 이 그림입니다. 응답은 사라질 수 있으므로, 기록 시스템에서 영구적 상태로 돌아가는 두 개의 독립적인 경로가 존재하며, 두 경로 모두 두 번 이동해도 안전합니다.
코레오그래피(Choreography)가 아닌 오케스트레이션(Orchestration)
서비스들이 서로의 이벤트에 반응하는 방식인 코레오그래피(Choreography) 대안은 에이전트 환경에서 두 배로 중요한 이유 때문에 거부되었습니다. 발생적 사가(Emergent saga)를 고객에게 설명할 수 없기 때문입니다. 에이전트는 매 턴마다 "우리는 어디에 있으며, 다음에 어떤 일이 일어날 수 있는가?"라는 질문에 답해야 합니다. currentState와 HATEOAS 스타일의 availableActions를 가진 중앙 제어 기록(Control Record)이 바로 그 답입니다. 의도적으로 남겨둔 하나의 세부 사항은 다음과 같습니다. disbursed(지급됨)는 종결 상태이며 보상(Compensation)이 없습니다. 이에 대한 취소 경로는 대칭성을 위해서만 존재할 뿐 결코 유효하지 않습니다. 이미 떠난 돈은 '실행 취소(Undo)'가 아니라 새로운 비즈니스 프로세스(채권 회수)입니다.
멱등성(Idempotency): 키가 곧 계약이다
모든 논리적 명령(logical command)은 멱등성 키(idempotency key)를 포함하며, 이 키들의 _스키마(scheme)_는 비즈니스 의도를 인코딩합니다:
// 키는 엔티티(ENTITY)별로 결정론적(deterministic)입니다. 따라서 재시뮬레이션(re-simulation) 시 새로운 키를 받게 됩니다 (기존의 상수
// `${id}:simulation` 키는 loans-api가 재시뮬레이션을 중복으로 처리하여 오래된 시뮬레이션 결과를 반환하게 만들었습니다):
// - simulation: `${id}:simulation:${nonce}` — 시뮬레이션 명령(COMMAND)마다 새로운 논스(nonce)를 사용합니다.
...
이 세 줄을 다시 읽어보십시오. 이들은 "_동일한 명령_이 무엇을 의미하는가?"라는 질문에 대한 세 가지 서로 다른 답변입니다. 하나의 시뮬레이션에 대한 재시도(retry)는 하나의 논스를 재생(replay)합니다. 새로운 시뮬레이션은 새로운 논스를 생성합니다 (기존의 상수 키 버전은 재시뮬레이션을 중복 처리하여 오래된 견적을 반환했는데, 이는 실제 버그였습니다). 그리고 하나의 여정(journey)은 정확히 단 한 번의 지급(disbursement)을 받습니다.
loans-api 측면에서 엔벨로프(envelope)는 매우 단순하며, 그 단순함이 핵심입니다:
export async function runIdempotentWrite<T>(
uow: UnitOfWork, idempotencyKey: string, responseSchema: ZodType<T>,
produce: (tx: LoansTx) => Promise<T>,
...
기록된 응답은 도메인 쓰기(domain write)와 동일한 트랜잭션(transaction) 내에 저장됩니다 (idempotency_keys.key가 기본 키(primary key)이며, 이 고유 제약 조건(unique constraint) 자체가 정당성 증명입니다). 재생된 키는 기록된 응답을 반환합니다. 쓰기 작업은 존재하지만 영수증(receipt)은 존재하지 않는 상태의 구간은 존재하지 않습니다.
재시도(Retry): 제한적이고, 분류되며, 호출자에게는 보이지 않음
멱등성이 밑바탕에 깔려 있으면 재시도가 안전해집니다. 하지만 오직 _올바른 실패(failures)_에 대해서만 재시도를 할 가치가 있습니다:
// 일시적 오류(Transient) = 재시도(RETRY)를 통해 해결 가능한 결함: 5xx UpstreamError 또는 순수
// 전송 실패(transport failure, 연결 재설정 / 타임아웃). 종단 오류(Terminal) = 모든 4xx DomainError
// (유효성 검사, 찾을 수 없음, 잘못된 상태 전이): 재시도는 인내심만 낭비할 뿐입니다.
스케줄은 5회 시도, 기본 1초, ×2 증가 방식 — 총 백오프(backoff) 시간은 약 15초 — 이며, 가산적(additive) 지터(jitter)를 사용합니다. 가산적 방식이 중요한 이유는 다음과 같습니다: 전체 지터(full jitter) 방식은 지연 시간을 0에 가깝게 수렴시킬 수 있는 반면, 이 시스템의 카오스 테스트(chaos test)는 지속적인 장애 상황에서 elapsed > 12s를 *단언(assert)*함으로써 스케줄이 실제로 실행되었음을 증명합니다. 하한선을 줄일 수 있는 지터는 증명을 불안정하게(flaky) 만들지만, 단순히 더하기만 하는 지터는 하한선에 영향을 주지 않으면서 동시 재시도 수행자들 간의 상관관계를 제거(decorrelate)합니다.
복합 구성(composition) 부분이 바로 복사해서 사용할 가치가 있는 부분입니다:
export function createRetryingLoansClient(inner: LoansClient, policy: RetryPolicy): LoansClient {
return {
simulate: (request) => policy.execute(() => inner.simulate(request)),
...
컴포지션 루트(composition root)에 위치한 단순한 데코레이터(decorator)입니다. 커맨드 서비스(command service)는 LoansClient에 의존하며 재시도가 존재한다는 사실을 알지 못합니다. 반대로 재시도 모듈은 대출(loan)의 의미론(semantics)에 대해 전혀 모릅니다. 모듈당 하나의 비밀을 유지합니다. 즉, 스케줄이 변경되면 파일 하나만 변경됩니다. 단위 테스트(unit tests)는 가짜 타이머(fake timers)와 가짜 난수(fake randomness)를 주입하고, 운영 환경(production)은 아무것도 주입하지 않습니다.
전제 조건은 놓칠 수 없는 곳에 명시되어 있습니다:
// PRECONDITION: `op`는 반드시 멱등성(idempotent)을 가져야 합니다. 재시도는 이전 시도가 이미 다운스트림(downstream)에 커밋되었으나 응답만 유실된 경우(모호한 '요청 도달-응답 유실' 실패 사례), 해당 호출을 다시 실행할 수 있습니다. 모든 loans-api 호출은 이 조건을 충족합니다.
트랜잭션 아웃박스(Transactional Outbox): 이벤트는 반드시 존재해야 한다
재시도는 호출자(caller)의 관점을 해결합니다. 하지만 오케스트레이터(orchestrator)의 투영(projection) 또한 이벤트가 필요합니다. 요청 핸들러(request handler) 내부에서 브로커(broker)로 직접 발행하는 방식은 이벤트가 유실되는 원인이 됩니다. 아웃박스 패턴(outbox pattern)은 그 구멍을 메웁니다. loans-api는 하나의 트랜잭션 내에서 도메인 행(domain row)과 이벤트 행(event row)을 동시에 기록합니다:
CREATE TABLE IF NOT EXISTS outbox (
id TEXT PRIMARY KEY, -- 이벤트의 UUIDv7 = 엔드 투 엔드(end to end) 중복 제거/순서 키
event_type TEXT NOT NULL,
...
한 가지 미묘한 조치: 이벤트 ID를 먼저 생성하여 HTTP 응답과 아웃박스(outbox) 행 모두에 포함시킵니다. 동기식 경로(synchronous path)와 비동기식 경로(async path)가 동일한 UUIDv7을 전달하므로, 프로젝션(projection)은 단 한 번의 비교만으로 양쪽 모두에서 중복을 제거(dedup)할 수 있습니다.
DBOS-durable 릴레이(relay)는 매초 테이블의 데이터를 비웁니다(drain). FOR UPDATE SKIP LOCKED를 사용하여 점유하고, 내구성이 있는 단계(durable step) 내에서 SNS로 발행(publish)한 다음, status = 'PENDING' 조건으로 보호된 NOTIFIED 상태로 표시합니다. 발행과 표시 사이에 크래시(crash)가 발생한다면? 해당 행은 다음 틱(tick)에 다시 발행됩니다. 이것이 바로 **설계에 의한 최소 한 번 전달(at-least-once by design)**이며, 이는 문제가 되지 않습니다. 왜냐하면…
…컨슈머(consumer)가 세 가지 방식으로 멱등성(idempotency)을 보장하기 때문입니다
SNS는 SQS 큐로 팬아웃(fan out)하며(3회 수신 실패 시 DLQ로 이동), 오케스트레이터의 컨슈머는 동기식 경로에서 사용하는 것과 동일한 어플라이어(applier)를 통해 이벤트를 적용합니다:
// 1. 중복 제거 / 순서 보장 가드. eventIds는 UUIDv7(단조 증가, 문자열 정렬 가능)입니다:
// 오래된 이벤트나 이미 적용된 이벤트는 아무 작업도 수행하지 않습니다(no-op).
if (current.lastAppliedEventId !== null && event.eventId <= current.lastAppliedEventId) {
...
…그리고 최종 쓰기(write)는 단조 증가하는 version에 대한 비교 및 교체(compare-and-swap, CAS) 작업입니다. state에 대한 CAS가 아닙니다. 재시뮬레이션(re-simulation)은 simulated → simulated 형태의 자기 루프(self-loop)가 되며, 상태(state)를 키로 사용하는 CAS는 오래된 행과 일치하게 됩니다. 이는 실제 발생했던 버그였으나, 단조 증가하는 버전(monotonic version)을 통해 해결되었습니다.
따라서 큐는 **신뢰할 수 있는 원천(source of truth)이 아니라 백스톱(backstop, 안전장치)**입니다. 상태는 loans-api 내에서 어쨌든 내구성을 유지하며, 동기식 경로는 즉시 프로젝션하고, 큐에 쌓인 거의 모든 이벤트는 중복 제거되어 아무 작업도 수행하지 않게(no-op) 됩니다. 코드에서 언급된 솔직한 비용은 다음과 같습니다: 백스톱은 모든 도메인 이벤트를 전달하므로, 백로그(backlog)를 0으로 유지하기 위해서만 실제 운영 환경의 전체 볼륨을 처리해야 합니다. 이것이 바로 이 방식이 처리량을 초당 약 10개의 메시지로 제한했던 기존의 '초당 1틱' DBOS 스케줄링 워크플로우 대신, 경쟁적인 SQS 컨슈머를 사용하게 된 이유입니다.
엔드 투 엔드(end to end)로 재현된 장애 상황
다음은 액터(actor)별 전체 시퀀스입니다:

시나리오 추적: 시뮬레이션(simulate), 1차 시도(attempt 1), 커밋(commit), 응답 유실(response lost).
- 키
CR-1:simulation:<nonce>가 단 한 번 발행됨; 1차 시도에서 행(row) + 아웃박스(outbox) 이벤트E+ 기록된 응답을 커밋하지만, 전송 과정에서 200 응답이 끊김. - 재시도 클라이언트(retry client)는 전송 오류를 일시적인(transient) 것으로 분류하고, 약 1초간 대기한 후 동일한 키로 재시도함.
- loans-api는 해당 키를 찾아냄 → 기록된 응답을 반환함. 두 번째 시뮬레이션도, 두 번째 이벤트도 발생하지 않음.
- 오케스트레이터(orchestrator)가
E를 동기적으로 투영(project)함: 버전 CAS 0→1, 상태simulated. - 그 사이 릴레이(relay)가
E를 발행함 → SNS → SQS; 컨슈머(consumer)는E ≤ lastAppliedEventId임을 확인 → 아무 작업도 수행하지 않음(no-op); 메시지 삭제.
최종 상태: 정확히 하나의 시뮬레이션, 버전 1인 하나의 제어 레코드(Control Record) — 아무런 장애가 발생하지 않았던 실행 결과와 구별할 수 없음. E2E(end-to-end) 테스트 스위트는 Toxiproxy를 사용하여 이 정확한 경로를 강제하며, 단일 시뮬레이션 발생 여부와 백오프(backoff)가 작동했음을 증명하는 경과 시간 하한선(elapsed-time floor)을 모두 검증함.
결론
여기서 다루는 내용은 전혀 생소한 것이 아닙니다 — Saga 오케스트레이션(orchestration), 멱등성 키(idempotency keys), 아웃박스(outbox), 멱등적 컨슈머(idempotent consumer)는 모두 20년 된 개념들입니다. 이 조합을 프로덕션 수준(production-grade)으로 만드는 핵심은 각 메커니즘이 다른 메커니즘이 작동할 것임을 가정한다는 점입니다: 재시도는 응답이 유실될 것을 가정하고, 키는 재시도가 발생할 것을 가정하며, 아웃박스는 프로세스가 발행 도중 종료될 것을 가정하고, 컨슈머는 큐가 메시지를 두 번 전달하거나 순서가 어긋날 것을 가정합니다. 그리고 카오스 테스트(chaos tests)는 아무것도 가정하지 않습니다 — 결함을 주입하고 측정할 뿐입니다.
단 한 문장만 기억한다면 다음과 같습니다: 모든 명령이 정확한 의미를 갖도록 만들고(키 스킴), 모든 쓰기 작업이 스스로를 알리게 하며(아웃박스), 모든 적용(apply) 작업이 반복되어도 해롭지 않게 만드십시오(멱등적 투영) — 그러면 재시도는 더 이상 두려운 대상이 아니라 지루한 과정이 될 것입니다. 지루함이 바로 목표입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기