자바스크립트 중급 #6 fetch API와 에러 처리

#5 옵셔널 체이닝과 nullish 병합 다음, 이번엔 자바스크립트로 외부 세상과 통신하는 도구를 정리합니다. 브라우저와 Node 양쪽에서 동작하는 표준 fetch API입니다.

fetch 기본 #

가장 단순한 fetch
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);

세 줄로 끝납니다. fetch는 Promise를 반환해서 — await로 풉니다(#2 비동기 입문에서 본 패턴).

fetch의 결과는 Response 객체입니다. 응답 본문을 어떻게 해석할지에 따라 메서드가 달라요.

응답 해석 메서드
await response.json();        // JSON 파싱
await response.text();        // 텍스트 그대로
await response.blob();        // 바이너리 (이미지/파일 등)
await response.arrayBuffer(); // ArrayBuffer (저수준)
await response.formData();    // FormData

함정 1 — 4xx/5xx는 catch로 안 잡힌다 #

자바스크립트의 fetch는 HTTP 상태 코드가 4xx/5xx 여도 reject 하지 않습니다. 네트워크 자체가 실패했을 때만 reject 합니다.

이건 throw 안 일어남
try {
  const res = await fetch('/api/not-found');
  // 상태가 404 인데도 여기까지 옴
  console.log(res.status);   // 404
} catch (err) {
  // 네트워크 실패가 아니라면 안 들어옴
}

이 동작이 다른 언어/라이브러리(axios 등)와 다르고, 혼란을 자주 일으켜요. 상태 검사를 직접 해야 합니다.

권장 패턴 — 상태 직접 검사
const res = await fetch('/api/users');
if (!res.ok) {
  throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();

res.ok는 상태가 2xx 범위면 true. 이 검사를 빠뜨리면 4xx 응답을 그대로 JSON 파싱하려다 또 다른 에러로 이어져요.

메서드와 본문 — POST/PUT/DELETE #

기본은 GET. 다른 메서드를 쓰려면 옵션 객체를 두 번째 인자로 줍니다.

POST 요청
const res = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: '커티스', age: 30 }),
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = await res.json();

세 가지 핵심:

  1. method — 기본 ‘GET’, POST/PUT/PATCH/DELETE 등
  2. headersContent-Type으로 본문 형식 명시
  3. body — 보낼 본문. 객체는 직접 못 보내고 JSON.stringify로 문자열화

FormData 보내기 #

파일 업로드처럼 multipart 본문이 필요하면:

FormData
const formData = new FormData();
formData.append('name', '커티스');
formData.append('avatar', fileInput.files[0]);

const res = await fetch('/api/upload', {
  method: 'POST',
  body: formData,
  // Content-Type 은 일부러 안 적음 — 브라우저가 자동 설정
});

FormData를 보낼 때는 Content-Type을 직접 적지 않습니다. 브라우저가 boundary를 포함한 정확한 헤더를 자동으로 만들어 줍니다.

헤더와 인증 #

인증 토큰
const token = localStorage.getItem('token');

const res = await fetch('/api/me', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
});

쿠키를 함께 보내려면 credentials 옵션:

쿠키 포함
const res = await fetch('/api/me', {
  credentials: 'include',  // 다른 origin 에도 쿠키 보냄
});

기본값은 'same-origin' (같은 도메인일 때만 쿠키). 외부 API가 쿠키 인증을 쓰면 'include'가 필요합니다.

쿼리 파라미터 — URLSearchParams #

URL에 쿼리 문자열을 붙일 때 직접 문자열을 만드는 건 위험합니다. URLSearchParams가 인코딩까지 처리해 줍니다.

안전한 쿼리
const params = new URLSearchParams({
  q: '검색어 with spaces',
  page: '1',
  sort: 'date',
});

const res = await fetch(`/api/posts?${params}`);
// /api/posts?q=%EA%B2%80%EC%83%89%EC%96%B4+with+spaces&page=1&sort=date

+, &, 한글, 공백 모두 자동으로 URL 인코딩됩니다.

AbortController — 요청 취소 #

오래 걸리는 요청을 도중에 취소하고 싶을 때 사용합니다.

AbortController
const controller = new AbortController();

setTimeout(() => controller.abort(), 5000);   // 5초 뒤 취소

try {
  const res = await fetch('/api/slow', {
    signal: controller.signal,
  });
  console.log(await res.json());
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('취소됨');
  } else {
    throw err;
  }
}

이게 #2 비동기 입문 에서 살짝 언급한 timeout의 모던 답입니다. Promise.race + 타이머보다 깔끔합니다.

AbortSignal.timeout() — ES2022 #

자주 사용하는 패턴이라 짧은 형태도 추가됐습니다.

타임아웃 한 줄
const res = await fetch('/api/slow', {
  signal: AbortSignal.timeout(5000),
});

5초 초과 시 자동으로 abort. AbortController를 직접 만들 필요 없습니다.

동시 요청 취소 — 컴포넌트 언마운트 패턴 #

리액트 컴포넌트가 언마운트됐는데 그동안 진행 중이던 fetch 결과가 늦게 도착하면, 없어진 컴포넌트에 setState를 호출해 경고가 나요. AbortController가 표준 답입니다.

컴포넌트에서
function UserPage({ id }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then((r) => r.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();   // cleanup
  }, [id]);

  // ...
}

return () => controller.abort()가 핵심. 언마운트나 의존성 변경 시 진행 중이던 fetch가 취소됩니다.

스트리밍 응답 — for await #

큰 응답을 chunk 단위로 받고 싶을 때.

스트림 읽기
const res = await fetch('/api/large');

for await (const chunk of res.body) {
  console.log(chunk);   // Uint8Array 조각
}

res.body는 ReadableStream. for await (#3 이터레이터와 제너레이터)로 chunk를 차례로 받아옵니다. AI streaming 응답, 큰 파일, 서버-전송 이벤트 같은 경우에 만나요.

Node 에서의 fetch #

옛날 Node에서는 fetch가 없어서 node-fetch 패키지를 설치해야 했습니다. Node 18부터는 fetch가 빌트인입니다. 추가 설치 없이 바로 사용합니다.

Node 에서도 동일
const res = await fetch('https://api.example.com/users');
const users = await res.json();

스크립트, CLI 도구, 서버사이드 모두 같은 API로 통신할 수 있습니다.

로깅과 재시도 — 작은 wrapper #

여러 곳에서 같은 패턴(상태 검사, 에러 로깅)이 반복되면 wrapper 함수를 만드는 게 깔끔합니다.

간단한 wrapper
async function api(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${url}`);
  }

  return res.json();
}

// 사용처
const users = await api('/api/users');
const created = await api('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: '커티스' }),
});

이걸 더 발전시키면 — 재시도, 타임아웃, 인증 토큰 자동 추가 등을 한 군데서 관리할 수 있습니다. 큰 앱이라면 결국 비슷한 wrapper가 자기만의 모양으로 만들어집니다.

라이브러리를 쓸까, fetch 만으로 갈까 #

axios, ky, ofetch 같은 fetch 위에 얹는 라이브러리도 많습니다. 각자의 트레이드오프:

옵션장점단점
빌트인 fetch의존성 0, 표준4xx 검사 직접, 보일러플레이트
ky작고 모던, fetch 기반의존성 추가
axios풍부한 기능, 옛날부터 표준무겁고 커스텀 동작 많음
TanStack Query캐싱/동기화까지학습 비용

작은 프로젝트, 라이브러리, 학습 코드는 fetch 만으로 충분합니다. 큰 앱이면 보통 wrapper 또는 데이터 페칭 라이브러리(TanStack Query) 와 합쳐지는 흐름입니다.

마무리 #

이번 글에서 정리한 내용:

  • fetch는 Promise 반환, 응답은 Response 객체
  • .json(), .text(), .blob()로 본문 해석
  • 4xx/5xx는 자동으로 throw 안 됨 — res.ok 직접 검사
  • POST는 method/headers/body 옵션 + JSON.stringify
  • FormData는 Content-Type 자동
  • URLSearchParams로 안전한 쿼리 인코딩
  • AbortController / AbortSignal.timeout()로 취소
  • for await (chunk of res.body)로 스트리밍
  • Node 18+ 도 빌트인 fetch
  • 작은 wrapper / TanStack Query 같은 라이브러리는 큰 앱에서 가치

다음 글(#7 JSON 다루기와 직렬화)에서는 자바스크립트 데이터를 JSON으로 주고받을 때 자주 만나는 것들 — parse/stringify의 옵션, Date/특수값 처리, 깊은 복사 트릭의 함정까지 정리합니다.

X