Node.js에서 내장 fetch를 쓰기 시작하면 곧바로 부딪히는 문제가 있습니다. 응답 본문은 Web Stream인데, 기존 서비스 코드는 fs, zlib, pipeline()처럼 Node 스트림 중심으로 짜여 있는 경우가 많다는 점입니다.

이럴 때 핵심은 Readable.fromWeb()Writable.toWeb()로 Web Stream과 Node 스트림의 경계를 명확하게 변환하는 것입니다. 결론부터 말하면 fetch 응답, 파일 저장, 압축, 백프레셔 제어를 한 흐름으로 다뤄야 할 때 이 브리지 패턴을 이해해 두면 스트림 타입 혼선과 메모리 낭비를 크게 줄일 수 있습니다.

왜 fromWeb/toWeb 브리지가 필요한가

H3. fetch는 편해졌지만 스트림 모델은 두 가지가 공존한다

요즘 Node.js에서는 별도 라이브러리 없이도 fetch()를 바로 쓸 수 있습니다. 문제는 response.body가 우리가 예전에 익숙했던 Node Readable이 아니라 Web ReadableStream이라는 점입니다.

그래서 아래 같은 상황에서 바로 막히기 쉽습니다.

  • pipeline()에 fetch 응답을 바로 연결하려는 경우
  • fs.createWriteStream()으로 곧장 저장하려는 경우
  • gzip, transform, parser 같은 기존 Node 스트림 체인에 붙이려는 경우
  • Web Stream과 Node Stream이 섞여 에러 처리 방식이 달라지는 경우

즉 fetch를 도입했다고 해서 스트림 경계가 사라진 것이 아니라, 브라우저 계열 스트림과 Node 계열 스트림이 함께 존재하게 된 것에 가깝습니다.

H3. 타입을 억지로 섞기보다 경계에서 변환하는 편이 안전하다

가끔은 response.body.pipe(...)가 왜 안 되는지부터 헷갈립니다. 이유는 단순합니다. Web Stream과 Node 스트림은 비슷해 보여도 같은 객체가 아니기 때문입니다.

실무에서는 이 원칙이 중요합니다.

  • fetch 응답을 기존 Node 스트림 파이프라인에 넣을 때는 Readable.fromWeb()
  • 기존 Node writable을 Web API가 기대하는 대상으로 넘길 때는 Writable.toWeb()
  • 중간에서 타입을 섞어 임시 처리하지 말고, 시작점에서 한 번 명확히 변환

이렇게 해야 에러 전파, 백프레셔, 종료 처리도 추적하기 쉬워집니다.

Readable.fromWeb 기본 패턴

H3. fetch 응답을 Node pipeline에 연결할 때 가장 먼저 떠올리면 된다

가장 흔한 패턴은 원격 파일이나 API 응답을 받아서 Node 스트림 체인으로 넘기는 경우입니다.

import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';

const response = await fetch('https://example.com/data.csv');

if (!response.ok || !response.body) {
  throw new Error(`download failed: ${response.status}`);
}

await pipeline(
  Readable.fromWeb(response.body),
  createWriteStream('./data.csv')
);

핵심은 response.body를 먼저 Readable.fromWeb()으로 감싸 Node Readable로 만든 뒤, 그다음부터는 익숙한 pipeline() 흐름으로 처리하는 것입니다.

스트림 연결 이후의 에러 전파와 종료 정리는 Node.js stream backpressure 가이드에서 다룬 원칙과 똑같이 보는 편이 좋습니다.

H3. 압축 해제나 변환 스트림도 중간에 자연스럽게 끼울 수 있다

fetch 응답을 바로 저장하는 것보다, 중간에 압축 해제나 변환이 들어가는 경우가 더 많습니다.

import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createGunzip } from 'node:zlib';
import { createWriteStream } from 'node:fs';

const response = await fetch('https://example.com/export.ndjson.gz');

if (!response.ok || !response.body) {
  throw new Error(`download failed: ${response.status}`);
}

await pipeline(
  Readable.fromWeb(response.body),
  createGunzip(),
  createWriteStream('./export.ndjson')
);

이 구조의 장점은 분명합니다.

  • Web Stream 구간은 입구에서 끝남
  • 이후는 기존 Node 생태계 도구를 그대로 활용 가능
  • 중간 transform 추가가 쉬움
  • 실패 시 한 경로에서 정리하기 좋음

Writable.toWeb는 언제 쓰나

H3. Web API가 WritableStream을 기대할 때 Node writable을 연결할 수 있다

반대 방향도 있습니다. 일부 API나 유틸은 Web WritableStream을 기대하는데, 실제로는 이미 Node writable을 가지고 있는 경우입니다.

이때 Writable.toWeb()를 쓰면 Node writable을 Web WritableStream처럼 넘길 수 있습니다.

import { Writable } from 'node:stream';
import { createWriteStream } from 'node:fs';

const fileStream = createWriteStream('./result.txt');
const webWritable = Writable.toWeb(fileStream);

이 패턴은 아직 Readable.fromWeb()만큼 자주 보이지는 않지만, Web Stream 중심 유틸과 Node 기존 자산을 붙일 때 꽤 유용합니다.

H3. 전환 방향을 헷갈리지 않으면 코드가 훨씬 덜 꼬인다

이름이 비슷해서 자주 헷갈리는데, 아래처럼 외우면 편합니다.

  • Web → Node: Readable.fromWeb()
  • Node → Web: Writable.toWeb() 또는 Readable.toWeb()

즉 “내가 지금 가진 것이 무엇이고, 다음 API가 무엇을 기대하는지”만 먼저 확인하면 됩니다. 이 기본 확인만 해도 스트림 관련 디버깅 시간이 꽤 줄어듭니다.

실무에서 자주 하는 실수

H3. response.body가 null일 수 있다는 점을 빼먹기 쉽다

fetch 응답은 항상 body가 있다고 생각하기 쉽지만, 실제로는 그렇지 않습니다. 특히 상태 코드나 요청 방식에 따라 response.body가 비어 있을 수 있습니다.

그래서 아래 체크는 습관처럼 넣는 편이 좋습니다.

if (!response.ok || !response.body) {
  throw new Error(`unexpected response: ${response.status}`);
}

스트림 변환 이전에 이 가드를 두면, 뒤에서 타입 에러처럼 보이는 문제를 훨씬 빨리 분리할 수 있습니다.

H3. pipeline 대신 수동 이벤트 처리로 마감하면 누수와 누락이 생기기 쉽다

아래처럼 직접 data, end, error를 엮는 코드는 짧아 보여도 점점 위험해집니다.

const nodeReadable = Readable.fromWeb(response.body);
nodeReadable.pipe(fileStream);

단순 저장 정도는 동작할 수 있지만, 중간 transform이 늘고 실패 경로가 복잡해지면 종료 정리가 지저분해집니다. 이럴 때는 pipeline()이 더 안전합니다.

이 원칙은 대용량 입력을 다룰 때 특히 중요합니다. 줄 단위 후처리가 필요하다면 Node.js FileHandle.readLines 가이드처럼 “저장”과 “읽기/처리” 단계를 분리하는 편이 운영상 더 단정할 때도 많습니다.

H3. 백프레셔가 사라진 것이 아니라 브리지 뒤에서 계속 중요하다

Web Stream에서 Node 스트림으로 바꿨다고 해서 성능 문제가 자동 해결되지는 않습니다. 브리지는 타입 경계를 맞춰 줄 뿐이고, 흐름 제어 자체는 여전히 설계해야 합니다.

특히 아래 상황을 같이 봐야 합니다.

  • 다운스트림 writable이 느릴 때 메모리 사용량이 안정적인가
  • 중간 transform이 CPU를 과도하게 잡아먹지 않는가
  • 취소나 timeout 시 다운로드가 깔끔하게 멈추는가
  • 실패한 파일이 반쯤 저장된 채 남지 않는가

이 지점은 Node.js timers/promises setTimeout 가이드에서 다룬 AbortSignal 기반 취소 패턴과도 자연스럽게 연결됩니다.

fetch 다운로드 코드를 더 안전하게 만드는 체크리스트

H3. timeout과 취소 신호를 같이 붙여 두는 편이 운영에서 낫다

네트워크 다운로드는 성공 경로보다 실패 경로가 더 중요합니다. 무한 대기, 느린 응답, 상위 작업 취소를 방치하면 배치나 API worker가 불필요하게 오래 붙잡힐 수 있습니다.

import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';

const signal = AbortSignal.timeout(5000);
const response = await fetch('https://example.com/report.json', { signal });

if (!response.ok || !response.body) {
  throw new Error(`download failed: ${response.status}`);
}

await pipeline(
  Readable.fromWeb(response.body),
  createWriteStream('./report.json')
);

취소 조건이 더 많다면 timeout 하나로 끝내지 말고, 상위 요청 취소나 운영자 중단 신호를 함께 묶는 편이 좋습니다. 이럴 때는 Node.js AbortSignal.any 가이드에서 다룬 조합 방식이 잘 맞습니다.

H3. 저장 후 후처리는 단계 분리가 더 단순할 때가 많다

실무에서는 모든 것을 한 파이프라인에 몰아넣기보다,

  1. 우선 안전하게 다운로드하고
  2. 그다음 파일을 읽어 검증하거나 파싱하고
  3. 마지막으로 DB 적재나 색인 작업을 수행하는

식으로 나누는 편이 장애 분석에 유리할 때가 많습니다.

특히 큰 CSV, NDJSON, 로그 파일은 다운로드와 파싱을 한 번에 처리하다가 중간 실패 시 복구가 더 어려워질 수 있습니다. 스트리밍 자체보다 운영 복구 단순성이 더 중요하면 단계 분리를 우선해도 괜찮습니다.

언제 fromWeb/toWeb를 선택하면 좋은가

H3. 이런 상황이면 바로 후보에 올릴 만하다

아래 중 두세 개 이상 해당하면 fromWeb/toWeb 브리지가 꽤 잘 맞습니다.

  • 내장 fetch를 이미 쓰고 있다
  • 기존 코드는 pipeline(), fs, zlib 중심이다
  • 대용량 다운로드를 메모리에 모두 올리고 싶지 않다
  • Web Stream과 Node 스트림 타입 혼선이 반복된다
  • 스트림 경계에서 timeout, 취소, 백프레셔를 정리하고 싶다

반대로 전 구간이 이미 Web Stream 중심으로 잘 짜여 있다면 굳이 Node 스트림으로 바꿀 이유는 없습니다. 중요한 것은 “무조건 변환”이 아니라, 어느 생태계 도구를 중심으로 갈지 먼저 정하고 경계에서 한 번만 변환하는 것입니다.

마무리

Node.js의 Readable.fromWeb()Writable.toWeb()는 단순한 호환성 API처럼 보이지만, 실제로는 fetch 시대의 스트림 경계를 정리해 주는 핵심 도구에 가깝습니다.

특히 fetch 응답을 파일 저장, 압축 해제, 변환 파이프라인과 연결해야 한다면 입구에서 Readable.fromWeb()로 Node 스트림으로 바꾼 뒤 pipeline() 규칙으로 밀고 가는 방식이 가장 실용적입니다. 스트림 타입을 억지로 섞는 대신 경계를 명확히 두면, 코드도 읽기 쉬워지고 운영 중 장애 분석도 훨씬 빨라집니다.

함께 보면 좋은 글