Node.js Fetch API: 모든 튜토리얼이 빼먹는 것들 (2026 가이드)

최종 업데이트: May 14, 2026
AI 요약
Stop relying on basic fetch calls in production. Learn how to handle timeouts, HTTP errors, retry logic, and connection pooling in Node.js for 2026.

대부분의 Node.js fetch 튜토리얼은 await fetch(url)만 알려주고 끝나요. 그런데 실제 운영 환경에 들어가면 500 오류가 조용히 묻히고, 요청은 타임아웃 없이 90초씩 멈추고, 금요일 밤을 통째로 원래는 너무 당연했어야 할 문제를 디버깅하는 데 쓰게 되죠.

저는 에서 내부 도구와 데이터 파이프라인을 꽤 오래 만들어 왔는데, 분명히 말씀드릴 수 있어요. “튜토리얼에서는 fetch가 잘 되는데”와 “운영 환경에서도 fetch가 잘 된다” 사이의 간극이 가장 크게 아픈 지점입니다. Reddit의 한 개발자가 아주 정확하게 말했어요. “운영 환경에 들어가 보면, 기본 fetch보다 더 견고한 무언가가 필요하다는 걸 깨닫게 된다.”

또 다른 사람은 이렇게 고백했습니다. “웹 개발자로 3년 일하고 나서야 알았다. fetch API의 catch 블록은 HTTP 오류를 잡는 용도가 아니다.” 이 가이드는 대부분의 튜토리얼이 건너뛰는 다섯 가지, 즉 오류 처리 함정, AbortController 타임아웃, 재시도 로직, 연결 재사용, 그리고 구조화된 데이터 추출을 위해 언제 fetch를 넘어가야 하는지를 다룹니다. 운영 환경에서 fetch 호출이 조용히 실패한 적이 있다면, 이 글이 딱 필요하실 거예요.

nodejs-fetch-error-handling-flowchart.png

Node.js Fetch API란 무엇인가요?

Node.js Fetch API는 Node.js에서 HTTP 요청(GET, POST, PUT, DELETE 등)을 보낼 수 있게 해 주는 내장 브라우저 호환 방식입니다. Axios, node-fetch, 또는 다른 패키지를 따로 설치할 필요가 없어요. 브라우저에서 fetch()를 써 본 적이 있다면, 문법은 이미 알고 계신 겁니다. 이제 같은 API를 서버에서도 쓸 수 있습니다.

간단한 버전 연혁은 이렇습니다.

이정표Node 버전변경 사항
실험적 fetch 플래그v17.5.0 / v16.15.0--experimental-fetch 뒤에 숨겨진 상태로 fetch 추가
기본 전역 fetchv18.0.0Undici 기반의 실험적 fetch가 전역으로 제공됨
안정화된 fetchv21.0.0더 이상 실험 기능이 아님
2026 운영 기준선v22 LTS / v24 LTS운영 환경에 권장됨. v20은 이제 EOL

내부적으로 Node의 fetch는 Undici로 구동됩니다. Undici는 Node.js 전용으로 만들어진 고성능 HTTP 클라이언트예요. 예전의 내장 http 모듈에 의존하지 않습니다. 실질적인 장점은, 브라우저 코드, Express 백엔드, 서버리스 함수, CLI 스크립트 어디에서든 같은 방식으로 동작하는 현대적인 Promise 기반 HTTP API를 쓸 수 있다는 점입니다.

Node.js Fetch API가 프로젝트에서 중요한 이유

Node 18 이전에는 새 프로젝트를 시작할 때마다 늘 같은 절차를 밟았습니다. npm install axios 혹은 npm install node-fetch를 하는 거죠. 2026년 기준으로, 프로젝트가 관리되는 Node LTS에서 돌아간다면 기본적인 HTTP 요청에는 의존성이 전혀 필요 없습니다. 번들 크기, 공급망 보안, 온보딩 측면에서 정말 큰 이점이에요. 이제 프론트엔드와 백엔드 개발자가 같은 API를 공유하니까요.

기본 fetch가 특히 빛나는 곳은 다음과 같습니다.

상황기본 fetch가 잘 맞는 이유운영 시 주의점
REST API를 호출하는 Express/Fastify 백엔드익숙한 async/await, 별도 의존성 없음타임아웃과 response.ok 검사를 추가하세요
서버리스 함수(Lambda, Vercel 등)콜드 스타트 부담이 작고, 패키지 설치가 필요 없음플랫폼 최대 실행 시간보다 짧게 타임아웃을 잡으세요
CLI 스크립트와 자동화 작업프로젝트 설정 없이 간단한 GET/POST 가능불안정한 API에는 재시도/백오프를 추가하세요
웹훅 전송 또는 전달표준 HTTP 메서드와 헤더 사용멱등성이 없는 POST를 무작정 재시도하지 마세요
리포트와 대시보드API에서 JSON을 가져오기 좋음반복 처리 시 페이지네이션과 연결 풀링을 사용하세요
마이크로서비스 간 통신단순한 내부 HTTP 호출에 잘 맞음재시도, 훅, HTTP/2가 필요하면 Got 또는 Undici를 직접 고려하세요

새로운 Node 22+ 프로젝트라면, 기본 fetch가 가장 합리적인 선택입니다. 물론 fetch가 제공하지 않는 기능이 필요하다는 걸 알고 있을 때는 예외예요(인터셉터, 내장 재시도, HTTP/2 등). npm 다운로드 수치만 봐도 시장이 전환 중이라는 게 보입니다. 를 기록하지만, 그중 많은 부분은 레거시와 간접 의존성입니다. , , , 정도입니다. 흐름은 분명합니다. 기본 fetch가 새로운 기준선이 되었고, 서드파티 클라이언트는 특정 요구사항을 위한 도구입니다.

2026년 기준: 기본 fetch vs node-fetch vs Axios vs Got vs Ky 의사결정표

개발자 포럼에서 가장 자주 보는 질문은 이겁니다. “Node.js에서는 어떤 HTTP 클라이언트를 써야 하나요?” 한 Reddit 사용자는 이렇게 정리했죠. “언어/프레임워크에 이미 기능이 내장돼 있는데 왜 라이브러리를 가져와야 하죠?” 타당한 지적입니다. 다만 답은 무엇이 필요한지에 따라 달라집니다.

http-client-libraries-comparison.png

기능기본 fetchnode-fetch v3axiosgot v15ky v2
Node.js 버전≥18(22/24 LTS 권장)≥12.20폭넓음≥22≥22
설치 필요 여부아니요
ESM + CJS 지원둘 다(전역)ESM 전용(v3)둘 다ESM 전용ESM 전용
4xx/5xx에서 자동 거부아니요아니요
내장 재시도아니요아니요아니요
요청 인터셉터아니요아니요예(hooks)예(hooks)
스트리밍 지원Web ReadableStream제한적강력한 Node streamsfetch 기반
번들/설치 용량0 KB약 107 KB, 3개 의존성약 2.8 MB, 4개 의존성약 355 KB, 12개 의존성약 405 KB, 0개 의존성
HTTP/2 지원Undici dispatcher 통해 가능아니요아니요아니요(fetch 래퍼)

ESM/CJS 관련해서 짚고 넘어가면, node-fetch v3는 ESM 전용이라 require()를 쓰던 많은 프로젝트가 깨졌습니다. 기본 fetch는 전역으로 제공되기 때문에 CJS와 ESM 파일 모두에서 import 없이 바로 사용할 수 있어요. CommonJS 때문에 node-fetch v2에 묶여 있었다면, 기본 fetch가 그 문제를 깔끔하게 해결해 줍니다.

초기 안정성 우려도 있었죠. 맞습니다. Node 18의 초기 fetch 구현에는 실제 버그가 있었습니다. Reddit의 한 개발자는 *“최근에 기본 Node 18 fetch에서 엄청난 버그를 겪어서 앱을 변환해야 했다”*고 했어요. 그건 2023년 이야기입니다. 2026년 현재 Node 22와 24 LTS에서는 그런 문제들이 해결됐습니다. 기본 fetch는 운영 환경에서 써도 됩니다.

기본 fetch를 계속 써도 좋은 경우

다음 상황이라면 기본 fetch를 선택하세요.

  • 프로젝트가 Node 22 LTS 또는 Node 24 LTS에서 실행됩니다.
  • 요청이 단순한 REST 호출(GET, POST, PUT, DELETE)입니다.
  • response.ok, JSON 파싱, 타임아웃, 재시도를 위한 작은 래퍼를 추가할 의향이 있습니다.
  • 의존성을 0에 가깝게 유지하고 공급망 우려를 줄이고 싶습니다.
  • 브라우저와 서버의 API 일관성이 중요합니다.
  • 서버리스나 엣지 환경처럼 내장 API가 선호되는 곳에서 작업합니다.

Axios, Got, Ky가 더 적합한 경우

Axios는 요청/응답 인터셉터에 팀이 의존할 때, 예를 들어 자동 인증 토큰 갱신, 테넌트 헤더, 중앙 집중식 로깅이 필요할 때 적합합니다. HTTP 오류에서 기본적으로 거부되길 원하거나, 더 오래된 Node 런타임과의 호환성이 필요해도 좋습니다.

Got는 내장 재시도, hooks, 고급 타임아웃 단계, streams, 페이지네이션 헬퍼, Unix socket, 프록시/캐싱 워크플로, HTTP/2 지원이 필요한 고처리량 Node 서비스에 맞게 만들어졌습니다. Node 전용 HTTP 작업을 위한 스위스 아미 나이프라고 보면 됩니다.

Ky는 fetch의 단순함은 좋지만 보일러플레이트는 줄이고 싶을 때 가장 잘 맞습니다. 작은 패키지에 의존성 없이 재시도, 타임아웃, hooks, HTTPError를 기본 제공해요.

Node.js Fetch API로 GET 요청 보내는 방법

async/await를 사용한 GET 요청은 이렇게 생겼습니다.

1const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
2const post = await response.json();
3console.log(post.title);
4// → "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"

그리고 .then() 체인을 선호한다면 이런 식입니다.

1fetch('https://jsonplaceholder.typicode.com/posts/1')
2  .then(response => response.json())
3  .then(post => console.log(post.title))
4  .catch(error => console.error(error));

둘 다 동작합니다. 하지만 아직 운영 환경에 안전하다고는 할 수 없어요(곧 그 이유를 보실 겁니다).

알아두면 좋은 response 읽기 메서드:

메서드언제 사용할까
response.json()서버가 JSON을 반환할 때
response.text()서버가 HTML, 일반 텍스트, CSV, Markdown을 반환할 때
response.arrayBuffer()이미지나 파일 같은 바이너리 데이터가 필요할 때
response.body스트리밍/청크 단위 처리가 필요할 때

더 나은 패턴은, 실제로 오류를 확인하는 방식입니다.

1async function getPost(id) {
2  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/$\{id\}`);
3  if (!response.ok) {
4    throw new Error(`HTTP $\{response.status\} $\{response.statusText\}`);
5  }
6  return response.json();
7}
8const post = await getPost(1);
9console.log(post.title);

if (!response.ok) 한 줄이 튜토리얼과 운영 코드의 차이입니다. 그리고 여기서 가장 큰 함정으로 이어집니다.

Node.js Fetch API로 POST 요청 보내는 방법

POST 요청도 같은 형태입니다. 다만 method, headers, body를 지정해 주면 돼요.

1const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
2  method: 'POST',
3  headers: {
4    'Content-Type': 'application/json',
5  },
6  body: JSON.stringify({
7    title: 'Node fetch 가이드',
8    body: '운영 환경의 fetch에는 오류 처리가 필요합니다.',
9    userId: 1,
10  }),
11});
12if (!response.ok) {
13  throw new Error(`HTTP $\{response.status\}`);
14}
15const created = await response.json();
16console.log(created.id); // → 101

다른 요청 타입 보내기(PUT, DELETE, PATCH)

PUT, PATCH, DELETE는 method 값만 다를 뿐 구조는 완전히 같습니다.

1// PUT — 전체 교체
2await fetch('https://jsonplaceholder.typicode.com/posts/1', {
3  method: 'PUT',
4  headers: { 'Content-Type': 'application/json' },
5  body: JSON.stringify({ id: 1, title: '교체됨', body: '전체 교체', userId: 1 }),
6});
7// PATCH — 부분 수정
8await fetch('https://jsonplaceholder.typicode.com/posts/1', {
9  method: 'PATCH',
10  headers: { 'Content-Type': 'application/json' },
11  body: JSON.stringify({ title: '부분 수정' }),
12});
13// DELETE
14await fetch('https://jsonplaceholder.typicode.com/posts/1', {
15  method: 'DELETE',
16});

Express body-parser 함정: Express 서버에 JSON을 POST하는데 req.bodyundefined로 나온다면, 대부분의 경우 해결책은 이것입니다. express.urlencoded()가 아니라 express.json()을 써야 합니다. 서버는 Content-Type: application/json 본문을 파싱하기 위해 라우트 전에 express.json() 미들웨어가 필요해요. 이건 Express 관련 중에서도 가장 흔한 것 중 하나이고, 매번 사람을 헷갈리게 합니다.

1import express from 'express';
2const app = express();
3app.use(express.json()); // ← JSON POST 본문에 필요한 것은 이것입니다
4app.post('/api/posts', (req, res) => {
5  res.json({ received: req.body });
6});

운영 환경을 망가뜨리는 fetch() 오류 함정

fetch-error-handling-flowchart.png

대부분의 운영 fetch 버그는 여기서 발생합니다.

fetch()는 HTTP 4xx 또는 5xx 오류에서 promise를 reject하지 않습니다. 네트워크 수준 실패, 즉 DNS 오류, 인터넷 연결 없음, 요청 취소 같은 경우에만 reject돼요. 서버가 403 Forbidden이나 500 Internal Server Error를 반환해도 fetch는 그걸 성공한 응답으로 봅니다. 따라서 .catch() 블록은 절대 실행되지 않고, try/catch도 이를 잡지 못합니다. 코드는 서버가 보낸 어떤 내용이든 그냥 처리해 버립니다.

는 이 점을 분명히 설명하지만, 대부분의 튜토리얼은 이 부분을 대충 넘어갑니다. 그 결과 아래 같은 코드는 멀쩡해 보여도 오류를 조용히 삼켜 버립니다.

1try {
2  const response = await fetch('https://api.example.com/private');
3  const data = await response.json(); // ← 403이어도 여기까지 실행됩니다
4  console.log('성공한 것처럼 보임:', data);
5} catch (error) {
6  // 여기에는 네트워크 수준 실패만 들어옵니다
7  console.error('잡힘:', error);
8}

각 패턴이 실제로 무엇을 잡는지 간단히 정리하면 이렇습니다.

패턴네트워크 오류 포착4xx/5xx 포착JSON 안전 파싱재사용 가능성
원시 .then(res => res.json())예(.catch() 통해)아니요content-type 가드 없음아니요
await fetch()와 함께 쓰는 try/catch아니요content-type 가드 없음아니요
호출마다 수동 if (!res.ok) 확인호출마다 다름부분적
커스텀 fetchJSON() 래퍼

재사용 가능한 fetchJSON() 래퍼 만들기

한 번 래퍼를 만들어 두세요. 모든 곳에서 import해서 쓰면 됩니다. 파일마다 if (!response.ok)를 복붙하는 일은 이제 그만하세요.

1export class HTTPError extends Error {
2  constructor(message, { status, statusText, url, body }) {
3    super(message);
4    this.name = 'HTTPError';
5    this.status = status;
6    this.statusText = statusText;
7    this.url = url;
8    this.body = body;
9  }
10}
11export async function fetchJSON(url, options = {}) {
12  const response = await fetch(url, {
13    headers: {
14      Accept: 'application/json',
15      ...options.headers,
16    },
17    ...options,
18  });
19  const contentType = response.headers.get('content-type') || '';
20  const isJSON = contentType.includes('application/json');
21  const body = isJSON ? await response.json().catch(() => null) : await response.text();
22  if (!response.ok) {
23    throw new HTTPError(`HTTP $\{response.status\} $\{response.statusText\}`, {
24      status: response.status,
25      statusText: response.statusText,
26      url: response.url,
27      body,
28    });
29  }
30  return body;
31}

이제 서버가 403을 반환하면:

1try {
2  const data = await fetchJSON('https://api.example.com/private');
3} catch (error) {
4  if (error instanceof HTTPError) {
5    console.error(`서버가 $\{error.status\}를 반환했습니다:`, error.body);
6  } else {
7    console.error('네트워크 또는 기타 실패:', error);
8  }
9}

이 오류 객체에는 상태 코드, 응답 본문, URL이 모두 들어 있습니다. 로깅, 알림, 사용자 메시지에 필요한 정보가 전부 들어 있는 셈이죠. 한 번 만들어서 어디서나 쓰세요.

AbortController와 타임아웃: Node.js Fetch API의 운영 패턴

request-retry-pooling-flowchart.png

타임아웃이 없으면 원격 서버가 응답을 멈췄을 때 fetch 호출은 무한정 멈춰 있습니다. Express 라우트는 막히고, Lambda는 실행 시간을 다 써 버리고, 스크립트는 그냥... 그대로 멈춰 있죠.

상위 검색 결과들을 확인해 봤는데, Node.js 전용 fetch 튜토리얼 중 요청 취소나 타임아웃을 다루는 글은 단 하나도 없었습니다. 그런데도 타임아웃은 개발자들이 Axios나 Got을 계속 쓰는 가장 큰 이유 중 하나예요. Reddit 스레드 제목 자체가 *“Node fetch does not timeout”*일 정도입니다.

AbortSignal.timeout() 사용하기(Node 18.11+)

가장 간단한 방법은 옵션 하나를 더 넣는 것입니다.

1try {
2  const response = await fetch('https://api.example.com/data', {
3    signal: AbortSignal.timeout(5000), // 5초
4  });
5  if (!response.ok) throw new Error(`HTTP $\{response.status\}`);
6  const data = await response.json();
7  console.log(data);
8} catch (error) {
9  if (error.name === 'TimeoutError') {
10    console.error('요청이 5초 후 타임아웃되었습니다.');
11  } else {
12    throw error;
13  }
14}

참고로 AbortSignal.timeout()AbortError가 아니라 TimeoutError를 던집니다. 경험 많은 개발자들도 이 부분을 헷갈릴 때가 있어요.

AbortController로 수동 타임아웃 걸기

더 세밀한 제어가 필요하거나, 단순한 타이머가 아니라 사용자 동작에 따라 요청을 취소해야 한다면 이렇게 하세요.

1const controller = new AbortController();
2const timeout = setTimeout(() => controller.abort(), 5000);
3try {
4  const response = await fetch('https://api.example.com/data', {
5    signal: controller.signal,
6  });
7  const data = await response.json();
8  console.log(data);
9} catch (error) {
10  if (error.name === 'AbortError') {
11    console.error('요청이 수동으로 중단되었습니다.');
12  } else {
13    throw error;
14  }
15} finally {
16  clearTimeout(timeout);
17}

AbortError와 TimeoutError 구분하기

이 구분은 로깅과 사용자 메시지에서 중요합니다.

중단 경로catch 블록의 오류 이름
AbortSignal.timeout(ms)TimeoutError
controller.abort()AbortError
DNS/네트워크 실패보통 TypeError: fetch failed

실무 예시를 보죠. Express 라우트가 외부 API를 호출하고 3초 안에 응답해야 하는 경우입니다.

1app.get('/dashboard', async (req, res, next) => {
2  try {
3    const data = await fetchJSON('https://api.example.com/report', {
4      signal: AbortSignal.timeout(3000),
5    });
6    res.json(data);
7  } catch (error) {
8    if (error.name === 'TimeoutError') {
9      res.status(504).json({ error: '상위 API가 타임아웃되었습니다' });
10      return;
11    }
12    next(error);
13  }
14});

이 패턴이 없으면, 상위 API가 느릴 때 클라이언트가 포기할 때까지 라우트 전체가 묶여 버립니다.

재시도 로직과 연결 재사용: Node.js Fetch API를 운영 수준으로 끌어올리기

기본 fetch에는 재시도가 내장돼 있지 않습니다. 네트워크 순간 장애나 일시적인 503이 뜨면 요청은 그냥 실패합니다. 운영 환경의 대부분 읽기 작업에서는 이걸 그대로 둘 수 없어요.

지수 백오프가 있는 조합형 재시도 래퍼

의도적으로 짧게 만들었습니다. 실제 핵심 로직은 대략 10줄 정도예요.

1const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
2export async function fetchWithRetry(url, options = {}, retries = 2) {
3  for (let attempt = 0; ; attempt++) {
4    try {
5      const response = await fetch(url, options);
6      if (response.ok || ![408, 429, 500, 502, 503, 504].includes(response.status)) {
7        return response;
8      }
9      if (attempt >= retries) return response;
10    } catch (error) {
11      if (attempt >= retries) throw error;
12    }
13    await wait(250 * 2 ** attempt); // 250ms, 500ms, 1000ms...
14  }
15}

언제 재시도하고, 언제 하지 말아야 할까?

  • 재시도해야 하는 경우: 멱등적인 GET 및 HEAD 요청, 일시적 상태 코드(408, 429, 500, 502, 503, 504), 네트워크 순간 장애.
  • 재시도하면 안 되는 경우: 레코드를 생성하거나, 결제를 처리하거나, 부작용을 일으키는 비멱등 POST 요청 — 단, idempotency key를 사용한다면 예외입니다.
  • Retry-After를 존중하세요: 429(속도 제한)와 503(서비스 사용 불가)에서는 백오프 전에 Retry-After 헤더를 확인하세요.

직접 재시도 로직을 만들고 싶지 않다면, 는 재시도, 타임아웃, hooks, HTTPError를 기본 제공하는 가벼운 fetch 래퍼입니다. 의존성도 없어요.

Undici의 Agent와 Pool로 연결 재사용하기

고처리량 루프, 예를 들어 수백 페이지를 스크래핑하거나, 배치로 API를 호출하거나, 서비스를 폴링할 때는 TCP 연결을 재사용하면 시간을 크게 아낄 수 있습니다. 새 연결을 만들 때마다 DNS 조회, TCP 핸드셰이크, 그리고 HTTPS의 경우 TLS 협상이 새로 필요하거든요.

Node의 fetch는 Undici로 구동되므로, 커스텀 dispatcher를 전달할 수 있습니다.

1import { Agent } from 'undici';
2const agent = new Agent({
3  keepAliveTimeout: 10_000,
4  keepAliveMaxTimeout: 60_000,
5});
6const response = await fetch('https://api.example.com/data', {
7  dispatcher: agent,
8});

특정 origin에 대해 더 세밀하게 제어하려면:

1import { Pool } from 'undici';
2const pool = new Pool('https://api.example.com', { connections: 10 });
3const response = await fetch('https://api.example.com/data', {
4  dispatcher: pool,
5});
6// 사용 후:
7await pool.close();

를 보면 연결 재사용과 풀링이 처리량을 크게 높일 수 있음을 알 수 있습니다. 로컬 벤치마크에서 undici - dispatch는 초당 약 22,234건, undici - fetch는 약 5,904건이었습니다. 실제 수치는 달라질 수 있지만 방향성은 분명합니다. 같은 origin에 요청을 많이 보낸다면 풀링이 중요해요.

하나 더 말씀드리면, 응답 본문은 항상 소비하거나 취소하세요. 소비되지 않은 본문은 Node의 HTTP 내부에서 리소스 누수를 일으킬 수 있습니다.

Node.js Fetch API로 스트리밍 응답 처리하기

대용량 파일 다운로드, 청크로 전송되는 JSON 피드, 서버 전송 이벤트, LLM 출력은 전체 응답을 다 받은 뒤 처리하면 시간과 메모리를 낭비하는 경우가 많습니다. 스트리밍을 쓰면 데이터가 도착하는 즉시 처리할 수 있습니다.

streaming-data-chunking-process.png

Node 18 이상에는 브라우저 호환 ReadableStream이 포함되어 있습니다. 줄바꿈으로 구분된 JSON 응답을 스트리밍하면서 도착하는 즉시 각 줄을 처리하는 방법은 다음과 같습니다.

1const response = await fetch('https://example.com/large-file.ndjson');
2if (!response.ok) throw new Error(`HTTP $\{response.status\}`);
3const reader = response.body.getReader();
4const decoder = new TextDecoder();
5let buffer = '';
6while (true) {
7  const { value, done } = await reader.read();
8  if (done) break;
9  buffer += decoder.decode(value, { stream: true });
10  let newlineIndex;
11  while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
12    const line = buffer.slice(0, newlineIndex).trim();
13    buffer = buffer.slice(newlineIndex + 1);
14    if (line) {
15      const item = JSON.parse(line);
16      console.log('처리됨:', item.id);
17    }
18  }
19}

더 단순한 텍스트 스트리밍, 예를 들어 LLM 출력을 stdout으로 바로 보내고 싶다면:

1const response = await fetch('https://example.com/stream');
2const reader = response.body.getReader();
3const decoder = new TextDecoder();
4for (;;) {
5  const { value, done } = await reader.read();
6  if (done) break;
7  process.stdout.write(decoder.decode(value, { stream: true }));
8}

스트리밍은 기본 fetch와 Got이 모두 강한 영역입니다. Axios는 스트리밍 지원이 좀 더 제한적입니다.

fetch()가 한계에 부딪힐 때: API를 활용한 구조화된 웹 스크래핑

어느 순간이 되면 fetch 자체가 더 이상 병목이 아닙니다. 진짜 문제는 *“HTML은 가져왔는데, 이제 뭘 하지?”*가 되죠.

web-scraping-vs-ai-extraction-api-comparison.png

fetch는 HTTP 클라이언트입니다. 바이트, 텍스트, JSON, HTML을 가져올 뿐이에요. 제품 카드, 가격, 평점, 연락처 표 같은 개념은 전혀 모릅니다. 구조화된 웹 스크래핑을 하려면 보통 아래의 원시 스택이 필요합니다.

  1. HTML을 내려받기 위한 fetch()
  2. CSS 선택자로 요소를 찾는 Cheerio(또는 유사 도구)
  3. 커스텀 페이지네이션 로직
  4. 페이지가 클라이언트 사이드 렌더링일 때의 JavaScript 렌더링
  5. 프록시/안티봇/CAPTCHA 처리
  6. 사이트 레이아웃이 바뀔 때마다 선택자 유지보수

전형적인 fetch + Cheerio 예시는 이렇습니다. 제품 제목을 스크래핑하는 데 코드가 약 15줄쯤 필요해요.

1import * as cheerio from 'cheerio';
2const response = await fetch('https://example-store.com/products');
3if (!response.ok) throw new Error(`HTTP $\{response.status\}`);
4const html = await response.text();
5const $ = cheerio.load(html);
6const products = $('.product-card')
7  .map((_, el) => ({
8    name: $(el).find('.product-title').text().trim(),
9    price: $(el).find('.price').text().trim(),
10    url: new URL($(el).find('a').attr('href'), response.url).href,
11  }))
12  .get();
13console.log(products);

이 방식은 HTML 구조가 안정적인 페이지에서는 잘 작동합니다. 하지만 금방 취약해져요. JavaScript 렌더링 콘텐츠, 바뀌는 클래스명, 안티봇 대응, 페이지네이션까지 더해지면 복잡도가 급격히 올라갑니다.

Thunderbit의 Open API: 원시 HTML을 한 번에 구조화된 데이터로

여기서부터는 다른 종류의 도구가 유용해집니다. 에서는 JavaScript 렌더링, 안티봇 보호, 레이아웃 변경 같은 골치 아픈 부분을 처리해 주는 API 계층을 만들었습니다. 그래서 사용자는 원하는 데이터에만 집중하면 돼요.

Distill API (POST /distill): 어떤 URL이든 깔끔한 Markdown으로 변환합니다. LLM에 넣거나, 지식 베이스를 만들거나, 콘텐츠 분석을 할 때 유용해요. HTML 파서가 필요 없습니다.

Extract API (POST /extract): 원하는 구조화된 데이터(제품명, 가격, 평점 등)를 설명하는 JSON Schema를 정의하면 AI가 이를 추출합니다. CSS 선택자도 필요 없고, 레이아웃이 바뀌어도 깨지지 않습니다.

Thunderbit의 Extract API를 사용해 같은 제품 스크래핑 작업을 해 보면, native fetch로 이렇게 호출할 수 있습니다.

1const response = await fetch('https://openapi.thunderbit.com/openapi/v1/extract', {
2  method: 'POST',
3  headers: {
4    Authorization: `Bearer $\{process.env.THUNDERBIT_API_KEY\}`,
5    'Content-Type': 'application/json',
6  },
7  body: JSON.stringify({
8    url: 'https://example-store.com/products',
9    renderMode: 'basic',
10    schema: {
11      type: 'object',
12      properties: {
13        products: {
14          type: 'array',
15          items: {
16            type: 'object',
17            properties: {
18              name: { type: 'string', description: '제품명' },
19              price: { type: 'string', description: '표시된 제품 가격' },
20              rating: { type: 'number', description: '평균 고객 평점' },
21            },
22            required: ['name', 'price'],
23          },
24        },
25      },
26      required: ['products'],
27    },
28  }),
29});
30if (!response.ok) throw new Error(`Thunderbit API: $\{response.status\}`);
31const result = await response.json();
32console.log(result.data);

비교해 보면, fetch + Cheerio 약 15줄과 취약한 선택자 조합 대신, 깔끔한 JSON을 반환하는 단일 API 호출이면 충분합니다. 배치 작업의 경우 Thunderbit는 한 번의 batch extract 호출로 최대 50개 URL, batch distill 호출로 최대 100개 URL까지 지원합니다.

Thunderbit는 fetch를 대체하는 도구가 아닙니다. fetch는 전송 수단이고, Thunderbit는 원시 HTML 파싱이 진짜 문제일 때 사용하는 추출 계층입니다. 가격이 궁금하다면 에서 600 API 유닛으로 먼저 실험해 볼 수 있고, 유료 플랜은 월 6달러부터 시작합니다. 브라우저에서 바로 노코드 추출을 하고 싶다면 도 확인해 보세요.

구조화된 스크래핑 접근법에 대해 더 알고 싶다면, , , 가이드에서 구체적인 워크플로를 자세히 다룹니다.

빠른 참고: Node.js Fetch API 치트시트

이 섹션은 북마크해 두면 좋습니다. 복붙할 패턴이 필요할 때 다시 오세요.

패턴예시 코드
기본 GETconst res = await fetch(url); const data = await res.json();
기본 POSTawait fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
HTTP 오류 확인if (!res.ok) throw new Error(\HTTP ${res.status}`);`
타임아웃(간단)await fetch(url, { signal: AbortSignal.timeout(5000) });
수동 중단const c = new AbortController(); setTimeout(() => c.abort(), 5000); await fetch(url, { signal: c.signal });
재시도 대상 상태 코드408, 429, 500, 502, 503, 504를 재시도하세요. POST를 무작정 재시도하지 마세요.
JSON 래퍼fetchJSON()으로 ok를 확인하고, content type을 파싱하고, HTTPError를 던지세요.
연결 풀import { Pool } from 'undici'; const pool = new Pool(origin, { connections: 10 }); fetch(url, { dispatcher: pool });
스트림 청크const reader = res.body.getReader(); await reader.read()를 반복하세요
구조화 추출웹페이지의 원시 HTML이 아니라 필드가 목표라면 Thunderbit Extract API를 사용하세요.

결론과 핵심 요점

Node.js의 기본 fetch는 2026년 기준으로 운영 환경에서 사용할 수 있습니다. 새 프로젝트에 node-fetch도 필요 없고, 기본 Axios 의존성도 필요 없어요. 하지만 원시 fetch()만으로는 운영용 HTTP 전략이 되지 않습니다.

대부분의 튜토리얼이 건너뛰는 다섯 가지, 그리고 이 가이드가 다룬 내용은 다음과 같습니다.

  1. 오류 함정: fetch()는 4xx/5xx에서 예외를 던지지 않습니다. 항상 response.ok를 확인하거나 fetchJSON() 같은 래퍼를 사용하세요.
  2. 타임아웃: 간단한 경우 AbortSignal.timeout()을 쓰세요. AbortSignal.timeout()TimeoutError를 던지고, 수동 controller.abort()AbortError를 던집니다.
  3. 재시도 로직: 내장돼 있지 않습니다. 멱등 요청과 일시적 실패에는 지수 백오프를 추가하세요. 또는 기본 제공 재시도가 필요한 경우 Ky를 사용하세요.
  4. 연결 재사용: 고처리량 루프에서는 Undici의 AgentPooldispatcher 옵션으로 사용하세요.
  5. 구조화 추출: 단순한 원시 HTML이 아니라 웹페이지에서 데이터를 얻어야 한다면, 깨지기 쉬운 CSS 선택자를 직접 관리하기보다 Thunderbit 같은 추출 API를 고려하세요.

한 문장으로 정리한 의사결정 기준은 이렇습니다. 대부분의 프로젝트에는 기본 fetch, 인터셉터가 필요하면 Axios, 내장 재시도와 HTTP/2가 필요하면 Got, fetch 스타일에 더 나은 기본값이 필요하면 Ky, 그리고 fetch 기반 스크래핑 스크립트가 너무 복잡해졌다면 Thunderbit API를 사용하세요.

이 가이드의 패턴을 직접 시도해 보세요. 그리고 Thunderbit가 구조화된 추출을 어떻게 처리하는지 보고 싶다면, 에서 시작하는 것도 좋고, 에서 워크스루를 보셔도 됩니다.

AI 웹 스크래핑을 위해 Thunderbit 사용해 보기

자주 묻는 질문

1. fetch는 Node.js에 기본 포함되어 있나요, 아니면 설치해야 하나요?

fetch는 Node.js 18 이상에 기본 포함되어 있어 설치가 필요 없습니다. Node 21에서 안정화되었고, Node 22 LTS와 Node 24 LTS에서 완전히 지원됩니다. 더 오래된 Node 버전에서는 node-fetch npm 패키지를 사용할 수 있지만, 새 프로젝트는 관리되는 LTS 릴리스를 대상으로 하는 것이 좋습니다.

2. fetch는 404나 500 응답에서 오류를 던지나요?

아니요. fetch는 네트워크 수준 실패(DNS 오류, 연결 없음, 요청 중단)에서만 promise를 reject합니다. 404, 403, 500 같은 HTTP 응답은 response.ok === false인 상태로 정상적으로 resolve됩니다. 반드시 response.ok 또는 response.status를 직접 확인해야 하며, 이 가이드에 나온 fetchJSON() 같은 래퍼를 사용해도 됩니다.

3. Node.js에서 fetch에 타임아웃을 어떻게 추가하나요?

가장 간단한 방법은 Node 18.11+에서 사용할 수 있는 AbortSignal.timeout(ms)입니다. await fetch(url, { signal: AbortSignal.timeout(5000) })처럼 쓰면 됩니다. 요청이 5초를 넘기면 TimeoutError가 발생합니다. 더 세밀한 제어가 필요하면 AbortController를 직접 만들고 setTimeout에서 controller.abort()를 호출하세요. 수동 패턴은 AbortError, AbortSignal.timeout()TimeoutError를 잡으면 됩니다.

4. Node.js에서 웹 스크래핑에 fetch를 사용할 수 있나요?

네. 하지만 fetch는 원시 HTML만 돌려줍니다. 특정 요소를 추출하려면 Cheerio 같은 파서가 필요하고, 페이지네이션, JavaScript 렌더링 페이지, 안티봇 대응을 위한 커스텀 로직도 추가해야 합니다. 제품명, 가격, 연락처 정보처럼 깔끔한 JSON이 필요한 대규모 구조화 데이터 추출이라면, CSS 선택자나 레이아웃 의존 코드 없이 AI가 구조화 데이터를 반환하는 를 고려해 보세요.

5. 2026년에 Axios에서 기본 fetch로 바꿔야 하나요?

Node 22+의 새 프로젝트라면 기본 fetch가 강력한 기본 선택지입니다. 의존성이 없고, Promise 기반이며, 브라우저 fetch와 같은 API를 공유합니다. 요청/응답 인터셉터, 기본 HTTP 오류 거부, 또는 더 오래된 Node 버전과의 호환성이 필요하다면 Axios를 유지하세요. 둘 다 유효한 선택이고, 결정은 프로젝트가 실제로 쓰는 기능에 달려 있습니다.

더 알아보기

Fawad Khan
Fawad Khan
파와드는 글을 쓰며 생계를 이어가고, 솔직히 꽤 좋아해요. 그는 문구 한 줄이 사람들 기억에 남게 만드는 요소와, 반대로 그냥 스크롤해 지나치게 만드는 요소를 오랜 시간 연구해 왔어요. 마케팅에 대해 물어보면 몇 시간이고 이야기할 거예요. 카르보나라에 대해 물어보면 더 오래 이야기할 거고요.

Thunderbit 체험하기

단 2번 클릭으로 리드와 기타 데이터를 수집하세요. AI 기반입니다.

Thunderbit 받기 무료예요
AI로 데이터 추출하기
데이터를 Google Sheets, Airtable, Notion으로 손쉽게 전송하세요
PRODUCT HUNT#1 Product of the Week