본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 08. 12:44

AI 코딩 에이전트는 '중간'이 아니라 '접점'에서 고장 난다: 실전에서 겪은 5가지 사고와 소소한 대책

요약

AI 코딩 에이전트가 실전 운영 환경에서 모델 자체의 성능보다 git, CI, 네트워크 등 외부 접점(Interface)에서 발생하는 오류로 인해 실패하는 사례를 분석합니다. 실제 발생한 대규모 코드 오염 사고와 이를 방지하기 위한 pre-push hook 기반의 기술적 대책을 제시합니다.

핵심 포인트

  • AI 에이전트의 실패는 모델 성능보다 외부 인터페이스 접점에서 주로 발생함
  • 컨플릭트 마커가 포함된 채로 커밋/푸시되는 국소 최적화 문제를 경계해야 함
  • git pre-push hook을 활용하여 push 직전 단계에서 안전 장치를 마련해야 함
  • 정규 표현식을 이용한 컨플릭트 마커 검사와 merge 소스 허용 리스트 도입이 필요함

AI 코딩 에이전트를 실전(Production)에서 돌리기 시작하며 알게 된 사실이 있다. 고장 나는 것은 모델이 작성하는 코드의 내용이 아니다. 고장 나는 것은 git, CI, 인증, 네트워크와 같은 '외부와의 접점(Interface)'이다.

모델 자체는 상당히 똑똑하다. 함수도 쓰고, 테스트도 쓰고, 리팩터링(Refactoring)도 한다. 하지만 그 결과물을 push 하거나, CI를 기다리거나, PR을 merge 하거나, 토큰을 갱신하거나, 다른 서비스에 질의하는 등의 '작업 전후' 과정에서 사고가 발생한다. 게다가 인간이라면 무의식적으로 피하고 있는 종류의 사고가 많다.

Codens의 Purple(오케스트레이션 본체)에서 최근 몇 주간 실제로 겪고 해결한 5가지 사고를 나열한다. 모두 실제 운영 환경의 task ID와 날짜가 남아 있는 실화이며, 대책도 모두 merge 완료되었다. 공통된 설계 철학이 마지막에 핵심적인 역할을 하므로, 그때까지 읽어주길 바란다.

사고 1: AI의 어설픈 merge가 12,000행을 PR에 흘려보낼 뻔했다

첫 번째 사례가 가장 아찔했다.

opsguide-back이라는 프로젝트의 Purple task가 PR을 하나 생성했다. 내용을 확인하니 +12,162행 / 149개 파일 변경, 그중 2개 파일에 <<<<<<< 가 그대로 남아 있었다. 커밋 그래프(Commit graph)를 추적해 보니 다음과 같았다.

e567ce67 (merge commit, "chore: Fix HYBRID_SEARCH...")
├ parent[0] = 0b069e5d (develop의 최신 상태, main보다 +1468 commits)
└ parent[1] = 2940de35 (원래 의도했던 feature commit)

무슨 일이 일어났는가. fix 단계에서 AI가 "테스트 수정을 반영하자"라고 판단하여 git merge develop을 실행했다. merge 과정에서 컨플릭트(Conflict)가 발생했다. AI는 이를 중간까지만 해결하고, 남은 마커(Marker)가 붙은 상태 그대로 git commit을 해버렸다. push 된 것은 develop의 차분(Diff) 전체와 미해결된 컨플릭트 마커였다. 누군가 merge 버튼을 눌렀다면, main은 develop의 1468개 커밋만큼의 드리프트(Drift)로 단번에 오염될 뻔했다.

인간이라면 절대 하지 않을 행동이다. "develop을 main 대상 PR에 merge 한다"는 판단 자체를 하지 않으며, 설령 컨플릭트가 발생하더라도 모두 해결할 때까지 commit 하지 않는다. 하지만 AI는 눈앞의 테스트를 통과시키겠다는 국소 최적화(Local optimization)를 위해 아무렇지 않게 이런 행동을 한다.

대책: push 직전에서 2단계로 차단하기

gitpre-push hook을 하나 끼워 넣었다. AI의 git push가 실제로 통과하는 지점이 여기이므로, 여기에 배치하는 것이 확실하다.

#!/bin/bash
set -u
# 1단계: 컨플릭트 마커 검사 (상시 활성화, 설정 불필요)
...

포인트는 "커밋된 트리(HEAD)를 본다"는 것이다. 워킹 디렉터리(Working directory)는 나중에 정리(Cleanup)될 수 있지만, 커밋에 포함되어 버린 마커는 남는다. push 되는 것은 HEAD이므로, 그곳을 git grep 한다.

정규 표현식을 ^(<<<<<<< |======= |>>>>>>> )로 설정한 것은 오탐(False positive) 방지 대책이다. =======는 Markdown의 헤더 구분선이나 표에서 자주 등장하므로, 행 시작 부분 + 직후에 공백이 있는 git 컨플릭트 마커의 정확한 형태에만 매칭되도록 했다.

2단계는 "merge 소스 허용 리스트(Allowlist)"이다. 이는 워크플로우 단위로 설정할 수 있다. 정책 파일(Policy file)이 있을 때만 동작한다.

# 2단계: merge-source allowlist (정책 파일이 있을 때만)
# {
# "feature": "feature/<task-id>",
...

push 되는 ref에 새로운 merge commit이 있다면, 그 각 parent가 feature / base / allowed 중 하나로부터 도달 가능한지를 git merge-base --is-ancestor로 확인한다. 허용되지 않은 소스로부터의 merge라면 push를 거부한다. 공란이면 체크하지 않는다(Opt-in).

for p in $parents; do
ok=0
for rname in "${refs[@]}"; do
...

그리고 소소하지만 중요한 것이 fail-safe (페일 세이프) 방침이다. 후크(hook) 자체에 버그가 있어 다운되더라도, push 자체는 통과되도록 설계했다. 가드(guard)의 버그로 인해 모든 워크플로우(workflow)가 멈추는 것이, 가끔 사고를 통과시켜 버리는 것보다 더 무섭기 때문이다. 1단계는 git grepgit log만 사용하여 표면적을 작게 유지하고, 2단계는 jq가 없으면 그대로 통과(permissive)하도록 폴백(fallback)한다.

사고 2: 일시적인 네트워크 단절을 '영구 장애'로 오판하여 인간에게 넘겨버리는 경우

셀프 호스팅(self-host)한 모델 게이트웨이(Cloudflare를 거치는 vLLM)를 경유하는 태스크가 약 27분간 작동한 후 exit 1로 종료되었다. 로그는 다음과 같다.

API Error: The socket connection was closed unexpectedly.

게이트웨이는 세션 시작 시점에는 건강한 상태였고(엔트리 포인트가 설정 취득에 성공함), 조사 시점에는 GET /health가 0.56s에 200을 반환하고 있었다. 즉, 세션 도중의 찰나의 단절이었다. 이는 이미 524 리트라이(retry) 경로에서 처리하고 있는 'Cloudflare 전단의 과부하' 패턴이, Node.js의 fetch 관점에서는 '소켓이 닫혔다'라는 다른 증상으로 나타난 것뿐이었다.

하지만 기존의 리트라이 판정 정규 표현식은 524 / origin_response_timeout / connection reset / Too Many Requests는 커버하고 있었으나, '소켓이 닫힌' 케이스에 대한 엔트리가 없었다. 결과적으로 이 태스크는 '비일시적 장애(exit=1), 리트라이 하지 않음'으로 분류되었고, Purple은 develop 단계를 통째로 Slack으로 에스컬레이션(escalation)하여 인간의 재디스패치(re-dispatch)를 기다리는 상태가 되었다.

대책: 일시적 장애 리스트에 추가한다. 그리고 '오탐의 비용이 저렴함'을 믿는다.

수정 자체는 몇 가지 패턴을 추가하는 것뿐이다.

# transient(깔끔하게 재시도해도 좋은)로 간주하는 패턴
_GW_TRANSIENT_RE = re.compile(
r"524|origin_response_timeout|Too Many Requests|"
...

쉘(shell) 측(per-job 컨테이너의 리트라이 루프)과 Python 측(워크플로우 엔진의 클린 재시도 판정) 양쪽에 모두 적용했다.

여기서 작용하는 생각이 이 글의 핵심 중 하나다.

일시적 장애 리스트에 추가하는 것은 언제나 안전하다. 오탐(실제로는 영구 장애인데 transient로 취급)하더라도 낭비되는 것은 30~90초의 백오프(backoff)뿐이다. AI는 동일한 프롬프트에 대해 멱등성(idempotency)을 가지므로 상태가 망가지지 않는다. 반대로 놓치는 경우(실제로는 transient인데 영구 장애로 취급)는 Slack으로 에스컬레이션되어 인간의 손을 멈추게 한다.

즉, 오탐은 싸고, 놓치는 것은 비싸다. 따라서 분류기는 transient 쪽으로 치우치게 만드는 것이 정답이 된다. 이는 AI 에이전트뿐만 아니라 잡 시스템(job system) 전반에 적용되는 비대칭성이지만, 에이전트는 '1회 실행에 수십 분 + 비용이 수반'되므로 인간 에스컬레이션의 단가가 특히 높다.

사고 3: 나중에 등록되는 CI 체크를 기다리지 않고 merge 해버리는 경우

wait_ci(CI 대기 단계)는 PR을 연 시점에서 관측할 수 있었던 체크 리스트로부터 '필수 체크'를 구성하고 있었다.

그런데 opsguide-back의 test 잡은 Docker 빌드를 포함하기 때문에, PR이 열린 후 약 3분 뒤에 등록된다. PR-open 시점의 스냅샷에는 이 test가 포함되어 있지 않다. 그래서 wait_citest를 기다리지 않고 일찍 통과해 버렸고, 후속 단계인 merge_pr이 이를 밟았다.

Repository rules blocked merge: 405
Required status check "test" is failing.

실제 타임라인(2026-05-20, opsguide-back #11284):

04:25:25 wait_ci 시작 required_checks=[check-develop-only-files,
export-and-check, format-check, lint, check-single-head] ← test가 없음
04:28 test 잡이 시작
...

대책: 「관측된 체크」가 아니라 「branch protection이 요구하는 체크」를 진실로 삼는다

GitHub의 branch protection에는 required_status_checks라는, GitHub가 merge를 게이트(gate)할 때 사용하는 정준(canonical) 리스트가 있다. 스냅샷이 아니라 이것을 읽으러 간다.

def get_required_status_check_contexts(repo, branch):
# branch protection의 required_status_checks를 읽는다.
# 404/403일 때는 []를 반환하며, 보호되지 않은 branch / 권한 없음은 기존 동작으로 폴백(fallback).
...

이것을 wait_ci의 필수 체크에 strict=True로 합류시킨다. strict 모드는 「아직 나타나지 않은 필수 체크도 기다리는」(해당 run이 None/미완료라면 waiting()을 반환) 동작이므로, 3분 뒤에 나타날 test도 제대로 기다리고, 평가하여, 실패라면 fix로 돌린다. merge 시의 405 에러로 빠져나가는 일이 없어졌다.

교훈은 「지금 보이는 것」을 시스템의 진실로 삼지 않는 것이다. 비동기적으로 등록되는 CI 체크의 세계에서는 관측 스냅샷은 반드시 낡기 마련이다. 게이트의 정의 그 자체(branch protection)를 보러 가야 한다.

사고 4: merge 직전에 재큐(re-queued)되는 고속 체크의 「단수형」

이것은 정규 표현식 하나에 관한 이야기지만, 디버깅에 시간을 낭비하게 만드는 종류의 것이다.

두 개의 태스크가 merge-pr에서 다음과 같이 실패했다.

Repository rules blocked merge: 405
Required status check "check-branch-name" is expected.

_is_ci_pending_error(CI 보류 중인지를 판정하는 함수)는 GitHub의 복수형 표현인 "N of M required status checks are expected""are expected"에만 매치(match)되고 있었다. 필수 체크가 딱 1개만 미완료일 때, GitHub는 단수형Required status check "X" is expected.를 반환한다. 이것이 보류 감지를 빠져나가, 하드 실패(hard failure)로 취급되었다.

wait_ci가 초록색(성공)으로 보였던 체크가 merge 시에 다시 보류 상태가 되는가. check-branch-name은 고속 체크이며, merge_pr은 merge 직전에 merge base를 재계산한다. GitHub는 새로운 head에 대해 branch protection을 재평가하며, 그 (빠른) 체크가 다시 success를 보고할 때까지 아주 짧은 순간 동안 「expected」 상태로 돌아간다. 바운드(bound)된 리트라이(retry) 루프는 바로 이 찰나의 순간을 위해 만들어졌음에도, 단수형 때문에 루프에 포함시키지 못했다.

대책

# 단수형 "is expected"도 복수형 "are expected"도 둘 다 리트라이에 포함시킨다
if "expected" in error_message.lower():
    return True # CI-pending으로 취급하여, 백오프(backoff) 후 재시도

"expected"라는 가공되지 않은 토큰에 매치시킨다. GitHub의 merge 차단 메시지에서 "expected"를 포함하는 것은 이 보류 계열의 문구뿐이므로, 서명 커밋 필수와 같은 실제 정책 거부를 일시적 오류(transient)로 오분류할 걱정은 없다(기존 회귀 테스트로 커버 완료).

사소하지만, 이런 「단수형/복수형」, 「3분의 지연」 같은 현실의 왜곡이 자율 에이전트를 멈추게 하는 실체다.

사고 5: 빌려온 토큰이, 받자마자 이미 만료됨

Codens의 per-task 워커는 공유된 OAuth 자격 증명(credential)을 「빌려서」 동작한다. 이때 refreshToken은 의도적으로 제거된다. 이유가 있는데, 만약 제거하지 않으면 각 워커의 CLI가 독립적으로 리프레시(refresh)하여 공유된 OAuth identity를 로테이트(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)은 스스로 리프레시(refresh)할 수 없다. 받은 accessToken이 수령 시점에 이미 expiresAt을 지나 있다면, 워커(worker)의 작업 전 체크 단계에서 "refreshToken이 없음"으로 막히게 되고, POST /jobs가 500 에러를 반환한다.

대책: refreshToken을 가지고 있는 source 측에서 반환하기 전에 리프레시한다

자격 증명(credential)을 보유한 source 서비스의 GET /claude-auth가 저장된 자격 증명을 (만료된 상태 그대로) 반환했던 것이 원인이었다. 정형적인(canonical) refreshToken을 가지고 있는 것은 source 측뿐이므로, 반환하기 전에 그곳에서 리프레시를 수행한다.

async def get_claude_auth():
# 반환하기 전에 토큰을 갱신. 남은 시간이 5분 이상이면 no-op이므로,
# 일반적인 케이스에서 라운드 트립(round-trip)은 늘어나지 않는다.
...

ensure_valid_token은 토큰의 남은 유효 시간이 5분 이상이면 아무것도 하지 않는다. 따라서 평소에는 비용이 들지 않는다(zero-cost). 임계값 미만으로 떨어졌을 때만, refreshToken을 가진 유일한 장소(source)가 리프레시를 수행하고, 새로운 토큰을 기록한 뒤 반환한다.

"리프레시는 빌려 쓰는 쪽이 한다"라는 단순한 설계가, 공유 identity(shared identity)라는 아키텍처 제약과 맞지 않았던 것이다. 리프레시할 수 있는 주체는 하나뿐이다. 그렇다면 그 주체가 반환하기 전에 수행해야 한다.

공통되는 3가지 원칙

5가지를 나열해 보면, 대책들에 공통된 패턴이 보인다.

1. 자율 에이전트(autonomous agent)는 접점에서 고장 난다. 모델의 출력 품질이 아니라, git, CI, 인증, 네트워크라는 경계(boundary)에서 사고가 발생한다. 따라서 대책 또한 똑똑한 모델을 만드는 것이 아니라 경계의 견고함을 높이는 방향으로 향한다. pre-push hook, CI 게이트의 신뢰할 수 있는 원천(source of truth), 토큰의 리프레시 주체. 이 모두 모델과는 무관한 고전적인 시스템 설계의 문제다.

2. 오탐(false positive)과 미탐(false negative)의 비용은 비대칭적이다. 일시적인 장애를 오탐하는 것은 수십 초의 백오프(backoff)로 해결되지만, 미탐은 인간의 에스컬레이션(escalation)으로 이어진다. 에이전트는 1회 실행 시간이 길고 비용도 많이 들기 때문에, 인간의 업무를 중단시키는 단가가 특히 높다. 따라서 분류기(classifier)는 "재시도(retry) 쪽으로" 치우치게 설정한다. 멱등성(idempotency)이 이를 뒷받침한다.

3. 가드는 페일 세이프(fail-safe)하게. 안전장치 자체의 버그가 메인 흐름을 멈춰서는 안 된다. pre-push hook은 hook 내부에서 예기치 않은 에러가 발생하더라도 push가 통과되도록 한다. 가끔 사고를 놓칠 위험보다, 전체 워크플로우가 멈출 위험을 더 무겁게 보고 있기 때문이다.

AI에게 맡기는 범위를 넓힐수록, 이런 "중간이 아닌 곳"의 수수한 구현이 힘을 발휘한다. 모델이 똑똑해져도 git의 컨플릭트 마커(conflict marker)는 사라지지 않으며, CI 체크는 비동기로 등록되고, 토큰은 만료된다. 에이전트를 프로덕션에서 계속 구동시킨다는 것은, 결국 이러한 접점들을 하나씩 메워가는 작업이었다.

Codens는 이 모든 부분을 프로덕트에 통합해 두었다. 관심이 있다면 확인해 보길 바란다.

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0