본문으로 건너뛰기
JSL107's Tech Note
돌아가기

실패를 성공으로 기록하면 재시도는 시작되지 않는다

8분 분량이 글 고치기
목차 · 7

주 1회 실행하는 워커에서 한 번 실패하면 단순한 일시 오류로 끝나지 않아요. 다음 실행이 일주일 뒤라면 그 한 번의 실패로 한 주치 결과가 사라져요.

재시도는 있었지만 실패하지 않았다

한 주간 실행 원장 데이터를 살펴보다가 실제 사례를 확인했어요.

같은 기간에 여러 코드 검토 역할 작업과 소수의 기획 역할 작업도 같은 원인으로 실패했어요. 매일 실행하는 워커는 다음날 다시 실행돼 정상적으로 복구됐지만, 주간 워커의 결과는 그대로 사라졌어요.

처음에는 재시도 설정이 없는 줄 알았어요. 하지만 스케줄러 설정 파일에는 이미 다음 설정이 들어 있었어요.

attempts: 2,
backoff: {
type: 'exponential',
delay: 60_000,
}

재시도가 없는 게 문제가 아니었어요. BullMQ가 해당 job을 실패로 인식하지 못하고 있었어요.

실패를 격리하는 코드가 실패의 의미까지 지웠다

작업 오케스트레이터는 한 그룹에 속한 여러 task를 차례로 실행해요. 기존 구현은 개별 task가 예외를 던지면 이를 잡아서 다음과 같은 실패 안내 항목으로 바꿨어요.

⚠️ {taskIdentifier} 자동 생성 실패 …

이 동작은 필요해요. 한 task가 실패했다고 같은 그룹의 다른 task까지 실행하지 못하게 해서는 안 되니까요.

문제는 그룹 안의 유효한 작업이 모두 실패해도 같은 흐름을 탔다는 점이에요. 실패 안내가 전달 항목 목록에 들어가면 목록 길이가 0보다 커졌고, 오케스트레이터는 메시징 플랫폼에 알림까지 정상적으로 보낸 뒤 반환했어요. BullMQ에는 다음과 같은 결과만 보였어요.

task 실패
→ catch에서 실패 안내로 변환
→ 메시지 발송 성공
→ orchestrator 정상 반환
→ job 성공 처리
→ retry 시작 안 함

사용자에게 실패를 알리는 일과 실행 자체를 실패로 끝내는 일은 별개예요. 실패 안내를 보냈더라도 복구가 필요하다면 job은 실패 상태로 남아야 해요.

“모두 실패했는가”보다 “전달할 결과가 있는가”

처음에는 실행한 task 수와 실패한 task 수를 비교해서 모두 실패했을 때 예외를 다시 던지려고 했어요. 하지만 이 기준에는 skip이라는 경계 조건이 있었어요.

일부 task는 조건을 충족하지 않으면 정상적으로 skip: true를 반환해요. 예를 들어 여러 task가 쿼터 문제로 실패하고 다른 한 task가 정상적으로 skip되면 숫자는 다음과 같아요.

실행 수: 여러 건
실패 수: 일부

단순 비교로는 전멸이 아니지만 사용자에게 전달할 실제 결과는 하나도 없어요. 이 상태를 성공으로 처리하면 발송 가드와 슬롯 완주 표식이 남고, 재시도까지 다시 막혀요.

그래서 최종 판정 기준을 task 개수가 아니라 산출물 중심으로 바꿨어요.

if (
failedTaskCount > 0 &&
!hasDeliverableSummary &&
previews.length === 0
) {
// 실패 안내 후 throw
}

실패가 하나 이상 있으면서 전달할 summary와 preview가 모두 없는지를 판정 기준으로 삼았어요. 이 조건을 충족하면 해당 실행은 실패예요.

skip은 실패가 아니지만 전달할 수 있는 산출물도 아니에요. 반대로 일부 task가 summary나 preview를 만들었다면 기존처럼 성공한 결과를 전달해요. 부분 실패 때문에 성공한 결과까지 다시 생성하거나 중복 발송하지 않기 위해서예요.

throw의 위치도 복구 흐름의 일부다

예외를 다시 던지는 것만으로는 부족했어요. 발송 가드와 슬롯 완주 표식을 기록하기 전에 반드시 실패해야 했어요.

발송 가드를 먼저 소비하면 재시도할 때 이미 발송한 실행으로 판단해서 조용히 종료해요. 슬롯 완주 표식을 남겨도 재진입 차단에 걸려요. 둘 중 하나라도 먼저 기록하면 job을 실패 처리하더라도 재시도는 실질적으로 동작하지 않아요.

바꾼 흐름은 다음과 같아요.

task 실행
→ 전달 가능한 산출물 없음 + 실패 존재
→ 각 target에 실패 안내 발송
→ 가드와 슬롯 표식은 기록하지 않음
→ 예외 발생
→ BullMQ가 job 실패로 기록
→ 재시도

예외를 던지기 전에 모든 메시지 수신 대상에게 실패 안내를 직접 보내요. 기존 cron 실패 알림은 실패 알림 수신자 환경 변수가 설정되지 않은 환경에서는 발송되지 않기 때문이에요.

실패 안내를 보내는 도중 메시징 플랫폼 오류가 발생해도 원래의 그룹 실패를 덮어쓰지 않아요. 경고 로그만 남기고 최초의 전멸 오류를 다시 던져요. 재시도할 때마다 안내가 중복될 수 있지만, 저빈도 작업이 조용히 유실되는 것보다는 중복 안내를 감수하기로 했어요.

주간 워커에는 분 단위 재시도가 부족했다

실패가 제대로 전파되도록 고친 뒤에는 재시도 간격도 다시 살펴봤어요.

기존에는 최초 실패 후 60초 뒤에 한 번 더 시도했어요. 하지만 실제 장애 원인은 수 시간 뒤에 해제되는 모델 사용량 한도였어요. 60초 뒤에 재시도하면 같은 오류를 한 번 더 확인할 가능성이 높았어요.

모든 워커의 backoff를 늘리는 것도 적절하지 않았어요. 예약 작업 큐는 concurrency=1로 직렬 실행되므로 고빈도 작업의 긴 재시도가 쌓이면 정상 cron 슬롯까지 밀릴 수 있거든요.

그래서 다음 실행 기회가 먼 저빈도 cron에만 별도 정책을 적용했어요.

const lowFrequencyRetryOptions = {
attempts: 4,
backoff: {
type: 'exponential',
delay: 1_800_000,
},
};

최초 실행 후 30분, 60분, 120분 간격으로 최대 세 번 재시도해요. 원래 실행부터 마지막 재시도까지 약 3시간 30분의 복구 시간을 확보한 셈이에요. 일간·평일·분 단위 작업은 기존 attempts: 2, 60초 backoff를 유지했어요.

저빈도 여부는 cron의 일 또는 요일 필드가 단일 고정값인지에 따라 판별해요. 범위, 목록, 간격 표현이 들어간 일정은 저빈도로 분류하지 않아요. 환경 변수로 schedule을 재정의할 수 있으므로 코드 기본값이 아니라 override까지 반영한 최종 cron을 기준으로 판단해요.

BullMQ가 지원하는 초 필드 포함 6필드 cron도 고려했어요.

0 17 * * 5    → 저빈도
0 0 17 * * 5  → 저빈도
10 17 * * 1-5 → 기존 정책
*/3 * * * *   → 기존 정책

401은 인증 캐시를 무효화해야 회복된다

같은 조사 과정에서 외부 증권 API 조회가 한 번 401을 받은 뒤 앱을 재시작할 때까지 계속 실패하는 문제도 확인했어요.

기존 외부 증권 API 클라이언트는 토큰 만료 시각 60초 전까지 캐시된 access token을 재사용했어요. 서버가 클라이언트에서 알고 있는 만료 시각보다 먼저 토큰을 무효화하면 문제가 생겨요.

서버에서 토큰 무효화
→ 클라이언트는 캐시 토큰이 유효하다고 판단
→ 같은 토큰으로 요청
→ 401
→ 캐시를 유지
→ 다음 요청도 같은 토큰
→ 다시 401

다음날 조회가 성공한 건 자가회복 덕분이 아니었어요. 앱을 재시작하면서 메모리 캐시가 비워졌기 때문이에요.

이 문제를 해결하려면 인증된 요청이 401을 반환할 때 캐시를 폐기하고 토큰을 다시 발급받아야 해요. 다만 무한 재시도는 막아야 하므로, 바꾼 클라이언트는 첫 번째 401에만 다음 절차를 수행해요.

기존 토큰으로 요청
→ 401 확인
→ cachedAccessToken 제거
→ 새 토큰 발급
→ 동일 요청 한 번 재시도
→ 다시 401이면 그대로 실패

HTTP 상태는 메시지 문자열을 파싱하지 않고 외부 증권 API HTTP 오류 타입의 status 필드로 전달해요. 운영 로그 및 테스트와의 호환성을 위해 기존 오류 메시지는 유지했어요.

재시도 대상은 401로만 제한했어요. 403은 권한 문제이고, 429는 rate limit이며, 5xx는 서버 오류예요. 이런 응답까지 토큰 재발급으로 대응하면 불필요한 인증 요청만 늘어날 수 있어요.

결과와 검증 범위

변경 후에는 전달 가능한 산출물이 없는 실패가 BullMQ job 실패로 전파돼요. 발송 가드와 슬롯 완주 표식도 남지 않아서 기존 재시도 장치가 실제로 동작해요. 저빈도 cron은 수 시간 동안 장애에서 회복할 기회를 확보했고, 외부 증권 API의 401은 앱을 재시작하지 않아도 토큰을 한 번 갱신해 복구를 시도해요.

회귀 테스트에는 다음 경계 조건도 포함했어요.

정적 검사와 전체 테스트, 빌드는 모두 exit code 0으로 통과했어요. 기본 테스트 스위트와 의존 관계 분석 관련 테스트도 모두 통과했어요.

다만 실제 모델 쿼터가 소진된 상황에서 마지막 재시도가 성공하는 과정과 실제 외부 증권 API의 401 복구는 단위 테스트로만 검증했어요. 운영 재기동 후 동작 확인도 이번 변경 범위에는 포함하지 않았어요.

이번 문제의 핵심은 재시도 횟수가 아니었어요. 먼저 복구 장치가 실패를 관찰할 수 있도록 그 의미를 끝까지 보존해야 했어요. 실패를 성공으로 바꾸는 순간 아무리 retry를 잘 설정해도 시작되지 않아요.

근거: 내부 변경 검토 기록


이 글 고치기
이 글 공유하기:

이전 글
문서 드리프트를 넘어 생성기의 정확성 검증하기