자동화에서 실패를 처리하는 일은 단순히 예외를 잡는 데서 끝나지 않아요. 실패를 어디에 기록하는지, 다음 실행이 그 기록을 읽을 수 있는지, 정상적인 0건과 장애로 인한 0건을 구분할 수 있는지까지 함께 설계해야 해요.
이번에는 PR 리뷰 준비 단계에서 실패한 작업이 작업 실행 원장에 남지 않아 같은 요청을 반복하던 문제를 다뤘어요. 제안 판정 게이트가 실패를 빈 배열로 바꿔 정상적인 “제안 0건”처럼 보이게 만든 문제도 있었어요. 두 문제 모두 실패 자체보다 실패가 관찰 가능한 경계 밖에서 사라졌다는 점이 문제였어요.
원장보다 먼저 실패하면 재시도 정책도 작동하지 않는다
PR 리뷰 스윕은 5분마다 열린 PR을 조회해 아직 리뷰하지 않은 PR을 골라요. 재시도 여부는 작업 실행 원장을 기준으로 결정해요.
기존에는 다음 순서로 실행했어요.
PR 상세·diff 조회
↓
리뷰 usecase 진입
↓
작업 실행 원장 생성
문제는 코드 호스팅 서비스에서 diff를 가져오는 첫 단계가 작업 실행 원장을 만들기 전에 실행된다는 점이었어요.
실제로 다수 파일에서 대규모 변경이 발생한 PR이 있었어요. 전체 변경량이 코드 호스팅 서비스의 unified diff 한도인 20,000줄을 넘으면서 요청이 406 too_large로 실패했어요.
GET /repositories/.../pull-requests/... - 406
Sorry, the diff exceeded the maximum number of lines (20000)
이 실패는 에러 로그에는 남았지만 작업 실행 원장에는 기록되지 않았어요. 다음 스윕은 원장에서 해당 PR을 찾지 못해 “한 번도 시도하지 않은 PR”로 판단했어요.
그 결과 기존의 두 안전장치가 모두 무력화됐어요.
- 실패 후 10분 동안 재시도를 막는 쿨다운
- 24시간 동안 실패 시도를 3회로 제한하는 재시도 예산
두 정책 모두 FAILED 상태인 작업 실행 기록을 기준으로 판단하기 때문이에요. 기록이 없으면 정책도 적용할 수 없고, 5분 주기에서는 같은 실패가 하루에도 수백 회 반복될 수 있었어요.
실패를 소비하는 정책과 기록 위치를 맞추기
관련 변경에서는 리뷰 usecase에 진입하기 전에 발생한 실패도 작업 실행 원장에 기록하도록 바꿨어요.
새로 만든 사전 리뷰 실패 기록 함수는 작업 실행 원장 기록 서비스를 이용해 다음 정보를 남겨요.
{
workerRole: WorkerRole.REVIEWER,
triggerKind: TriggerKind.REVIEW_SWEEP,
requestSnapshot: {
changeRef,
repository,
reviewNumber,
dryRun,
},
}
여기서 중요한 값은 requestSnapshot.changeRef예요. 재시도 판정 쿼리가 이 값을 기준으로 과거 실행을 찾기 때문이에요. 실패를 기록해도 조회 키가 기존 성공·실패 기록과 다르면 재시도 정책은 여전히 그 기록을 찾지 못해요.
모든 실패를 스윕 계층에서 다시 기록해서도 안 돼요. 리뷰 usecase는 진입한 뒤 자체적으로 작업 실행 기록을 만들고, 실패하면 FAILED로 마감해요. 같은 실패를 바깥에서 한 번 더 기록하면 실제로는 한 번 시도했는데 두 번으로 계산돼 24시간 재시도 예산이 두 배 빠르게 소진돼요.
이 문제를 막으려고 리뷰 흐름 진입 여부를 나타내는 플래그로 책임 경계를 나눴어요.
리뷰 usecase 진입 전 실패 → 스윕 usecase가 기록
리뷰 usecase 진입 후 실패 → 리뷰 usecase가 기록
실패를 기록하는 과정이 DB 장애 등으로 다시 실패하면 별도의 warn 로그를 남겨요. 원래 오류를 기록한 뒤 다시 던지는 작업 실행 원장 기록 서비스의 계약과 기록 과정 자체에서 발생한 장애를 구분하기 위한 처리예요.
구조적 실패와 일시적 실패를 구분하기
단순히 “diff 조회 실패”라고만 남기면 운영자는 코드 호스팅 서비스의 일시적인 장애인지, 네트워크 문제인지, 재시도해도 해결되지 않는 크기 제한인지 다시 조사해야 해요.
최종 구현에서는 PR 상세와 diff를 Promise.allSettled로 병렬 조회해요. diff 조회에 실패하면 상세 응답의 additions + deletions를 확인하고, 20,000줄을 초과한 경우 다음과 같이 구체적인 원인으로 바꿔요.
PR 전체 변경량이 코드 호스팅 서비스의 diff 한도(20000줄)를 넘어
리뷰할 수 없습니다.
초기에는 상세를 먼저 조회한 뒤 한도를 넘으면 diff 요청을 아예 보내지 않는 순차 실행도 검토했어요. 하지만 상세 조회와 diff 조회 사이에 새로운 push가 들어오면 이전 headSha와 새로운 diff가 섞일 수 있는 시간이 더 길어진다는 문제가 있었어요.
최종 코드에서는 병렬 조회를 유지해 기존보다 정합성 위험 구간이 넓어지지 않게 했어요. 이 때문에 “실패가 뻔한 요청을 사전에 보내지 않는다”는 초기 목표는 최종 구현에서 제외했어요. 대신 실패한 뒤 상세 정보로 구조적 한도 초과를 명확히 분류하고, 그 실패를 원장에 남기는 방식을 택했어요.
완전한 스냅샷 일치를 보장하려면 headSha에 고정된 diff 조회처럼 별도의 설계가 필요해 이번 변경에서는 다루지 않았어요.
실패를 0건으로 바꾸면 장애가 정상처럼 보인다
두 번째 문제는 제안 판정 게이트에서 발생했어요. 이 게이트는 감지된 상태 변화 가운데 사용자에게 먼저 제안할 항목을 모델로 판정해요. 기존 코드는 모델 호출이나 응답 파싱에 실패하면 빈 배열을 반환했어요.
catch {
return [];
}
서비스를 중단하지 않고 제안도 하지 않는 fail-closed 정책 자체는 합리적이었어요. 문제는 실패 이유를 전혀 남기지 않았다는 점이에요.
두 경우 모두 외부에서는 같은 결과로 보였어요.
유효한 제안이 없음 → 제안 0건
모델 호출이 실패함 → 제안 0건
결과값만으로는 정상 판정과 시스템 장애를 구분할 수 없었어요. 자동화는 계속 실행되는 것처럼 보여도 실제 의사결정 계층은 멈춰 있을 수 있었어요.
안전한 기본 동작을 유지하면서 침묵만 제거하기
관련 변경에서는 fail-closed 동작은 그대로 두고 실패 로그만 추가했어요.
catch (error) {
const reason = error instanceof Error
? error.message
: String(error);
logger.error(
`제안 판정 게이트 실패 — 변화 ${detectedChanges.length}건을 ` +
`제안 0건으로 처리: ${reason}`,
);
return [];
}
모델 호출에 실패해도 반환값은 여전히 []예요. 사용자에게 잘못된 제안을 노출하지 않는 기존 정책은 그대로 유지해요.
대신 로그에는 다음 정보를 남겨요.
- 게이트 판정이 실패했다는 사실
- 처리하려던 변화 건수
- 모델 호출 또는 파싱 실패 사유
Error 객체가 아닌 문자열 등으로 reject된 경우에도 String(error)로 변환해 기록해요. 실패 객체의 형태가 예상과 다르다는 이유로 관찰 지점에서 다시 실패하지 않게 한 거예요.
회귀 테스트에서는 정상 응답, 입력 변화가 없는 경우, 일반적인 Error, 문자열 실패를 각각 검증했어요. 특히 모델 호출이 실패하면 반환값은 빈 배열로 유지하면서 로그에는 실패 사유와 변화 건수가 포함되는지 확인했어요.
재시도 정책과 운영자가 읽을 수 있는 실패
첫 번째 변경으로 실패를 작업 실행 원장에 연결했어요. 이제 조회 단계에서 실패하더라도 다음 실행이 이전 실패를 찾을 수 있어요. 단위 테스트 기준으로 조회 단계 실패에도 10분 쿨다운과 24시간 3회 예산을 적용할 조건을 갖췄어요.
두 번째 변경으로 실패를 로그에 연결했어요. 이전에는 제안 결과가 0건이라는 사실만 볼 수 있었지만, 이제 운영자는 정상적인 무제안과 모델 게이트 장애를 구분할 수 있어요.
다만 두 변경 모두 병합 시점에는 테스트와 정적 검증까지만 확인했어요. 실제 주기 실행이 설정된 재시도 예산에 맞춰 수렴하는지, 실제 모델 장애가 운영 로그에 기대한 형태로 나타나는지는 서비스에 반영한 뒤 검증해야 했어요. “운영에서 문제가 완전히 사라졌다”기보다는 실패가 재시도 정책과 운영 관찰 체계 안으로 들어올 수 있게 경계를 바로잡았다고 보는 편이 정확해요.
실패 처리는 기록의 위치까지 설계해야 한다
자동화에서는 재시도 횟수나 쿨다운 값만 정한다고 충분하지 않아요. 정책이 실패를 찾지 못하면 어떤 제한도 작동하지 않아요. 안전을 위해 빈 결과를 반환하더라도 정상적인 빈 결과와 장애로 인한 빈 결과는 구분해야 해요.
이번 사례에서 확인한 기준은 다음과 같아요.
- 실패는 다음 재시도 판정이 조회하는 원장에 남겨야 한다.
- 원장의 식별 키는 기존 판정 쿼리와 정확히 맞아야 한다.
- 한 번의 실패가 여러 계층에서 중복 기록되지 않도록 책임 경계를 정해야 한다.
- 구조적 실패와 일시적 실패를 구분할 수 있는 원인을 남겨야 한다.
fail-closed정책은 유지하더라도 실패까지 침묵시켜서는 안 된다.- 테스트 통과와 실제 운영 수렴은 별도의 검증 단계로 구분해야 한다.
관찰 가능성은 대시보드나 로그를 추가하는 부가 기능이 아니에요. 자동화가 실패를 기억하고 다음 행동을 바꾸며 운영자가 상태를 해석할 수 있게 하는 신뢰성 설계의 일부예요.