AI 자동 비행 시스템이 3일 동안 아무것도 병합하지 못한 이유: 범인은 리뷰 '신선도' 확인 기능이었다
요약
AI 에이전트 기반 자동 개발 시스템에서 '리뷰 신선도 확인' 기능으로 인해 발생한 병합 중단 장애 사례를 다룹니다. 시스템 복구 과정에서 참조 시간이 업데이트되며 기존 승인을 무효화하는 논리적 오류와 그 교훈을 설명합니다.
핵심 포인트
- AI 에이전트 워크플로우 설계 시 외부 상태 처리 방식의 중요성
- 시스템 재진입(Re-arming) 시 참조 시간 재설정이 초래하는 부작용
- 상태를 스냅샷이 아닌 이벤트 기반으로 취급해야 할 필요성
- 방어적 코드(신선도 검사)가 시스템 전체의 교착 상태를 유발할 수 있음
Codens에서는 AI 에이전트를 이용해 24시간 개발을 진행합니다. PRD(Product Requirements Document)가 태스크로 변환되고, 에이전트들이 이를 구현하여 PR(Pull Request)을 열고, AI 리뷰어가 승인하며, 승인된 PR은 자동으로 병합됩니다. 인간은 주로 검토된 결과와 알림을 읽는 역할을 합니다. 이것이 저희의 아이디어였는데, 어느 날 아침 대시보드에 3일 연속으로 완료된 태스크가 0개라는 것을 확인했습니다.
'자동 비행 시스템이 다운되었다.'
하지만 실제로는 그렇지 않았습니다. 모든 컴포넌트는 정상 작동했고, 계획은 생성되고 있었으며, 태스크도 제출되었고, 에이전트들은 PR을 열었습니다. AI 리뷰어는 이미 그들을 승인했습니다. CI(Continuous Integration) 상태도 녹색이었습니다. 유일하게 일어나지 않은 것은 병합 과정뿐이었습니다.
원인은 PR 리뷰를 조회하는 코드에 추가된 신선도 확인(freshness check) 기능이었습니다. 이는 저희가 다른 사고의 재발을 막기 위해 자체적으로 추가한 방어적 검사였습니다. 이 글은 그 상황이 어떻게 전개되었는지, 그리고 AI 에이전트를 넘어 일반화될 수 있는 교훈: **외부 상태를 이벤트로 취급할 것인가, 아니면 스냅샷으로 취급할 것인가?**에 대한 이야기입니다.
배경: wait_review 단계
저희의 워크플로우 엔진은 각 태스크를 implement → create_pr → wait_ci → wait_review → merge_pr와 같은 고정된 단계를 거치도록 만듭니다. 저희는 AI에게 '다음으로 무엇을 할지' 결정하게 하는 것은 운영상 추론이 불가능하게 만든다는 것을 일찍 깨달았기 때문에, 전환 과정은 엔진에 하드 코딩되어 있습니다.
wait_review는 만남의 장소입니다. 이곳에서는 GitHub PR 리뷰를 조회하고 승인이 들어오면 다음 단계로 진행합니다. 내부에는 다음과 같은 검사가 있었습니다:
# 간략화된 코드 예시
fresh_reviews = [
r for r in pr_reviews
...
review_initiated_at보다 최신 리뷰만 확인하도록 했습니다. 합리적으로 보였습니다.
신선도 확인 기능이 존재했던 이유
이는 실제 사고를 방지하기 위해 추가되었습니다:
- 리뷰어가 승인(APPROVE)합니다.
- 에이전트가 후속 커밋을 푸시합니다.
- 리뷰어가 변경 요청(CHANGES_REQUESTED)을 제출합니다.
- 조회 기능이 **오래된 승인(stale APPROVE)**을 감지하고 어쨌든 병합해 버립니다.
변경 요청 이후에 오래된 승인을 기반으로 병합하는 것은 명백히 잘못되었기 때문에, 저희는
재실행(Re-arming)이 기존 승인을 무효화함
문제는 review_initiated_at이 언제 업데이트되느냐였습니다.
엔진에는 Spot 인스턴스 회수(reclamation) 및 프로세스 재시작에 대비한 복구 경로(recovery path)가 있습니다. 즉, 중단된 워크플로우(workflow)를 다시 가져와 현재 단계를 **재진입(re-enters)**합니다. wait_review 단계에 재진입할 때, 새로운 커밋의 존재 여부와 상관없이 review_initiated_at이 현재 시간으로 재설정(re-armed)되었습니다.
이로 인해 다음과 같은 타임라인이 가능해졌습니다:
10:00 리뷰어가 승인 (submitted_at = 10:00)
10:30 Spot 인스턴스 회수 → 워크플로우 복구로 인해 해당 단계 재진입
→ review_initiated_at이 10:30으로 재설정됨
...
승인은 GitHub에 여전히 존재하며, 이는 명백한 기존 승인(standing approval)입니다. 하지만 필터의 참조 시간이 승인 시점을 지나쳐 버렸기 때문에, 엔진은 영원히 "아직 리뷰되지 않음"이라는 결론을 내립니다. 완벽한 교착 상태(deadlock)입니다.
고약한 점은 이것이 **확률적(probabilistic)**이라는 것입니다. 만약 승인이 이루어지기 전에 복구 재진입이 발생한다면 아무런 문제가 없습니다. 오직 재진입이 승인 이후에 발생하는 워크플로우들만 조용히 멈춰버립니다. Spot 인스턴스 비중이 높은 인프라에서는 재진입이 매우 빈번하게 발생하며, 이것이 며칠 동안 누적되어 결국 "완료 건수 0"이라는 결과로 나타난 것입니다.
실제로 어떻게 진단했는가
"Autopilot이 작동하지 않음"에서 근본 원인(root cause)을 찾아내는 과정은 한 가지 습관에 달려 있었습니다: 엔드포인트(endpoint)를 보지 말고, 분포(distribution)를 보라.
- 완료 건수(엔드포인트)만 관찰했을 때는 모든 것이 일률적으로 멈춰 있는 것처럼 보였습니다.
- 진행 중인(in-flight) 워크플로우를 단계별로 집계해 보니, 28개가
wait_review단계에 비정상적으로 멈춰 있었습니다. - 해당 PR들을 교차 검증한 결과: 모두 승인되었고, CI는 통과(green)했으며, 병합되지 않은 상태였습니다.
이 시점에서 "엔진이 승인을 인식하지 못한다"는 사실이 확립되었고, 나머지는 단순히 폴링(poll) 코드를 읽는 작업이었습니다. 죽은 컴포넌트를 찾아 헤매는 것보다 사체가 쌓이는 곳을 세는 것이 더 효과적이었습니다. 왜냐하면 죽은 컴포넌트는 없었기 때문입니다. 잘못된 규칙을 충실히 실행하고 있는 살아있는 프로세스는 헬스 체크(health check)에 절대 나타나지 않습니다.
해결책: 이벤트가 아닌 스냅샷을 평가하라
해결책의 핵심은 한 문장으로 요약됩니다. **"새로운 리뷰가 도착했는가?"**라고 묻는 대신, 매 폴링(poll)마다 **"지금 현재 유효한 리뷰 상태는 무엇인가?"**를 계산하는 것입니다.
# 각 리뷰어의 최신 리뷰만 유지
latest_by_reviewer = {}
for r in sorted(pr_reviews, key=lambda r: r.submitted_at):
...
이것은 말 그대로 GitHub 자체 UI가 사용하는 의미론(semantics)입니다. 리뷰 패널은 각 리뷰어의 최신(latest) 상태를 보여주며, 승인(approval)이 언제 일어났는지는 유효한 판결에 아무런 역할을 하지 않습니다. 이는 화려하지는 않지만 견고한 결론으로 이어집니다. 만약 외부 시스템의 상태를 기준으로 게이트(gate)를 설정한다면, 해당 시스템 자체의 의미론을 채택하십시오.
그렇다면 원래 발생했던 사고(변경 요청이 있은 후 오래된 승인 상태를 바탕으로 병합된 문제)는 어떻게 될까요? 리뷰어별 최신 상태(Per-reviewer-latest) 방식은 이를 자동으로 해결합니다. 변경을 요청한 리뷰어는 CHANGES_REQUESTED 상태에 있으므로, 이전의 APPROVE 상태가 결코 승리할 수 없습니다. 신선도 확인(freshness check)은 동일한 리뷰어가 변경을 요청하고 새로운 커밋이 반영되었을 때 재리뷰(re-review)를 디바운싱(debouncing)하는 정당하고 좁은 역할로만 살아남게 됩니다.
배포 후, wait_review 백로그(backlog)는 28개에서 8개로 감소했으며, 15개의 작업이 25분 만에 완료되었습니다. 조건식(predicate)이 수정되자마자 3일 동안 쌓여있던 승인된 PR들이 순식간에 빠져나간 것입니다.
2단계: 실패는 전파되어야 한다
우리는 그것이 끝이라고 생각했습니다. 하지만 다음 날, 원인은 달랐지만 동일한 증상이 다시 나타났습니다. 바로 의존성 체인(dependency chains) 때문이었습니다.
버그가 발생했던 기간 동안 일부 작업들이 wait_review 단계에서 실패로 표시되었고, 그들의 **하위 작업(downstream tasks)**들은 해당 의존성들에 걸려 차단(blocked)된 상태로 남아 있었습니다. 자동 복구 작업(auto-recovery job)은 단지 "차단된 작업을 대기 중(pending)으로 되돌리기"만 수행했을 뿐, 스케줄러의 시작 조건은 여전히 "모든 의존성 완료"로 남아 있었습니다. 결과적으로 실패한 조상(ancestor)을 가진 작업들은 대기 중(pending)과 차단(blocked) 상태 사이를 영원히 맴돌게 되었습니다. 아무런 효과도 없는 재시도 루프(no-op retry loop)가 형성된 것입니다.
해결책은 '페일 패스트 (fail-fast)' 방식을 도입하는 것이었습니다. 의존성 (dependency)이 실패했을 때, 하위 요소들을 모호하게 차단 (blocked) 상태로 두지 말고, 명시적으로 실패 (FAILED)로 표시하고 이를 하류 (downstream)로 전파하는 것입니다. 눈에 보이는 실패는 단 한 번의 동작(
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기