자율 코딩 에이전트는 중간이 아니라 경계에서 무너진다
요약
AI 코딩 에이전트의 실패는 코드 작성 능력이 아닌 Git, CI, 인증 등 외부 경계(seams)와의 상호작용에서 발생합니다. 본문은 머지 충돌 마커를 포함한 채 커밋을 시도하는 등의 실제 장애 사례를 통해, 에이전트 운영 시 경계 지점의 안정성을 확보하기 위한 설계 교훈을 제시합니다.
핵심 포인트
- 에이전트의 실패는 모델의 코드 작성 능력이 아닌 외부 시스템 접점에서 발생함
- 머지 충돌 마커가 포함된 채 커밋되는 등의 국소적 최적화 위험성 경고
- pre-push 훅을 활용하여 커밋된 트리를 스캔하는 방어적 설계 필요
AI 코딩 에이전트 (AI coding agents)를 프로덕션 환경에서 한동안 운영해 본 결과, 한 가지 사실이 명확해졌습니다. 실패는 모델이 작성하는 코드 자체에서 발생하는 것이 아닙니다. 실패는 git, CI, 인증 (auth), 네트워크와 같은 경계(seams), 즉 외부 세계와의 접점에서 발생합니다.
모델 자체는 진정으로 능력이 있습니다. 함수를 작성하고, 테스트를 작성하며, 리팩토링 (refactor)을 수행합니다. 무너지는 것은 작업 '주변'의 모든 것입니다: 결과물을 푸시 (push)하고, CI를 기다리고, PR (Pull Request)을 머지 (merge)하고, 토큰을 갱신하고, 다른 서비스를 호출하는 일들 말입니다. 그리고 이러한 실패는 종종 인간이라면 생각할 필요도 없이 피했을 종류의 것들입니다.
다음은 지난 몇 주 동안 Codens' Purple (오케스트레이션 코어)에서 우리가 겪고 해결한 다섯 가지 사건입니다. 모두 실제 사례이며, 프로덕션 작업 ID와 날짜가 포함되어 있습니다. 모든 수정 사항은 머지되었습니다. 마지막에는 이 사건들을 하나로 묶어주는 공통된 설계 교훈이 있습니다.
사건 1: 절반만 해결된 머지가 12,000줄의 코드로 PR을 거의 범람시킬 뻔한 사례
이것은 정말 무서운 사건이었습니다.
opsguide-back에 대한 Purple 작업이 PR을 생성했습니다. 내부를 확인해 보니: +12,162줄 / 149개 파일 변경, 그중 2개 파일에는 문자 그대로 <<<<<<< 마커가 포함되어 있었습니다. 커밋 그래프 (commit graph)는 다음과 같았습니다:
e567ce67 (merge commit, "chore: Fix HYBRID_SEARCH...")
├ parent[0] = 0b069e5d (develop tip, +1468 commits over main)
└ parent[1] = 2940de35 (the actual feature commit)
무슨 일이 일어났는가: 수정 단계에서 AI는 일부 테스트 수정을 백포트 (backport)하기 위해 git merge develop를 하기로 결정했습니다. 머지 충돌 (merge conflict)이 발생했습니다. AI는 이를 부분적으로 해결하고, 트리 (tree)에 여전히 마커가 남아 있는 상태에서 그대로 git commit을 밀어붙였습니다. 푸시된 내용은 무엇이었나: develop의 전체 분기 사항과 해결되지 않은 충돌 마커였습니다. 만약 누군가 머지를 클릭했다면, main 브랜치는 단 한 번에 develop의 1,468개 커밋 드리프트 (drift)로 오염되었을 것입니다.
인간은 이렇게 하지 않습니다. 애초에 main을 대상으로 하는 PR에 develop을 머지하지 않을 것이며, 충돌이 발생하면 완전히 해결될 때까지 커밋하지 않을 것입니다. 하지만 AI는 하나의 테스트를 통과시키기 위해 국소적으로 최적화(optimizing locally)하느라 망설임 없이 이를 수행합니다.
해결책: 푸시 시점에 두 단계로 차단하기
단일 git pre-push 훅(hook)입니다. 이곳은 AI의 git push가 실제로 실행되는 지점이므로, 가드(guard)가 위치해야 할 곳입니다.
#!/bin/bash
set -u
...
핵심은 커밋된 트리 (committed tree) (HEAD)를 스캔하는 것입니다. 작업 디렉토리(working directory)는 정리되었을 수 있지만, 커밋에 포함된 마커(marker)들은 그대로 남아 있습니다. HEAD는 푸시될 예정인 상태이므로, 이를 대상으로 git grep을 수행합니다.
정밀도를 위해 ^(<<<<<<< |======= |>>>>>>> ) 정규 표현식(regex)을 사용하는 것이 중요합니다. =======는 마크다운(markdown) 제목이나 표에서 자주 등장하므로, 줄 시작 부분 다음에 공백이 오는 git 충돌 마커(conflict marker)의 정확한 형태만 일치하도록 합니다.
레이어 2(Layer 2)는 워크플로(workflow)별로 설정 가능한 머지 소스 허용 목록(merge-source allowlist)입니다. 이는 정책 파일(policy file)이 존재할 때만 실행됩니다.
# Layer 2: merge-source allowlist (only when a policy file exists)
# {
# "feature": "feature/<task-id>",
...
푸시된 참조(ref)의 각 새로운 머지 커밋(merge commit)에 대해, git merge-base --is-ancestor를 사용하여 모든 부모(parent)가 feature / base / allowed 중 하나로부터 도달 가능한지 확인합니다. 허용되지 않은 소스로부터의 머지는 거부됩니다. 정책이 비어 있으면 확인을 수행하지 않으며, 이는 선택 사항(opt-in)입니다.
for p in $parents; do
ok=0
for rname in "${refs[@]}"; do
...
그리고 화려하지는 않지만 중요한 부분은 **페일 세이프 (fail-safe)**입니다. 만약 훅(hook) 자체에 버그가 있어 오류가 발생하더라도 푸시는 계속 진행됩니다. 가드(guard)의 버그로 인해 모든 워크플로가 중단되는 것은 가끔 사고가 통과되는 것보다 더 나쁩니다. 레이어 1은 단순히 git grep과 git log를 사용하므로 공격 표면(surface area)이 매우 작으며, 레이어 2는 jq를 사용할 수 없는 경우 허용 모드(permissive)로 전환됩니다.
사고 2: 영구적 실패로 오분류된 일시적인 네트워크 끊김
자체 호스팅된 모델 게이트웨이(Cloudflare 뒤의 vLLM)를 통해 라우팅된 작업이 약 27분간의 작업 후 종료 코드 1(exit 1)과 함께 중단되었습니다:
API Error: The socket connection was closed unexpectedly.
게이트웨이는 세션 시작 시에는 정상이었고, 제가 확인했을 때는 GET /health가 0.56초 만에 200을 반환하며 즉시 복구되었습니다. 즉, 세션 중간에 발생한 일시적인 연결 끊김이었습니다. 이는 이미 524 재시도 경로(retry path)를 유발하는 Cloudflare 기반의 과부하 패턴과 동일하지만, node의 fetch에서 닫힌 소켓(closed socket) 형태로 나타난 것뿐입니다.
문제는 기존의 재시도 정규 표현식(retry regex)이 524 / origin_response_timeout / connection reset / Too Many Requests는 포함하고 있었지만, 닫힌 소켓(closed-socket) 케이스에 대한 항목은 없었다는 점입니다. 그 결과, 해당 작업은 "일시적이지 않은 오류 (exit=1), 재시도하지 않음"으로 분류되었고, 전체 단계가 사람이 다시 배정할 때까지 기다리도록 Slack으로 에스컬레이션(escalation)되었습니다.
해결책: 패턴을 추가하고, 오탐(false positive)은 비용이 저렴하다고 믿기
# 일시적인 것으로 간주하는 패턴 (깔끔하게 재시도해도 안전함)
_GW_TRANSIENT_RE = re.compile(
r"524|origin_response_timeout|Too Many Requests|"
...
분류기 양쪽 모두에 추가했습니다: 작업별 컨테이너 내부의 셸(shell) 측 재시도 루프와 워크플로우 엔진 내의 Python 측 클린-재시도 탐지기(clean-retry detector)입니다.
이 포스트 전체의 핵심 아이디어는 다음과 같습니다:
일시적(transient) 목록에 항목을 추가하는 것은 항상 안전합니다. 오탐(false positive, 실제 영구적 실패를 일시적 실패로 취급하는 것)은 단지 30~90초의 백오프(backoff)를 낭비할 뿐입니다. AI는 동일한 프롬프트에 대해 멱등성(idempotent)을 가지므로 상태가 손상되지 않습니다. 반면, 미탐(false negative, 실제 일시적 실패를 영구적 실패로 취급하는 것)은 Slack으로 에스컬레이션되어 사람의 개입을 중단시킵니다.
오탐은 비용이 저렴하지만, 미탐은 비용이 비쌉니다. 따라서 분류기가 일시적인 오류 쪽으로 편향되도록 하세요. 이러한 비대칭성은 일반적인 작업 시스템에서도 유효하지만, 에이전트(agent)의 경우 더욱 극명합니다. 각 실행은 수십 분의 시간과 추론 비용(inference cost)이 소요되므로, 사람에게 에스컬레이션되는 단위 비용이 이례적으로 높기 때문입니다.
사고 3: 늦게 등록되는 CI 체크가 나타나기도 전에 병합하기
wait_ci는 PR이 열렸을 때 관찰 가능한 체크 항목들로부터 "필수 체크(required checks)" 목록을 구축했습니다.
하지만 opsguide-back의 test 작업은 먼저 Docker 이미지를 빌드하기 때문에, PR이 열린 후 약 3분 뒤에 등록됩니다. 이는 PR 오픈 시점의 스냅샷(snapshot)에는 포함되어 있지 않았습니다. 결과적으로 wait_ci는 이를 기다리지 않고 조기에 통과해 버렸고, 하위 단계인 merge_pr에서 다음 오류가 발생했습니다:
Repository rules blocked merge: 405
Required status check "test" is failing.
실제 타임라인 (2026-05-20, opsguide-back #11284):
04:25:25 wait_ci starts required_checks=[check-develop-only-files,
export-and-check, format-check, lint, check-single-head] ← test 없음
04:28 test job starts
...
해결책: 관찰된 스냅샷이 아닌 브랜치 보호(branch protection) 설정을 신뢰할 것
GitHub의 브랜치 보호(branch protection)에는 required_status_checks가 있습니다. 이것이 GitHub이 실제로 머지(merge)를 제어하는 정식 목록(canonical list)입니다. 스냅샷 대신 이 설정을 읽어야 합니다.
def get_required_status_check_contexts(repo, branch):
# 브랜치 보호의 required_status_checks를 읽습니다.
# 보호되지 않은 브랜치나 권한 누락 시 404/403에 대해 []를 반환합니다.
...
이 컨텍스트들을 strict=True 옵션과 함께 wait_ci의 필수 체크 항목으로 합집합(union) 처리합니다. 엄격 모드(strict mode)는 아직 나타나지 않은 필수 체크 항목에 대해 이미 대기(waits) 기능을 수행하므로(실행이 None이거나 미완료일 때 waiting()을 반환), 늦게 나타나는 test 작업도 이제 기다렸다가 평가됩니다. 따라서 실패할 경우 머지 시 405 오류로 넘어가는 대신 fix 단계로 라우팅됩니다.
교훈: "지금 내가 볼 수 있는 것"이 시스템의 진실이 되게 하지 마세요. CI 체크가 비동기적으로 등록되는 세상에서, 관찰된 스냅샷은 항상 과거의 데이터(stale)가 될 수밖에 없습니다. 게이트(gate) 정의 자체를 읽으세요.
사고 4: 머지 시점에 재큐(re-queued)된 빠른 체크의 단수형 문제
이 문제는 단 하나의 정규 표현식(regex) 문제였지만, 오후 전체를 잡아먹을 만한 종류의 것이었습니다.
두 개의 작업이 merge-pr 단계에서 다음 오류와 함께 실패했습니다:
Repository rules blocked merge: 405
Required status check "check-branch-name" is expected.
_is_ci_pending_error는 오직 복수형 표현인 "N of M required status checks are expected" — 즉, "are expected"에만 일치했습니다. 정확히 하나의 필수 체크(required check)가 완료되지 않았을 때, GitHub는 단수형인 Required status check "X" is expected.를 사용합니다. 이 메시지는 대기 상태 감지기(pending detector)를 그대로 통과하여 즉각적인 실패(hard failure)로 이어졌습니다.
wait_ci 체크가 통과(green)되었음에도 왜 머지(merge) 시점에 재큐(re-queue)가 발생했을까요? check-branch-name은 빠른 체크(fast check)이며, merge_pr은 머지 직전에 머지 베이스(merge base)를 다시 계산합니다. GitHub는 새로운 헤드(head)에 대해 브랜치 보호(branch protection) 규칙을 재평가하며, 성공을 다시 보고하기 전까지 해당 빠른 체크를 잠시 다시 "expected" 상태로 보고합니다. 제한된 재시도 루프(bounded retry loop)는 정확히 이 구간을 위해 설계되었으나, 단일 체크 케이스에서는 루프 내부로 진입하지 못했던 것입니다.
수정 사항 (Fix)
# 단수형 "is expected"와 복수형 "are expected"를 모두 재시도로 라우팅
if "expected" in error_message.lower():
return True # CI 대기 상태로 취급; 대기 후 재시도
단순 토큰인 "expected"를 매칭합니다. "expected"를 포함하는 GitHub 머지 차단(merge-block) 메시지는 이러한 대기 중인 체크(pending-check) 문구뿐이므로, 매칭 범위를 넓히더라도 실제 정책 거부(필수 서명 커밋 등)를 일시적인 오류로 잘못 분류할 위험은 없습니다. 이는 기존 회귀 테스트(regression test)에 의해 검증됩니다.
화려하지는 않지만, 단수형 대 복수형의 차이와 3분의 지연 시간이 자율 에이전트(autonomous agents)를 멈추게 하는 실제 요인들입니다.
사고 5: 도착하는 순간 이미 만료되어 버린 빌려온 토큰
Codens의 태스크별 워커(per-task workers)는 빌려온 공유 OAuth 자격 증명(credentials)으로 실행됩니다. refreshToken은 의도적으로 제거되었습니다. 만약 제거하지 않았다면, 각 워커의 CLI가 독립적으로 토큰을 갱신하여 공유된 OAuth ID를 순환(rotate)시키게 되고, 이는 형제 워커들 사이에 연쇄적인 401 오류를 일으키게 됩니다.
src.token_refresh: token is expired — attempting refresh before job start
src.token_refresh: Token refresh failed: no refreshToken in credentials file
POST /jobs HTTP/1.1 500 Internal Server Error
따라서 대여인(borrower)은 스스로 토큰을 갱신할 수 없습니다. 만약 수신한 accessToken이 수신 시점에 이미 expiresAt을 지났다면, 워커(worker)의 작업 전 점검(pre-job check) 단계에서 "no refreshToken" 오류로 중단되며 POST /jobs는 500 에러를 반환합니다.
해결책: refreshToken을 보유한 소스(source)가 반환하기 전에 갱신한다
근본 원인: 소스 자격 증명 서비스(source credential service)의 GET /claude-auth가 만료 시간을 포함하여 저장된 자격 증명을 있는 그대로 반환했습니다. 정식(canonical) refreshToken을 보유한 유일한 곳은 소스이므로, 반환하기 전에 그곳에서 갱신을 수행해야 합니다.
async def get_claude_auth():
# 반환하기 전에 갱신합니다. 남은 수명이 5분 이상이면
# 아무 작업도 하지 않으므로(no-op), 일반적인 경우에는
# 추가적인 왕복 시간(round-trips)이 발생하지 않습니다.
...
ensure_valid_token은 토큰의 남은 시간이 5분보다 많으면 아무 작업도 하지 않으므로, 일반적인 경우에는 비용이 들지 않습니다. 임계값 미만일 때만 refreshToken을 가진 유일한 장소(소스)가 갱신을 수행하고, 새 토큰을 기록한 뒤 이를 반환합니다.
"대여인이 갱신한다"는 단순한(naive) 설계는 공유된 정체성(shared identity)이라는 아키텍처적 제약 조건과 일치하지 않았습니다. 오직 한 당사자만이 갱신할 수 있습니다. 따라서 그 당사자가 반환하기 전에 갱신을 수행합니다.
반복되는 세 가지 원칙
다섯 가지 사례를 나열해 보면 해결책들이 일정한 형태를 공유하고 있음을 알 수 있습니다.
1. 자율 에이전트는 경계(seams)에서 무너진다. 출력 품질이 아니라, git, CI, 인증(auth), 네트워크와 같은 경계에서 무너집니다. 따라서 해결책은 더 똑똑한 모델을 만드는 것이 아니라 경계를 강화하는 데 집중합니다. pre-push hook, CI 게이트를 위한 신뢰할 수 있는 소스(source of truth), 토큰을 갱신할 적절한 당사자 지정 — 이 모든 것은 모델과는 무관한 전형적인 시스템 설계(systems design)의 영역입니다.
2. 거짓 양성(False positives)과 거짓 음성(False negatives)의 비용은 비대칭적이다. 일시적인 오류를 잘못 분류(false positive)하면 수십 초의 백오프(backoff) 비용이 들지만, 오류를 놓치면(false negative) 인간의 개입(escalation) 비용이 발생합니다. 에이전트 실행은 길고 비용이 많이 들기 때문에, 인간을 멈추게 하는 비용은 이례적으로 높습니다. 분류기가 재시도(retry) 쪽으로 편향되도록 하십시오. 멱등성(Idempotency)이 있다면 그것은 안전합니다.
3. 가드(Guards)는 페일 세이프(fail-safe)여야 합니다. 안전 메커니즘 자체의 버그가 메인 흐름을 중단시켜서는 안 됩니다. 프리 푸시 훅(pre-push hook)은 훅 자체에서 예기치 않은 오류가 발생할 경우 푸시가 진행되도록 허용합니다. 우리는 "가끔 사고가 새어 나가는 것"보다 "모든 워크플로우가 중단되는 것"을 더 무겁게 고려합니다.
AI에게 더 많은 일을 맡길수록, 이러한 중간 단계가 아닌 경계(not-in-the-middle)의 디테일들이 빛을 발합니다. 더 똑똑한 모델이라 할지라도 git 충돌 표시(conflict markers)를 사라지게 만들지 못하며, CI 체크를 동기식(synchronously)으로 등록하지도 못하고, 토큰의 만료를 막을 수도 없습니다. 프로덕션 환경에서 에이전트를 계속 실행 상태로 유지하는 것은 결국 이러한 이음새(seams)들을 하나씩 메워가는 작업임이 드러났습니다.
Codens는 이 모든 것을 제품에 구축해 두었습니다. 관심이 있다면 확인해 보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기