Node.js 서비스에서 timeout을 설정한 뒤에도 장애가 줄지 않는 경우가 있습니다. 그럴 때 로그를 보면 deadline exceeded, context deadline exceeded, request timeout 같은 메시지가 반복됩니다. 문제는 이 에러를 단순히 “응답이 늦었다” 정도로만 다루면, 실제 원인과 후속 대응이 뒤섞인다는 점입니다.

deadline exceeded는 보통 시간 예산을 다 써 버렸다는 신호입니다. 중요한 것은 timeout 자체보다, 그 뒤에 어떤 정리 작업이 실행되고 어떤 에러 응답을 남기며 어떤 재시도를 허용할지입니다. 이 글에서는 Node.js 백엔드에서 deadline exceeded 에러를 어떻게 해석해야 하는지, 그리고 timeout 이후를 시스템 친화적으로 처리하는 방법을 정리합니다.

Node.js Deadline Exceeded 에러를 먼저 분리해서 봐야 하는 이유

H3. 같은 timeout처럼 보여도 실제 원인은 다르다

현업에서 deadline exceeded 에러는 하나의 원인으로만 발생하지 않습니다. 아래처럼 여러 계층에서 비슷한 형태로 나타납니다.

  • upstream API 응답이 늦어 요청 예산이 소진된 경우
  • DB 쿼리나 lock 대기로 내부 시간이 대부분 소모된 경우
  • connection pool 대기 중에 이미 deadline을 넘긴 경우
  • 재시도가 겹치며 남은 시간이 사라진 경우

겉으로는 모두 timeout처럼 보이지만, 대응은 완전히 다릅니다. 예를 들어 DB가 문제라면 쿼리 제한과 pool 정책을 먼저 봐야 하고, 외부 API가 문제라면 회로 차단과 fallback이 더 중요합니다. 요청 전체 예산을 나누는 기본 원칙은 Node.js Timeout Budget 가이드와 함께 보면 더 명확해집니다.

H3. timeout 이후 처리가 더 큰 장애를 만들 수 있다

많은 팀이 timeout 숫자만 조정하다가, 정작 더 위험한 부분을 놓칩니다. 바로 timeout 이후의 동작입니다.

대표적으로 이런 문제가 생깁니다.

  • 이미 실패한 요청을 무제한 재시도함
  • 취소된 요청의 후속 작업이 계속 진행됨
  • timeout과 비-timeout 에러를 같은 심각도로 집계함
  • 사용자에게는 실패를 반환했는데 내부 작업은 계속 실행됨

이 상태에서는 timeout 자체보다 후속 부하와 상태 불일치가 더 큰 문제로 번집니다. 그래서 deadline exceeded 에러는 “늦었네”가 아니라 “예산을 초과한 뒤 무엇이 남아 있는가”의 관점에서 봐야 합니다.

Deadline exceeded는 어떻게 해석해야 하나

H3. 이 에러는 실패 원인이라기보다 실패 경계에 가깝다

deadline exceeded는 종종 루트 원인이 아니라 최종 경계 신호입니다. 즉, 어떤 단계가 충분히 빨리 끝나지 못했고, 시스템이 더 기다리지 않겠다고 결정한 결과입니다.

이 관점이 중요한 이유는 분류 기준이 달라지기 때문입니다.

  • 원인 분류: DB 지연, 외부 API 지연, CPU 포화, 큐 적체
  • 경계 분류: deadline exceeded, request timeout, abort

둘을 구분해야 대시보드와 알림이 더 정확해집니다. 원인 지표와 경계 지표를 분리하면, 단순 timeout 증가인지 아니면 실제 병목 이동인지 더 빨리 파악할 수 있습니다.

H3. 남은 시간 없이 시작된 작업은 애초에 실행하지 않는 편이 낫다

deadline 기반 설계에서 자주 놓치는 원칙이 하나 있습니다. 이미 남은 시간이 거의 없다면 작업을 시작하지 않는 것입니다.

예를 들어 전체 요청 예산이 2초인데, 인증과 라우팅에서 1.8초를 썼다면 남은 200ms로 외부 API 호출을 새로 시작하는 것은 대부분 손해입니다. 그 요청은 성공 확률이 낮고, 실패할 때는 downstream 자원만 더 점유합니다.

이럴 때는 fail-fast가 낫습니다. 과부하 구간에서 이런 판단은 tail latency를 줄이는 데 특히 중요합니다. 관련 맥락은 Node.js Queue Timeout 가이드와도 연결됩니다.

Node.js에서 timeout 이후를 안정적으로 처리하는 패턴

H3. 요청 단위 deadline을 만들고 모든 하위 작업에 전달한다

가장 먼저 할 일은 timeout을 개별 옵션으로 흩어 두지 않는 것입니다. 요청 단위 deadline을 하나 만들고, DB 호출과 외부 API 호출에 같은 기준을 전달하는 편이 안전합니다.

function createDeadline(totalMs) {
  const deadlineAt = Date.now() + totalMs;

  return {
    deadlineAt,
    remainingMs() {
      return Math.max(0, deadlineAt - Date.now());
    },
    isExpired() {
      return Date.now() >= deadlineAt;
    },
  };
}

async function fetchWithDeadline(url, deadline) {
  const remaining = deadline.remainingMs();
  if (remaining < 150) {
    throw new Error('deadline_exceeded_before_call');
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), remaining);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return response;
  } finally {
    clearTimeout(timer);
  }
}

이 방식의 장점은 세 가지입니다.

  • 모든 하위 작업이 같은 시간 예산을 본다
  • 이미 늦은 요청이 새 작업을 시작하지 않게 막을 수 있다
  • 로그에 남은 시간을 함께 기록하기 쉬워진다

H3. timeout 이후 재시도는 남은 시간과 budget이 있을 때만 허용한다

deadline exceeded 직후 재시도를 바로 붙이는 설계는 위험합니다. 특히 트래픽이 몰린 시점에는 재시도가 2차 부하를 만들어 장애를 더 오래 끌 수 있습니다.

그래서 재시도는 아래 두 조건을 같이 봐야 합니다.

  1. 남은 시간 예산이 실제로 충분한가
  2. 재시도 budget이 아직 남아 있는가

예를 들면 전체 요청 deadline이 2초이고 첫 호출이 1.7초를 썼다면, 남은 300ms 안에서 같은 작업을 다시 시도하는 것은 대부분 의미가 없습니다. 재시도 정책 자체는 Node.js Retry Budget 가이드처럼 별도 budget으로 제한하는 편이 안전합니다.

H3. 취소 신호를 무시하는 작업은 따로 찾아내야 한다

Node.js 코드에서 AbortController를 도입했는데도 timeout 이후 CPU 사용량이나 DB 부하가 계속 남는 경우가 있습니다. 이때는 하위 라이브러리나 내부 함수가 취소 신호를 실제로 반영하는지 확인해야 합니다.

특히 아래 경우를 점검할 만합니다.

  • fetch는 취소되지만 후속 파싱 작업은 계속 도는 경우
  • 큐 작업이 enqueue된 뒤에는 요청 취소와 무관하게 계속 처리되는 경우
  • DB 드라이버는 앱 timeout을 알지만 서버 쪽 statement는 계속 실행되는 경우

이 문제를 놓치면 “사용자는 실패했는데 시스템은 계속 일하는” 비효율이 쌓입니다. DB 관점 대응은 Node.js Query Timeout 가이드와 함께 보는 것이 좋습니다.

에러 응답과 관측 지표는 어떻게 나눠야 하나

H3. 사용자 응답과 내부 에러 코드를 분리한다

사용자에게는 너무 많은 내부 사정을 노출할 필요가 없습니다. 하지만 운영 관점에서는 세분화된 코드를 남겨야 합니다.

예를 들어 아래처럼 분리할 수 있습니다.

  • 사용자 응답: 504 Gateway Timeout 또는 서비스 정책에 맞는 공통 메시지
  • 내부 코드: UPSTREAM_DEADLINE_EXCEEDED, DB_STATEMENT_TIMEOUT, POOL_ACQUIRE_TIMEOUT
  • 로그 필드: remaining_ms, attempt, dependency, abort_source

이렇게 분리하면 사용자 경험은 단순하게 유지하면서, 운영 데이터는 더 정교하게 남길 수 있습니다.

H3. timeout 카운트만 보지 말고 시작 거부율도 함께 본다

좋은 deadline 설계는 timeout 수치만 줄이는 것이 아닙니다. 애초에 늦게 시작될 작업을 더 일찍 거부해 전체 시스템을 보호하는 것도 포함합니다.

그래서 아래 지표를 함께 보는 편이 좋습니다.

  • deadline exceeded 발생 수
  • 시작 전 early reject 수
  • dependency별 남은 시간 분포
  • 재시도 횟수와 최종 성공률
  • timeout 이후에도 계속 실행된 작업 수

특히 early reject가 약간 늘어도 p99 latency와 자원 점유가 개선된다면, 시스템 전체로는 더 건강한 방향일 수 있습니다.

실무 적용 체크리스트

H3. 운영 전에 이 다섯 가지는 확인한다

Node.js에서 deadline exceeded 대응을 정리할 때는 아래 항목부터 맞추면 시행착오를 줄일 수 있습니다.

  • 요청 단위 deadline이 DB, 외부 API, 큐 처리에 일관되게 전달되는가
  • 남은 시간이 부족하면 새 작업을 시작하지 않도록 막았는가
  • timeout 이후 재시도는 budget과 remaining time으로 제한되는가
  • 취소 신호를 무시하는 라이브러리나 함수가 없는가
  • 사용자 응답 코드와 내부 원인 코드가 분리돼 있는가

이 다섯 가지가 정리되면 deadline exceeded는 막연한 에러 문자열이 아니라, 시스템 경계를 관리하는 운영 도구가 됩니다.

마무리

Node.js에서 deadline exceeded 에러는 단순히 느린 요청의 흔적이 아닙니다. 시스템이 더 이상 기다리지 않겠다고 결정한 순간이며, 그 이후를 어떻게 처리하느냐가 안정성을 크게 좌우합니다.

핵심은 간단합니다. timeout을 넣는 것보다, timeout 이후를 설계하는 것이 더 중요합니다. 요청 단위 deadline, 남은 시간 기반 실행 판단, 제한된 재시도, 취소 전파가 함께 있어야 deadline exceeded 에러가 장애 증폭 장치가 아니라 보호 장치로 작동합니다.