XHR부터 Ky까지 HTTP 클라이언트 라이브러리 파헤치기

2026. 4. 24. 15:27·프론트엔드

 

어떤 상황에 어떤 HTTP 클라이언트 라이브러리를 사용하시겠습니까?

 

 

웹 개발자라면 HTTP 클라이언트를 사용해 보았을 것이다.

외부 API와 데이터를 연동하고 요청/응답 처리를 쉽게 하기 위해서 사용한다.

이 글에서는 자바스크립트에서 사용되는 fetch, XHR, axios, ky에 대해 다뤄볼 것이다.

각 클라이언트의 차이점과 구동방식, 철학까지 자세히 뜯어볼 것이다.

 

 

먼저 다음 코드를 보자

// fetch
const res = await fetch('/api/users/1');
const data = await res.json();

// ky
const data = await ky.get('/api/users/1').json();

// axios
const { data } = await axios.get('/api/users/1');

 

왜 fetch에서는 await 를 두 번 써야 할까?

셋 다 같은 일을 한다. 근데 `await`의 횟수가 다르다.

fetch는 두 번, axios와 ky는 한 번만 사용한다. ky와 axios는 같은 이유로 한 번만 사용하는 걸까?
이 차이를 쫓아가는 것이 이 글의 출발점이다.

 

1. HTTP 클라이언트 변천사

등장한 시간 순서는 다음과 같다.

XHR → axios → fetch → ky

`fetch`가 표준으로 자리 잡기 전에 `XHR`의 불편함을 해결하려고 나온 것이 `axios`이고,

`fetch`가 나온 뒤에 그걸 얇게 감싼 것이 바로 `ky`이다.

 

XHR - 콜백 기반의 상태 머신

XHR은 2000년대 초 등장한 최초의 브라우저 비동기 통신 API다. 당시 JS에는 Promise가 없었기 때문에, 결과를 받으려면 콜백을 이벤트 핸들러로 등록해야 했다.

 

문제는 두 가지였다.

  1. 순차적인 요청이 필요할 때 콜백 안에 콜백을 중첩해야 했다. (콜백 지옥)
    유저 정보를 가져오고, 그 유저의 게시글을 가져오고, 그 게시글의 댓글을 가져오는 3단계 요청만 해도 코드가 오른쪽으로 계속 밀려나갔다. 에러 처리를 각 단계마다 추가하면 걷잡을 수 없어진다.
  2. 에러 처리가 파편화되어 있었다.
    HTTP 에러는 `onload` 안에서 `status`를 직접 확인해야 했고, 네트워크 에러는 `onerror`, 타임아웃은 `ontimeout`, 취소는 `onabort`로 각각 따로 등록해야 했다. 하나라도 빠뜨리면 해당 상황이 조용히 무시됐다.

특히 4xx/5xx는 에러가 아니라 정상 응답으로 `onload`에 들어오기 때문에, ok 체크를 빠뜨리면 서버 에러를 성공으로 처리하는 버그가 생겼다.

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users/1', true);

xhr.onload = function () {
  if (xhr.status >= 200 && xhr.status < 300) {
    const data = JSON.parse(xhr.responseText);
  } else {
    console.error(xhr.status); // 4xx/5xx도 여기서 수동 처리
  }
};

xhr.onerror = function () {
  // DNS 실패, TCP 연결 실패, CORS 위반, abort
  // status는 0, 실제 원인은 브라우저 콘솔에서만 확인 가능
  console.error('네트워크 에러');
};

xhr.ontimeout = function () {
  // onerror가 아닌 별도 이벤트 — 둘 다 등록해야 한다
};

xhr.send(); // 반드시 리스너 등록 후에 호출

 

Axios - XHR에 Promise를 씌운 라이브러리

axios는 XHR을 내부에서 그대로 사용하되, Promise를 씌웠다. onload와 onerror 콜백을 하나의 Promise로 합쳐서 

콜백 지옥을 해결하고자 했다.

 

내부 동작을 단순하게 풀어쓰면 이렇다.

function xhrAdapter(config) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(config.method, config.url);

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve({ data: JSON.parse(xhr.responseText), status: xhr.status });
      } else {
        reject(new AxiosError({ response: xhr })); // HTTP 에러도 reject
      }
    };

    xhr.onerror = () => {
      reject(new AxiosError({ request: xhr })); // 네트워크 에러도 reject
    };

    xhr.send(config.data);
  });
}

두 콜백을 단일 reject로 합치다 보니 에러를 필드로 구분하는 구조가 자연스럽게 생겼다.

이게 이따가 다룰 axios 에러 모델을 이해하는 핵심이 된다.

 

fetch - 스트림 기반 웹 표준 API

fetch는 XHR의 구조적 한계를 해결하기 위해 브라우저 웹 표준으로 등장했다.

단순히 콜백을 Promise로 바꾼 게 아니라 설계 철학 자체가 다르다.

XHR은 하나의 객체에 요청 설정, 응답 저장, 에러 처리, 취소까지 모든 책임을 몰아넣은 반면, fetch는 이것을 역할별로 분리했다. `Request`는 요청을, `Response`는 응답을, `AbortController`는 취소 제어를 각각 독립적으로 담당한다.

가장 큰 차이는 응답 바디를 스트림으로 다룬다는 것이다. 이것이 `await`를 두 번 써야 하는 직접적인 이유다.

XHR은 바디를 전부 받을 때까지 기다렸다가 한 번에 넘겨줬다.

fetch는 다르다. 헤더가 도착하는 순간 첫 번째 await가 풀리고, 바디는 아직 네트워크에서 흘러 들어오는 중인 상태로 Response 객체를 돌려준다. 바디를 실제로 읽으려면 `res.json()`을 호출해야 하고, 여기서 두 번째 `await`가 필요하다.

const res = await fetch('/api/users/1'); // 헤더 도착 시 resolve
const data = await res.json();           // 바디를 끝까지 읽고 파싱 완료

 

불편해 보이지만 의도된 설계다. 헤더를 먼저 받아서 상태 코드를 확인하고, 바디를 받을지 말지 결정할 수 있다.

바디의 크기가 엄청 크다면 헤더만 보고 다운로드를 중단할 수 있는 것이다. ChatGPT처럼 텍스트가 타이핑되듯 스트리밍 되는 응답도 이 구조 덕분에 가능하다.

 

ky - fetch를 감싼 라이브러리

fetch는 잘 설계된 API지만 로우레벨이라 실무에서 그대로 쓰기엔 불편한 부분이 있다.

가장 대표적인 불편함이 두 가지다.

  1. 404나 500 같은 HTTP 에러가 발생해도 자동으로 throw 하지 않는다.
    `res.ok`를 직접 확인하지 않으면 에러 응답을 정상으로 처리하는 버그가 생긴다.

  2. JSON 파싱을 수동으로 해야 한다.
    `await res.json()`을 매번 직접 호출해야 하고, 이걸 빠뜨리면 파싱 되지 않은 Response 객체를 그대로 쓰게 된다.

 

Ky는 이 불편함을 보완하기 위해 등장한 fetch 래퍼 라이브러리다. 여기서 중요한 게 "래퍼"라는 표현이다.

(래퍼란 기존 기능을 그대로 두고 그 위에 편의 기능만 얹은 것을 말한다.)

Axios는 XHR이라는 완전히 다른 기반 위에 `Promise` 인터페이스를 씌운 것이지만, Ky는 fetch 위에 얇게 덧댄 것이다. fetch의 Request/Response 객체 모델을 그대로 유지한다. 이 차이가 실질적인 영향을 준다.

Ky로 보낸 요청은 내부적으로 fetch를 쓰기 때문에 Service Worker가 그대로 가로챌 수 있다. (Service Worker 는 브라우저와 네트워크 사이에서 요청을 가로채는 스크립트이다.) `response.body`에 접근하면 여전히 `ReadableStream`을 얻을 수 있어서 스트리밍 응답도 처리할 수 있다. fetch 생태계의 장점을 그대로 가져가면서 불편함만 없앤 것이다.

 

2. 버퍼 vs 스트림 - fetch의 await가 두 번인 이유

네트워크 계층에서 데이터는 항상 조각조각 흘러 들어온다.

HTTP 응답이 어떻게 전달되는지 이해하려면 네트워크 계층부터 살펴봐야 한다.

서버가 응답을 보낼 때 데이터를 한 번에 쏘지 않는다. TCP 프로토콜이 데이터를 패킷 단위로 잘라서 전송하고, 브라우저가 받아서 순서대로 조립한다. 이 과정은 브라우저가 알아서 처리하기 때문에 개발자가 신경 쓸 필요는 없다.

 

중요한 건 응답이 헤더와 바디로 나뉜다는 것이다. 헤더는 크기가 작아서 빠르게 도착한다. 상태 코드, Content-Type, Content-Length 같은 정보가 담겨 있다. 헤더가 먼저 파싱 되고 바디는 그 이후에 읽힌다. 

 

XHR과 Axios는 이 스트림을 내부에서 전부 받을 때까지 축적했다가 완성된 덩어리로 넘겨주는 버퍼 방식이다. 중간에 끼어들 방법이 없다. fetch는 다르다. 스트림을 그대로 노출해서 개발자가 직접 제어할 수 있다. 다만 `res.json()` 같은 편의 메서드를 쓰면 내부적으로 스트림을 전부 읽어서 합쳐주기 때문에 버퍼처럼 느껴진다. 밑바닥은 여전히 스트림이지만, API가 그것을 추상화해 준 것이다.

1단계: 헤더 도착
  HTTP/1.1 200 OK
  Content-Type: application/json
  Content-Length: 50000

2단계: 바디 조각들이 순차적으로 도착
  { "id": 1, "name": "찰리" ... }

 

XHR - 버퍼 방식

XHR은 네트워크에서 데이터가 도착하는 대로 내부에서 누적한다. 개발자가 중간에 끼어들 방법이 없다.

바디를 전부 받고 나서야 `onload`가 트리거 되고, 그때 `xhr.responseText`로 접근할 수 있다. 이미 완성된 하나의 문자열이다.

`onprogress` 이벤트로 중간 데이터를 받을 수는 있다. 하지만 fetch의 스트림처럼 청크 단위로 깔끔하게 꺼내오는 구조가 아니다. `responseText`에 데이터가 누적되는 방식이라, 이전에 읽은 길이를 기억해 뒀다가 새로 추가된 부분만 잘라내는 계산을 직접 해야 한다. 멀티바이트 문자(한글 등)가 청크 경계에서 잘리면 인코딩이 깨질 수도 있다. 쓸 수는 있지만 사실상 실용적이지 않다.

 

fetch - 스트림 방식

fetch는 스트림 방식이다. `await fetch()`가 resolve 되는 시점에 헤더 정보는 사용할 수 있지만, 바디는 아직 흘러 들어오는 중이다.

const res = await fetch('/api/users/1');
// 이 시점: 헤더는 도착했지만 바디는 아직 오는 중
// res.status, res.headers → 사용 가능
// res.body → ReadableStream

 

바디를 읽으려면 `res.json()`을 호출해야 한다. 이 메서드는 `res.body`라는 파이프에서 모든 청크들을 끝까지 다 읽어 합친 뒤 파싱해서 반환한다. 그래서 `await`가 한 번 더 필요하다.

const data = await res.json();
// 이 시점: 바디 스트림을 끝까지 다 읽고 파싱 완료

 

이게 불편해 보이지만 의도된 설계다. 헤더를 먼저 확인하고 바디를 받을지 말지 결정할 수 있다.

바디의 용량이 엄청 크다면 헤더만 보고 다운로드를 중단할 수 있는 것이다.

더 나아가 청크가 도착할 때마다 즉시 처리하는 것도 가능하다.

ChatGPT처럼 텍스트가 타이핑되듯 보이는 스트리밍 응답이 대표적인 예다.

 

const res = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ message: '안녕' })
});

const reader = res.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  document.getElementById('output').textContent += decoder.decode(value);
}

 

단, 스트림은 한 번만 읽을 수 있다. 파이프에 흐르는 물처럼 한 번 소비하면 끝이다.

const res = await fetch('/api/data');
const text = await res.text();   // 스트림 소비됨
const json = await res.json();   // TypeError: body stream already read

 

`res.status`나 `res.headers`는 영향 없다. 바디만 닫힌다.

두 번 읽어야 하는 상황이라면 소비 전에 `res.clone()`으로 미리 복사해둬야 한다.

const clone = res.clone();
const text = await res.text();   // 원본 소비
const json = await clone.json(); // 복사본 소비

 

Axios - 버퍼 방식

axios는 XHR 기반이라 바디를 전부 받은 다음에야 Promise를 resolve 한다. JSON 파싱까지 끝낸 완성된 객체를 반환한다.

const { data } = await axios.get('/api/users/1');
// data는 이미 파싱된 JS 객체

 

`await` 한 번으로 끝나는 이유다. 편리하지만 스트리밍은 불가능하다. 브라우저 환경에서 axios는 XHR의 한계를 그대로 가져간다.

 

Ky - 스트림 방식이지만 await는 한 번?

Ky는 fetch 기반이라 내부적으로는 fetch와 똑같이 두 단계를 거친다. 그런데 await가 한 번처럼 보인다.

const data = await ky.get('/api/users/1').json();

 

fetch였다면 이렇게 써야 했을 코드다.

const res = await fetch('/api/users/1');
const data = await res.json();

 

ky는 이 두 단계를 `.json()` 메서드 하나로 묶어서 한 번에 처리할 수 있도록 구현했다.

내부에서는 헤더 대기, HTTP 에러 체크, 바디 파싱이 순서대로 실행되고 있지만, 개발자 입장에서는 `await` 한 번으로 끝난다.

Axios → 내부에서 바디를 전부 버퍼링한 후 resolve. 스트림 접근 불가.
Ky    → Promise 체이닝으로 두 단계를 캡슐화. res.body는 여전히 ReadableStream.

 

axios와 ky 모두 `await`가 한 번이지만 내부는 다르다. Axios는 바디를 전부 버퍼링 한 뒤 resolve 한다.

ky는 fetch의 스트림 구조를 그대로 유지하면서 불편함만 없앤 것이다.

Ky에서 `response.body`에 접근하면 여전히 `ReadableStream`을 얻을 수 있는 이유다.

 

3. 에러모델 - 철학의 차이

에러 처리에서 각 라이브러리의 설계 철학이 가장 선명하게 드러난다.

HTTP 4xx / 5xx를 에러로 볼 것인가?

XHR과 fetch는 "서버가 응답했으면 통신은 성공"이라는 관점이다. 에러 처리는 개발자 몫이다.

// XHR — onload 안에서 직접 확인
xhr.onload = () => {
  if (xhr.status >= 400) { /* 에러 처리 */ }
};

// fetch — res.ok로 직접 확인
const res = await fetch('/api');
if (!res.ok) { /* 에러 처리 */ }

 

axios와 ky는 다르다.

"2xx가 아니면 에러"라는 관점으로, HTTP 에러를 자동으로 throw 한다. catch 블록 하나로 모든 에러를 처리할 수 있다.

// Axios
try {
  await axios.get('/api');
} catch (e) {
  // 네트워크 에러든 4xx/5xx든 여기서 처리
}

// Ky
try {
  await ky.get('/api');
} catch (e) {
  // 동일
}

단, 에러를 어떻게 구조화하느냐는 둘이 다르다. 이게 axios와 ky의 가장 큰 철학 차이다.

 

XHR - 콜백에서 throw를 쓸 수 없다.

XHR 에러 모델을 이해하려면 왜 throw를 쓸 수 없는지부터 알아야 한다.

try {
  xhr.onload = () => {
    throw new Error('실패'); // try-catch가 잡을 수 없다
  };
  xhr.send();
} catch (e) {
  // 못 잡음
}

왜 잡히지 않을까?

`xhr.send()`는 요청을 보내고 즉시 반환된다. `onload`는 나중에 서버 응답이 도착했을 때 브라우저가 별도의 호출 스택에서 실행한다. 그 시점에는 try-catch 블록이 이미 끝난 상태다.

throw는 같은 호출 스택 안에서만 전파되기 때문에, 콜백 안에서 던진 에러는 바깥의 try... catch에 닿지 않는다.

콜백은 구조적으로 에러를 throw로 전파할 수 없다. 그래서 XHR은 에러 처리를 위한 이벤트를 상황마다 따로 등록하는 방식으로 대신했다. Promise가 등장한 이유 중 하나도 바로 이 문제다. 

xhr.onload = () => {
  // 서버가 뭘 응답했든 여기 온다. 404, 500도 포함이다.
  if (xhr.status >= 200 && xhr.status < 300) { /* 성공 */ }
  else { /* HTTP 에러 — 직접 처리 */ }
};
xhr.onerror = () => {
  // DNS 실패, TCP 실패, CORS 위반 등
  // xhr.status는 0이고 실제 원인은 코드로 구분할 수 없다
};
xhr.ontimeout = () => { /* 타임아웃 — onerror와 별도 이벤트다 */ };
xhr.onabort = () => { /* 명시적 취소 */ };

네 가지 중 하나라도 빠뜨리면 해당 상황이 조용히 무시된다.

특히 `onerror`만 등록하고 `onload`에서 `status`체크를 빠뜨리는 실수가 많다.

이 경우 4xx/5xx 응답을 성공으로 처리하는 버그가 생긴다.

 

fetch - catch에서 잡히는 에러는 TypeError다.

fetch는 네트워크 실패만 reject 한다. 404든 500이든 서버로부터 응답이 왔으면 resolve다.

try {
  const res = await fetch('/api');
} catch (e) {
  // 여기는 네트워크 단절, CORS, AbortError만 옴
  // 4xx/5xx는 여기서 안 잡힘
}

 

여기서 주의할 점이 있다. catch로 잡히는 에러가 상황마다 타입이 다른데, 대부분 구분이 어렵다.

fetch의 catch로 잡히는 에러는 대부분 TypeError인데, 원인이 전혀 다를 수 있다.

네트워크 단절인지, CORS 문제인지, 잘못된 URL인지 코드로 구분할 방법이 없다. 브라우저마다 message도 달라서 그걸로 파싱 하면 더 위험하다. 가장 흔한 함정은 ok 체크를 빠뜨리는 것이다. 404가 났을 때 서버가 HTML로 에러 페이지를 돌려주는 경우가 많은데, 이걸 `res.json()`으로 파싱 하면 SyntaxError가 난다. 에러 응답인데 파싱 에러처럼 보이는 것이다.

그래서 바디를 읽기 전에 항상 `res.ok`를 먼저 확인하는 습관이 필요하다.

// fetch 에러 처리 실전 패턴
async function safeFetch(url) {
  let res;
  try {
    res = await fetch(url);
  } catch (e) {
    if (e instanceof DOMException && e.name === 'AbortError') {
      throw e; // abort는 그냥 전파
    }
    throw new Error('네트워크 오류'); // TypeError — 원인 불명
  }

  if (!res.ok) {
    // HTTP 에러는 catch가 아닌 여기서 처리
    const body = await res.json().catch(() => null);
    throw new Error(body?.message ?? `HTTP ${res.status}`);
  }

  return res.json();
}

 

fetch는 타임아웃 옵션도 제공하지 않는다. 일정 시간이 지나도 응답이 없으면 그냥 계속 기다린다. 타임아웃이 필요하다면 `AbortController`와 `setTimeout`을 조합해서 직접 구현해야 한다.

async function fetchWithTimeout(url, ms = 5000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    clearTimeout(id);
  }
}

 

 

Axios - AxiosError 단일 클래스

Axios는 모든 에러를 AxiosError 하나로 통일하고 필드로 구분한다.

2xx 아닌 응답은 자동 reject를 하고 `error.response`로 HTTP 에러를, `error.request`로 네트워크 에러를 구분한다.

try {
  const { data } = await axios.get('/api/data');
} catch (error) {
  if (axios.isAxiosError(error)) {
    if (error.response) {
      // 서버가 응답했지만 2xx 아님
      console.log(error.response.status); // 404, 500...
      console.log(error.response.data);   // 에러 body
    } else if (error.request) {
      // 요청은 갔지만 응답 없음 (네트워크 단절)
    }
  }
}

 

isAxiosError?

isAxiosError는 타입이 아니라 타입 가드 함수다. catch 블록에서 잡히는 에러가 항상 AxiosError라는 보장이 없기 때문에 필요하다.

왜 에러 클래스를 여러 개로 나누지 않고 하나로 통일했을까?

XHR의 두 콜백을 Promise 하나로 합치는 과정에서 자연스럽게 이 구조가 생겼다.
자세히 말하자면 XHR의 `onload` 와 `onerror`를 단일 `reject`로 합치는 과정에서 자연스럽게 이 구조가 생겼다. 두 상황을 하나의 `reject`로 합치다 보니, 타입으로 구분하는 대신 필드 유무로 구분하는 구조가 된 것이다.

 

AxiosError의 구조는 다음과 같이 생겼다.

// Axios 내부에서 대략 이런 식으로 만들어짐
class AxiosError extends Error {
  response;  // 서버가 응답했을 때 (HTTP 에러)
  request;   // 요청은 갔는데 응답 없을 때 (네트워크 에러)
  config;    // 원본 요청 설정
  code;      // 'ECONNABORTED', 'ERR_CANCELED' 등
}

 

HTTP 에러와 네트워크 에러를 하나의 타입에 담되, 어떤 상황인지는 프로퍼티로 구분하는 구조다.

catch (error) {
  if (error.response) {
    // response 있음 → 서버가 응답은 했는데 2xx 아님 (HTTP 에러)
  } else if (error.request) {
    // request만 있고 response 없음 → 네트워크 에러
  }
}

isAxiosError가 필요한 이유

catch에 잡히는 에러가 항상 AxiosError라는 보장이 없다.

try {
  await axios.get('/api');
} catch (error) {
  // error가 AxiosError일 수도 있고
  // 내 코드에서 throw한 일반 Error일 수도 있고
  // JSON.parse 실패한 SyntaxError일 수도 있음

  error.response.status // ← 타입 모르는 채로 접근하면 TS 에러
}

 

그래서 `isAxiosError`로 먼저 좁혀준다.

catch (error) {
  if (axios.isAxiosError(error)) {
    // 이 블록 안에서 error는 AxiosError로 타입 확정
    error.response?.status // 안전하게 접근 가능
  }
}

ky - 에러 클래스 분리

Ky는 fetch 기반이지만 Axios처럼 2xx가 아닌 응답은 자동으로 throw 한다.

그리고 Axios의 인터셉터와 같은 역할을 하는 hooks를 제공한다.

hooks에는 요청 전, 응답 후 등 여러 시점이 있는데, 실무에서 가장 많이 쓰이는 패턴은 토큰 갱신이다.

let refreshPromise = null;

const api = ky.create({
  prefixUrl: '/api',
  timeout: 10000,
  retry: { limit: 2, statusCodes: [503] }, // 401은 retry 대상에서 제외

  hooks: {
    beforeRequest: [
      (req) => {
        // 모든 요청이 나가기 전에 토큰을 헤더에 붙인다
        req.headers.set('Authorization', getToken());
      }
    ],
    afterResponse: [
      async (req, opts, res) => {
        if (res.status === 401) {
          // 여러 요청이 동시에 401을 받아도 갱신은 한 번만 한다
          if (!refreshPromise) {
            refreshPromise = ky.post('auth/refresh').json()
              .finally(() => { refreshPromise = null; });
          }
          const { token } = await refreshPromise;
          setToken(token);
          req.headers.set('Authorization', token);
          return ky(req); // 갱신된 토큰으로 원래 요청 재시도
        }
      }
    ]
  }
});

 

retry 설정에서 401을 제외하는 게 중요하다. 401이 retry 대상에 포함되면 토큰 갱신 전에 재시도가 먼저 일어나서 무한 401이 발생한다.

 

ky는 에러를 종류별로 다른 클래스로 만들어서 던지는데, 이를 `instanceof`로 구분한다.

import { HTTPError, TimeoutError } from 'ky';

try {
  await ky.get('/api/data');
} catch (e) {
  if (e instanceof HTTPError) {
    // HTTP 에러 (4xx/5xx)
    e.response.status
  } else if (e instanceof TimeoutError) {
    // 타임아웃
  } else if (e.name === 'AbortError') {
    // 취소
  }
}

 

그럼 ky에서는 모든 에러 타입이 클래스로 이미 지정되어 있나?

`KyError`라는 베이스 클래스가 있고, `HTTPError`, `NetworkError`, `TimeoutError`, `ForceRetryError` 네 개가 이걸 상속한다.

KyError                  // 베이스 클래스
  ├── HTTPError          // 2xx 아닌 응답
  ├── NetworkError       // DNS 실패, 연결 거부, 오프라인
  ├── TimeoutError       // 타임아웃 초과
  └── ForceRetryError    // afterResponse hook에서 강제 retry 시

 

사용할 때는 이렇게 구분한다.

import ky, { HTTPError, NetworkError, TimeoutError } from 'ky';

try {
  await ky.get('/api/data');
} catch (e) {
  if (e instanceof HTTPError) {
    // 서버가 응답했지만 4xx/5xx
    e.response.status;
  } else if (e instanceof NetworkError) {
    // DNS 실패, 네트워크 단절
  } else if (e instanceof TimeoutError) {
    // 타임아웃
  }
}

 

아니면 "ky에서 발생한 에러인지"만 확인하고 싶으면 isKyError() 타입 가드로 한 번에 좁힐 수도 있다.

import { isKyError } from 'ky';

if (isKyError(e)) {
  // HTTPError, NetworkError, TimeoutError, ForceRetryError 전부 해당
}

 

Axios랑 비교하면 설계 철학 차이가 명확하다.

Axios → AxiosError 하나에 다 때려넣고 e.response / e.request 프로퍼티로 구분
ky    → 상황별로 클래스를 분리해서 instanceof로 구분

 

저 에러 클래스를 사용시에 import 해줘야하는 번거로움이 발생하지만 상황별로 타입이 명확하게 분리되어 있다.

 

 

레이스 컨디션 — 인터셉터와 토큰 갱신

Axios 인터셉터로 만료된 토큰을 자동 갱신하는 패턴에서 레이스 컨디션이 발생할 수 있다. 꽤 유명한 버그 패턴이다.

문제 - 인터셉터 실행 컨텍스트는 요청마다 독립적이다

세 요청 A, B, C가 동시에 나가는데 셋 다 만료된 토큰을 들고 있다.

t=0ms  A 인터셉터 진입 → refreshToken() 호출 시작
t=0ms  B 인터셉터 진입 → refreshToken() 호출 시작  ← A 완료 안됐는데 이미 시작
t=0ms  C 인터셉터 진입 → refreshToken() 호출 시작  ← 얘도

t=100ms A 완료 → 새 토큰으로 갱신 성공
t=100ms B 완료 → 이미 무효화된 토큰으로 갱신 시도 → 실패
t=100ms C 완료 → 마찬가지로 실패

 

왜 B, C가 A를 기다리지 않을까?

handleError(A_error); // 각자 독립 실행
handleError(B_error); // A의 await와 아무 관계없음
handleError(C_error); // 마찬가지

 

 

await는 자기 실행 컨텍스트 안에서만 효력이 있다.

A가 await로 멈춰있는 그 시간에 B, C는 이미 인터셉터에 진입해서 각자 `refreshToken()`을 호출하고 있다.

해결 - 갱신 Promise를 공유한다

let refreshPromise = null; // 함수 바깥에 - 3개 실행이 공유

api.interceptors.response.use(
  res => res,
  async error => {
    if (error.response?.status !== 401) throw error;

    if (!refreshPromise) {
      // 첫 번째 요청만 실제로 갱신 요청을 보낸다
      refreshPromise = refreshToken().finally(() => {
        refreshPromise = null;
      });
    }

    // A, B, C 모두 같은 Promise를 await한다
    // Promise는 한 번 settled되면 몇 번을 .then()해도 같은 값을 반환한다
    await refreshPromise;
    return api.request(error.config); // 원래 요청 재시도
  }
);

 

 

핵심은 두 가지다.

  1. refreshPromise 변수가 함수 바깥에 있어야 한다. 함수 안에 있으면 실행마다 새로 만들어진다.
  2. Promise의 불변성을 활용한다. Promise는 한 번 완료되면 몇 번을 await해도 같은 값을 반환한다.
const p = refreshToken(); // Promise 하나

await p; // A — 실제로 갱신 요청 나감
await p; // B — 이미 완료됐으면 즉시 resolve
await p; // C — 동일

 

 

갱신 요청은 딱 한 번만 나가고, 나머지는 그 결과를 공유한다.

  • 주의할 점: retry 기본값의 statusCodes에 401이 포함되면 갱신 전에 재시도가 먼저 일어나서 무한 401이 된다. Ky의 hooks 방식도 같다.
  • fetch에서 이 문제가 덜 발생하는 이유: fetch 자체가 뭔가를 막아서가 아니라, interceptor라는 중앙 제어 레이어가 없어서 구조적으로 병렬 갱신 시나리오가 만들어지기 어려운 것이다. fetch로 토큰 자동 갱신을 직접 구현하면 똑같은 문제가 생기고 똑같은 해결책을 써야 한다.

에러 타입 한눈에 보기

// XHR
xhr.onerror   → 네트워크
xhr.ontimeout → 타임아웃
xhr.onload + status 체크 → HTTP 에러

// fetch
e.name === 'AbortError'  → 취소
e.name === 'TypeError'   → 네트워크
// HTTP 에러는 catch 안 옴 → ok 체크로 수동 처리

// Axios
axios.isAxiosError(e)        → Axios 에러 타입 가드
e.response                   → HTTP 에러 (서버 응답 있음)
e.request && !e.response     → 네트워크 에러
e.code === 'ECONNABORTED'    → 타임아웃
e.code === 'ERR_CANCELED'    → 취소

// ky
e instanceof HTTPError    → HTTP 에러
e instanceof TimeoutError → 타임아웃
e.name === 'AbortError'   → 취소

 

4. 그래서 뭘 써야 하나

왜 요즘 대세가 axios인가?

Axios는 2014년에 나와서 오랫동안 사실상 표준으로 쓰였다. 이유가 있다.

  1. 인터셉터로 공통 로직을 한 곳에 모을 수 있다. 인증 토큰 주입, 에러 공통 처리, 로깅을 요청마다 직접 쓰지 않아도 된다.
  2. 자동 JSON 처리로 직렬화와 Content-Type 설정을 신경 쓰지 않아도 된다.
  3. 4xx/5xx도 catch로 처리할 수 있어서 팀 전체가 일관된 에러 처리 구조를 갖기 쉽다.
  4. 브라우저에서는 XHR, Node.js에서는 http 모듈을 사용해서 같은 코드가 두 환경에서 동작한다.

axios의 한계

그런데 Axios에 취약한 상황이 명확하게 있다.

Streaming: 브라우저 환경에서 Axios는 XHR 기반이라 스트리밍을 제대로 다룰 수 없다. ChatGPT 같은 스트리밍 응답, 대용량 파일 진행률 표시가 필요하다면 fetch나 Ky를 써야 한다.

Service Worker: Axios의 XHR 요청은 Service Worker의 fetch 이벤트를 발생시키지 않는다. MSW(Mock Service Worker)로 API를 모킹 할 때 Axios 요청은 가로채지 못한다. PWA 오프라인 캐싱에서도 Axios 요청은 제외된다.

 

상황별 선택 기준

  • Axios: 팀 프로젝트에서 일관된 에러 처리와 인터셉터 구조가 필요할 때. Node.js와 브라우저를 같은 코드로 다뤄야 할 때.
  • fetch: 스트리밍 응답을 세밀하게 제어해야 할 때. 외부 의존성을 최소화해야 할 때. Service Worker와 완전히 통합해야 할 때.
  • Ky: fetch의 불편함(수동 에러 처리, 2중 await)을 없애고 싶은데 fetch 생태계(`Service Worker`, `Streaming`)와 호환성도 유지하고 싶을 때. 번들 사이즈에 민감할 때. (Ky는 약 4KB, Axios는 약 14KB)
  • XHR을 직접: 파일 업로드 진행률을 세밀하게 제어해야 할 때(onprogress). 

 

마무리

 

글 처음에 던진 질문으로 돌아가 보자.

왜 fetch는 await를 두 번 써야 할까?

 

fetch는 헤더가 도착하는 순간 개발자에게 제어권을 넘긴다. "헤더는 왔어, 바디는 네가 직접 읽어."

Axios는 바디까지 전부 받아서 파싱까지 끝낸 다음에 건네준다. "다 됐어, 여기."

Ky는 fetch처럼 동작하면서도 Axios처럼 편하게 쓸 수 있도록, 그 불편함만 얇게 감싼 것이다.

이 차이가 이 글에서 다룬 모든 것들의 근원이다.

어떤 라이브러리가 더 좋다기보다, 각 라이브러리가 어떤 문제를 해결하기 위해 어떤 설계를 선택했는지를 이해하면 상황에 맞는 선택을 할 수 있다.

'프론트엔드' 카테고리의 다른 글

Toss Frontend Fundamentals 실전 적용 - 반환값 18개짜리 훅 해체하기  (0) 2026.05.25
내 손으로 만든 Toss Frontend Fundamentals 코치  (2) 2026.05.25
[Web Storage] localStorage 파헤치기  (1) 2026.04.14
[Web API] Intersection Observer API  (0) 2026.04.11
Network 탭으로 읽는 HTTP  (0) 2026.03.30
'프론트엔드' 카테고리의 다른 글
  • Toss Frontend Fundamentals 실전 적용 - 반환값 18개짜리 훅 해체하기
  • 내 손으로 만든 Toss Frontend Fundamentals 코치
  • [Web Storage] localStorage 파헤치기
  • [Web API] Intersection Observer API
yun_cic
yun_cic
  • yun_cic
    체대생의 개발 기록
    yun_cic
  • 전체
    오늘
    어제
    • 분류 전체보기 (43) N
      • 백엔드 (1)
      • 프로젝트 (5)
      • etc (6)
      • 대외활동 (1)
      • 강의자료 (5)
      • 프론트엔드 (13)
        • Language (4)
        • Library (2)
      • 우테코 (12) N
        • 회고 (6) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
    • 포트폴리오 페이지
  • 공지사항

  • 인기 글

  • 태그

    해커톤
    우테코 8기
    크롤링
    fastapi
    우아한테크코스 8기
    fe
    Python
    백엔드
    크몽
    todo
    외주
    MySQL
    Crawling
    Selenium
    bs4
    채널톡
    메모
    KUCC
    개발자 #코딩 #체대생
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
yun_cic
XHR부터 Ky까지 HTTP 클라이언트 라이브러리 파헤치기
상단으로

티스토리툴바