본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

Native fetch — Node의 내장 HTTP 클라이언트

~12 min · io-net, fetch, undici, http-client

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Node의 전역 fetch는 브라우저와 익숙한 요청 API를 공유해. 다만 서버 환경의 쿠키, 프록시, 연결 관리까지 브라우저와 같다고 가정하면 안 돼."

Node에 들어온 Fetch API

Node 18에서 전역 fetch가 처음 제공됐고 Node 21에서 안정 기능이 됐어. 구현은 Node에 포함된 Undici를 사용해. 그래서 단순한 HTTP 요청이라면 별도 클라이언트 패키지 없이 Request, Response, Headers, FormData 같은 웹 표준 API를 쓸 수 있어.

이 말이 모든 HTTP 라이브러리가 불필요하다는 뜻은 아니야. 인증 갱신, 스키마 검증, 세밀한 재시도 정책, 쿠키 저장소처럼 프로젝트가 요구하는 기능이 있다면 전용 계층이 여전히 유용해. 먼저 내장 API가 필요한 범위를 충족하는지 확인하고 의존성을 고르면 돼.

응답 상태는 직접 확인해

const res = await fetch('https://api.github.com/zen');
if (!res.ok) {
  throw new Error(`${res.status} ${res.statusText}`);
}
console.log(await res.text());

const created = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Pippa' }),
});
if (!created.ok) throw new Error(`HTTP ${created.status}`);

fetch는 서버가 404나 500을 반환해도 정상적인 HTTP 응답으로 처리해. 이때 프로미스는 이행되고 res.okfalse가 돼. DNS 실패나 연결 실패처럼 응답 자체를 받지 못한 경우에는 프로미스가 거부돼. 따라서 호출부는 전송 실패와 HTTP 상태 실패를 따로 다뤄야 해.

서버 환경에서 달라지는 점

API 모양은 브라우저와 비슷하지만 실행 환경은 달라.
  • CORS: 브라우저의 동일 출처 정책이 적용되지 않아. 대신 호출할 주소를 애플리케이션이 직접 검증해야 해.
  • 쿠키: 브라우저 같은 쿠키 저장소가 자동으로 붙지 않아. 상태이 필요한 요청이라면 별도로 관리해야 해.
  • 본문 스트림: res.body는 Web ReadableStream이어서 for await이나 getReader()로 점진적으로 읽을 수 있어.
  • 연결과 프록시: 연결 재사용과 전송 세부 사항은 포함된 Undici 구현이 맡아. 특별한 프록시나 풀 정책이 필요하면 현재 Node와 Undici 문서에 맞춰 명시적으로 구성해.

본문을 전부 버퍼링하지 않기

res.json(), res.text(), res.arrayBuffer()는 편리하지만 본문 전체를 모은 뒤 결과를 돌려줘. 큰 파일이나 JSON Lines 응답은 res.body를 스트리밍하면 메모리 사용량을 일정하게 유지할 수 있어. 청크 경계가 줄 경계와 일치하지 않으므로 남은 문자열을 다음 청크와 이어 붙여야 해.

응답 본문을 사용하지 않을 때도 무시한 채 남겨 두지 마. 필요 없다면 취소하고, 필요하다면 끝까지 소비해야 연결 자원을 예측 가능하게 관리할 수 있어.

취소와 시간 제한

fetchAbortSignal을 받아. 독립된 시간 제한에는 AbortSignal.timeout(ms)를 쓰고, 사용자 취소나 상위 작업 취소와 함께 묶어야 한다면 컨트롤러나 결합 신호를 사용해. 취소는 요청과 본문 소비에 전달되며, 구체적인 소켓 재사용과 정리는 런타임 구현이 맡아.

try {
  const res = await fetch('https://slow.example/data', {
    signal: AbortSignal.timeout(3_000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  console.log(await res.json());
} catch (error) {
  if (error.name === 'TimeoutError') {
    console.error('request timed out');
  } else {
    throw error;
  }
}

재시도는 요청의 성격부터 확인해

네트워크 오류나 5xx 응답을 재시도하면 일시적인 장애를 견딜 수 있지만, 모든 요청을 같은 방식으로 다시 보내면 안 돼. GET처럼 멱등인 요청은 비교적 안전하지만 결제 생성 같은 POST는 서버가 이미 처리했을 수 있어. 재시도 가능 상태, 최대 횟수, 지수 백오프, 지터, Retry-After, 멱등성 키를 API 계약에 맞춰 정해야 해.

Pippa의 고백

예전에는 HTTP 요청이 보이면 먼저 라이브러리부터 골랐어. 이제는 반대로 시작해. 상태 확인, JSON 변환, 취소, 스트리밍만 필요하다면 내장 fetch로 충분해. 프로젝트가 쿠키 저장소나 복잡한 재시도 정책을 실제로 요구할 때만 그 기능을 소유한 계층을 더해. 의존성을 줄이는 것보다 중요한 건 경계를 알고 선택하는 거야.

Code

응답 전체를 버퍼링하지 않는 JSON Lines 처리·javascript
// 큰 JSON Lines 응답을 한 줄씩 처리한다
const res = await fetch('https://api.example.com/huge.jsonl');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error('response body is missing');

const decoder = new TextDecoder();
let buffer = '';
let count = 0;

for await (const chunk of res.body) {
  buffer += decoder.decode(chunk, { stream: true });
  const lines = buffer.split('\n');
  buffer = lines.pop() ?? '';

  for (const line of lines) {
    if (!line) continue;
    const event = JSON.parse(line);
    count += 1;
    // event를 처리한다
  }
}

buffer += decoder.decode();
if (buffer) {
  JSON.parse(buffer);
  count += 1;
}
console.log(`processed ${count} events`);
멱등 요청에만 적용하는 재시도 뼈대·javascript
// 멱등 요청을 위한 단순한 재시도 뼈대
async function fetchWithRetry(url, init = {}, options = {}) {
  const { retries = 3, baseMs = 200, signal } = options;
  const method = (init.method ?? 'GET').toUpperCase();
  if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
    return fetch(url, { ...init, signal });
  }

  for (let attempt = 0; ; attempt += 1) {
    try {
      const response = await fetch(url, { ...init, signal });
      if (response.ok || response.status < 500) return response;
      if (attempt === retries) return response;
    } catch (error) {
      if (attempt === retries || signal?.aborted) throw error;
    }

    const delay = baseMs * 2 ** attempt + Math.random() * 50;
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

External links

Exercise

전역 fetch만 사용해 페이지가 있는 공개 API를 읽는 CLI를 만들어. 각 응답에서 상태를 확인하고, 요청마다 10초 시간 제한을 적용하고, 다음 페이지가 없을 때까지 순회해. API가 rate-limit 또는 다음 페이지 정보를 어떤 헤더에 담는지도 기록해.
Hint
GitHub API를 쓴다면 Link 헤더의 rel="next" URL을 따라갈 수 있어. User-Agent를 보내고 x-ratelimit-remaining도 확인해. 헤더 파서는 공백 모양 하나에만 의존하지 않도록 항목을 쉼표로 나눈 뒤 각 관계를 읽는 편이 안전해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.