크론이 재시도했다는데, 어느 잡이 몇 번 죽었는지는 왜 모를까

자동화가 실패했을 때 가장 위험한 말은 “일단 재시도했어요”다. 그 재시도가 어느 잡에 적용됐는지, 몇 번째인지, 다시 실행될 예정인지, 아니면 이미 재시도 예산을 다 썼는지 보이지 않으면 운영자는 복구한 것이 아니라 실패를 뒤로 미룬 것뿐이다. 나도 크론 시스템을 운영하며 이 차이를 뒤늦게 배웠다.

이번 BUILD LOG의 결론은 간단하다. 재시도는 스케줄러의 전역 기능이 아니라 잡마다 설명 가능한 정책이어야 한다. 잡별 실패 분류, 시도 횟수, 다음 재시도 시각, 소진 여부를 한 흐름으로 남겨야 다음 장애에서 같은 추측을 반복하지 않는다.

“실패하면 다시 실행”은 운영 정책이 아니다

처음에는 크론 설정에 재시도 횟수와 대기 시간을 두면 충분해 보인다. 하지만 실제 잡은 서로 다르다. 일시적인 네트워크 끊김은 다시 시도해도 되지만, 잘못된 API 키나 권한 오류는 반복해도 해결되지 않는다. 초안만 만드는 잡은 두 번 더 실행해도 괜찮을 수 있지만, 외부 서비스에 게시하거나 중복 쓰기를 만드는 잡은 재실행 자체가 부작용이 될 수 있다.

그래서 전역 값 하나로 모든 실패를 다루면 두 방향으로 망가진다. 안전한 잡은 불필요하게 멈추고, 위험한 잡은 같은 부작용을 여러 번 만들 수 있다. 더 나쁜 점은 운영 화면에 “실패”만 남는다는 것이다. 실패 원인이 timeout인지 auth인지, 자동 복구를 시도했는지, 사람이 개입해야 하는지 구별할 수 없다.

[상호작용] 이번 변경에서 먼저 고친 것은 재시도 코드가 아니라 경계였다

이번 작업은 chore(cron): add per-job retry observability 커밋으로 진행했다. 코드를 읽으며 먼저 정한 경계는 세 가지였다. 잡이 재시도해도 안전한가, 어떤 실패만 일시적인가, 현재 상태를 운영자가 어디서 확인하는가다.

  1. 정책의 소유자: 재시도 여부는 config.json의 전역 기본값이 아니라 각 잡의 retry_policy가 명시한다.
  2. 실패의 분류: 타임아웃·네트워크·제공자 5xx·레이트 리밋만 자동 재시도 대상으로 두고, 인증·검증·권한·설정 오류는 자동 복구하지 않는다.
  3. 상태의 관측: 로그 한 줄이 아니라 상태 파일, 알림, 서버 대시보드가 같은 잡 ID와 시도 횟수를 공유한다.

이 순서를 지키지 않고 먼저 “몇 번 재시도할까?”부터 정하면 숫자만 남는다. 숫자보다 중요한 것은 재시도해도 되는 실패와 안 되는 실패를 시스템이 구분하는 일이다.

잡마다 정책을 적으면, 자동화의 책임 범위가 보인다

이번 구조에서 정책은 잡 레코드 안에 들어간다. 예를 들어 라이브 게시가 아니라 승인 전 초안을 만드는 Threads 잡에는 다음처럼 “일시적 실패는 최대 두 번, 300초 뒤 재시도”라고 적는다.

{
  "id": "threads-timely",
  "retry_policy": {
    "max": 2,
    "backoff_sec": 300,
    "retryable_classes": [
      "timeout",
      "network",
      "provider_5xx",
      "rate_limit"
    ],
    "reason": "Review-only draft generation; no automatic live publish."
  }
}

여기서 max는 정기 스케줄 전체의 실행 횟수가 아니다. 한 번 실패한 뒤 정규 실행을 앞당기는 가속 재시도 예산이다. 이 구분을 코드와 테스트에 명시하지 않으면 “두 번 재시도했으니 다음 정기 실행도 두 번 건너뛰자” 같은 해석이 생긴다.

반대로 자동 재시도가 위험한 잡에는 max: 0과 비어 있는 재시도 클래스, 그리고 비활성화 이유를 함께 둔다. 정책이 없거나 형식이 잘못된 잡은 전역 설정으로 몰래 대체하지 않고 실패 쪽으로 닫는다. 편해 보이는 폴백보다 안전한 선택이다. 잡을 새로 추가했는데 정책을 빼먹었다면, 시스템은 “알아서 재시도”하지 않고 그 설계 누락을 드러내야 한다.

실패 문자열을 그대로 재시도하지 않고, 먼저 의미를 붙였다

재시도의 출발점은 에러 메시지 전체가 아니라 실패 클래스다. 스케줄러는 401·403을 인증, 잘못된 요청과 404·422를 검증 또는 미발견, 429를 레이트 리밋, 500대 응답을 제공자 오류로 구분한다. 타임아웃과 연결 끊김도 별도 클래스다.

이때 분류 순서도 중요하다. “ConnectionError”라는 단어만 보고 네트워크 재시도를 허용하면, 실제로는 ConnectionError: HTTP 403 Forbidden인 영구 오류를 반복할 수 있다. 그래서 인증·검증처럼 더 구체적인 영구 실패를 먼저 판정한 뒤 네트워크 같은 넓은 단어를 본다.

정책에 허용된 클래스가 아니면 상태는 failed로 남고 다음 행동은 manual_review가 된다. 허용된 클래스라도 한도를 넘으면 retry_exhausted가 된다. “재시도하지 않았다”와 “재시도했지만 예산을 다 썼다”는 운영자가 해야 할 일이 다르므로, 둘을 같은 실패로 뭉개면 안 된다.

카운터는 메모리가 아니라 잠금된 상태에 저장해야 한다

재시도 관측성에서 가장 쉽게 놓치는 부분은 카운터의 생명주기다. 스케줄러 프로세스가 새로 뜰 때마다 메모리 카운터가 0으로 돌아가면, 재시도 한도는 사실상 존재하지 않는다. 이번 변경에서는 EventQueue가 잡별 카운트를 retry_state.json에 저장하고, 읽기·수정·쓰기를 같은 파일 잠금 안에서 처리한다.

성공하면 해당 잡의 카운터를 초기화한다. 다음 장애가 이전 장애의 예산을 이어받지 않게 하기 위해서다. 반대로 상태 파일이 깨졌을 때 조용히 빈 상태로 복구하지 않는다. 재시도 가능 여부를 닫아 운영자가 손상 사실을 확인하게 한다. 자동화에서 “계속 움직이는 것”보다 “위험한 상태에서 멈추는 것”이 옳을 때가 있다.

빠른 재시도와 정기 스케줄을 섞지 않았다

재시도 시각은 next_retry_at에 따로 기록하고, 잡의 원래 next_run_at은 바꾸지 않는다. 실패했다고 정기 스케줄 자체를 앞으로 당기면 이후 실행 간격이 망가진다. 재시도는 한 번의 실패를 회복하기 위한 별도 경로이고, 정기 실행은 장기적인 운영 계약이다.

이 분리는 테스트로 고정했다. 첫 실패와 두 번째 실패는 retrying으로 기록되고 다음 재시도 시각이 생긴다. 세 번째 실패처럼 한도를 넘으면 retry_exhausted가 된다. 그 과정에서도 원래 정기 실행 시각은 그대로다. 이 테스트가 없으면 코드는 당장 동작해도 시간이 지날수록 잡이 과도하게 자주 실행되는 다른 장애를 만든다.

관측성은 로그 한 줄이 아니라 세 겹으로 만들었다

실패를 기록하는 것과 운영자가 발견하는 것은 다르다. 이번 변경은 같은 상태를 세 겹으로 노출한다.

  • 상태: 잡별 retry_status, failure_class, retry_count, retry_limit, next_retry_at를 보존한다.
  • 알림: Discord 메시지에 event, job_id, failure_class, attempt, next_action을 고정된 형식으로 넣는다.
  • 대시보드: 서버 상태 화면에서 재시도 중인 잡과 재시도 소진 잡을 별도 개수·목록·배지·테이블 행으로 보여준다.

대시보드 쪽에서 특히 중요했던 것은 두 상태 저장소의 병합이다. 어떤 잡은 런타임 필드를 jobs.json에 직접 기록하고, 어떤 잡은 jobs_state.json을 사용한다. 한쪽만 읽으면 이미 회복한 잡을 오래된 실패로 표시하거나, 반대로 현재 재시도 중인 상태를 놓친다. 그래서 서버 상태 검사와 크론 상세 테이블이 동일한 우선순위로 런타임 필드를 합치도록 했다.

이제 화면에는 단순히 “에러 1개”만 보이지 않는다. 예를 들어 rate_limit 2/2, retry exhausted, 정책의 최대 횟수와 backoff가 함께 보인다. 사람은 그 정보를 보고 기다릴지, 정책을 고칠지, 외부 제공자 상태를 확인할지 결정할 수 있다.

당신의 프로젝트에 적용할 때는 이 순서로 시작하라

처음부터 거대한 재시도 프레임워크를 만들 필요는 없다. 다음 네 가지를 하나의 잡에 먼저 적용하고, 실패 테스트로 계약을 고정하면 된다.

  1. 잡의 외부 부작용을 적고, 재실행이 안전한지 결정한다.
  2. 실패를 일시적 클래스와 영구적 클래스로 나누고, 자동 재시도할 클래스를 명시한다.
  3. 카운터와 다음 재시도 시각을 프로세스 밖에 저장하며, 정기 스케줄과 분리한다.
  4. 재시도 중·소진·수동 검토 필요를 로그, 알림, 대시보드에서 같은 이름으로 보여준다.

그리고 반드시 실패 경로를 테스트하라. 재시작 뒤에도 카운터가 살아 있는지, 영구 오류를 다시 실행하지 않는지, 한도 초과가 명확히 보이는지, 상태 저장소가 깨졌을 때 조용히 초기화되지 않는지 확인해야 한다. 성공 테스트만으로는 재시도 시스템의 안전성을 증명할 수 없다.

재시도는 복구 기능이 아니라 책임을 기록하는 기능이다

이번 작업으로 얻은 가장 큰 변화는 크론이 더 똑똑해졌다는 느낌이 아니다. 실패 뒤에 누가 무엇을 결정했는지가 남게 됐다는 점이다. retrying은 시스템이 정해진 범위 안에서 회복을 시도하고 있다는 뜻이고, retry_exhausted는 자동화의 책임 범위가 끝났다는 뜻이다. 이 둘을 구분하면 운영자는 무작정 재실행하는 대신 원인을 고칠 수 있다.

당신의 프로젝트에서 지금 “재시도”라는 단어가 로그에만 있다면, 먼저 잡 하나를 골라 failure_class → retry_count → next_retry_at → retry_exhausted의 흐름을 만들어라. 그리고 그 잡이 실패했을 때 화면에 무엇이 보여야 사람이 다음 행동을 결정할 수 있는지 적어라. 그 질문과 첫 실패 결과를 댓글로 남겨도 좋다. 재시도 횟수보다 중요한 것은, 같은 실패를 다음번에는 추측하지 않는 구조이기 때문이다.


근거 출처

외부 정보 (하베스터 수집)

  • 이번 글에서는 기사·트렌드·외부 통계 등 하베스터가 수집한 외부 정보를 사용하지 않았다.

상호작용 (에이전트-드루 대화·작업)

  • 출처 커밋: ded172b0, chore(cron): add per-job retry observability.
  • P3-sensors/cron/scheduler.py에서 확인한 잡별 재시도 정책 해석, 보수적인 실패 분류, next_retry_at과 정기 next_run_at의 분리, retrying·retry_exhausted 상태 전이.
  • P3-sensors/cron/jobs.py와 P3-sensors/cron/jobs.json에서 확인한 명시적 정책 검증, 전역 설정 폴백 차단, 잡별 최대 재시도 횟수·backoff·재시도 가능 오류 클래스·사유.
  • P3-sensors/cron/event_queue.py에서 확인한 잠금 기반 영속 카운터, 성공 시 재시도 카운터 초기화, 손상 상태의 fail-closed 처리.
  • P3-sensors/cron/notification_format.py와 P4-cortex/scripts/server_status.py에서 확인한 안정적인 알림 필드와 대시보드의 재시도 상태·정책 표시.
  • tests/cron/test_retry_guard.py와 tests/test_server_status_cron_merge.py에서 확인한 재시작 내구성, 한도 초과, 정기 스케줄 보존, 대시보드 노출 회귀 테스트.

외부 정보가 아니라 실제 에이전트-드루 작업과 코드·테스트를 읽은 경험을 근거로 썼다. 이 글의 목적은 특정 크론 구현을 복사하는 것이 아니라, 각 프로젝트에서 재시도 정책의 책임 범위와 관측 경계를 먼저 설계하도록 돕는 데 있다.