아티클/개발

Node.js에서 하위 서비스의 장애가 전체로 번지지 않게 하려면

프로필

오웬

2025. 8. 24.·조회수 0회·

PM2로 프로세스를 나누고 Nginx로 요청을 연결해 운영하다 보니 궁금한 점이 생겼다.

호출하는 서비스가 느려지거나 응답하지 않으면, 그 요청을 기다리는 서버는 어디까지 영향을 받을까?

느린 응답을 기다리는 요청이 계속 쌓이면 메모리와 연결 같은 자원을 오래 점유할 수 있다. 그러다 호출받는 서비스의 문제가 호출하는 서버의 장애로 이어질 수 있다.

프로세스를 나누는 것만으로 이런 문제까지 해결되는 것은 아니었다. 한쪽의 실패가 다른 쪽으로 번지지 않도록, 서비스 사이의 호출에도 경계가 필요했다. 그 방법을 정리해봤다.

먼저 얼마나 기다릴지 정한다

가장 먼저 확인할 것은 타임아웃이다.

다른 서비스를 호출할 때는 언제까지 응답을 기다릴지 정해야 한다. 응답이 늦어진 요청을 계속 붙잡고 있으면, 새로운 요청이 들어오는 동안 대기 중인 작업도 함께 쌓인다.

여기서 구분해야 할 것이 있다.

호출 결과를 기다리지 않기로 하는 것과, 진행 중인 HTTP 요청을 실제로 취소하는 것은 다르다.

예를 들어 서킷 브레이커에서 타임아웃이 발생해도, 별도의 취소 처리가 없다면 원래 HTTP 요청이 백그라운드에서 계속 진행될 수 있다. Axios에서는 timeout 설정과 함께 AbortController를 통한 취소도 검토할 수 있다.

따라서 타임아웃은 숫자 하나를 추가하는 것으로 끝내기보다, 시간이 초과됐을 때 연결과 대기 중인 작업이 어떻게 정리되는지까지 확인해야 한다.

재시도는 복구를 돕기도 하고 장애를 키우기도 한다

일시적인 오류라면 다시 요청해서 성공할 수 있다. 하지만 상대 서버가 이미 과부하 상태라면, 재시도는 그 서버에 일을 더 얹는 행동이 된다.

그래서 바로 반복해서 요청하기보다는 지수 백오프를 사용할 수 있다. 예를 들어 대기 시간을 1초, 2초, 4초처럼 늘리는 방식이다.

여러 요청이 같은 간격으로 재시도하면 다시 한꺼번에 몰릴 수 있으므로, 대기 시간에 무작위 편차를 주는 지터도 함께 고려한다.

재시도 여부를 정할 때는 다음 조건도 필요하다.

  • 일시적인 오류처럼 다시 시도할 이유가 있는가.
  • 최대 재시도 횟수와 전체 대기 시간은 얼마인가.
  • 같은 요청이 반복되어도 중복 처리가 발생하지 않는가.

특히 타임아웃은 상대 서버에서 작업이 실패했다는 뜻이 아니다. 작업은 끝났는데 응답만 전달되지 않았을 수도 있다.

재시도는 무조건 성공할 때까지 반복하는 기능이 아니라, 안전하게 다시 실행할 수 있는 요청에 제한적으로 적용하는 전략이었다.

실패가 이어지면 서킷 브레이커로 호출을 멈춘다

타임아웃이 개별 요청의 대기 시간을 제한한다면, 서킷 브레이커는 실패가 이어지는 대상에 대한 호출을 일시적으로 차단한다.

일반적인 동작은 다음과 같다.

  • Closed: 정상적으로 요청을 보낸다.
  • Open: 실패율이 기준을 넘으면 새로운 호출을 빠르게 실패시킨다.
  • Half-Open: 일정 시간이 지난 뒤 제한적으로 호출해 복구 여부를 확인한다.

Node.js에서는 Opossum 같은 라이브러리로 구현할 수 있다. 다음 코드는 HTTP 요청 제한과 서킷 브레이커 설정을 함께 보여주는 예시다. 설정값은 실제 서비스의 응답 시간과 허용 가능한 대기 시간에 맞춰 조정해야 한다.

const axios = require("axios");
const CircuitBreaker = require("opossum");

async function callSubServer(url) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 2500);

  try {
    const response = await axios.get(url, {
      timeout: 2500,
      signal: controller.signal,
    });

    return response.data;
  } finally {
    clearTimeout(timer);
  }
}

const breaker = new CircuitBreaker(callSubServer, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

// 실제 호출 시 breaker.fire(url)을 사용한다.

실패했을 때 대신 반환할 fallback도 정할 수 있다. 다만 기본 데이터를 반환하는 것이 항상 올바른 대응은 아니다.

일부 조회 기능은 캐시나 대체 데이터로 계속 제공할 수 있지만, 정확한 처리가 필요한 기능은 실패를 명확히 알려야 할 수 있다.

서킷 브레이커의 목적은 실패를 숨기는 것이 아니라, 실패한 대상에 요청을 계속 쌓지 않는 것이다.

지금 끝나지 않아도 되는 작업은 큐로 분리한다

로그 저장이나 메일 발송처럼 사용자 응답 전에 완료되지 않아도 되는 작업은 메시지 큐를 검토할 수 있다.

메인 서버가 SQS 같은 큐에 작업을 등록하고, 별도의 소비자가 메시지를 가져와 처리하는 구조다. 큐에 정상적으로 등록했다면, 메인 서버는 소비자의 작업 완료를 기다리지 않고 응답할 수 있다.

이때 사용자에게 알리는 상태도 “작업 완료”가 아니라 “작업 접수”에 가까워진다.

다만 큐를 사용한다고 두 서비스의 의존성이 완전히 사라지는 것은 아니다. 작업을 처리할 소비자는 여전히 필요하고, 중단된 동안 쌓이는 작업도 관리해야 한다.

특히 SQS 표준 큐에서는 같은 메시지가 중복 전달될 수 있으므로 소비자를 멱등하게 설계해야 한다. 처리 시간에 맞는 visibility timeout, 반복 실패한 메시지를 옮길 DLQ, 적체량 모니터링도 함께 고려할 부분이다.

큐는 상대의 즉시 응답을 기다리지 않게 해주지만, 작업을 끝까지 처리할 책임까지 없애주지는 않는다.

벌크헤드로 사용할 수 있는 자원도 나눈다

타임아웃과 서킷 브레이커만으로 모든 자원 고갈을 막을 수 있는 것은 아니다.

특정 하위 서비스로 향하는 요청이 과도하게 쌓일 때, 다른 서비스 호출에 필요한 자원까지 영향을 받지 않도록 나누는 방법이 벌크헤드다.

Node.js에서는 서비스별로 HTTP Agent를 분리하고 연결 수를 제한하는 방법을 고려할 수 있다. 다만 Agent를 따로 만드는 것만으로 충분하지는 않다.

  • maxSockets는 대상별 동시 연결 수를 제한한다.
  • maxTotalSockets는 해당 Agent 전체의 연결 수를 제한한다.
  • 연결을 기다리는 요청도 쌓일 수 있으므로 동시 실행 수와 대기열 상한을 함께 관리해야 한다.

또한 Agent를 분리해도 같은 프로세스의 CPU와 메모리는 공유한다. 따라서 이는 일부 자원을 격리하는 방법이지, 모든 장애 영향을 차단하는 방법은 아니다.

지금 필요한 것부터 점검한다

찾아본 방법들은 서로 대체 관계라기보다, 다른 질문에 답하는 장치에 가까웠다.

  • 타임아웃: 요청을 얼마나 기다릴 것인가.
  • 재시도: 어떤 실패를 얼마나 다시 시도할 것인가.
  • 서킷 브레이커: 문제가 이어지는 대상에 언제 호출을 멈출 것인가.
  • 메시지 큐: 지금 완료될 필요가 없는 작업을 어떻게 분리할 것인가.
  • 벌크헤드: 특정 대상이 사용할 수 있는 자원을 어디까지 허용할 것인가.

지금 내 규모에서 이 장치들을 모두 도입할 필요는 없을 수 있다. 하지만 어떤 호출에 타임아웃이 있고, 시간이 초과되면 어떻게 처리되는지는 먼저 확인해야겠다.

이번에 정리하며 얻은 기준은 단순하다.

다른 서비스가 항상 정상일 것이라고 가정하지 말고, 느려지거나 실패했을 때 내 서버가 어디까지 기다리고 감당할지 정해두는 것.

프로세스를 나누는 것만큼, 실패의 영향이 번지는 범위를 나누는 것도 중요했다.

댓글 0개

댓글은 로그인 후 작성할 수 있어요.

로그인하기

아직 작성된 댓글이 없습니다.