본문 바로가기
AI 인공지능

AI 코드리뷰 자동 머지 파이프라인이 3일간 멈춘 이유

by 요즘IT 2026. 7. 2.

혹시 이런 상상 해보신 적 있나요?

기획서 넣으면 AI가 알아서 작업을 쪼개고, 코드 짜고, PR 올리고, 다른 AI가 리뷰해서 승인하면 자동으로 머지까지. 사람은 알림만 확인하면 되는 개발 파이프라인.

상상이 아니라 실제로 이렇게 운영하는 팀이 있습니다. 24시간 AI 에이전트가 돌아가는 이른바 '개발 오토파일럿'이죠.

근데 어느 날 아침, 대시보드를 봤더니 완료된 작업이 3일 연속 0건이었다고 해요.

"오토파일럿이 죽었네."

어? 근데 이상하죠? 죽은 게 아니었거든요. 모든 컴포넌트가 멀쩡했습니다. 계획도 생성되고, 에이전트가 PR도 열고, AI 리뷰어는 이미 승인까지 끝낸 상태. CI도 전부 초록불. 딱 하나, 머지만 일어나지 않았어요.

오늘은 이 장애 회고를 뜯어보려고 합니다. AI 자동화 얘기 같지만, 사실 외부 시스템 상태를 폴링하는 모든 서비스에 그대로 적용되는 이야기거든요.

범인은 '신선도 검사'였다

범인부터 말씀드릴게요. PR 리뷰를 폴링하는 코드에 들어있던 freshness check, 그러니까 신선도 검사였습니다.

비유하자면 이런 거예요. 우편함을 확인하는 로봇이 있는데, "내가 기다리기 시작한 시각 이후에 도착한 편지만 유효한 걸로 친다"는 규칙을 갖고 있는 겁니다.

코드로 보면 이렇게 생겼어요.

# 단순화한 버전
fresh_reviews = [
    r for r in pr_reviews
    if r.submitted_at > workflow.review_initiated_at
]
if any(r.state == "APPROVED" for r in fresh_reviews):
    advance_to_merge()

기다리기 시작한 시각(review_initiated_at)보다 새로운 리뷰만 보겠다. 얼핏 합리적이죠?

여기서 잠깐. 이 검사, 아무 이유 없이 넣은 게 아니었습니다.

어제의 안전장치가 오늘의 범인

이 팀은 예전에 진짜 사고를 겪었어요.

  1. 리뷰어가 승인(APPROVE)을 눌렀어요
  2. 그 뒤에 에이전트가 추가 커밋을 푸시했고
  3. 리뷰어가 다시 보더니 수정 요청(CHANGES_REQUESTED)을 남겼는데
  4. 폴링 로직이 옛날 승인을 주워다가 그냥 머지해버린 겁니다

수정 요청이 있는데 낡은 승인으로 머지되면 큰일이잖아요. 그래서 "기다리기 시작한 시점 이후의 리뷰만 믿자"는 검사를 넣은 거죠. 그 시점에서는 정답이었습니다.

문제는 review_initiated_at이 언제 갱신되느냐였어요.

이 팀은 비용 아끼려고 스팟 인스턴스를 씁니다. 스팟 인스턴스가 뭐냐면, 클라우드 업체가 남는 서버를 싸게 빌려주는 대신 필요하면 예고 없이 뺏어가는 상품이에요. 그래서 프로세스가 중간에 죽는 게 일상이고, 죽으면 복구 로직이 워크플로우를 주워서 현재 단계에 다시 진입시킵니다.

그런데 이 재진입 과정에서 review_initiated_at을 현재 시각으로 다시 세팅해버렸던 거예요. 새 커밋이 있든 없든 상관없이요.

그러면 이런 타임라인이 가능해집니다.

10:00  리뷰어가 승인 (submitted_at = 10:00)
10:30  스팟 회수 → 복구 로직이 단계 재진입
       → review_initiated_at이 10:30으로 갱신
10:31  폴링: 10:00 > 10:30 → 거짓
       → "새 리뷰 없음"

리뷰어는 이미 승인했으니 새 리뷰가 올 리가 없죠. 그럼 이 워크플로우는? 영원히 기다립니다.

승인은 깃허브에 버젓이 살아있어요. 근데 필터의 기준 시각이 그 승인을 지나쳐버려서, 엔진 입장에서는 "아직 리뷰 안 옴"이라는 결론만 반복하는 거예요. 완벽한 데드락이죠.

더 악질인 건 이게 확률적이라는 점입니다. 재진입이 승인보다 먼저 일어나면 아무 문제 없어요. 승인 이후에 재진입한 워크플로우만 조용히 얼어붙습니다. 스팟 환경이라 재진입이 잦다 보니 며칠 사이에 이런 게 쌓여서, 결국 "완료 0건"으로 터진 거고요.

deadlock timeline diagram
deadlock timeline diagram

헬스체크는 전부 초록불이었다

여기서 생각해볼 게 있어요. 이걸 어떻게 찾았을까요?

헬스체크로는 절대 못 찾습니다. 죽은 컴포넌트가 하나도 없거든요. 살아있는 프로세스가 잘못된 규칙을 성실하게 실행 중인 상황이라, 모니터링상으로는 전부 정상이에요.

이 팀의 진단 습관이 하나 있었는데요. 끝단 지표 말고 분포를 보는 겁니다.

완료 건수만 보면 그냥 다 멈춘 것처럼 보여요. 근데 진행 중인 워크플로우를 단계별로 집계해봤더니, wait_review 단계에 28건이 비정상적으로 몰려 있었던 거죠. 그 PR들을 까보니 전부 승인 완료, CI 초록불, 머지만 안 됨.

시체가 어디 쌓여있는지 세어보는 게, 죽은 컴포넌트를 찾아 헤매는 것보다 빨랐다는 얘기입니다. 애초에 죽은 컴포넌트가 없었으니까요.

해결: 이벤트가 아니라 스냅샷을 보라

수정은 한 문장으로 요약돼요. "새 리뷰가 왔나?"를 묻지 말고, **"지금 이 순간 유효한 리뷰 상태가 뭔가?"**를 매번 계산하라.

# 리뷰어별로 가장 최신 리뷰만 남긴다
latest_by_reviewer = {}
for r in sorted(pr_reviews, key=lambda r: r.submitted_at):
    latest_by_reviewer[r.user.login] = r

effective_states = [r.state for r in latest_by_reviewer.values()]

if "CHANGES_REQUESTED" in effective_states:
    hold()
elif "APPROVED" in effective_states:
    advance_to_merge()

이거, 사실 깃허브 UI가 쓰는 방식 그대로예요. 리뷰 패널은 리뷰어별 최신 상태만 보여주잖아요. 승인이 언제 됐는지는 판정에 아무 역할이 없고요.

그러면 원래 사고였던 "낡은 승인으로 머지" 문제는요? 공짜로 해결됩니다. 수정 요청을 남긴 리뷰어의 최신 상태가 CHANGES_REQUESTED니까, 그 사람의 옛날 승인이 이길 방법이 없거든요.

배포하고 나서 wait_review에 쌓여있던 28건이 8건으로 줄었고, 25분 만에 15개 작업이 완료됐다고 해요. 3일치 승인된 PR이 조건문 하나 고치자마자 와르르 빠진 겁니다.

반전: 다음 날 또 멈췄다

근데 여기서 반전이 있어요. 다음 날 같은 증상이 또 나타납니다. 이번엔 원인이 달랐어요. 의존성 체인이었죠.

버그가 살아있던 기간에 wait_review에서 실패 처리된 작업들이 있었는데, 그 작업에 의존하는 하위 작업들이 문제였습니다. 자동 복구는 "blocked를 pending으로 되돌리기"만 하고, 스케줄러의 시작 조건은 "모든 의존성이 완료"였거든요. 조상이 실패한 작업은 pending과 blocked 사이를 영원히 튕겨 다니는 무한 재시도 루프에 빠진 거예요.

해결은 fail-fast였습니다. 의존성이 실패하면 하위 작업을 애매하게 blocked로 두지 말고, 명시적으로 FAILED로 전파하는 거죠. 눈에 보이는 실패는 "조상 재시도" 한 번으로 고칠 수 있는데, 눈에 안 보이는 blocked는 아무도 재시도하지 않으니까요.

workflow dependency failure propagation
workflow dependency failure propagation

이 사건에서 건질 것

솔직히 저는 이 회고 읽으면서 AI 자동화보다 더 오래된 교훈이 보였어요. 외부 시스템 상태를 폴링하는 모든 서비스에 해당하는 얘기거든요.

첫째, 외부 상태는 이벤트가 아니라 스냅샷으로 평가하세요. "T 시각 이후에 뭔가 왔나?"로 게이트를 걸면, T 관리에서 실수 한 번에 이미 성립한 사실(살아있는 승인)이 영구히 필터 뒤로 사라집니다. "지금 유효한 상태"를 매번 다시 계산하면 멱등하고, 복구에도 안전해요.

둘째, 타임스탬프 재갱신은 그 이전의 모든 사실을 지우는 행위입니다. 재시도나 복구 경로에서 무심코 now()를 박아 넣으면, 그 순간 이전에 확정된 것들이 전부 필터에 걸려요. 재갱신은 전제가 실제로 바뀌었을 때만. 예를 들면 새 커밋이 들어왔을 때만요.

셋째, 어제의 안전장치가 오늘의 유력 용의자입니다. 신선도 검사는 실제 사고에 대한 올바른 대응이었어요. 범위가 너무 넓었을 뿐이죠. 방어 로직을 추가할 때 "이게 오작동하면 뭐가 멈추는지"도 같이 적어두면, 다음에 뭔가 멈췄을 때 제일 먼저 확인할 수 있습니다.

넷째, "멈췄다"는 끝단 지표가 아니라 단계별 적체 분포로 진단하세요. 초록불 헬스체크는 잘못된 규칙을 성실히 수행하는 살아있는 프로세스를 못 잡아냅니다.

마무리

한마디로 정리하면 이렇습니다. AI가 개발을 자동화해도, 그 자동화를 멈추는 건 여전히 사람이 짠 조건문 한 줄이라는 거.

자동 머지까지 붙인 파이프라인에서 "3일간 완료 0건"은 불편이 아니라 개발 라인 전체의 생산 중단이에요. 헬스체크 초록불 믿지 마시고, 각 대기 지점에 몇 건이 쌓여있는지를 대시보드에 올려두세요. 이 팀이 이번 장애로 영구 추가한 모니터링이 바로 그거였습니다.

혹시 여러분 파이프라인에도 *_initiated_at = now() 같은 코드가 복구 경로에 숨어있지 않나요? 한 번쯤 뒤져볼 만합니다.