브라우저의 HTTP 통신 방식은 XHR(XMLHttpRequest)과 fetch가 있으며, 이들을 더 편하게 쓰려고 만든 wrapper(라이브러리)는 XHR을 기반으로 한 axios, fetch를 기반으로 한 ky가 있다.
이 글에서는 각 방식의 문법에 대해서는 다루지 않는다.
각 방식이 내부적으로 어떤 구조 위에서 동작하는지, 비동기 처리를 어떤 모델로 다루는지, 그리고 에러를 어떤 방식으로 해석하고 전달하는지를 중심으로 살펴보겠다.
XHR은 이벤트/콜백 기반 모델로 가장 초기의 비동기 모델이다. XHR은 Promise라는 개념이 없던 시절에 만들어졌기 때문에 Promise처럼 결과를 기다리는 구조와 다르다. XHR에서는 요청 하나를 담당하는 객체가 내부적으로 자신의 상태를 계속 바꾸며 진행되고, 개발자는 그 상태 변화를 이벤트를 통해 감시한다. 즉, XHR은 응답을 한 번에 반환하는 모델이 아니라, 요청 객체의 상태가 바뀌는 과정을 따라가며 반응하는 모델이다.
XHR은 readyState라는 값을 통해 현재 요청이 어느 단계에 있는지를 나타낸다. 요청은 한 번에 완료되지 않고 여러 단계를 거치며 진행된다. open()으로 요청을 초기화한 뒤 send()를 호출하면 실제 네트워크 요청이 시작되고, 이후 상태가 바뀔 때마다 readystatechange 이벤트가 발생한다. 그리고 요청이 정상적으로 완료되면 마지막에 load 이벤트가 발생한다.
- 0단계: UNSENT
- 1단계: OPENED (서버 접속됨)
- 2단계: HEADERS_RECEIVED (헤더 도착)
- 3단계: LOADING (데이터 다운로드 중)
- 4단계: DONE (끝)
Q.
readyState값을 추적하며 요청이 완료되었다는 것을 감시하는 것은 너무 불편하지 않을까?A. 과거에는
onreadystatechange에서 개발자가 직접 모든 단계 감시했지만, 현재는onload를 사용하여 요청이 완료된 뒤 실행할 동작만 작성하면 되므로, 상태 변화를 직접 추적하는 부담이 줄어든다.
콜백 지옥을 해결하고 현대 웹 표준으로 자리 잡은 비동기 모델이 바로 fetch이다. XHR이 상태 변화를 이벤트로 감시하는 모델이라면, fetch는 응답을 하나의 값으로 다루는 모델이다.
fetch는 요청을 보내면 즉시 Promise를 반환한다.
이 Promise는 네트워크 요청이 완료되면 Response 객체로 resolve된다. 이 구조 덕분에 콜백을 중첩해서 작성할 필요 없이, then이나 async/await을 통해 흐름을 직선적으로 구성할 수 있다.
중요한 점은 fetch가 XHR처럼 요청의 중간 상태를 직접 노출하지 않는다는 것이다. XHR은 readyState 변화에 따라 요청의 진행 과정을 관찰할 수 있지만, fetch는 완료된 응답을 중심으로 동작한다.
fetch에서 await를 두 번 사용하는 이유는 응답을 두 단계로 나누어 처리하기 때문이다.
const response = await fetch(url);
const data = await response.json();
이 흐름은 다음과 같이 나눌 수 있다.
첫 번째 await (fetch)
a. 서버로부터 응답의 메타데이터를 받는다
b. 여기에는 상태 코드, 헤더 등이 포함된다
c. 이 시점에서는 body 데이터가 아직 완전히 읽히지 않았을 수 있다
두 번째 await (response.json)
a. Response 객체의 body 스트림을 끝까지 읽는다
b. 데이터를 하나의 문자열로 합친다
c. JSON.parse를 수행한다
d. 최종 데이터를 반환한다
body 데이터의 크기가 수백 MB, GB라고 가정하면 데이터를 다 받을 때까지 기다리지 않고 헤더를 먼저 보고 판단할 수 있기 때문이다.
XHR과 fetch의 차이는 단순한 문법이 아니라, 데이터를 다루는 방식에서부터 갈린다. 이 차이는 이후 axios, ky 같은 라이브러리의 동작 방식에도 그대로 영향을 준다.
XHR은 응답을 responseText로 제공하는데, 이 값은 이미 완전히 다운로드된 문자열이다.
즉, 서버로부터 데이터를 모두 수신한 뒤, 메모리에 하나의 큰 문자열로 저장하고, 그 결과를 한 번에 제공한다.
서버는 원래부터 데이터를 조각(chunk)으로 보내는데, XHR은 조각들을 모두 모아서 한 번에 준다.
const text = xhr.responseText;
const data = JSON.parse(text);
이 구조는 이해하기 쉽지만, 데이터가 클 경우 메모리를 많이 사용하고, 중간 처리나 스트리밍 처리가 어렵다.
fetch는 응답을 바로 문자열로 주지 않고 Response 객체로 감싼다.
const response = await fetch(url);
이때 response.body는 스트림으로 구성되어 있다. 즉, 데이터가 한 번에 오는 것이 아니라 여러 조각으로 나뉘어 전달된다. 이 구조에서는 데이터를 받는 과정과 데이터를 사용하는 과정이 분리된다.

버퍼 기반 모델은 데이터를 모두 수신한 뒤 하나의 결과로 제공하는 방식이고, 스트림 기반 모델은 데이터를 조각 단위로 수신하면서 도착하는 즉시 처리할 수 있는 방식이다.
앞에서 본 것처럼 fetch의 응답 body는 스트림이다. 따라서 실제 데이터를 사용하려면 이 스트림을 끝까지 읽고, 문자열로 변환한 뒤, 다시 JSON으로 파싱하는 과정이 필요하다. fetch에서는 이 과정을 response.json()이 담당한다. 다만 fetch의 json()은 어디까지나 응답 body를 소비하고 파싱하는 메서드일 뿐이다. HTTP 상태 코드가 400이나 500이어도 자동으로 에러를 던지지 않는다.
반면 ky의 json()은 역할이 다르다. 정확하게 말하면 역할의 범위가 다르다. ky는 fetch 기반 라이브러리이지만, HTTP 상태 코드가 4xx/5xx이면 에러를 던진다. 즉 ky의 json()은 body를 파싱하는 역할을 하지만 요청 실패 여부까지 담당한다고 볼 수 있다.
const data = await ky.get('users/1').json();
위 메서드는 ky가 반환하는 ResponsePromise 객체의 내부 메서드인 json()이다. 즉, 오버로딩이 아닌 따로 ky가 구현한 메서드이다.
fetch의 json()은 응답 body 스트림을 소비하고 JSON으로 파싱하고, ky의 json()은 요청 실패 여부까지 추가로 다룬다고 볼 수 있다.
그렇다면 같은 이름을 쓰지만 왜 역할이 다를까?
그 이유는 바로 설계 철학의 차이이다. fetch는 웹 표준 API이기 때문에 특정 방식을 강제하기보다는 개발자가 직접 결정하고 처리할 수 있도록 자유도를 높여준다. 반면 ky는 라이브러리이므로 반복 작업을 줄이고 사용 편의성을 높이는 방향으로 설계되었다.
여기까지 보면 ky는 fetch의 불편한 부분을 줄여주어 편의성을 높여주는 라이브러리라는 점을 알 수 있다. 그렇다면 많은 프로젝트에서 사용되는 axios는 어떤 방식으로 요청을 처리할까?
axios도 ky와 마찬가지로 HTTP 요청을 더 편하게 다루기 위한 라이브러리이다. 하지만 axios와 ky는 출발점이 다르다고 볼 수 있는데, 그 이유는 axios는 XHR을 기반으로 한 라이브러리이기 때문이다.
Q. 왜 axios는 XHR을 기반으로 만들었을까?
A. fetch보다 axios가 먼저 등장했기 때문이다.
axios 또한 단순히 응답을 JSON으로 파싱하는 역할을 넘어서, 요청을 보내기 전과 응답을 받은 후의 흐름을 가로채고 조작할 수 있는 구조를 제공한다. 이 기능이 바로 interceptor이다.
interceptor는 말 그대로 요청이나 응답을 중간에서 가로채는 기능이다. axios에서는 요청이 서버로 전송되기 전이나 응답이 전달되기 전에 특정 로직을 실행시킬 수 있다.
interceptor를 사용하면 요청과 응답의 흐름을 한 곳에서 통제할 수 있다. 개별 API 호출마다 같은 로직을 반복해서 작성하지 않고, 공통 처리를 중앙에서 관리할 수 있다.
대표적으로 다음과 같은 작업을 할 수 있다.
인증 토큰 자동 추가
Authorization 헤더를 붙이는 작업을 한 번에 처리할 수 있다.공통 에러 처리
토큰 갱신 로직
로깅 및 디버깅
즉, interceptor는 단순히 요청을 보내는 도구가 아니라, HTTP 요청/응답 흐름 전체를 중앙에서 제어할 수 있게 해주는 구조이다.
하지만 비동기 작업이 interceptor 안에 들어가면 문제가 생길 수 있다. 대표적인 문제가 race condition이다.
예를 들어 요청 interceptor에서 access token 만료 여부를 확인하고, 필요하면 refresh token으로 토큰을 갱신한다고 해보자.
client.interceptors.request.use(async (config) => {
const token = await refreshTokenIfNeeded();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
이 상태에서 여러 요청이 동시에 발생하면 각 요청은 독립적으로 interceptor를 통과한다.
const p1 = client.get('/api/users');
const p2 = client.get('/api/posts');
const p3 = client.get('/api/comments');
해당 로직의 내부 흐름은 다음과 같이 진행된다.
p1 요청 → 인터셉터 진입 → refreshTokenIfNeeded() 실행 중...
p2 요청 → 인터셉터 진입 → refreshTokenIfNeeded() 또 실행 중...
p3 요청 → 인터셉터 진입 → refreshTokenIfNeeded() 또 실행 중...
해당 로직의 문제는 무엇일까?
바로 토큰 갱신이 세 번 동시에 일어나는 것이다. 세 요청이 같은 만료된 토큰을 기준으로 동시에 갱신을 시도할 수 있다는 것이 문제이다.
여러 요청이 동시에 토큰 만료 상태를 감지하면, 각 요청이 서로 독립적으로 토큰 갱신을 시도하게 된다. 이 과정에서 동일한 갱신 요청이 중복으로 발생하고, 공유 상태인 토큰이 비동기적으로 덮어써지면서 race condition이 발생할 수 있다.
핵심은 갱신 중인데 서로 모른다는 것이다. 그림으로 시각화하면 다음과 같다.

이제 마지막으로 각 방식이 에러를 어떻게 바라보는지 비교해보자.
두 방식 모두 4xx/5xx 응답이 와도 catch가 실행되지 않는다. 두 방식 입장에서 중요한 것은 응답을 받았는가이다. 서버가 404를 보내더라도, 서버로부터 응답을 받은 것이기 때문에 실패로 바라보지 않는 것이다.
즉, XHR과 fetch는 HTTP 에러를 실패라기보다 응답으로 본다.
따라서 개발자가 직접 상태 코드를 확인해야 한다.
XHR
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText);
} else {
console.error('HTTP Error:', xhr.status);
}
};
fetch
const response = await fetch('/api/users/1');
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const data = await response.json();
axios와 ky는 라이브러리이다. 따라서 이들은 반복 작업을 줄이는 것이 설계 철학이므로 HTTP 에러를 에러로 처리한다.
하지만 여기서 두 라이브러리의 에러 타입에서 차이가 존재한다.
axios는 대부분의 에러를 AxiosError 하나로 감싼다.
async function fetchUser() {
try {
const response = await axios.get<{ name: string }>('/api/users/1');
} catch (error) {
if (error instanceof AxiosError) {
if (error.response) {
// 서버가 응답을 반환한 경우 (HTTP 에러)
console.log('HTTP Error:', error.response.status);
} else if (error.request) {
// 요청은 보냈지만 응답이 없는 경우 (네트워크 에러)
console.log('Network Error');
} else {
// 요청 설정 중 에러
console.log('Config Error:', error.message);
}
} else {
console.error('Unknown error', error);
}
}
}
ky는 상황에 따라 에러 타입을 분리한다.
async function fetchUser() {
try {
const data = await ky.get('/api/users/1').json<{ name: string }>();
} catch (error) {
if (error instanceof HTTPError) {
// HTTP 상태 코드 에러
console.log('HTTP Error:', error.response.status);
} else if (error instanceof TimeoutError) {
// 타임아웃 에러
console.log('Timeout Error');
} else if (error instanceof DOMException && error.name === 'AbortError') {
// 요청 취소
console.log('Abort Error');
} else {
console.error('Unknown error', error);
}
}
}
XHR
- HTTP 404/500: onload에서 status로 직접 확인
- 네트워크 에러: onerror
fetch
- HTTP 404/500: resolve된 Response에서 ok/status로 직접 확인
- 네트워크 에러: reject
axios
- HTTP 404/500: AxiosError로 reject
- 네트워크 에러: AxiosError로 reject
ky
- HTTP 404/500: HTTPError로 reject
- timeout: TimeoutError
- abort: AbortError
지금까지 XHR, fetch, axios, ky가 HTTP 요청을 어떻게 처리하는지 살펴봤다.
이 네 가지는 단순히 문법이나 사용 방식의 차이가 아니라, 비동기 모델과 에러를 해석하는 방식, 그리고 설계 철학에서 차이가 있다.
정리하면 다음과 같다.
또한 에러를 바라보는 관점에서도 차이가 있다.
결국 어떤 방식을 사용할지는 상황에 따라 달라진다.
이 글에서 살펴본 차이들은 단순한 구현 방식의 차이가 아니라, 얼마나 제어를 직접 할 것인가 vs 얼마나 추상화에 맡길 것인가에 대한 선택이라고 볼 수 있다.
따라서 특정 방식이 더 좋다고 하기보다는, 각 방식이 어떤 철학 위에서 설계되었는지를 이해하고 상황에 맞게 선택하는 것이 중요하다.