-
AI 자율 루프의 dead-letter 패턴 — 3-strike 자동 스킵과 진척률 80%가 사실은 32%였던 이야기 (Ralph Loop 시리즈 7편)IT 2026. 5. 18. 21:00
자동 진행 루프를 짤 때 가장 어려운 결정은 막힌 항목을 어떻게 처리할까 다. 처음 ralph-loop 를 짤 때는 단순했다 — 검증 후크가 실패하면 즉시
exit 2. 사람이 들어와서 보고 다시 시작하라는 의도였다. 결과는 — 자율성 0. 한 시간 자리 비웠다 돌아오면 ralph-loop 가 첫 항목에서 막힌 채 멈춰있었다.이 글은 그 이후 도입한 3-strike 자동 스킵 패턴과, 그 패턴이 만든 진척률 거짓말 함정 — 진척률 80% 가 사실은 32% 였던 — 을 다룬다. 자율성을 위해 한 결정이 어떻게 진척의 의미를 흐리는지, 그리고 사용자가 직접 짚어준 통찰로 어떻게 한 줄로 회복했는지. 시리즈 7편이고 1~6편은 컨텍스트 / 부팅·셧다운 / vLLM 헬스 / prompt 다층 / opencode 함정 / 외부 검증을 다뤘다.
출발점 — 즉시 exit 의 자율성 비용
초창기 ralph-loop.sh 의 검증 분기는 단순했다.
# 초기 버전 (사람 개입 강제) if grep -nq "^- \[ \] ${escaped}\$" CHECKLIST.md; then echo "⚠️ 검증 1 실패 (CHECKLIST 미토글)" exit 2 fi if ! git log --oneline -1 --since="3 minute ago" | grep -q .; then echo "⚠️ 검증 2 실패 (commit 누락)" exit 3 fi echo "✅ iter 완료"이 코드의 의도는 모든 실패는 명시적 사람 결정이 필요하다 였다. 그런데 운영해보니 — 모델이 한 항목 (예:
src/spawner.js) 에서 도구 호출 schema 에러로 막히면 거기서 영영 종료. 다른 35개 항목은 시도조차 못 함. 사람이 와서 "막힌 항목 건너뛰고 다음 거 해" 라고 손으로 CHECKLIST 를 토글해주든지 항목을 지워줘야 했다.이게 자율 시스템이 아니라 반자동 시스템 이다. 사람이 매 막힘마다 들어와야 하니 출장 중이거나 잘 때는 진척 0. ralph-loop 의 가치가 "내가 자는 동안 38 iter 돌아가는 것" 인데 그 가치를 못 살린다.
해결의 출발점은 — 막힌 항목은 자동으로 우회하되, 그 우회 사실을 명시적으로 표시 하는 것. dead-letter queue 의 메시지 큐 패턴과 같은 발상이다.
3-strike 패턴 — 시도 카운트와 자동 마킹
구현은 두 부분이다. 시도 카운트 누적 + 3회 도달 시 자동 마킹이다.
# 시도 카운트 (같은 항목을 몇 번째 처리하는가) prev_item=$(cat "$LOG_DIR/.prev_item" 2>/dev/null || echo "") attempts=$(cat "$LOG_DIR/.attempts" 2>/dev/null || echo "0") if [ "$item" = "$prev_item" ]; then attempts=$((attempts + 1)) else attempts=1 fi printf '%s' "$item" > "$LOG_DIR/.prev_item" echo "$attempts" > "$LOG_DIR/.attempts" echo " 시도 $attempts/3" # (... opencode 호출 + 검증 ...) # 검증 분기 if $checked && $committed; then echo "✅ iter 완료 — 다음 항목으로" echo "0" > "$LOG_DIR/.attempts" : > "$LOG_DIR/.prev_item" else echo "⚠️ iter 미완 (시도=$attempts/3)" if [ "$attempts" -ge 3 ]; then # 3회 연속 실패 — 항목을 [!] 로 마킹하고 다음으로 sed -i "s|^- \[ \] ${escaped}\$|- [!] ${escaped}|" CHECKLIST.md git add CHECKLIST.md git commit -m "skip: $(printf '%s' "$item" | head -c 60) (3회 실패)" || true echo "🚫 항목 자동 스킵 → CHECKLIST 에 [!] 마킹 후 진행" echo "0" > "$LOG_DIR/.attempts" : > "$LOG_DIR/.prev_item" fi fi코드 설명 —
.prev_item과.attempts두 상태 파일이 같은 항목으로 몇 번째 시도하는가 를 추적한다. 새 iter 가 같은 항목으로 들어오면 attempts++, 다른 항목이면 1로 리셋. 검증 통과 시 둘 다 비움. 검증 실패가 누적돼 3회에 도달하면 — sed 로 CHECKLIST 의- [ ]를- [!]로 변경하고, 그 변경 자체를 "skip: ..." commit 으로 박는다. 이 commit 이 박힌 후 다음 iter 가 시작되면 grep^- \[ \]패턴이 그 항목을 건너뛰고 다음 미체크 항목으로 자연스럽게 진행한다.그림 설명 — 같은 항목으로 시도가 누적되는 과정. iter N 의 시도 1, 시도 2, 시도 3 모두 검증 실패하면 — sed 로 항목을
[!]로 마킹하고 그 변경 자체를 commit 으로 박는다. 그 commit 후 다음 iter (N+1) 가 시작되면 — grep 의^- \[ \]패턴이[!]와 매치 안 되니 자연스럽게 다음 미체크 항목 으로 진행. 사람 개입 0회..attempts와.prev_item두 상태 파일이 시도 카운트의 지속성 을 책임진다.왜 `[!]` 를 commit 으로 박나
스킵 마킹을 sed 로만 하고 끝낼 수도 있는데 — 굳이 commit 을 추가로 박는다.
sed -i "s|^- \[ \] ${escaped}\$|- [!] ${escaped}|" CHECKLIST.md git add CHECKLIST.md git commit -m "skip: $(printf '%s' "$item" | head -c 60) (3회 실패)" || true왜 commit 까지? 두 가지 이유가 있다.
첫째, 검증 후크가 commit 존재 를 진척의 한 조건으로 본다. 만약 sed 만 하고 commit 안 하면 — CHECKLIST.md 가 dirty (uncommitted changes) 인 채로 다음 iter 가 시작된다. 다음 iter 의 git_status 캡처에 그 dirty 가 포함되고 모델이 그걸 "미해결 변경이 있다" 로 잘못 인지할 수 있다. commit 까지 하면 그 변경이 이미 영구적으로 박힌 상태 로 명시된다.
둘째, git history 가 dead-letter 의 audit log 가 된다.
git log --oneline | grep '^skip:'한 줄로 어느 항목들이 자동 스킵됐나 를 시간순으로 본다. 사후 분석에 가치 있다 — "파서 변경 전에 스킵된 항목들 vs 변경 후" 의 비교 가 즉시 가능. 우리도 이걸로 "qwen3_xml 파서 시점에 어느 항목들이 스킵됐는지" 를 정확히 추적했다.|| true가 끝에 붙어있는데 — commit 이 어떤 이유로든 실패해도 (이미 staged 가 비어있다거나) ralph-loop 가 죽지 않게 한다.set -e활성화 환경에서 자율 운영의 안전장치다.진척률의 거짓말 — 80% 가 사실은 32% 였다
3-strike 패턴이 자율성을 살렸지만 — 새로운 함정이 생겼다. 진척률 의 의미가 흐려진 것이다.
운영 중 사용자한테 진척 보고를 보내면 이런 형태였다.
완료(x): 9 / 잔여([ ]): 16 / 스킵(!): 19 → 진척률: 9/(9+16) = 36%이걸 보고 "36% 진척했네" 로 인지했다. 그런데 사용자가 핵심 질문을 던졌다 — "스킵은 어떤 의미야? 스킵도 잔여로 가야 하지 않나?"
맞는 말이다. 스킵은 의미적으로 미완성 이다. ralph-loop 가 자동 우회한 거지 작업을 끝낸 게 아니다. 그러면 진정한 진척률은:
완료(x): 9 / 진정한 미완성: 16 + 19 = 35 → 진정한 진척률: 9/(9+35) = 9/44 = 20%36% 가 아니라 20% 였다. 우리 인지가 16%p 만큼 부풀려진 상태로 운영하고 있었다. 즉 — 3-strike 자동 스킵이 진척처럼 느껴지게 만든다. 그런데 실제로는 "이 항목은 자동으로 못 했음" 의 표시일 뿐이다.
이 함정이 위험한 이유는 — 환경 변경의 효과 를 잘못 판단할 수 있어서다. 만약 진척률 36% → 50% 로 보이면 환경 변경이 잘 되고 있다고 착각하는데, 실제로는 완료가 늘어난 게 아니라 스킵이 늘어난 케이스일 수 있다. 즉 진척이 정체된 상태에서 dead-letter 만 쌓이고 있는데도 진척률은 올라가는 사고다.
그림 설명 — 3-bucket 분류로 진척률을 표현해야 정확하다. 잘못된 계산은 스킵을 분모에서 빼는 거고, 정확한 계산은 잔여로 카운트 한다. 함정 사례를 보면 완료가 그대로인데 잔여가 줄고 스킵이 늘어나도 잘못된 계산은 진척률이 오르는 것처럼 보인다. 이게 환경 변경 효과 측정 같은 운영 결정에서 잘못된 결론으로 직결될 수 있다.
처방 — sed 한 줄로 32% 가 84% 로
사용자 통찰 후 즉시 처방을 적용했다. 핵심 발상은 — 스킵된 항목들 중 다수가 qwen3_xml 파서 시점에 도구 호출 schema 에러로 막혔던 거지, 본질적으로 모델이 못하는 게 아니다. 파서를 qwen3_coder 로 변경한 후엔 같은 항목들이 통과 가능성이 매우 높다. 그러니 모든 [!] 마크를 잔여로 되돌리고 다시 시도하게 하면 된다.
# 변경 전 (직전 환경 변경 후 정체 상태) 완료(x): 9, 잔여([ ]): 8, 스킵(!): 20 정확한 진척률 = 9/37 = 24% # 스킵 → 잔여 일괄 복원 sed -i 's/^- \[!\] /- [ ] /' CHECKLIST.md # 시도 카운트 리셋 (같은 항목으로 즉시 3-strike 재발 방지) rm -f .ralph-logs/.prev_item .ralph-logs/.attempts # 변경 자체를 commit (audit log) git add CHECKLIST.md git commit -m "feat: revert all skipped items back to pending after parser fix" # 변경 후 — 잔여 28 모두 시도 가능 완료(x): 9, 잔여([ ]): 28, 스킵(!): 0이 sed 한 줄이 환경 변경의 효과를 다시 측정 가능 하게 만들었다. 그 다음 ralph-loop 사이클에서 잔여 28개 중 23개가 통과해 — 진척률이 32% (= 9/28) → 84% (= 32/38) 로 도약했다. 만약 이 통찰 없이 진척률 80% 를 자연스러운 진척 으로 봤다면 — qwen3_coder 파서의 진짜 효과를 영영 못 측정했을 것이다.
왜 사용자가 짚어줘야 알았나 — 운영 metric 의 인지적 함정
이 거짓말을 우리(나) 가 못 알아챈 이유를 돌아보자. 매 cron 보고에 "완료 9 / 잔여 8 / 스킵 20" 같은 형태로 3-bucket 을 명시했다. 데이터는 다 보여졌다. 그런데 인지적으로는 "잔여 8 = 거의 다 끝났음" 로 받아들였다. 스킵 20 은 "이미 처리된 분류" 처럼 무의식적으로 느껴졌다.
왜? UI/UX 의 한 패턴이다 — 사용자가 진행 중인 항목 과 완료/실패한 항목 을 다른 카테고리로 분리하면 진행 중 만 남은 일 로 인지한다. 완료와 실패가 같은 "이미 끝난 분류" 로 묶인다. 그런데 실패와 완료는 의미적으로 정반대다. 같은 분류로 묶이면 인지 함정에 빠진다.
그래서 운영 metric 의 명시는 — 단순히 3-bucket 카운트만 보여주는 게 아니라 진정한 미완성 = 잔여 + 스킵 을 명시적 한 줄로 추가해야 한다. ralph-loop 의 cron 보고를 다음과 같이 수정했다.
📊 Ralph Loop 상태 - 완료(x): 9 - 잔여([ ]): 8 - 스킵(!): 20 ← 진정 미완성, 사람 검토 또는 환경 변경 후 재시도 필요 - 진정한 진척률: 9/37 = 24%이렇게 "스킵 = 진정 미완성" 한 줄을 옆에 붙이면 인지 함정을 막는다.
3-strike 의 적정값 — 왜 3인가
"왜 3 strike 인가" 도 짚자. 1 또는 2 또는 5 가 아닌 3 의 근거는 — 경험적 trade-off 다.
strike 수 장점 단점 1 (즉시 스킵) 막힘 시간 최소 일시적 환경 문제 (vLLM 일시 hang) 도 즉시 스킵 → 가짜 스킵 多 2 한 번 재시도 기회 일시적 vs 영구 실패 구분이 약함 3 일시적 실패는 재시도, 영구 실패는 빨리 우회 — 균형 — 5 더 많은 재시도 기회 같은 항목에서 ~10 ~ 15분 정체 가능 ∞ — 막힘 = 무한 정체. dead-letter 의미 사라짐 3 이 영구 실패의 고집을 빨리 포기 하면서 일시적 실패에는 충분한 기회 를 주는 균형점이었다. 38 iter 운영 중 — 일시적 vLLM hang 으로 1~2 strike 후 통과한 케이스가 3건, 3 strike 도달해 자동 스킵 처리된 케이스가 19건 (변경 전), 영구 실패가 2 strike 안에 통과한 케이스 0건. 즉 3 strike 가 영구 실패 19건은 모두 스킵 했고 일시적 실패 3건은 자동 회복 시켰다. 적정값이었다.
일반화 — dead-letter 패턴은 어디나 있다
이 패턴은 ralph-loop 만의 것이 아니다. 모든 자동 처리 큐에 비슷한 게 있다.
- RabbitMQ / SQS: dead-letter queue 자체가 N회 재시도 실패한 메시지를 옮기는 표준 패턴
- K8s pod backoff: CrashLoopBackOff 가 N회 재시작 실패 후 backoff 늘리는 동일 발상
- CI/CD 파이프라인: flaky test 가 N회 retry 후 failed 로 기록되는 것
- cron 작업: failed exec count 누적 후 disable
공통 원칙은 — 막힌 작업 단위를 시스템이 자동 우회하되, 그 우회 사실을 명시적 표시로 남겨 사람이 사후 점검 가능하게 한다. ralph-loop 의
[!]마크와 skip: commit 메시지가 그 표시다.그리고 진척률의 거짓말 함정도 일반화된다. dead-letter 큐 깊이가 진척에 보이지 않으면 — 전체 큐 처리율 = 100% 처럼 보이지만 사실은 처리 못 한 메시지가 dead-letter 에 누적되고 있는 상태일 수 있다. 운영 metric 은 항상 dead-letter 깊이까지 함께 보여야 한다.
결론 — 자율성과 진정한 진척의 균형
이 글의 두 가지 결론은 이렇다.
첫째, 자동 우회 없이 자율은 없다. 막힘에 사람 개입 강제하면 ralph-loop 의 가치 자체가 무너진다. 3-strike dead-letter 는 자율성을 위한 필요악 이다.
둘째, 자동 우회의 결과는 명시적으로 추적해야 한다.
[!]마크와 skip commit 이 그 추적이고, 진척률 표현에서 스킵을 잔여로 카운트 해야 진정한 진척이 보인다. 그렇지 않으면 진척처럼 보이는 정체 라는 가장 위험한 운영 상태에 빠진다.그리고 이 함정을 사용자가 직접 짚어줘야 알아챘다는 게 — 자율 시스템 운영의 또 다른 교훈이다. 시스템이 보여주는 metric 과 사용자가 인지하는 의미 는 종종 다르고, 그 차이가 위험한 결정으로 직결될 수 있다. metric 의 명시적 의미 표현 — "스킵 = 진정 미완성" 같은 한 줄 — 이 그 차이를 줄인다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글