Published on

Node.js fetch 업로드 멈춤 디버깅 - 스트림과 AbortController

Authors

서버로 파일을 업로드하는 코드가 로컬에서는 잘 되는데, 특정 환경(컨테이너, EKS, 프록시 뒤, S3 호환 스토리지 등)에서 fetch가 끝나지 않고 멈추는 경우가 있습니다. 로그를 찍어보면 요청은 나갔고, 응답도 안 오고, 에러도 없고, 프로세스는 조용히 대기 상태로 들어갑니다.

이 글은 Node.js 내장 fetch(Undici 기반)에서 업로드가 “멈춘 것처럼 보이는” 전형적인 원인을 스트림 관점에서 분해하고, AbortController로 타임아웃을 걸어 실제로 어디서 막히는지 관측하는 디버깅 루틴을 정리합니다.

증상 패턴 정리

다음 중 하나라도 해당하면 스트림 업로드 디버깅을 시작할 타이밍입니다.

  • 작은 파일은 되는데 큰 파일에서 fetch가 끝나지 않음
  • 서버는 요청을 받았다고 하는데 클라이언트는 응답을 못 받음
  • CPU 사용률은 낮고, 메모리는 유지되며, 프로세스가 계속 살아있음
  • 컨테이너나 EKS에서만 재현됨
  • 프록시나 로드밸런서 뒤에서만 재현됨

네트워크 레벨 이슈(예: DNS, TLS handshake timeout)도 원인이 될 수 있으니, 클러스터 이슈가 의심되면 함께 점검하는 편이 좋습니다. 예를 들어 EKS에서 타임아웃이 잦다면 EKS TLS handshake timeout 원인·해결 9가지 같은 체크리스트로 기반 네트워크를 먼저 안정화하는 것도 도움이 됩니다.

Node.js fetch 업로드가 멈추는 핵심 원인 6가지

1) 요청 바디 스트림이 “끝나지 않음”(EOF 미도달)

가장 흔한 케이스입니다. Readable 스트림이 end를 내지 못하면 서버는 바디가 계속 올 것으로 기대하고, 클라이언트는 업로드를 끝내지 못합니다.

  • 파일 스트림을 만들었지만 파이프 연결이 끊김
  • 커스텀 Readable에서 push(null)을 안 함
  • PassThrough를 쓰면서 종료 처리를 누락

디버깅 포인트는 “내가 넘긴 바디가 정말로 종료되는가”입니다.

2) Content-Length 불일치 또는 미설정으로 인한 서버 대기

서버나 중간 프록시가 Content-Length를 강하게 요구하거나, 반대로 chunked 전송을 제대로 처리하지 못하면 업로드가 멈춘 것처럼 보일 수 있습니다.

  • 일부 서버는 Transfer-Encoding: chunked를 싫어함
  • Content-Length가 실제 바이트와 다르면 서버가 더 받으려 대기하거나, 조용히 끊음

Node.js fetch는 스트림 바디를 주면 보통 Content-Length를 자동 계산할 수 없습니다. 파일 크기를 알고 있다면 명시하는 편이 안전합니다.

3) 백프레셔(backpressure) 무시로 인한 교착

스트림은 생산자와 소비자 속도가 다를 때 내부 버퍼로 조절합니다. 커스텀 스트림 구현에서 write의 반환값이나 drain 이벤트를 무시하면, 메모리가 쌓이거나 특정 지점에서 진행이 멈춘 것처럼 보일 수 있습니다.

4) Expect: 100-continue 또는 프록시 동작 차이

일부 HTTP 클라이언트나 프록시 구성은 큰 업로드에서 Expect: 100-continue 흐름을 타기도 합니다. 서버가 100 Continue를 안 보내거나 프록시가 중간에서 다르게 처리하면, 클라이언트는 바디 전송을 시작하지 못하고 대기할 수 있습니다.

5) keep-alive, half-close, 서버가 응답을 flush 하지 않음

서버는 업로드를 다 받았지만 응답 바디를 flush 하지 않거나, 커넥션을 유지한 채로 응답을 끝내지 않으면 클라이언트는 await res.text() 같은 부분에서 멈춥니다. 이때 “업로드가 멈췄다”로 오해하기 쉽습니다.

즉, 멈춘 지점이 “업로드 중”인지 “응답 읽기 중”인지 분리해야 합니다.

6) 타임아웃 부재: 영원히 기다리는 코드

Node.js fetch는 기본적으로 요청 타임아웃이 없습니다. 네트워크가 애매하게 끊기거나, 서버가 응답을 안 주면 무한 대기가 가능합니다. 그래서 AbortController는 선택이 아니라 필수입니다.

재현 가능한 최소 코드: 파일 스트림 업로드

아래 예시는 파일을 스트림으로 업로드합니다. 핵심은 다음입니다.

  • 파일 크기를 알면 Content-Length를 넣는다
  • AbortController로 전체 타임아웃을 건다
  • 어디에서 멈추는지 로그를 촘촘히 박는다
import fs from 'node:fs';
import { stat } from 'node:fs/promises';

async function uploadFile({ url, path, timeoutMs = 60_000 }) {
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(new Error('upload timeout')), timeoutMs);

  const s = fs.createReadStream(path);
  s.on('error', (e) => console.error('file stream error', e));
  s.on('end', () => console.log('file stream end'));
  s.on('close', () => console.log('file stream close'));

  const { size } = await stat(path);

  const startedAt = Date.now();
  try {
    console.log('fetch start');

    const res = await fetch(url, {
      method: 'PUT',
      body: s,
      signal: ac.signal,
      headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Length': String(size)
      }
    });

    console.log('fetch got response headers', res.status);

    const text = await res.text();
    console.log('response body length', text.length);

    return { status: res.status, text };
  } finally {
    clearTimeout(t);
    console.log('elapsed ms', Date.now() - startedAt);
  }
}

uploadFile({
  url: 'https://example.com/upload',
  path: './big.bin',
  timeoutMs: 120_000
}).then(console.log).catch(console.error);

여기서 멈춤이 발생하면, 로그를 기준으로 멈춘 위치를 2개로 나눌 수 있습니다.

  • fetch start 이후 fetch got response headers가 안 찍힘: 업로드 중 또는 서버가 헤더를 안 보냄
  • 헤더는 받았는데 res.text()에서 멈춤: 서버가 응답 바디를 끝내지 않거나 커넥션 종료를 안 함

스트림이 끝나는지 강제 검증하기

업로드가 멈추는 가장 흔한 실수는 “내 바디 스트림이 종료되지 않는 것”입니다. 다음처럼 바이트 카운팅과 종료 이벤트를 붙여 실제로 끝나는지 확인하세요.

import { Transform } from 'node:stream';

function byteCounter() {
  let total = 0;
  return new Transform({
    transform(chunk, _enc, cb) {
      total += chunk.length;
      if (total % (16 * 1024 * 1024) < chunk.length) {
        console.log('uploaded bytes', total);
      }
      cb(null, chunk);
    },
    flush(cb) {
      console.log('byteCounter flush total', total);
      cb();
    }
  });
}

사용:

import fs from 'node:fs';

const file = fs.createReadStream('./big.bin');
const body = file.pipe(byteCounter());

await fetch('https://example.com/upload', {
  method: 'PUT',
  body
});
  • flush가 안 찍히면 바디가 끝나지 않은 것입니다.
  • uploaded bytes가 특정 숫자에서 멈추면, 그 지점에서 읽기 또는 네트워크 전송이 막힌 것입니다.

AbortController로 “단계별 타임아웃” 만들기

전체 타임아웃 하나만 걸면 원인 파악이 어렵습니다. 업로드는 보통 다음 단계로 나뉩니다.

  1. DNS 및 TCP 연결
  2. TLS 핸드셰이크
  3. 요청 헤더 전송
  4. 요청 바디 업로드
  5. 응답 헤더 수신
  6. 응답 바디 읽기

Node.js fetch에서 세부 타이밍을 완벽히 분리하기는 어렵지만, 실무적으로는 “업로드 시간”과 “응답 읽기 시간”을 분리해도 원인 범위를 크게 줄일 수 있습니다.

function abortAfter(ms, reason) {
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(new Error(reason)), ms);
  return { ac, cancel: () => clearTimeout(t) };
}

async function uploadWithPhases(url, body, headers) {
  const uploadTimeout = abortAfter(120_000, 'upload phase timeout');

  try {
    const res = await fetch(url, {
      method: 'POST',
      body,
      headers,
      signal: uploadTimeout.ac.signal
    });

    uploadTimeout.cancel();

    const readTimeout = abortAfter(30_000, 'read response timeout');
    try {
      const text = await res.text();
      return { status: res.status, text };
    } finally {
      readTimeout.cancel();
    }
  } finally {
    uploadTimeout.cancel();
  }
}

이렇게 하면 “업로드가 느린 것”과 “서버가 응답을 안 주는 것”을 분리할 수 있습니다.

서버가 chunked 업로드를 싫어할 때: Content-Length를 넣는 전략

파일 업로드처럼 크기를 알 수 있으면 Content-Length를 명시하세요. 특히 다음 환경에서 효과가 큽니다.

  • 일부 API Gateway, 프록시, 레거시 서버
  • 특정 WAF 규칙
  • 스토리지 호환 API

주의할 점은 Content-Length는 반드시 실제 바이트와 일치해야 한다는 것입니다. gzip 같은 변환 스트림을 중간에 끼우면 길이가 달라집니다. 이때는 Content-Length를 넣지 말고 서버가 chunked를 지원하는지 확인해야 합니다.

응답 읽기에서 멈추는 경우: res.body 소비와 서버 flush

await fetch(...) 자체는 끝났는데 await res.text()에서 멈춘다면, 업로드는 끝났고 “응답 바디가 끝나지 않는” 상황일 수 있습니다.

대응 방법:

  • 서버가 Content-Length 또는 chunked로 응답을 정상 종료하는지 확인
  • 응답을 굳이 끝까지 읽을 필요가 없다면 최소한만 읽고 res.body.cancel() 고려
  • 클라이언트 측에서 응답 읽기 타임아웃을 별도로 적용

예를 들어, 상태 코드만 필요하면:

const res = await fetch(url, { method: 'PUT', body, signal });

// 바디를 끝까지 읽지 않아도 되는 상황이라면
res.body?.cancel();

if (!res.ok) {
  throw new Error('upload failed ' + res.status);
}

단, 일부 런타임에서는 바디를 소비하지 않으면 커넥션 재사용에 영향이 있을 수 있으니, 대량 트래픽 환경에서는 성능 테스트 후 결정하세요.

컨테이너나 EKS에서만 멈출 때 체크리스트

환경 의존 멈춤은 보통 “네트워크 경로 중간 장치”가 원인입니다.

  • 프록시가 업로드 바디를 버퍼링하다가 타임아웃
  • 로드밸런서 idle timeout이 짧음
  • DNS 이슈로 특정 노드에서만 지연
  • NAT 게이트웨이, 라우팅, MTU 문제

특히 EKS에서는 애플리케이션 로그가 멀쩡한데 Readiness가 실패하거나, 특정 요청만 타임아웃인 경우가 있어 네트워크와 헬스체크를 함께 봐야 합니다. 비슷한 결의 트러블슈팅으로 EKS에서 Readiness 실패인데 로그는 정상일 때도 같이 참고하면 원인 분리에 도움이 됩니다.

DNS 타임아웃이 의심되면 CoreDNS 상태도 점검하세요. 업로드 자체가 멈춘 것처럼 보여도, 실제로는 업로드 대상 호스트를 해석하는 단계에서 지연이 걸릴 수 있습니다. 이 경우 AWS EKS CoreDNS CrashLoopBackOff와 DNS 타임아웃 해결 체크리스트가 유용합니다.

실전 디버깅 루틴: “멈춘 지점”을 먼저 확정

다음 순서로 보면 시간을 크게 줄일 수 있습니다.

1) 업로드와 응답을 분리해서 로그를 박기

  • fetch 호출 직전
  • fetch가 반환되어 응답 헤더를 받는 시점
  • res.text() 또는 res.arrayBuffer() 완료 시점

이 3개만 있어도 멈춘 구간이 확정됩니다.

2) 바디 스트림이 끝나는지 이벤트로 확인

  • end, close, error
  • 바이트 카운팅 Transformflush

종료 이벤트가 안 오면 1차 원인은 거의 스트림입니다.

3) 타임아웃을 반드시 건다

  • 전체 타임아웃
  • 응답 읽기 타임아웃

타임아웃이 없으면 장애가 “조용히” 장기화됩니다.

4) Content-Length 전략을 결정한다

  • 파일처럼 크기를 알면 Content-Length를 명시
  • 변환 스트림이 있거나 길이를 모르면 chunked 지원 여부를 서버와 합의

5) 중간 장치 타임아웃을 확인한다

  • 로드밸런서 idle timeout
  • 프록시 request body timeout
  • ingress controller 설정

애플리케이션에서 아무리 AbortController를 잘 써도, 중간 장치가 먼저 끊으면 “중간에서 멈춘 것 같은” 증상이 재현됩니다.

마무리: 멈춤을 에러로 바꾸면 해결이 시작된다

Node.js fetch 업로드 멈춤 문제는 대개 “스트림이 끝나지 않음” 또는 “타임아웃 부재로 무한 대기”로 귀결됩니다. 그래서 해결의 첫 단계는 멈춤을 관측 가능한 실패로 바꾸는 것입니다.

  • 바디 스트림 종료를 이벤트로 검증
  • 바이트 카운팅으로 정체 지점 파악
  • AbortController로 업로드와 응답 읽기 타임아웃 분리
  • 필요 시 Content-Length 명시

이 루틴을 적용하면, 현상이 “가끔 멈춤”에서 “항상 특정 단계에서 타임아웃”으로 바뀌고, 그때부터는 네트워크 설정이든 서버 구현이든 정확히 손댈 지점이 보이기 시작합니다.