크래시 발생 후 AI 에이전트가 동일한 작업을 반복하지 않도록 방지한 방법
요약
자율 코딩 에이전트가 크래시 발생 후 동일한 작업을 반복하여 중복 PR을 생성하는 문제를 해결하기 위한 멱등성 패턴을 소개합니다. 실행 전 확인, 안정적인 키 사용, 로컬 원장 구축을 통해 중복 부작용을 방지하는 방법을 다룹니다.
핵심 포인트
- 크래시 발생 시 '행동은 일어났으나 기록되지 않은' 상태를 고려해야 함
- 재시도를 단순 재실행이 아닌 상태 파악 후 잔여 작업 수행으로 정의
- 행동 전 확인(Check-before-act) 패턴 도입 필요
- 무작위 식별자 대신 작업과 연결된 안정적인 식별자(Stable keys) 사용
요약 (TL;DR)
저의 자율 코딩 에이전트(autonomous coding agent)가 작업 도중 크래시(crash)가 발생한 후, 재시작 시 이미 작업을 완료했다는 사실을 인지하지 못해 동일한 풀 리퀘스트(pull request)를 세 번이나 생성한 적이 있습니다. 이를 해결하기 위해 제가 구축한 멱등성 패턴(idempotency pattern) — 실행 전 확인(check-before-act), 안정적인 키(stable keys), 그리고 로컬 원장(local ledger) — 과 중복된 부작용(side effects)을 추적하며 얻은 다섯 가지 교훈을 소개합니다.
문제 상황
저의 에이전트는 다단계 작업(multi-step tasks), 여러 단계의 도구 호출(tool calls) 등 긴 시간 동안 관리자 없이 실행됩니다. 첫 번째 행동과 마지막 행동 사이에 몇 분의 간격이 생기기도 합니다. 네트워크 일시 오류(network blip), 메모리 부족으로 인한 프로세스 종료(out-of-memory kill), 혹은 제가 실수로 노트북 덮개를 닫는 등의 상황이 발생하여 실행 도중 중단되기 전까지는 문제가 없습니다. 하지만 이런 일이 발생하면 프로세스는 다시 시작되면서 자신이 할 수 있는 유일한 일, 즉 작업을 다시 집어 들고 처음부터 재시도(retry)하는 행동을 합니다.
이 문제가 실제로 저를 괴롭혔던 첫 번째 사례는, 에이전트가 풀 리퀘스트(pull request)를 생성하며 끝나는 작업을 수행하던 중이었습니다. 에이전트는 브랜치(branch)를 성공적으로 생성하고, 커밋(commit)을 푸시(push)했으며, PR을 열었습니다. 하지만 작업이 완료되었다는 기록을 어디에도 남기기 전에 프로세스가 종료되었습니다. 다음 실행 시, 에이전트는 "완료" 표시가 없는 작업을 발견하고 처음부터 끝까지 재시도하여 동일한 변경 사항에 대해 두 번째 PR을 생성했습니다. 이틀 후, 동일한 코드 경로에서 발생한 약간 다른 형태의 크래시로 인해 세 번째 PR이 생성되었습니다.
제가 이를 알아차렸을 때, 하나의 변경 사항에 대해 세 개의 열린 PR, 아무도 정리하지 않을 두 개의 떠도는 브랜치(stray branches)가 생겨났고, 한 가지 찝찝한 의문이 남았습니다. 중복된 파일 쓰기(file write)나 중복된 댓글(comment)은 목록에 떠 있는 중복 PR보다 훨씬 눈에 덜 띄기 때문에, 이미 누군가 눈치채지 못한 채 어딘가에 병합(merge)된 다른 "보이지 않는" 중복 작업들이 얼마나 더 많을까 하는 점이었습니다.
이 모든 것은 전통적인 의미에서의 로직 버그(logic bug)가 아니었습니다. 개별적인 모든 단계는 올바르게 작동했습니다. 문제는 오직 "성공"과 "실패"라는 두 가지 결과만을 고려할 때 나타나지 않는 간극이었습니다. 즉, "행동은 일어났지만 아직 일어났다는 사실을 모르는" 세 번째 상태가 존재하며, 크래시(crash)가 발생하면 정확히 그 상태에 빠질 수 있다는 점이었습니다. 저의 재시도(retry) 로직은 크래시가 발생했다는 것이 아무것도 일어나지 않았음을 의미한다고 가정했습니다. 하지만 반대로 증명될 때까지는 아무것도 일어나지 않았다고 가정했어야 했습니다.
해결 방법
해결책은 재시도를 "작업을 다시 실행하라"로 취급하는 것을 멈추고, "이미 무엇이 사실인지 파악한 다음, 남은 작업만 수행하라"로 취급하기 시작하는 것이었습니다.
이는 두 가지 요소로 나뉩니다.
1. 행동한 후에만 확인하는 것이 아니라, 행동하기 전에 확인하십시오. 브랜치(branch), PR(Pull Request), 파일, 댓글 등 무엇인가를 생성하기 전에, 이제 에이전트는 재시도 시점에 생성된 무작위 식별자가 아닌, 작업과 연결된 안정적인 식별자(stable identifier)를 사용하여 해당 항목이 이미 존재하는지 쿼리(query)합니다. 만약 이 작업의 키(key)가 태그된 PR이 이미 존재한다면, 다른 PR을 열지 말고 즉시 "완료" 단계로 건너뜁니다.
2. 부수 효과(side effect) 자체와 분리된, 의도(intent)에 대한 로컬 원장(local ledger)을 유지하십시오. 어떤 부수 효과들은 "내가 이것을 이미 수행했는가"를 쿼리할 수 있는 깔끔한 방법을 제공하지 않습니다 (예: Slack 메시지는 검색 가능한 자연스러운 중복 제거 키(dedup key)를 가지고 있지 않습니다). 이러한 경우, 에이전트는 행동을 시도하기
_전(before)_에 작은 기록을 작성합니다 — "작업 Y를 위해 X를 수행할 예정" — 그리고 성공을 확인한 후에만 완료로 표시합니다. 재시작 시, 에이전트는 먼저 원장을 읽습니다. "시도됨, 확인되지 않음"으로 표시된 작업은 재시도하기 전에 실제 상태를 먼저 확인합니다.
단순화된 흐름은 다음과 같습니다:
flowchart TD
A[재시작 후 작업 재개] --> B{원장 항목이 존재하는가?}
B -- 아니오 --> C[정상적으로 진행, 원장 항목 작성 후 행동 수행]
...
그리고 외부 아티팩트(artifact)를 생성하는 모든 것에 대해 제가 사용하는 '행동 전 확인(check-before-act)' 래퍼(wrapper)의 단순화된 버전입니다:
import hashlib
import json
from pathlib import Path
...
check_exists 함수는 통합(integration) 방식에 따라 실제로 달라지는 부분입니다. PR(Pull Request)의 경우 "이 키와 일치하는 브랜치나 레이블을 가진 열려 있거나 닫힌 PR을 검색"하는 것이 되고, 파일 쓰기의 경우 "파일에 이미 이 정확한 내용이 포함되어 있는가"가 되며, 알림(notification)의 경우 쿼리할 외부 시스템이 없으므로 단순히 "이 키가 완료된 상태로 원장(ledger)에 있는가"가 될 수 있습니다. 동작이 다시 실행되기 전에 _무언가_가 체크되기만 한다면 이러한 비대칭성은 괜찮습니다.
솔직히 말해서, 이 방식은 실질적인 오버헤드(overhead)를 추가한다는 점을 인정해야 합니다. 이제 모든 상태 변경 단계(mutating step)는 실제 작업이 수행되기 전에 추가적인 읽기(존재 확인)와 추가적인 쓰기(원장 업데이트) 비용을 발생시킵니다. 하루에 한 번 실행되는 작업이라면 이는 비용이 들지 않는 수준입니다. 하지만 좁은 루프 안에서 수백 개의 작은 파일 편집을 수행하는 에이전트의 경우, 모든 단계를 감싸는 것은 낭비이며, 속도를 늦추고 싶지 않은 바로 그 빠른 경로(fast path)를 느리게 만들 것입니다. 따라서 저는 이 패턴을 되돌리기 어렵거나 에이전트 자신의 작업 디렉토리 외부에서 보이는 단계, 즉 PR 열기, 댓글 게시, 브랜치 생성, 외부 API 호출에만 적용합니다. 저장소 내부의 순수하게 로컬이며 쉽게 반복 가능한 편집은 원장 항목이 필요하지 않습니다. 왜냐하면 이를 다시 실행하더라도 결과적으로 동일한 파일이 생성되기 때문이며, 생성될 중복 항목이 없습니다.
이러한 구분은 원장 메커니즘 자체만큼이나 중요하게 작용했습니다. 이 시스템의 첫 번째 버전은 사소한 로컬 쓰기를 포함한 모든 것을 감싸도록 설계되었고, 그 결과 원장 파일 자체가 병목 현상(bottleneck)이 되었습니다. 하나의 JSON 파일이 작업당 수십 번씩 읽히고 다시 쓰여졌기 때문입니다. 외부로 보이거나 되돌리기 어려운 효과를 생성하는 단계로 범위를 제한함으로써, 해당 오버헤드를 체감할 수 없는 수준으로 줄일 수 있었습니다.
교훈 (Lessons Learned)
-
단순히 실패(failure)만을 대비하는 것이 아니라, "수행함"과 "기록함" 사이의 간극을 고려하여 설계해야 합니다. 대부분의 재시도 로직 (retry logic)은 성공 또는 실패라는 두 가지 상태만을 고려하며, 크래시 (crash)를 "실패"로 취급합니다. 하지만 크래시는 작업이 성공한 직후, 결과가 기록되기 전의 몇 밀리초(ms) 사이에 발생할 수도 있습니다. 만약 재시도 로직이 이 두 상태를 구분할 수 없다면, 결국 이미 완료된 작업을 다시 수행하게 될 것입니다.
-
재시도는 명령이 아니라 질문이어야 합니다. "작업을 재시도하라"는 말은 암묵적으로 "그 일을 다시 하라"는 의미를 담고 있습니다. 재시작 시 실제로 원하는 것은 "무엇이 사실인지 확인한 다음, 누락된 것만 수행하라"는 것입니다. 이러한 관점의 전환이 해결책의 핵심이며, 나머지는 모두 구현 세부 사항에 불과합니다.
-
멱등성 키 (Idempotency keys)는 재시도를 유발하는 크래시 상황에서도 살아남아야 합니다. 매 시도마다 새로 생성되는 UUID는 이 용도로 사용할 수 없습니다. 이전 시도의 어떤 것과도 일치하지 않기 때문에, "이 키가 이미 존재하는지 확인"하는 절차는 항상 빈 결과를 반환하게 됩니다. 키는 작업 ID (task ID), 콘텐츠 해시 (content hash), 결정론적 슬러그 (deterministic slug)와 같이 재시작 여부와 상관없이 매번 동일하게 계산되는 안정적인 요소로부터 유도되어야 합니다.
-
모든 부수 효과 (side effect)를 조회할 수 있는 것은 아니며, 그것이 중복 제거 (dedup)를 생략할 핑계가 될 수는 없습니다. 자연스러운 멱등성 키를 지원하는 API(많은 결제 및 메시징 API가 그러함)는 이 과정을 쉽게 만듭니다. 태그 기반 검색을 지원하지 않는 댓글 게시와 같이 이를 지원하지 않는 경우에는 원장 (ledger)이 그 역할을 대신해야 합니다. 행동하기 전에 의도 (intent)를 먼저 기록하십시오. 그것이 핵심 비결입니다.
-
"그냥 재시도일 뿐이야"라는 사고방식이 이 버그를 유발합니다. 안전한 기본값은 "아무 일도 일어나지 않았다고 가정하고 다시 시도하라"가 아니라, "무언가 일어났을 수도 있다고 가정하고, 재시도하기 전에 일어나지 않았음을 증명하라"입니다. 이 단 하나의 가정의 전환이 견고한 재시도 시스템과, 백그라운드 작업이 조용히 동일한 내용을 세 번 게시해 버리는 시스템 사이의 차이를 만듭니다.
다음 단계 (What's Next)
현재 저는 새로운 통합(integration)이 추가될 때마다 check_exists 함수를 수동으로 작성하고 있습니다. 이 방식이 작동은 하지만 확장성(scale)이 떨어집니다. 에이전트에게 새로운 도구가 추가될 때마다 또 다른 맞춤형 중복 제거(dedup) 체크 로직을 만들어야 하기 때문입니다. 다음 단계는 에이전트가 수행하는 모든 부수 효과(side-effecting) 호출 앞에 위치하는 범용적인 멱등성(idempotency) 미들웨어를 구축하는 것입니다. 이 미들웨어는 작업 ID(task ID) + 단계 이름(step name) + 인자(arguments)의 콘텐츠 해시(content hash)를 기반으로 자동 키(key)를 생성합니다. 이렇게 되면 "내가 이미 이 작업을 수행했는가?"라는 질문이 통합 건별로 구현해야 하는 문제가 아니라, 프레임워크가 무료로 보장해 주는 기능이 됩니다.
마무리 (Wrap-up)
에이전트를 충분히 긴 시간 동안 무인(unattended) 상태로 실행한다면, 결국 최악의 순간—부수 효과가 발생한 직후이자 기록되기 직전—에 크래시(crash)가 발생할 것입니다. 모든 재시도(retry)를 "아직 아무 일도 일어나지 않았다고 가정"하는 것이 아니라, "아직 아무 일도 일어나지 않았음을 증명"하는 과정으로 취급하십시오. 그러면 중복 PR, 중복 메시지 버그라는 거대한 카테고리가 디버깅을 시작하기도 전에 사라질 것입니다.
이 내용이 유용했다면, 제가 매일 자율적인 Claude Code 설정을 운영하며 배우고 있는 것들을 더 확인하기 위해 이곳 Dev.to에서 저를 팔로우해 주세요. 그리고 만약 실패 시 재시도하는 무언가를 구축하고 있다면, 그것이 "실패"와 "성공했지만 소식을 듣지 못함"을 구분할 수 있는지 확인하는 데 한 시간 정도를 투자할 가치가 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기