글쓰는데 일주일 걸린거 실화인가요
오늘의 메뉴는 원정대 활동에서 진행한 HTTP Client 변천사이다. 원정대 활동을 하면서 방향성에 대해 계속 흔들렸는데, 이 주제는 꽤 마음에 들었다. 깊이 생각해볼 부분도 많고, 특히 공유회에서 많은 insight를 얻을 수 있었기 때문이었다.
이 글은 당연하겠지만 프론트엔드 개발자에게 보여주고 싶은 글이다. 프론트엔드 개발자가 아니라면 이해하기도 어렵고, 실제 적용할 순간도 딱히 없을 것 같다는 생각도 들었다. 어느 정도 네트워크와 통신 방식에 대해서 알고 있는 사람들에게 추천한다.
클라이언트의 종류를 대표적으로 XHR, fetch, axios, ky 총 네 가지로 정했고, 각각에 대해 흐름과 철학을 정리해봤다. 또한 우리는 이 네 가지에 대해서 학습할 때 전부 다 하는 것이 아니라 특정 키워드로 학습을 진행했다.
내가 학습한 내용을 모두 정리하기에는 양이 너무 많기 때문에, 내가 학습하면서 흥미를 느꼈던 내용 위주로 정리해보고자 한다.
글을 읽고 나서 당신은 우리가 정한 두 가지 키워드에 대해 각각 딥다이브를 해볼 수 있을 것이라고 생각한다.
아예 모르는 상태에서 보면 시기상 순서는 다음과 같을 것이다.
XHR -> fetch -> axios -> ky
나도 그랬고, 우리 원정대 모두가 이렇게 알고 있었다. 근데 충격적이게도 fetch보다 axios가 더 먼저 나왔다고 한다. (아지가 알려줬다)
내 친구 제미나이의 도움을 받아 등장 시기와 내용을 정리해봤다.
등장 시기를 보면 axios가 fetch보다 먼저이고, 그 말은 즉, axios는 fetch 기반이 아닌 xhr 기반이라는 말이다. 그럼 왜 우리가 axios가 fetch보다 이후에 등장한 것이라고 왜 당연하게 생각했을까? 이유는 여러 가지가 있다.
실무적/철학적 관점에서의 이유
- 보통 우리가 통신 순서를 fetch -> axios 순서로 공부하기 때문
- fetch의 큰 불편함을 axios가 해결하기 때문
- IE(Internet Explorer)에서 fetch가 동작하지 않았기 때문
- axios의 철학 중 하나로, 브라우저와 Node.js 환경 어디서든 같은 코드로 통신하게 했기 때문
위 기술적/철학적 이유를 보면, axios가 fetch보다 더 최신 것이라고 생각한 이유는 axios가 시대를 앞서간 명작(?)이었기 때문임을 볼 수 있다. 따라서 편의상 글에서는 클라이언트 설명 순서를 XHR, fetch, axios, ky 순서로 진행하겠다.
등장 시기와 내용을 보면 알 수 있겠지만, 어떤 점을 개선하고 싶었는지, 어떤 목적을 가졌는지에 대한 이유는 모두 그들의 철학에서 나타난다.
클라이언트 각각을 학습하면서 '왜 이렇게 구현했지?'라는 질문이 자주 떠올랐다. 이 질문에 대한 답변은 딱 한 개이다.
그들의 철학이 모두 다르기 때문이다.
나는 이유가 철학 때문이라고 들었을 때, 처음에는 솔직히 이해가 되지 않았다. 프로그래밍에 무슨 철학? 이렇게 기계적이고 정해져있는 분야에서 무슨 철학이 필요한가 생각이 들었다.
하지만 원정대 활동을 진행하고 학습하면서 철학이 생각보다 중요하다는 사실을 깨닫게 되었다. 사소한 것 하나에서 왜 그렇게 코드로 구현했는지 이유를 찾고 찾다 보면 결국 그들의 '철학'으로 답이 모여졌다.
어떤 선택을 해야 하는지, 어떤 결정을 내려야 하는지가 모두 철학이라는 이름의 길을 따라갔다. 나는 이런 관점이 매우 흥미로우면서도 신기했다. 그만큼 그들은 그들의 철학을 코드에 정말 잘 녹여냈다고 생각했다.
먼저 간단하게 각각의 클라이언트가 어떤 철학을 가졌는지 설명하고 아래에서 자세히 정리해보겠다.
1. XMLHttpRequest
통신의 시작부터 끝까지 모든 과정을 이벤트로 쪼개서 개발자가 직접 제어하겠다는
통제광철학2. fetch
HTTP 표준을 있는 그대로 브라우저에 매핑해서 네트워크 계층과 앱 계층을 완벽히 분리하겠다는
원론주의자철학.3. Axios
귀찮은 파싱, 직렬화, 에러 처리(4xx, 5xx) 등을 DX 향상을 목표로 하는
실용주의자철학4. ky
웹 표준(fetch)의 순수함은 유지하되, 개발자 경험(DX)을 해치는 단점만 해결하겠다는
미니멀리스트철학
그럼 철학이 어떻게 코드에 영향을 주었는지에 대해 자세히 살펴보자.
첫 번째는 XHR이다. XHR의 철학은 모든 것을 제어하겠다는 통제광 철학이라는데, 왜 이러한 철학을 가지게 됐을까?
1999년 XHR 등장 이전에는 버튼 하나만 눌러도 화면 전체가 하얗게 깜빡이며 새로고침되던 웹 1.0 시대였습니다.
위와 같은 시대적 배경에서, 모든 것이 재렌더링되는 것을 막기 위해서 XHR이 등장했다. 하지만 당시 JS에는 Promise나 async/await같은 비동기 개념이 존재하지 않았고, 따라서 JS가 할 수 있는 비동기 처리라고는 사용자의 클릭이나 마우스 이동같은 DOM 이벤트를 감지하는 것뿐이었다.
그래서 XHR 설계자들은 네트워크 통신도 DOM 이벤트처럼 취급했다.
"통신의 매 순간순간(시작, 진행 중, 완료, 에러)을 전부 이벤트로 쪼개서 알려줄 테니, 개발자가 이벤트 리스너를 달아서 직접 통제해라!"
결론적으로 위와 같은 배경과 설계로 인해 통제광 철학이 탄생하게 된 것이다.
// 1. 인스턴스 생성
const xhr = new XMLHttpRequest();
// 2. 통신의 모든 생명주기를 감시하는 이벤트 리스너
xhr.onreadystatechange = function() {
// readyState는 0(안보냄)부터 4(완료)까지 변함.
// 개발자가 이 숫자를 직접 확인해서 흐름을 통제해야 함.
if (xhr.readyState === 1) {
console.log('서버 연결됨 (OPENED)');
} else if (xhr.readyState === 3) {
console.log('데이터 받는 중... (LOADING)');
} else if (xhr.readyState === 4) {
// 3. 완료되어도 HTTP 상태 코드를 직접 검사해야 함
if (xhr.status >= 200 && xhr.status < 300) {
console.log('성공! 데이터:', xhr.responseText); // 파싱도 수동
} else {
console.log('HTTP 에러 발생:', xhr.status);
}
}
};
// 4. 세팅을 끝내고 네트워크 연결 및 요청
xhr.open('GET', 'https://api.example.com/data');
xhr.send();
1999년 당시 XHR이 등장할 때의 찐 노가다 기본 구조이다. 이후 2008년에 기본 구조가 바뀌긴 하는데, 우선 가장 처음 코드로만 살펴보자.
1. xhr 인스턴스 생성
const xhr = new XMLHttpRequest();
이 한 줄은 브라우저에게 네트워크 통신을 전담할 객체를 하나 만들어 달라고 명령하는 것이다. 비유하자면, 나를 대신할 우체부를 고용하는 것이다.
하지만 막 생성한 xhr 객체 안에는 통신에 필요한 상태값과 기능들이 텅 비어있다. 그래서 일일히 이벤트리스너와 통신 연결을 직접 수행해야 한다.
2. onreadystatechange() 메서드
xhr.onreadystatechange = function() {
if (xhr.readyState === 1) {
...
} else if (xhr.readyState === 3) {
...
} else if (xhr.readyState === 4) {
...
}
};
초기 xhr의 핵심이라고 할 수 있는 부분이다. on으로 시작하는 메서드는 모두 이벤트 리스너이므로, onreadystatechange() 메서드는 자신의 상태가 변할 때마다 주입한 function()을 실행한다.
xhr의 상태는 총 5단계로 나뉘는데,
0 (UNSENT): 인스턴스 생성, 아직 아무것도 진행되지 않은 상태
1 (OPENED): open() 호출, 내부적으로 설정값만 세팅된 상태 (아직 네트워크 요청 X)
2 (HEADERS_RECEIVED): 서버에 도착해서, 서버가 준 응답 헤더와 상태 코드를 받은 상태.
3 (LOADING): 서버가 주는 본문 데이터(Body)를 다운로드하고 있는 상태
4 (DONE): 데이터 다운로드가 종료된 상태
이 함수는 1, 2, 3, 4 상태로 변할 때마다 계속해서 호출된다. 그래서 내부에서 if (xhr.readyState === 4)처럼 분기 처리를 해줘야만 쓸데없는 로직 실행을 막을 수 있다.
3. if (xhr.status >= 200 && xhr.status < 300)
if (xhr.status >= 200 && xhr.status < 300) {
...
} else {
...
}
readyState가 4가 되었다는 것은 네트워크 통신이 완료되었다는 뜻이다. 하지만 완료되었다고 해서 내가 원하는 데이터인지, 아니면 404와 500같은 HTTP 에러인지 확인할 수 없다.
따라서 개발자는 status를 확인해서 일일히 검사해야 하며, 심지어 네트워크 에러의 경우도 아래 else로 들어가기 때문에 HTTP 에러와 네트워크 에러가 섞여버리는 문제도 발생했다.
4. xhr.responseText
console.log('성공! 데이터:', xhr.responseText);
xhr.responseText는 서버가 보내준 진짜 본문 데이터가 담겨있는 속성이다. responseText는 JSON이 아닌 문자열(Text) 형태로 존재한다.
🔍 왜 이름이 responseJSON이 아니라 responseText일까?
1999년에는 JSON이라는 포맷이 대중화되지 않았기 때문에 대부분 단순한 텍스트나 XML 형식으로 데이터를 주고받았다.
그래서 서버가 JSON을 보내주더라도 이 안에는 그냥 거대한 하나의 문자열(String)로 들어있기 때문에, 직접JSON.parse(xhr.responseText)를 사용해야만 비로소 자바스크립트 객체로 변한다.
xhr.open('GET', ...)
xhr.open('GET', 'https://api.example.com/data');
GET 방식으로, 두 번째 인자로 전달한 URL로 길을 정해주는 단계이다.
이름이 open이라서 여기서 통신이 시작되는 것 같지만, 실제로는 네트워크 요청이 아직 출발하지 않는다. 단순히 내부적으로 설정값만 세팅하는 단계이며, 이 메서드가 실행되면 비로소 readyState가 1로 바뀐다.
xhr.send();
진짜로 네트워크 통신을 시작되는 단계이다. 통신이 시작되면서 위에서 우리가 설정해 둔 onreadystatechange 리스너가 작동하기 시작한다.
🔍 만약 GET이 아닌 POST 요청이었다면?
우리가 보낼 데이터를 xhr 손에 들려줘야 하므로xhr.send(JSON.stringify(postData))형태로 데이터를 집어넣어야 한다.
여기까지가 1999년 등장한 XHR의 기본 구조이다. 하지만 딱 보기에도 불편한 점이 한두 가지가 아니다. 웹이 발전한 현대의 시각으로는 너무 치명적인 한계가 존재한다.
상태가 변할 때마다 계속 리스너가 호출되는 점
네트워크 에러랑 HTTP 에러랑 구분이 안되는 점
파일 다운로드 시 현재 몇 퍼센트를 다운받은지 모르는 점
우선 개선된 코드를 살펴보자.
// 1. 인스턴스 생성
const xhr = new XMLHttpRequest();
// 2. 통신 완료 전용
// 기존의 'if (xhr.readyState === 4)'와 동일한 역할
// 이 함수는 오직 통신이 끝났을 때 딱 한 번만 실행됨
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
console.log('성공! 데이터:', JSON.parse(xhr.responseText));
} else {
console.error(`HTTP 에러 발생: ${xhr.status}`);
}
};
// 3. 네트워크 에러 전용
xhr.onerror = function() {
console.error('네트워크 에러 발생');
};
// 4. 진행률 추적 전용
// 서버가 전체 용량을 알려줬는지 확인 후 퍼센트 계산
xhr.onprogress = function(event) {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
console.log(`다운로드 진행률: ${percentComplete}%`);
}
};
// 5. 세팅을 끝내고 네트워크 연결 및 요청
xhr.open('GET', 'https://api.example.com/data');
xhr.send();
1. onload
과거 onreadystatechange() 리스너는 0단계부터 4단계까지 상태가 변할 때마다 함수가 불렸다. 하지만 onload는 통신을 완전히 마쳤을 때(4단계) 딱 한 번만 호출되기 때문에, 코드의 가독성이 상승했다.
❗️주의
onload는 통신이 끝났다는 뜻이지 '서버가 200 OK를 줬다'는 뜻이 아니기 때문에, 내부에서 xhr.status를 검사하는 로직은 여전히 필수이다.
2. onerror
과거에는 네트워크가 끊겨도 강제로 완료(4단계) 처리된 후 상태 코드가 0으로 찍혔지만, 네트워크 자체가 터졌을 때는 onerror로 빠진다.
"HTTP 에러(서버 응답 있음)"와 "네트워크 에러(서버 응답 없음)"가 물리적으로 완벽히 분리되었다.
3. onprogress
onprogress 이벤트는 현재 몇 바이트를 받았는지(loaded)와 전체 크기가 얼마인지(total)를 실시간으로 계산한다.
이를 이용해서 현재 데이터 다운로드 진행이 어느정도인지도 확인할 수 있게 되었다.
🔍 웹에서 대용량 파일이나 이미지를 받을 때 로딩 바가 차오르는 UI는 이 onprogress 이벤트 덕분!
그러나 위에서는 코드 예시를 위해서 console.log()를 사용했을 뿐이고, 실제로 데이터를 밖으로 꺼내오기 위해서는 콜백 함수(Callback)를 써야만 한다.
// 1. 성공했을 때 실행할 함수(onSuccess)와 실패했을 때 실행할 함수(onError)를 매개변수로 받음
function fetchUserData(url, onSuccess, onError) {
const xhr = new XMLHttpRequest();
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
// 2. 통신이 성공하면, 파싱한 데이터를 onSuccess 함수의 인자로 넣어서 "대신" 실행함
onSuccess(JSON.parse(xhr.responseText));
} else {
// 3. HTTP 에러 발생 시 onError 실행
onError(`HTTP 에러: ${xhr.status}`);
}
};
xhr.onerror = function() {
// 4. 네트워크 단절 시 onError 실행
onError('네트워크 에러 발생');
};
xhr.open('GET', url);
xhr.send();
}
다음과 같은 메서드를 구현해놓고, 유저 1번의 데이터를 가져온다고 가정해보자.
fetchUserData(
'/api/users/1',
// 성공 콜백 (onSuccess)
function(userData) {
console.log('1. 유저 정보 획득 성공!', userData);
document.getElementById('user-name').innerText = userData.name;
},
// 실패 콜백 (onError)
function(errorMessage) {
console.error('앗, 에러가 났네요:', errorMessage);
}
);
fetchUserData()의 인자로 url, 성공 콜백, 실패 콜백을 전달해서 실제로 가져온 데이터를 밖으로 가져올 수 있었다.
하지만 코드를 자세히 보시면 아주 치명적인 약점이 하나 남아있는데, 만약 연속으로 통신할 일이 발생한다면 어떨까?
"유저 정보를 가져온 뒤, 그 유저의 게시물 목록을 가져오고, 그중 첫 번째 게시물의 댓글을 가져와라" 같은 순차적인 비동기 작업이 존재한다고 가정했을 때 코드는 다음과 같아질 것이다.
fetchUserData('/api/users/1', function(userData) {
...
// 유저 정보를 바탕으로 게시물 목록을 가져옴
fetchUserData(`/api/posts?userId=${userData.id}`, function(posts) {
...
// 게시물 정보를 바탕으로 댓글 목록을 가져옴
fetchUserData(`/api/comments?postId=${posts[0].id}`, function(comments) {
...
}, function(error) { console.error('댓글 에러'); });
}, function(error) { console.error('게시물 에러'); });
}, function(error) { console.error('유저 에러'); });
보시다시피 코드가 우측으로 끝없이 파고들면서 가독성이 박살 나고, 에러 핸들링이 여기저기 흩어져 버렸다. 이것이 바로 그 유명한 '콜백 지옥(Callback Hell)'이다.
그래서 개발자들은 "함수 안에 함수 좀 그만 넣고, 그냥 아래로 잘 읽히게 코드를 짤 순 없을까? 성공하면 이거 해줄게 라고 '약속(Promise)'해 주는 객체가 있으면 좋겠는데..."
약속? 우리가 어디서 많이 들어보지 않았나? 바로 Promise이다.
2015년에 JS ES6에 Promise가 공식적으로 도입이 되면서 새로운 통신 표준을 발표하게 되는데, 그것이 바로 Fetch API의 등장이었다.
두 번째는 fetch이다. 위에서 fetch의 철학이 다음과 같다고 했다.
HTTP 표준을 있는 그대로 브라우저에 매핑해서 네트워크 계층과 앱 계층을 완벽히 분리하겠다는
원론주의자철학.
왜 fetch는 이러한 철학을 가지게 됐을까?
"XHR의 시대는 비동기 통신을 가능하게 했지만, 데이터를 꺼내 쓰기 위해 끊임없이 함수 안으로 파고드는 '콜백 지옥(Callback Hell)'이라는 끔찍한 재앙을 낳았습니다."
XHR의 시대는 비동기 통신을 가능하게 했지만, 동시에 데이터를 꺼내기 위한 콜백 지옥이라는 커다란 문제점을 만들었다.
위와 같은 시대적 배경 속에서, 2015년 자바스크립트 생태계에는 비동기를 우아하게 처리할 수 있는 Promise가 표준으로 등장하게 되었고, 낡은 XHR을 대체할 새로운 브라우저 내장 API가 탄생하게 되었다.
이 때 가장 중요하게 생각한 것은 편의성이 아니라 네트워크 레이어와 애플리케이션 레이어의 완벽한 분리였다. 그 컴퓨터네트워크의 5계층 맞다
그래서 Fetch 설계자들은 철저하게 HTTP 규약의 원칙을 따르기로 했다.
"우리는 네트워크만 완벽하게 수행할 것이다. 서버가 404를 주든 500을 주든, 통신 자체에 성공했다면 그것은 '성공'이다. 내용물이 에러인지 아닌지 판단하는 것은 개발자의 몫이다!"
결론적으로 위와 같은 배경과 설계로 인해, 어떤 상황에서도 웹 표준 규약을 어기지 않는 깐깐한 원론주의자 철학이 탄생하게 된 것이다.
fetch('https://api.example.com/data')
.then(response => { // 응답이 있는 경우
if (!response.ok) {
throw new Error(`HTTP 에러 발생: ${response.status}`);
}
return response.json();
})
.then(data => { // 파싱이 완료된 데이터
console.log('데이터:', data);
})
// 응답이 없는 경우
.catch(error => {
console.error('네트워크 에러 발생', error.message);
});
async/await가 없을 때의 Promise/then을 사용한 fetch 기본 구조이다. 이번에도 코드를 하나하나 뜯어보자.
1. fetch(url)
fetch('https://api.example.com/data')
fetch() 메서드는 Promise 객체를 반환하기 때문에, 콜백함수를 매개변수로 넘길 필요 없이 then 메서드로 코드를 이어갈 수 있다. 앞에서 xhr.open(), xhr.onload, xhr.onerror 와 같이 개발자가 설정해줘야 하는 것들이 모두 fetch 한줄로 끝난다.
🔍 여기서 Promise 객체란?
Promise란 쉽게 말해 카페의 '진동벨'과 같다. 당장 데이터를 줄 수는 없으니 "나중에 결과가 도착하면 꼭 알려줄게"라는 약속(객체)을 먼저 건네주는 것이다. 개발자는 이 진동벨이 울렸을 때 할 행동을 .then()으로 미리 예약해 두기만 하면 된다.
2. !response.ok
if (!response.ok) {
throw new Error(`HTTP 에러 발생: ${response.status}`);
}
이 부분이 fetch의 원론주의 철학이 매우 잘 드러나는 부분이다. 서버가 404나 500으로 응답해도 fetch는 catch로 잡아내지 않고 성공으로 처리해버린다. 즉, 통신 자체는 성공했으므로 HTTP 에러가 실패가 아닌 성공적인 에러라고 취급하는 것이다.
결국 개발자는 무조건 response.ok (상태 코드가 200~299 사이인지)를 확인하고, false라면 직접 throw new Error를 던져서 수동으로 에러 처리를 해야 하는 번거로움이 생기게 된다.
3. return response.json()
return response.json();
})
.then(data => {
...
})
XHR의 JSON.parse(xhr.responseText)와 비슷한 역할을 수행한다.
fetch에서는 응답만 가져올 뿐이고, 내용물이 이미지인지, 텍스트인지, JSON인지 데이터를 뜯는 건 개발자가 알아서 하라는 의도를 보여준다.
🔍 왜 데이터를 가져올 때도 .then()을 사용해야 하는가?
데이터를 파싱하는json()메서드가 비동기로 동작하기 때문에,.then()을 한 번 더 써서 기다려야 한다.
4. catch()
.catch(error => {
...
});
위에서 HTTP 에러인 경우는 if(!response.ok)로 검사한다고 했는데, 네트워크 에러인 경우가 바로 이 catch에서 잡히게 된다.
가장 중요한 것은 위에 then에서 throw한 에러가 이 catch에서 잡히게 되는데, 개발자가 직접 검사한 HTTP 에러인 경우도 이 catch에서 모이게 된다는 단점 특징이 있다.
위의 내용까지가 Promise를 사용한 fetch의 기본 구조이다. 근데 뭔가 불편한 점이 예상이 가지 않는가? 요청을 여러 번 보내야 하는 상황이라고 가정해보자.
// 1. 유저 정보 가져오기
fetch('/api/users/1')
.then(response => {
if (!response.ok) throw new Error('유저 정보 통신 에러');
return response.json(); // 파싱
})
.then(user => {
console.log('1. 유저:', user);
// 2. 게시물 목록 가져오기
return fetch(`/api/posts?userId=${user.id}`);
})
.then(response => {
if (!response.ok) throw new Error('게시물 에러');
return response.json(); // 파싱
})
.then(posts => {
console.log('2. 게시물 목록:', posts);
// 3. 댓글 가져오기
return fetch(`/api/comments?postId=${posts[0].id}`);
})
.then(response => {
if (!response.ok) throw new Error('댓글 에러');
return response.json(); // 파싱
})
.then(comments => {
console.log('3. 댓글 목록:', comments);
})
.catch(error => {
console.error('에러 발생:', error.message);
});
보기만 해도 가독성이 매우 박살나 보인다. 통신을 할 때마다 응답 검사와 파싱을 반복해야 하고, 직관적이지 않은 가독성때문에 요청이 늘어날수록 이해하기 힘들어진다.
무엇보다 가장 치명적인 문제는, 스코프(Scope) 단절 문제이다. 만약 맨 마지막 3번째 단계(댓글 가져오기)에서, 맨 처음 받아왔던 user.name을 화면에 같이 그리고 싶다면 어떻게 해야 할까?
각각의 .then()은 독립된 블록이기 때문에, 맨 아래쪽 .then()에서는 맨 위쪽의 user 변수에 접근할 수 없다. 결국 바깥에 전역 변수를 선언하거나 코드를 다시 안으로 억지로 꼬아 넣어야 하는 참사가 발생한다.
콜백 지옥을 탈출했지만 여전히 코드 읽기가 힘든 점
스코프 단절 문제로 인해 데이터에 접근하기 까다로운 점
위와 같은 Promise의 한계를 느낀 개발자들은 더 이상 .then()이라는 진동벨을 기다릴 필요가 없고, 코드의 가독성을 높이기 위해서 새로운 문법을 만드는데, 그것이 바로 우리가 흔히 사용하던 await/async이다.
우선 await/async 문법을 사용한 fetch 코드를 보자.
async function getCommentData() {
try {
// 1. 유저 정보 가져오기
const userResponse = await fetch('/api/users/1');
if (!userResponse.ok) throw new Error('유저 에러');
const user = await userResponse.json(); // 파싱
console.log('1. 유저:', user);
// 2. 게시물 목록 가져오기 (
const postResponse = await fetch(`/api/posts?userId=${user.id}`);
if (!postResponse.ok) throw new Error('게시물 에러');
const posts = await postResponse.json(); // 파싱
console.log('2. 게시물 목록');
// 3. 댓글 가져오기
const commentResponse = await fetch(`/api/comments?postId=${posts[0].id}`);
if (!commentResponse.ok) throw new Error('댓글 에러');
const comments = await commentResponse.json(); // 파싱
console.log('3. 댓글 목록:', comments);
} catch (error) {
console.error('에러 발생:', error.message);
}
}
1. async
async function getCommentData() {
...
}
async 문법은 이 함수 안에 비동기 작업(await 문법)이 있다는 것을 미리 JS 엔진에게 알려주는 문법이다. async를 사용해야만 await를 사용할 수 있다.
2. try-catch
try {
// 비동기 작업
} catch (error) {
// 에러 처리
}
try 내부의 비동기 작업 중 어떤 것이라도 에러를 발생시킨다면 아래의 catch로 던지라는 의미이다. fetch의 원론주의 철학으로 인해 HTTP 에러와 네트워크 에러는 모두 이 catch에서 잡히게 된다.
3. await fetch(url)
const userResponse = await fetch('/api/users/1');
네트워크 요청을 fetch 뒤 then으로 이어지는 것이 아니라, await 문법을 통해 기다리기만 하는 구조이다. await 뒤의 작업이 끝나기 전까지는 다음 줄로 넘어가지 않으므로 콜백 함수를 사용하지 않아도 결과물을 바로 담을 수 있다.
🔍 fetch에서 데이터를 보낼 때는 어떻게 해야 할까?
const response = await fetch('/api/users', { method: 'POST', // 1. 통신 방식 명시 headers: { 'Content-Type': 'application/json', // 2. 데이터 형식 수동 명시 }, body: JSON.stringify(userData) // 3. 데이터 수동 직렬화 (포장하기) });만약 데이터를 보내야 할 때는 통신 방식, 헤더, 바디를 다음과 같은 방식으로 전달해야 한다!
4. if (!userResponse.ok) throw new Error()
if (!userResponse.ok) throw new Error('유저 정보 에러');
Promise에서의 HTTP 에러 처리와 동일하다. 여기서 던진 에러는 맨 아래 catch에서 잡히게 된다.
5. await response.json()
const user = await userResponse.json();
결과물에서 내용물을 꺼내 JSON으로 변환하는 과정이다. 이 파싱 작업도 비동기로 동작하기 때문에 await를 한 번 더 써서 기다려야 한다.
await/async를 사용해서 우여곡절 끝에 가독성을 개선하고 DX를 향상시켰지만, fetch의 원론주의 철학의 고집을 해결할 순 없었다. 개발자가 수동으로 해야 하는 작업들이 여전히 많았다.
- 이중 await 문제
- 에러 수동 처리
- 데이터 직렬화와 헤더 세팅 수동 (데이터를 보내는 경우)
근데 이미 이런 귀찮은 것들을 해결하는 라이브러리가 있네?
우리가 위에서 클라이언트 등장 순서를 봤을 때 다음과 같다.
XHR -> axios -> fetch -> ky
Axios가 Fetch보다 1년 먼저 등장했다는 점이 중요하다. 기존에 axios를 사용하던 개발자들은 fetch의 등장이 엄청나게 매력적인 선택지가 아니었을 것이다.
fetch의 원론주의 철학이 뭐가 중요한가? 지금 당장 퇴근이 중요한데 무슨 원칙이 중요한가..
아무튼 Axios를 살펴 볼 차례이다.
맨 처음 설명한 것처럼 Axios는 fetch 기반이 아닌 XHR을 Promise로 감싼 구조이다(fetch보다 먼저 출시되었기 때문에). 기본적으로는 XHR의 단점(콜백 지옥)을 개선했고, fetch의 단점(이중 await, 에러 수동, 직렬화 등)을 완벽히 해결하는 모습을 보여준다.
귀찮은 파싱, 직렬화, 에러 처리(4xx, 5xx) 등을 DX 향상을 목표로 하는
실용주의자철학
왜 Axios는 DX를 최우선으로 생각하는 실용주의자 철학을 가지게 되었을까?
위에서도 언급했지만 공식 표준인 fetch가 '네트워크와 앱 계층의 분리'라는 원칙을 너무 엄격하게 지킨 나머지, 개발자들은 귀찮은 숙제를 떠안아야 했기 때문이다. 게다가 브라우저(XHR)와 서버(Node.js) 환경에서 각각 다른 통신 모듈을 써야 하는 파편화 문제도 개발자들을 괴롭히고 있었다.
이때 Axios 설계자들이 가장 중요하게 생각한 것은 규약이나 원칙이 아니라 '편의성 개선'과 '개발자 경험'이었다.
결론적으로 위와 같은 배경과 설계로 인해, 웹 표준 규약이라는 명분보다는 귀찮은 반복 작업을 내부에서 모두 처리해 주는 실용주의자 철학이 탄생하게 된 것이다.
import axios from 'axios';
// 1. Axios 인스턴스 생성
const apiClient = axios.create({
baseURL: 'https://api.example.com', // URL 설정
headers: { // 헤더 설정
'Content-Type': 'application/json',
}
});
// 2. 데이터 요청
async function fetchData() {
try {
// baseURL이 설정되어 있으므로 뒷부분 경로('/data')만 적으면 됨
const response = await apiClient.get('/data');
// Fetch의 response.json() 과정 생략
console.log('데이터:', response.data);
} catch (error) {
// HTTP 에러와 네트워크 에러 모두 catch에서 잡힘
console.error('에러 발생:', error.message);
}
}
fetchData();
엄밀히 따지면 초기 구조는 axios가 await/async보다 먼저 등장했기 때문에 Promise/then을 사용한 구조가 맞지만, 그 구조는 fetch때와 비슷한 구조로 작동하기 때문에 await/async로 설명하겠다.
1. axios.create()
const apiClient = axios.create({
baseURL: 'https://api.example.com', // URL 설정
headers: { // 헤더 설정
'Content-Type': 'application/json',
}
});
axios의 create() 메서드는 api 인스턴스를 미리 만들어주는 메서드이다.
fetch의 경우, API 도메인이 변경되거나 토큰 설정이 변경된다면 일일히 다 찾아서 바꿔야 할 것이다.
하지만 axios.create()로 인스턴스(apiClient)를 만들어두면 이 인스턴스의 설정 한 곳만 바꾸면 모든 통신에 일괄로 적용되기 때문에, URL 설정과 헤더 설정 등의 DX를 개선시켰다.
2. .get()
const response = await apiClient.get('/data');
미리 인스턴스에서 세팅한 baseURL과 결합하여 실제로는 https://api.example.com/data로 알아서 GET 요청이 날아간다.
3. response.data
console.log('데이터:', response.data);
이 한 줄이 바로 Axios 실용주의 철학의 핵심이다. Fetch 시절 우리를 괴롭혔던 파싱 과정이 완벽하게 해결되었고, Axios가 응답을 받는 순간 내부적으로 파싱해서 data라는 속성에 담아준다.
개발자가 일일히 파싱할 필요가 없어져서 그냥 데이터를 꺼내기만 하면 된다.
4. catch (error)
try {
...
} catch (error) {
console.error('에러 발생:', error.message);
}
fetch와의 가장 큰 철학적이면서도 설계적인 차이점인데, Axios에서는 HTTP 에러를 정상 응답이라고 생각하지 않는다!!
그래서 Axios의 try...catch 블록 안에서는 그런 수동 에러 처리가 필요 없다. Axios는 404, 500, 그리고 네트워크 에러를 모조리 "실패"로 간주하여 이 catch 블록으로 던진다.
따라서 개발자는 response.ok를 검사해서 에러 핸들링을 할 필요 없이, catch에서만 에러 핸들링을 하면 된다.
나는 코드를 보니 DX 측면에서 엄청 편해졌다고 생각이 들었다. Axios의 실용주의 철학으로 인해 DX가 효과적으로 개선되었고, 그 시대의 다른 개발자들도 나와 비슷한 생각을 하지 않았을까?
하지만 한편으로는 이런 생각도 든다.
Axios도 결국 구시대 유물인 XHR 기반이라서 무겁지 않을까?
브라우저에 이미 fetch라는 웹 표준이 있는데 굳이 라이브러리처럼 설치해야 할까?
fetch와 Axios의 장점만을 가져온 것은 없을까?
이 질문에 대답할 수 있는 것이 바로 Ky이다.
위에서 설명한 것처럼 Ky는 fetch의 모던함과 Axios의 편의성을 모두 챙기겠다는 미니멀리스트 철학이다.
웹 표준(fetch)의 순수함은 유지하되, 개발자 경험(DX)을 해치는 단점만 해결하겠다는 미니멀리스트 철학
Ky는 왜 이러한 철학을 가지게 되었을까?
개발자들에게 Axios의 DX 개선은 효과적이었지만, 결국 XHR에 의존하고 있는 구조나 용량 자체가 무거웠다는 점이 마음에 걸렸다.
Axios가 기반이 구시대이고 너무 무거웠기 때문에, 비교적 가벼운 브라우저 내장 표준을 사용하면서도 DX를 위해 가볍게 래핑하게 되었다.
개발자들이 귀찮아하는 이중 파싱, 에러 핸들링 등의 기능을 숏컷으로 제공해주고, 결론적으로 위와 같은 배경으로 인해 과거의 유산을 버리고 웹 표준을 준수, DX 향상까지 책임지는 미니멀리스트 철학이 탄생하게 된 것이다.
import ky from 'ky';
// 1. Ky 인스턴스 생성
const apiClient = ky.create({
prefixUrl: 'https://api.example.com', // Axios의 baseURL과 같은 역할
headers: { // 헤더 설정
'Content-Type': 'application/json',
}
});
// 2. 데이터 요청
async function fetchData() {
try {
// 3. Ky의 핵심 숏컷(Shortcut)
const data = await apiClient.get('data').json();
console.log('데이터:', data);
} catch (error) {
console.error('에러 발생:', error.message);
}
}
fetchData();
기본적으로 Axios와 비슷한 구조를 띄고 있다. 인스턴스 생성, await/async 문법, 에러 핸들링이 Axios와 거의 동일하다.
1. prefixUrl
Axios의 baseURL과 완벽히 동일한 역할을 한다. 하지만 다른 점이 있는데, Ky는 URL 조합 시 슬래시(/)가 중복되는 실수를 방지하도록 설계되었다. 그래서 apiClient.get('/data') 처럼 슬래시를 넣으면 에러가 발생한다.
2. await client.get(...).json();
const data = await apiClient.get('data').json();
Ky의 미니멀리스트 철학이 가장 잘 드러나는 부분이다.
Ky가 Fetch를 래핑했음에도 불구하고, 이중 파싱을 할 필요가 없는 이유는 바로 이 문법 덕분이다. Ky는 DX를 고려해서 일종의 숏컷을 제공한다.
fetch의 .json() 문법을 유지하면서도 axios의 response.data처럼 데이터를 한번에 꺼내주는 그 중간 지점에 있는 것이다.
3. catch (error)
fetch에서는 HTTP 에러를 정상 응답으로 처리했기 때문에 catch에서 알아서 잡히지 않았지만, Ky는 Axios와 동일하게 HTTP 에러를 실패로 취급하기 때문에 catch에서 잡히게 된다.
이렇게 각 클라이언트에 대한 내용이 드디어 끝이 났다.
지금까지 각 클라이언트가 어떤 시대적 배경 속에서 탄생했고, 어떤 철학을 코드에 녹여냈는지 살펴보았다.
하지만 학습을 진행하며 단순히 '이게 더 편하다, 저게 더 최신이다'를 넘어서, "내부 엔진은 대체 어떻게 돌아가길래 이런 차이가 발생하는 걸까?" 하는 본질적인 호기심이 생겼다.
수많은 궁금증 중, 깊게 파고들며 가장 흥미로웠던 두 가지 주제를 정리해 보았다.
위에서 우리는 XHR이 통제광 철학이고, 개발자가 일일히 모든 것을 신경써야 한다는 사실을 알았다. XHR 코드를 다시 살펴보자.
const xhr = new XMLHttpRequest();
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
console.log('성공! 데이터:', JSON.parse(xhr.responseText));
} else {
console.error(`HTTP 에러 발생: ${xhr.status}`);
}
};
xhr.onerror = function() {
console.error('네트워크 에러 발생');
};
xhr.open('GET', 'https://api.example.com/data');
xhr.send();
xhr 통신의 순서는 크게 세 단계로 이루어진다.
- 인스턴스 생성
- 성공/실패 시 행동 지정
- 통신
그런데 코드를 보면 xhr.send()가 맨 아래에 적혀있다. 어차피 리스너라면 send()를 맨 아래가 아니라 그냥 인스턴스를 생성하자마자 호출해도 되는 거 아닌가? 이런 생각이 들었다.
위에서는 설명하지 않았지만, XHR에는 동기 모드와 비동기 모드가 존재한다. 위에 코드에서 본 구조는 디폴트 모드인 비동기 모드이다.
동기 모드일 때 send()를 먼저 호출하게 되면, 자바스크립트의 엔진(싱글 스레드)은 그 즉시 네트워크 통신이 끝날 때까지 멈춰버린다. 즉, 엔진이 멈춰있기 때문에 send() 아랫줄에 적어둔 이벤트 리스너 코드는 아예 읽히지도 못한다. 결과적으로 응답이 와도 실행할 콜백이 등록되지 않아 영원히 아무 일도 일어나지 않게 된다.
그렇다면 멈추지 않는 비동기 모드에서는 send()를 먼저 써도 괜찮을까? 일반적으로는 그렇다. send()가 호출된 후 통신을 하는 동안 다음 코드들인 onload나 onerror가 등록이 되므로, 통신이 끝나기 전에 리스너가 등록이 된다면 문제가 발생하지 않는다.
하지만 만약 서버가 엄청나게 빠르거나, 요청한 데이터가 브라우저 캐시에 있어서 통신이 0.001초 만에 완료되었다고 가정해 보자. 자바스크립트 엔진이 다음 줄로 넘어가서 onload 리스너를 미처 등록하기도 전에, 네트워크 응답이 먼저 도착해서 완료 이벤트가 휙 지나가 버린다. 이를 컴퓨터 공학에서는 경쟁 상태(Race Condition)에 빠졌다고 하며, 이로 인해 치명적인 이벤트 유실이 발생하게 된다.
🔍 자바스크립트의 메모리 바인딩 원리
자바스크립트 메인 스레드는 네트워크 통신을 직접 하지 않습니다. 통신은 브라우저의 Web API(Network Thread)에게 역할을 위임한다.
xhr.onload = function() {...} 코드는 JS 힙(Heap) 메모리에 콜백 함수를 적재하고, 나중에 load 이벤트가 불리면 이 메모리의 함수를 Task Queue에 넣으라고 브라우저에게 Mapping 정보를 쥐여준다.
만약 send()를 먼저 호출해버리면, 이 Mapping 정보를 브라우저에게 넘겨주기도 전에 네트워크 통신이 시작되므로, 브라우저는 통신을 완료해도 자바스크립트 쪽의 어느 함수를 실행해야 할지 알지 못해 이벤트를 허공에 날려버리게 된다. (이를 Event loss라고 한다)
그래서 핵심은 뭔가?
네트워크 통신 요청을 보내기 전에는 반드시 이벤트를 등록해야 한다.
위에서 fetch 코드를 봤을 때, 이중 파싱 문제가 발생한다고 했다.
// 유저 정보 가져오기
const userResponse = await fetch('/api/users/1');
// 파싱
const user = await userResponse.json();
fetch로 응답을 받아오고, 그 응답을 한번 더 파싱해서 데이터를 가져오게 된다. 왜 이런 구조로 동작하는 걸까?
XHR은 기본적으로 데이터를 버퍼(Buffer) 방식으로 수신한다.
만약 10GB짜리 영화 파일을 다운로드한다고 가정하면, 브라우저 메모리는 10GB가 100% 꽉 찰 때까지 자바스크립트는 아무것도 하지 못하고 그저 기다려야만 한다. 모두 다운로드 되기 전까지는 데이터에 접근할 수 없다.
xhr.responseText 속성에는 항상 '완성된 전체 데이터'만 담긴다.
만약 1GB짜리 거대한 JSON이나 텍스트 파일을 받는다면? 1GB가 다 다운로드될 때까지 화면은 멈춰있는 치명적인 상황이 발생한다.
🔍 데이터가 모두 다운로드되기 전까지는 접근할 수 없지만,
onprogress리스너를 통해 다운로드 진행도까지는 파악할 수 있다!
Fetch는 스트림(ReadableStream)이라는 혁신적인 방식을 사용한다. 데이터가 10%만 다운로드되었어도, 그 10%의 데이터를 조각(Chunk) 단위로 먼저 꺼낼 수 있다.
데이터가 물 흐르듯 들어오기 때문에 브라우저 메모리를 적게 차지하며, 대용량 파일이나 실시간 채팅 같은 데이터를 처리하는 데 압도적으로 유리하다.
앞서 Fetch에서 이중 파싱에 대한 불편함을 이야기했다. 그 이유가 바로 Fetch가 스트림 방식이기 때문이다.
await fetch()가 완료되는 시점은 데이터가 전부 다운로드된 시점이 아니라, '서버의 응답 헤더(Header)`만 딱 도착한 시점이다. 바디(Body) 데이터는 여전히 가져오는 중이다.
따라서 흩어져서 들어오는 이 스트림 조각들을 하나도 빠짐없이 다 모은 다음, 완전한 하나의 데이터로 합쳐달라고 기다리는 명령어가 바로 await response.json()이었던 것이다.
🔍 XHR을 기반으로 하는 Axios는 버퍼 방식, Fetch를 기반으로 하는 Ky는 스트림 방식이다. 다만 Ky는 위에서 언급했던 것처럼 일종의 숏컷을 제공해서 편하게 완성된 데이터를 한번에 가져올 수 있다.
그럼 처음으로 다시 돌아와서, 그들의 철학을 다시 살펴보자.
1. XMLHttpRequest
통신의 시작부터 끝까지 모든 과정을 이벤트로 쪼개서 개발자가 직접 제어하겠다는
통제광철학2. fetch
HTTP 표준을 있는 그대로 브라우저에 매핑해서 네트워크 계층과 앱 계층을 완벽히 분리하겠다는
원론주의자철학.3. Axios
귀찮은 파싱, 직렬화, 에러 처리(4xx, 5xx) 등을 DX 향상을 목표로 하는
실용주의자철학4. ky
웹 표준(fetch)의 순수함은 유지하되, 개발자 경험(DX)을 해치는 단점만 해결하겠다는
미니멀리스트철학
각 클라이언트가 왜 이러한 철학을 가지고 있는지 이제 설명할 수 있을 것이라고 생각한다.
그래서 뭘 쓰는 게 좋을까? 나는 Axios가 가장 좋은 선택이라고 생각한다. 하지만 Axios도 Axios 나름대로의 단점이 존재하므로, 결론은 '모든 상황에 맞는 완벽한 도구는 없다'는 것이다.
우리가 각 도구의 철학을 깊게 파고든 이유도 바로 여기에 있다. 도구의 철학과 내부 동작 원리를 이해하면, 내 프로젝트의 상황에 맞는 가장 적절한 무기를 고를 수 있는 안목이 생기기 때문이다. 우리 원정대의 목표였기도 하다
Axios가 어울리는 곳: 이미 수많은 레거시 코드가 존재하거나, Node.js 서버와 브라우저 양쪽에서 완전히 동일한 통신 모듈을 사용해야 하는 경우
Fetch가 어울리는 곳: 외부 라이브러리(의존성)를 단 하나도 설치하고 싶지 않은 순수한 바닐라 JS 프로젝트나, 아주 단순하고 일회성인 통신만 필요한 경우
Ky가 어울리는 곳: 최신 모던 브라우저 환경을 타겟으로 하며, 번들 사이즈 최적화가 중요하면서도, Axios 만큼의 DX를 챙기고 싶은 경우
XHR은 어디에..?
이번 원정대 활동을 통해 HTTP Client라는 주제를 파고들면서, 처음에는 그냥 다들 쓴다길래 기계적으로 입력했던 npm install axios가 얼마나 많은 역사와 개발자들의 혼신의 철학이 담긴 명령어인지 깨닫게 되었다.
단순히 문법을 외우는 것을 넘어, "왜 send()는 맨 밑에 있어야 할까?", "왜 fetch는 두 번 기다려야 할까?"라는 본질적인 '왜(Why)?'를 던지고, 버퍼와 스트림, 이벤트 루프 같은 깊은 곳까지 딥다이브 해본 경험이 나름 뿌듯했다고 생각이 든다.
변천사와 코드, 내가 정말 흥미롭다고 생각한 내용들을 담아서 작성해봤다. 이 글을 읽고 나서 클라이언트에 대한 이해가 조금 더 깊어졌을 것이라고 생각한다.
내용을 작성하다보니 너무 방대해진 것 같다는 느낌도 들었다. 하나의 내용도 정말 자세하게 들어가면 끝도 없는 것 같은데, 어디까지 작성하면 좋을지도 참 어려운 문제인 것 같다.
왜 우리 원정대중에 나만 글을 제일 못쓰는 것 같지..? 😭
글 너무 잘봤습니다. 여담에서 글의 퀄리티에 대해 걱정하셨던데 완전 도움됐구요
해당 주제에서 어떤 과정으로 궁금증이 일어났는지 생각의 흐름이 잘 보여서 재밌게 읽었습니다.
다만 의문이 있다면
자바스크립트는 싱글 스레드 언어라 콜스택이 비워지기 전까진 태스크 큐에서 응답을 받을 수 없는 것으로 이해하고 있었습니다. 그래서 제가 생각하는 흐름으론 onload리스너가 등록이 끝난 뒤에야 응답을 받을 수 있다고 생각했어요!