AI 에이전트가 재시도 시 이메일을 다시 보내는 문제: 부작용을 위한 아웃박스
요약
기존 에이전트 프레임워크는 도구 호출을 실행 내부에서 처리하여 재시도나 리플레이 시 부작용(side effect)이 반복되는 문제가 있습니다. 본 글은 이 문제를 해결하기 위해 '아웃박스(outbox)' 디자인 패턴을 제안합니다. 의도를 상태로 기록하고, 런타임이 커밋 후에 한 번만 전달함으로써 데이터의 무결성을 보장합니다.
핵심 포인트
- 에이전트 단계에서 I/O를 직접 수행하지 말고 '의도'를 상태로 기록해야 합니다.
- 아웃박스 패턴은 의도를 원자적으로 커밋하고, 런타임이 이후 한 번만 전달하도록 설계되었습니다.
- 재시도나 리플레이 시 부작용 반복을 막고, 결정론적 재구성을 가능하게 합니다.
대부분의 에이전트 프레임워크는 한 단계를 "어떤 작업을 수행한 다음, 도구를 호출한다."로 모델링합니다.
따라서 도구 호출(이메일 전송, 웹훅 호출, 카드 청구 등)은 그래프 중간 어딘가에서 실행 내부에 발생합니다.
이 방식은 문제가 생길 때까지는 작동합니다. 재시도(retry), 충돌 후 재개(crash-resume), 또는 결정론적 리플레이(deterministic replay)를 추가하는 순간, 동일한 단계가 다시 실행되고 부작용(side effect)이 다시 발생합니다.
상태는 버전 관리하고 재현할 수 있지만, 세상은 그렇지 못합니다.
본 포스트는 제가 reactifact 0.14.0에 구현한 **아웃박스(outbox)**에 관한 것입니다. 현재 0.15.x 라인에서도 여전히 이 디자인을 사용하고 있습니다. produce 레코드는 컨텍스트 내에서 "무엇이 발생해야 하는지"를 아티팩트(artifact)로 기록합니다 (다른 모든 것과 원자적으로 커밋됨). 런타임은 안정적인 ID당 한 번, 커밋 후에 이를 전달합니다. 리플레이는 아무것도 다시 보내지 않고 답변을 재구성합니다.
요약: 에이전트 단계 내부에서 I/O를 수행하지 마십시오. "의도(intent)"를 상태로 기록하고 (다른 모든 것과 원자적으로 커밋됨), 런타임이 커밋 후에 한 번 전달하도록 하세요. 재시도, 재개 및 리플레이는 다시 보내지 않고 결정을 재구축합니다.
API 키 없이 약 1분 만에 실행 가능합니다:
pip install reactifact
python -m examples.outbox.main # 일곱 가지 케이스가 있으며, 각각 단언됨
코드: github.com/bzdvdn/reactifact
그래프 프레임워크에 익숙하다면, 차이점은 효과(effect)가 어디에 존재하는지입니다:
| 일반적인 에이전트 단계 | reactifact | |
|---|---|---|
| 단계는 한다 | 도구를 "실행 중" 호출한다 | 의도를 상태로 작성한다 |
| ... |
- 재시도/재개(Retry / resume). 충돌한 실행은 마지막 체크포인트(checkpoint)에서 재개되어 해당 단계를 다시 실행합니다. 이메일이 두 번 발송됩니다.
- 병렬 프로듀서(Parallel producers). 동일한 생성(generation) 내의 두 프로듀서는 모두
pre-commit스냅샷을 참조하며, 둘 다 "아직 수신 기록 없음(no receipt yet)"을 확인하고 둘 다 전송합니다. - 재생(Replay). 에이전트가 "왜 이메일을 보냈는지?"에 답하고 싶지만, 단계를 재실행하는 재생은 이메일을 다시 보내므로, 이는 진정한 의미의 재생이 아닙니다.
이를 방지하기 위해 노력할 수 있습니다. create_once(...) 스타일의 검사는 상태(state)에는 도움이 되지만, 가드(guard)는 커밋 시점(commit time)에 해결되며, 한 생성 내의 두 프로듀서는 동일한 pre-commit 스냅샷을 공유합니다. 따라서 둘 다 가드를 통과하고 둘 다 전송하게 됩니다. 이 검사는 잘못된 위치에 있습니다. 쓰기 작업(write)을 보호하는 것이지, 부작용(side effect)이 발생하는 시점보다 앞서 일어나는 것을 막는 것이 아니기 때문입니다.
의도는 상태이며, 전달은 그렇지 않다
아웃박스(outbox)는 순서를 역전시킵니다:
- 프로듀서는 의도(intent)를 기록합니다 —
PendingAction이라는 아티팩트가 생성되며, I/O 작업은 수행하지 않습니다. - 런타임은 프로듀서의 효과(의도 및 기타 모든 것)를 하나의 원자적 패치로 컴파일하여 커밋합니다.
- 커밋 후에, 런타임은 안정적인 ID(stable id)당 한 번씩, 커밋되었지만 아직 전달되지 않은 의도를 **디스패처(dispatcher)**에게 전달하고 그 결과를 상태로 기록합니다.
다음과 같이 읽히는 아티팩트에서:
from reactifact import PendingAction, ProduceCall, produce
@produce(Receipt, also_creates=[PendingAction])
...
effects.act(...)는 안정적인 ID action:{key} 아래에 PendingAction을 생성하며, 이미 존재하는 경우(idempotent re-run) None을 반환합니다. 프로듀서가 하지 않는 것을 주목하세요: 네트워크에 절대 접근하지 않습니다. 단지 변화를 상태로 명시할 뿐입니다.
전달은 별도의 주입된 단계(injected step)입니다.
async def dispatch(context, action):
# 실제 구현에서는 이 위치에 이메일/웹훅 API 호출 로직이 들어갑니다.
if action.data.idempotency_key in sent:
...
런타임은 각 생성(generation)의 커밋 후 아웃박스(outbox)를 비우면서, 각 액션에 대해 `dispatched` 상태로 표시하거나, 디스패처가 예외를 발생시키면 `failed` (그리고 재발생) 처리합니다. 실패한 디스패치는 손실된 효과(lost effect)가 아니라 _상태_입니다.
## 분리 구조가 제공하는 이점
- **재생(Replay)은 복원할 뿐, 다시 전송하지 않습니다.** reactifact 재생은 커밋 체인(commit chain)을 따라 걸으며 런타임을 실행하지 않고 `Context`를 재구축합니다. 기록된 알림은 상태로 읽어옵니다. 재생은 "왜 그것이 전송되었는가?"라는 질문에 다시 보내지 않고 답합니다.
- **재시도(Retries)는 중복되지 않습니다.** 재개된 실행은 동일한 안정 ID를 재파생하므로, `effects.act`는 `None`을 반환하고 두 번째 의도(intent)가 존재하지 않습니다.
- **병합된 브랜치(Merged branches)는 수렴합니다.** 독립적으로 같은 액션에 도달한 두 포크(forks)는 ID를 공유하며 **하나의** 의도로 병합됩니다. 디스패치 기록부(상태/타임스탬프/오류)는 3방향 병합 서명에서 제외되며, `dispatched` 측이 어떤 브랜치가 병합 대상이든 관계없이 승리합니다. 따라서 병합은 이미 전송된 액션을 부활시킬 수 없습니다. 동일한 ID를 가진 상충되는 페이로드(payload)는 조용한 마지막 쓰기 승리(last-write-wins)가 아니라 명시적인 병합 **충돌(conflict)**입니다.
- **동일 세대 중복은 붕괴됩니다.** 하나의 생성에서 의도를 기록하는 두 프로듀서가 각각 하나씩의 아티팩트와 한 번의 전송을 생성합니다. 이는 커밋 후 드레인(drain) 시점에 중복 제거(dedupe)가 발생하기 때문입니다.
사용자는 큐(queue)로부터 얻고 싶었던 운영 스토리(operational story)를 얻게 되지만, 이 "큐"는 이미 가지고 있는 버전 관리된 상태일 뿐입니다.
## 솔직한 한계점
이것은 의도적으로 메시지 브로커가 아니며, 그 한계점들은 명확하게 밝힐 가치가 있습니다:
- **정확히 한 번이 아닌, 적어도 한 번(at-least-once)입니다.** 실제 전송과 `dispatched` 커밋 사이에서 충돌이 발생하면 배달 처리가 재실행됩니다. 프레임워크가 I/O를 정확히 한 번(exactly-once)으로 처리할 수는 없기 때문에, 의도(intent)는 외부 시스템에 전달하는 `idempotency_key`를 포함하며, 이 키가 외부 시스템 측에서 중복을 제거합니다. 이것이 모든 실제 아웃박스(outbox)가 의존하는 동일한 계약입니다.
- **백그라운드 릴레이가 없습니다.** 다음 `arun()` 호출이나 명시적인 `flush_pending_actions()` 호출이 아웃박스를 비우며, 프레임워크가 폴러(poller)를 자동으로 실행하지 않습니다. 재시도 및 백오프 정책은 애플리케이션(디스패처를 래핑하는 방식)에 남아 있어야 합니다. 왜냐하면 이것은 제품 결정이지, 프레임워크의 반사적 동작이 아니기 때문입니다.
- **단일 프로세스(single-process) 기반입니다.** 아웃박스는 상태(state)가 리플레이 안전성(replay-safe)을 갖도록 하지만, reactifact를 분산 작업 큐(distributed task queue)로 만들지는 않습니다.
이것이 분리된 구조의 핵심입니다. 상태는 버전 관리되고 재현 가능하지만, 외부 세계는 그렇지 않으며, 이 둘 사이의 경계는 함수 중간에 숨겨진 것이 아니라 사용자가 직접 선언해야 하는 무언가여야 합니다.
## 오프라인 실행하기 (Run it offline)
이 전체 기능은 API 키가 필요 없는 독립적인 실행 파일 형태로 제공됩니다. `examples/outbox`는 일곱 가지 케이스를 순회하며 각각을 검증합니다:
.venv/bin/python -m examples.outbox.main
- 커밋 -> 디스패치 sent=['notify:42'] status=dispatched
- 재파생성 (re-derivation) 첫 번째 실행 sent 1; 재실행 sent 0
- 동일한 생성(same generation) producers=2 intents=1 sent=1
...
이 기능은 상관관계가 있는 구조화된 로깅과 우아한 종료를 갖춘 트랜잭션별 마감 시간(per-turn deadline)을 함께 묶어 0.14.0 버전에서 출시되었으며 (현재 릴리스는 0.15.x):
- 릴리스: [https://github.com/bzdvdn/reactifact/releases/latest](https://github.com/bzdvdn/reactifact/releases/latest)
- 코드: [https://github.com/bzdvdn/reactifact](https://github.com/bzdvdn/reactifact)
- 문서: `docs/en/patterns.md#outbox-external-side-effects` 및 `docs/en/durability.md#outbox-in-production`
이것이 절반의 이야기입니다. 아웃박스(outbox)는 외부 효과가 두 번 실행되는 것을 막아줍니다.
나머지 절반, 즉 답변 뒤에 있는 _상태(state)_를 검사하고 재현할 수 있게 만들어 해시(hash)하고 재생(replay)하며 추적 기록을 감사(audit)할 수 있도록 하는 것이 바로 [다음 게시물](https://dev.to/bzdvdn/auditable-agents-turn-the-answer-into-a-claim-you-can-check-2lm4-temp-slug-3862381)에서 다룰 내용입니다.
만약 여러분이 외부 세계와 상호작용하는 에이전트(agent)를 구축하고 있다면, 정말 알고 싶습니다. **재현 가능한 계산과 외부 효과 사이의 경계를 어디에 긋고 있나요?** Intents-as-state가 하나의 답변이지만, 다른 아이디어들도 궁금합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기