다단계 도구 호출, 중간에 실패하면 전부 어떻게 되돌리나

← 전체 목차 · 이전: 도구 호출은 어떻게 "딱 한 번만" 실행되게 만드는가 · 다음: 여러 Agent가 같은 도구를 동시에 부를 수 있는가

"에러를 잡아서 실패 메시지를 반환하고 사용자에게 다시 시도하라고 안내한다"는 답은 틀리지 않았지만 층위가 다르다. try-catch가 지키는 것은 프로그램 흐름이고, 원자성이 지키는 것은 업무 데이터 상태다. 예외를 잡아도 이미 차감된 재고와 이미 생성된 주문은 그대로 남아 있다. 원자성의 정의는 "실패 시 에러가 난다"가 아니라 "작업이 끝난 뒤 시스템 어디에도 중간 잔류가 없다"이다.

다섯 가지 실패 실효 시나리오

#실효 시나리오왜 어려운가
1다단계 쓰기에 트랜잭션 경계가 없음일부 성공·일부 실패 시 전체 커밋도 전체 취소도 불가
2도구에 원생 역조작(rollback)이 없음제3자 API가 애초에 취소 인터페이스를 제공하지 않음
3보상 실행 자체가 실패되돌리려는 동작도 하나의 호출이라 또 실패할 수 있음
4Agent 인스턴스가 중도 다운재시작 후 중단점을 못 찾아 중복 실행하거나 진행을 잃음
5불가역 조작에 사전 통제가 없음통지 발송·외부 심사 제출은 물리적으로 철회 불가

전자상거래 3단 예시가 이 문제를 가장 선명하게 보여준다: 재고 차감(성공) → 주문 생성(성공) → 결제 호출(실패). 결과는 재고와 주문이 중간 상태에 걸린 채 데이터가 어긋난다. 단순 재시도는 이 상태를 고치지 못하며, "보상은 새 업무 동작으로 영향을 상쇄하는 것이지 원래 동작을 지우는 것이 아니다"라는 점에서 롤백과 본질적으로 다르다.

기준선: 상태 머신 + Saga 보상

task_id  step_id  tool_name  execute_status  compensate_status  state_snapshot  retry_count

실행 규율 3가지:

  1. 실행 시작 전에 각 스텝의 정방향 실행 로직과 역방향 보상 로직을 동시에 등록한다 — 보상 로직은 실패한 뒤에 생각해내는 것이 아니라 처음부터 존재해야 한다.
  2. 매 스텝 실행 후 상태 스냅샷과 결과를 같은 데이터베이스 트랜잭션으로 영속화한다.
  3. 실패 시 마지막 성공 스텝부터 역순으로 대응 보상 동작을 트리거한다(C3 → C2 → C1).

상태 전이: 대기 → 실행중 → 보상중 → 완료 / 롤백완료 / 이상보류. 동시성 판정은 낙관적 락을 쓴다 — update ... where task_id=xxx and status=현재상태의 영향 행수가 1이면 선점 성공, 0이면 중복 요청으로 그대로 폐기한다. 이 방식은 별도의 락 서비스 없이 "쓰기 한 번"에 판정과 전이를 합친다.

역조작이 없는 도구는 "철회"가 아니라 "상쇄 정정"으로 처리한다: 잘못 보낸 통지는 회수할 수 없으니 정정 안내를 새로 보내고, 잘못 부여한 쿠폰은 삭제 대신 올바른 쿠폰을 재발급하고 잘못된 쿠폰은 동결한다. 반대 방향의 새 업무 동작으로 원래 효과를 상쇄하는 것이며, 도구의 원생 능력에 의존하지 않는다.

불가역 조작은 "철회"가 아니라 "순서 재배치"로 다룬다

물리적으로 되돌릴 수 없는 동작(통지 발송, 외부 심사 제출)에는 기술적 롤백이 없다. 여기서 쓸 수 있는 유일한 공학적 수단은 전치 검증 장벽이다: 불가역 조작을 실행 체인의 맨 끝에 배치하고, 앞단의 모든 스텝이 전부 성공해야만 트리거되게 한다. 어느 한 단계라도 실패하면 불가역 조작은 애초에 실행되지 않는다 — 롤백 가능성을 구조적으로 "거의 필요 없는 상태"로 압축하는 순서 설계다.

보상의 보상: 되돌리는 동작도 실패할 수 있다

보상 동작 역시 하나의 호출이므로 타임아웃·실패에서 자유롭지 않다. 여기서의 원칙은 **"차라리 명확하게 나쁜 상태에 머무를지언정 불분명하게 나쁜 상태로 미끄러지지 않는다"**다.

보상 재시도(횟수 제한) → 그래도 실패
  → 해당 업무 데이터를 잠금
  → 사람에게 경보
  → 추가 조작 전면 금지(더 이상의 자동 시도로 상태를 악화시키지 않음)

단순 재시도는 임시 미봉일 뿐 핵심 보장이 아니다. 진짜 최종 일관성은 "상태 머신 + 사람 재검토"의 분급 다운그레이드에서 나온다. 이상보류는 로그 한 줄이 아니라 사람 개입의 정식 진입점으로 설계해야 한다.

저장, 모니터링, 다운그레이드

  • 저장: 작업 실행과 상태 스냅샷은 같은 트랜잭션에 쓴다. 완료 후 7일 지난 기록은 히스토리 표로 이관하되, 이관 창구 안에서 재시도나 보상 요청이 없는지 반드시 확인한다.
  • 모니터링: 보상 성공률, 이상보류율, 상태 불일치 경보, 작업 복구 소요시간. 작업 성공률만 보면 보상 링크가 이미 끊어졌다는 사실이 가려진다 — 보상 성공률이 진짜 건강 지표다.
  • 다운그레이드: Saga 조정 서비스 자체가 장애가 나면, 업무는 모든 쓰기 작업을 중단하고 읽기 전용 조회만 개방하는 쪽으로 내려간다. 흔한 "가용성을 지키려고 일관성을 희생"하는 다운그레이드와 정반대 방향이다 — 데이터 정합성이 중요한 시나리오에서는 계속 쓰는 것이 서비스를 멈추는 것보다 더 위험하기 때문이다.

핵심 통찰

  1. 원자성의 판정 기준은 "에러 메시지가 있었는가"가 아니라 "작업이 끝난 뒤 시스템이 실행 전 상태로 돌아갔는가"다 — 통지가 이미 발송되고 액수가 이미 차감된 뒤에는 예외 스택이 아무리 완전해도 그 자체로는 아무것도 되돌리지 못한다.
  2. 부작용이 제3자 시스템으로 유출되는 순간 에러 처리는 의미를 잃는다 — 그래서 Agent의 트랜잭션 설계는 "도구를 호출하는" 층에서 이미 반대 동작을 준비해둬야 하며, "예외를 잡는" 층에서 보완하려 하면 이미 늦다.
  3. Agent 도구 체인에는 전통적 마이크로서비스의 기본 트랜잭션 경계가 없다 — 이질적인 제3자 도구를 호출하는 구조라 상당수는 철회 인터페이스조차 없다. 그래서 보상 로직은 도구 제공자에게 기대할 수 없고 Agent 쪽이 직접 등록·관리해야 한다.
  4. 보상 링크 자체도 실패할 수 있는 링크이므로 2단계 내결함성이 필요하다 — "보상만 추가하면 끝"이라는 설계는 절반짜리다. 핵심은 무한 재시도가 아니라 제때 손절하는 것: 재시도가 막히면 데이터를 잠그고 경보를 울리고 조작을 금지해, 불분명한 나쁜 상태로 미끄러지는 것을 막는다.
  5. 물리적으로 되돌릴 수 없는 조작에 대한 공학적 해법은 "철회"가 아니라 "순서"다 — 불가역 조작을 체인 맨 끝으로 옮겨 앞선 모든 단계가 성공해야만 실행되게 하면, 실패 시 그 조작은 애초에 일어나지 않았으므로 되돌릴 필요 자체가 구조적으로 사라진다. 이는 기술적 불가능을 프로세스 설계로 회피하는 전형적인 패턴이다.
  6. 상태는 반드시 데이터베이스에 내려앉아야 하고, 메모리 속 진행률은 진행률이 아니다 — 인스턴스 다운은 예외가 아니라 상시 상황이다. 실행과 상태 스냅샷을 같은 트랜잭션에 쓰는 것만이 "재시작 후 정확히 중단점으로 복귀·중복 실행 없음·진행 유실 없음" 세 조건을 동시에 만족시키는 유일한 전제다.
  7. 다운그레이드의 방향은 가용성 보호가 아니라 데이터 보호여야 하는 경우가 있다 — 일관성 요구가 높은 업무에서는 "계속 쓰기"가 "서비스 정지"보다 더 위험하므로, 롤백 서비스 장애 시 쓰기를 전면 중단하고 읽기 전용으로 내려가는 것이 옳은 선택이다.

엔지니어링 실전 Tips

  • "세 단계 전자상거래"로 자가 점검할 것: 재고 차감 → 주문 생성 → 결제 실패. 재고와 주문이 최종적으로 어느 상태에 멈추는지 설명하지 못하는 방안은 바로 탈락이다.
  • 보상 로직 없는 노드는 실행 체인 진입을 원천 금지할 것: 이 제약을 등록 단계에 걸면 "실행하다 보니 철회가 안 된다"는 사후 발견을 막을 수 있다.
  • 보상 등록표는 도구 단위로 유지할 것: 같은 도구가 여러 업무에서 재사용되므로 업무 단위로 중복 작성하면 누락이 생긴다.
  • 보상 재시도는 종료 조건을 명확히 할 것: 재시도 횟수와 잠금 단위(주문 단위인지 사용자 단위인지)를 사전에 정의해야, 경보가 울렸을 때 운영자가 무엇을 건드려도 되는지 알 수 있다.
  • 대사는 전체 표 비교가 아니라 핵심 지표만 확인할 것: 재고 수량, 주문 상태, 계좌 잔액처럼 상태를 대표하는 지표 몇 개로 충분하다.
  • 다운그레이드 스위치는 미리 만들어 둘 것: "쓰기 전면 중단 + 읽기 전용" 능력은 서비스가 정상일 때 미리 리허설해야 하며, 장애 당일 즉석 구현은 늦는다.
  • 병렬 분기는 대화 이력이 아니라 독립된 상태 체인으로 추적할 것: task_id는 공유하되 분기마다 별도의 branch_id를 두어 각자 대기 → 실행중 → 완료 / 실패를 독립적으로 관리해야 한다. 선형 대화 기록 하나로 여러 분기의 진행률을 되짚으려 하면 정보가 구조적으로 부족해 결국 전체 재실행으로 귀결된다. 재개 시에는 완료된 분기는 결과를 그대로 재사용하고 미완료 분기만 이어서 실행한다.
  • 중간 변수와 임시 자격증명은 정기 스냅샷으로 별도 보존할 것: 상태 스냅샷이 실행 성공/실패만 기록하고 그 순간의 중간 변수·임시 캐시·도구 호출 자격증명을 함께 남기지 않으면, 재개 시 "상태는 복구됐는데 파라미터가 없어 실행이 안 되는" 새로운 실패 유형이 생긴다. 스냅샷은 상태 필드와 실행 컨텍스트를 함께 담아야 한다.
  • AGENT/33("재시도인가 보상인가")와의 경계: AGENT/33이 단일 실패에서 재시도·보상·사람전환 중 무엇을 고를지의 의사결정 프레임을 다룬다면, 이 문서는 다단계 체인 전체가 실패했을 때 Saga 표 스키마·낙관적 락·역순 보상을 어떻게 구현하는지의 메커니즘 층위를 다룬다.