포스트

axios 형님, 이제 좀 쉬어요. ky가 왔거든요. (feat. JS/브라우저 비동기 처리 관련 TMI)

axios 형님, 이제 좀 쉬어요. ky가 왔거든요. (feat. JS/브라우저 비동기 처리 관련 TMI)

Intro

난 그동안 javascript를 이용한 프로젝트들에서, 비동기 통신을 해야 한다고 하면.. 그냥 몸이 자동 반사적으로 axios 를 써야 한다고 생각했던 것 같다. 말 그대로 axios 중독자 였던 것 같은데…

아니, 왜냐면.. fetch 라는 좋은 브라우저 내장 API가 있다는 걸 알면서도.. 귀찮은 게 컸던 것 같다. 매번 에러 나도 예외로 지가 안 던져주고, 타임아웃도 없고… 그래서 그냥 이런 기능 편하게 쓸 수 있는 axios 썼다. 진짜 말 그대로, 그냥 편하니까..

그런데 어느 날 문득, 이런 생각이 들었다. 하루 단위로 변한다는 프론트 세계에서.. fetch 에 기반한 다른 라이브러리 없는가.. 해서 말이다. 아니면, fetch 를 좀 더 쉽게 사용하는 방법 없다던가… axios 솔직히 많이 해먹으셨잖아(?) (그만큼 업계 표준이기도 해) 코드에서 늘 import axios from 'axios' 붙이고, 에러 처리 일원화를 위해서 인터셉터 객체 만들면 되겠지? 했던 날들이 매번 프로젝트 초기 세팅 때마다 연속이었다..

그냥 이러한 단순 호기심 및 새로움을 추구? 하는 마음에서.. 구글링을 해봤다. 최근에 또 새로운 프로젝트 초기 세팅을 진행하다가, 신선함을 한 번 추구해 보려고.. 그러다가 fetch 기반의 ky 라는 라이브러리가 있다는 것을 발견했다. 이 라이브러리의 github 한 줄 소개는 이랬다.

“A tiny and elegant HTTP client based on the Fetch API.” (오.. 요즘것들 느낌 나는 것 같아서 내 스타일임)

fetch 기반의 이 친구를 보고, 그리고 깃허브 리드미를 보니까 코드도 axios에 비해서 크게 복잡한 것 같지도 않고, 오류 처리도 비슷하게 인스턴스 객체 만들어서 쓸 수 있고, 자동으로 retry도 해주고.. 이 친구도 괜찮은 친구 같다는 것을 느꼈다.

이번 글에서는 내가 ky 라이브러리라는 것을 찾아보게 되면서, 이왕 이 김에 자바스크립트의 비동기 통신이 어떻게 발전해 왔고, 왜 이 라이브러리가 주목을 받게 되었는지 중심으로 이야기해 보려고 한다. 콜백 헬에서 시작된 긴 여정이 Promise 를 지나 async/await 으로 이어지고, 그리고 이제는 “표준을 더 사람답게 쓰는 법”까지… 레츠 고도리 해보자. (TMI 주의)



👨‍🏭 Javascript 비동기 처리 방식의 진화

우선 “비동기”라는 네이밍 자체는, 단어를 보면 쉽게 알 수 있듯 “동기”라는 단어의 정확히 대치되는 말이다. 컴퓨터 과학에서 동기(Synchronous)는 사전적으로 ‘동시에 일어난다’의 의미를, 비동기(Asynchronous)는 ‘동시에 일어나지 않는다’의 의미를 가지고 있다. 즉, 단어 자체만 보면 ‘비동기’라는 것은 통신에만 국한 되는 내용이 아니다. 어떤 작업을 요청했을 때, 그 결과를 기다리며 프로그램이 멈추지 않고 다른 일을 계속할 수 있다면, 그건 바로 “비동기”다. 즉, 요청(request)과 응답(response)이 ‘같은 타이밍에 일어나지 않아도 괜찮은’ 구조, 말 그대로 “동시에 안 일어나도 되는 세계”라고 보면 된다.

대표적으로, Javascript에서 setTimeout 이 있다.

1
2
3
console.log("시작");
setTimeout(() => console.log("3초 후 실행"), 3000);
console.log("");

위 코드를 실행하면, 콘솔엔 이렇게 찍힌다.

1
2
3
시작  
끝  
3초 후 실행

이게 바로 비동기(Asynchronous)의 핵심이다. setTimeout() 은 3초 후에 실행하라고 요청만 던지고, 그 결과를 기다리지 않는다. 대신 프로그램은 멈추지 않고 다른 일을 계속한다. 즉, “요청을 해놓고, 그 결과가 나중에 와도 괜찮은 구조”, 그게 바로 비동기라는 친구의 핵심이다.

콜백 지옥(Callback Hell), 진짜 에반데….

자바스크립트에서 비동기를 다루기 시작한 초창기에는, 이런 비동기 처리 방식을 “콜백 함수”로 해결했다.

예를 들어 데이터를 서버에서 가져오고, 그걸 파싱해서 다시 저장하는 일련의 과정이 있다고 가정해 보자. 그걸 구현하기 위한 그 시절(?) 코드는 대체로 이랬다고 한다.

1
2
3
4
5
6
7
getData((res) => {
  parseData(res, (parsed) => {
    saveData(parsed, (result) => {
      console.log("끝났다!");
    });
  });
});

음…. 지금은 나쁘지 않은 것 같은데, 여기에 데이터를 저장하고, 추가적인 비동기 처리 프로세스가 한 100가지 정도 더 있다고 가정해 보자. 들여쓰기 depth가 100을 넘어가는 아주 알흠다운(?) 코드가 완성 된다는 게 보이지 않는가?! 진짜 사람이 읽기 힘든 코드가 나올 것 같다;;

콜백이 중첩될수록 들여쓰기가 미쳐버리고, 에러 처리는 점점 더 깊숙이 들어가야 할 것이다. 한눈에 무슨 일이 벌어지는지도 (당연히) 알 수 없다. 그래서 사람들은 이걸 두고 “Callback Hell(콜백 지옥)”이라고 부르기로 했어요.. 비동기 처리 이따구로 할거면, 자바스크립트에서 “이게 비동기냐, 미동기냐 어휴” (헛소리 죄송합니다, 지피티가 쓴 거에요 / 진짜 어이없어서 갖고와봄) 할 수 밖에 없을 것 같다. 진짜 에바긴 해 저거.

나야, Promise - “그나마 숨통이 트이는 듯?” (TMI 대방출)

그렇게 모두가 고통 받다가 자바스크립트 ES6(2015) 버전에서 Promise 가 등장했다. 뭔가 객체 이름부터 “약속”이라는 아주 믿음직한 이름을 가지고 있다, “야, 나중에 이거 끝나면 알려줄게” - 딱 말 그대로다.

“중첩…… 멈춰! 이제, 체인으로 연결해 버려~”

1
2
3
4
5
6
getData() // getData 함수의 수행 결과로 Promise 객체 반환
  .then(parseData)
  .then(saveData)
  .then(() => console.log("끝났다!"))
  .catch((err) => console.error("에러 발생:", err))
  .finally(() => console.log("항상 실행되는 마지막 구문"));

위에서 봤던 코드, Promise 객체를 활용한 체이닝으로 연결해 놓은 버전이다. 이게 바로 Promise의 매력이다. 비동기 작업이 “끝나면” 다음 동작을 .then()으로 이어붙이고, 에러가 나면 .catch()에서 한 번 잡아주고, 작업 성공이든 실패든 상관없이 “무조건 마지막에 실행되는” 부분은 .finally()로 처리할 수 있다.

Promise 이야기 나온 김에, 조금 더 자세히 알아보자 (프론트엔드 개발형 코딩테스트 빈출 유형이다 / 복습겸..)

조금만 뜯어보면, Promise의 구조는 생각보다 단순하다. Promise는 ‘언젠가 완료될지도 모르는 비동기 작업’을 객체로 표현한 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// new Promise()로 Promise 객체 생성 시, 바로 호출됨 / promise 변수엔 Promise 객체 담긴다
const promise = new Promise((resolve, reject) => {
  // 비동기 작업 수행
  const success = true;
  if (success) {
    resolve("성공함"); // 성공 시 resolve
  } else {
    reject("터져버림"); // 실패 시 reject
  }
});

promise
  .then((result) => console.log(result)) // 성공 시 실행
  .catch((error) => console.error(error)) // 실패 시 실행
  .finally(() => console.log("작업 종료")); // 무조건 실행

여기서 중요한 건 Promise는 즉시 실행 객체다. 즉, new Promise()를 호출하는 순간, 내부의 콜백(executor)이 동기적으로 실행된다. 하지만, 콜백 안에서 수행되는 비동기 작업(setTimeout, fetch 등)은 이벤트 루프에 의해서 나중에 비동기적으로 처리된다. 콜백 자체는 동기적으로 바로 실행되는데, 그 안의 비동기 작업은 나중에 비동기적으로 처리된다는 것을 헷갈리지 말자. (이건, Promise 클래스의 생성자 설계 때문에 그런거다) 그래서 보통은 Promise를 “지연 실행”하기 위해, 아래처럼 함수를 정의하고 그 안에서 return new Promise() 형태로 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function makeCoffee() {
	// 이 Promise는 함수가 호출될 때만 실행된다
  return new Promise((resolve, reject) => {
    console.log("커피 만드는 중...");
    setTimeout(() => {
      const success = Math.random() > 0.2; // 20% 확률로 실패 (ㄹㅇㅋㅋ / 지피티 너 똑똑하다)
      if (success) resolve("커피 완성!");
      else reject("커피 머신 고장남, 에바야.");
    }, 1000);
  });
}

makeCoffee()
  .then((msg) => console.log("성공:", msg))
  .catch((err) => console.error("실패:", err))
  .finally(() => console.log("작업 종료!"));

그 안에서 resolve()reject()가 호출되면, 그 시점에 Promise의 상태(state)가 바뀐다.

Promise는 총 3가지 상태를 가진다.

  • pending - 대기 중. 아직 결과가 정해지지 않은 상태.
  • fulfilled - resolve()가 호출되어 성공적으로 완료된 상태.
  • rejected - reject()가 호출되어 실패한 상태.

한 번 fulfilled나 rejected 상태로 바뀌면, 다시 pending으로 돌아갈 수 없다고 한다. 즉, “약속은 한 번 정해지면 바꿀 수 없다.” (사람 간의 약속도 한 번 정해지면 왠만하면 바꾸지 맙시다, 신중하게 약속 잡읍시다 ㄹㅇㅋㅋ )

앞에서 잠시 언급하긴 했는데, Promise는 아래의 3가지 메소드로 완성된다.

  • .then()성공(fulfilled) 상태일 때 실행
  • .catch()실패(rejected) 상태일 때 실행
  • .finally() → 성공이든 실패든 무조건 마지막에 실행

.then()은 새로운 Promise를 반환하기 때문에, 체이닝(연결)이 가능하다. 이게 바로 Callback Hell(콜백 지옥….)에서 개발자들을 구원한 아주 조흔… 것이다.

1
2
3
4
5
doTask1()
  .then(doTask2) // Task1이 끝난 후에 Task2
  .then(doTask3) // Task2가 끝난 후에 Task3
  .catch(handleError) // 중간에 하나라도 실패하면 여기로
  .finally(cleanUp); // 마지막 정리

예전에는 “콜백 안에 콜백을 넣어서” 순서를 맞췄다면, 이제는 “위에서 아래로 읽히는 자연스러운 흐름”으로 코드를 작성할 수 있게 된 것이다! (아 속 편해)

하지만, 여전히 .then 이 많아지면, 즉 체이닝이 많아지면 코드가 길어지고, 조금만 복잡한 로직이 들어감녀 체인 관리가 귀찮아 보인다. “분명 콜백 헬 이럴 때보단 훨씬 좋아진 것 같긴 한데, 아직 2% 부족한듯..” 이런 느낌이 보면 볼수록 들긴 했어요.

하나 더 짚자면, Promise의 콜백(resolve, reject 관련..) “비동기적으로” 실행된다. 즉, resolve()가 바로 실행된다고 해서, 항상 그 다음 .then()이 즉시 동작하는 건 아니다. 자바스크립트 엔진은 Promise의 콜백을 Microtask Queue에 넣고, 현재 실행 중인 동기 코드가 모두 끝난 뒤에 실행하기 때문인데… 이에 대한 너무 자세한 설명을 하게 되면, 너무 Promise 관련 글이 길어질 것 같아서(이미 충분이 길어…..), 관련된 내용에 대해 설명이 잘 되어 있는 블로그 글 하나의 링크를 아래에 걸어 놓고자 한다. 해당 내용에 대해서 궁금 하다면, 아래 링크의 글을 한 번 참고해 보자.

https://velog.io/@onedanbee/비동기-Task-Queue-Macrotask-Queue-와-Microtask-Queue-에-대해서

async/await - 소듕한 친구 (인간다운 코드를 만들어준…)

자바스크립트 ES2017 버전에서는 async/await 이 등장했다. 이건 비동기 처리의 패러다임 전환이라고 개발자들이 말할 정도로… 직관적인 문법을 제공해 주는 것으로 난 알고 있다. 위에서 계속 작성했던 로직을 async/await 으로 작성해 보면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
async function processData() {
	try { // 우선 시도.. 레츠고
		const res = await getData();
		const parsed = await parseData(res);
		const result = await saveData(parsed);
		console.log("끝났다!", result);
	} catch(err) { // 비동기 처리 하다가, 에러 나면 여기로 온다 (에러 catch)
		console.error(err);
	}
}

오호.. 이게 나한테는 제일 익숙해. 그리고, 솔직히 제일 편하기도 해. 비동기인데 동기처럼 읽히거든. 한줄씩 읽히는 순서가 자연스럽고, 에러 처리도 try/catch 로 통일 되었거든. 비로소 “사람의 사고 흐름”과 코드의 실행 흐름이 일치?하기 시작한 거다.

자, 여기서 하나만 주의할 점을 짚고 넘어가겠다. await의 진짜 의미를 말이다.. await는 코드 전체를 멈추게 하는 키워드가 아니다. 정확히 말하자면, await가 있는 그 줄에서 Promise가 resolve(또는 reject)될 때까지, 해당 async 함수의 실행만 “일시 중단(suspend)”되는 것이다.

즉, await 아래의 코드들은 일단 “보류 상태”가 되지만, 브라우저의 이벤트 루프나 다른 함수들은 여전히 정상적으로 동작한다.

Promise가 완료되면, JS 엔진은 “다시 그 자리로 돌아와서” 중단된 함수의 실행을 재개한다. 이 덕분에 await 코드는 마치 동기 코드처럼 자연스럽게 읽히지만, 실제로는 비동기적 흐름 위에서 작동하는 착시 같은 문법이라 보면 된다. (하튼 편하잖아, 한잔해)

지금까지는 ‘비동기 처리’에 관련한 Javascript 자체에서 진화(?) 과정을 살펴 봤다. 이쯤 되면 js 개발 세상에 평화가 찾아왔을 것 같지만, 아직 갈 길이 남아 있다. 비동기를 “어떻게 다루는지”는 편해졌지만, “비동기 통신을 실제로 어떻게 보내느냐”에 대해서도 알아볼 필요가 있다. 여기서 XMLHttpRequest(XHR)fetch 이야기가 나온다.

이건 언어 차원이 아니라 브라우저의 통신 API 레벨에서의 이야기다. 즉, “비동기를 다루는 방법”이 아니라 “비동기를 수행하는 수단”의 방식 차이? 변화?라 볼 수 있다. 알아보도록 하자.



🌐 브라우저 기본 통신 API의 진화 - XMLHttpRequest / fetch

자, 지금까지 열심히 “비동기 처리를 어떻게 하느냐”에 대한 js에서의 진화 과정을 살펴봤다. 이제 알아볼 것은 그걸 “어떤 방식으로 서버랑 주고받느냐”에 대한 토픽으로 넘어가 보자. 아무리 js에서 문법이 사용하기 편해졌어도, 정작 브라우저가 서버와 데이터를 주고받는 “통신 방식”이 복잡하면, 개발자는 여전히 열심히 굴러야 하는 것이다….. 이러한 통신 방식에 대해서.. 브라우저가 사용하는 대표적인 도구 2가지, XMLHttpRequest , 그리고 fetch 에 대해서 알아 보도록 하자.

역사와 전통을 자랑하는 통신 방식 - XMLHttpRequest (a.k.a. XHR)

과거 웹 개발 시절(2000년대 초반…)로 잠깐 돌아가 보자. AJAX(Asynchronous JavaScript And XML)라는 단어가 세상을 지배하던 시절… 이 있었지. 페이지 전체를 새로고침하지 않고도 서버에서 데이터를 받아와 페이지 일부만 갱신할 수 있게 되었던 것… 그때 당시엔 혁명이었다고 한다.. 그 중심엔 XMLHttpRequest, 줄여서 XHR이 있었다고 한다. 이름부터 이미 시대의 향기가 느껴진다. XML….. (어지럽다) JSON 시대 이전의 유물의 느낌이 팍팍 든다!

그 시절의 비동기 통신 코드는 이런 식이었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const xhr = new XMLHttpRequest(); // XMLHttpRequest 객체 하나 새로 생성
xhr.open("GET", "https://api.example.com/data"); // GET 요청
xhr.onload = function () {
  if (xhr.status === 200) { // 성공 했다면 (response 상태 값이 200일 경우)
    const data = JSON.parse(xhr.responseText); // 직접 파싱해야 함
    console.log("성공:", data);
  } else {
    console.error("에러:", xhr.status);
  }
};
xhr.onerror = function () { // 요청했을 때, 요청 실패했으면
  console.error("요청 실패!");
};
xhr.send(); // 서버에 요청을 보낸다

보자마자 한숨부터 나온다. “왜 이렇게 귀찮지..?” 라는 생각밖에 안든다.

open, send, onload, onerror… 단순히 GET 요청 하나 보내는 것인데, 함수가 4개나 필요하다. 게다가 반환값은 JSON이 아닌 문자열이어갖고, 직접 JSON.parse()까지 해줘야 한다. 에러 핸들링도 Promise 가 아닌 콜백 기반… (살려다오)

결론적으로, XHR은 돌아는 가는데.. 개발자 입장에선 참 번거로운 친구인 것이다.

그럼에도 불구하고, 그 당시엔 이게 “혁신”이었기에.. “페이지 새로고침 없이 서버 데이터 갱신이 가능하다”는 건, 그 당시 웹에선 진짜 놀라운 일이었을 테니까..

하지만 세월이 지나고, Promiseasync/await 이 속속들이 등장하면서 사람들은 “JS는 이렇게 깔끔해 졌는데, 통신은 왜 아직도 이 모양임?”이라는 의문을 품을 수 밖에 없었다고 한다.

fetch의 등장 - 새로운 표준화의 시작

그러던 2015년, 드디어 변화가 시작되었다. 웹 표준 위원회(W3C)는 새로운 통신 API를 제안했다. 그 이름은, 우리에게 지금 매우 익숙한… fetch

“XMLHttpReqeust는 너무 레거시임. Promise 기반의 새로운 표준으로 가즈아”

이 새로운 API의 철학은 단순했다. “통신도 Promise 기반으로 해보자”

1
2
3
4
fetch("https://api.example.com/data")
  .then((res) => res.json())
  .then((data) => console.log("성공:", data))
  .catch((err) => console.error("에러:", err));

짧다, 깰꼼하다. 이제야 좀… JS 다운 통신 방식이 되었다.

  • fetch()Promise를 반환하므로, async/await과 완벽히 호환된다.
  • response.json()만 호출하면 바로 객체로 변환된다.
  • open, send, onload 같은 귀찮은 친구들은 이제 필요 없다.

이러한 특성 덕분에, 아래처럼 코드를 작성할 수도 있다.

1
2
3
4
5
6
7
8
9
async function getData() {
  try {
    const res = await fetch("https://api.example.com/data"); // fetch는 Promise 반환
    const data = await res.json();
    console.log("데이터:", data);
  } catch (err) {
    console.error("에러 발생:", err);
  }
}

이것이 바로, “비동기 통신의 새로운 표준”이라 볼 수 있다. XMLHttpRequest 같은 애 보다가, fetch 보니까, 눈이 다 정화된다 야.

물론, fetch가 모든 것을 해결해 준 건 아니다. 실제로 써보면 여전히 불편한 부분들이 몇 가지 있다. 아래가 대표적인 fetch의 불편한 점들이다.

  • HTTP 에러(4xx, 5xx)가 발생해도 자동으로 예외를 던지지 않는다.

    → 직접 if (!res.ok) 등으로 확인해야 함

  • 요청 타임아웃(timeout) 기능이 없다.

    AbortController를 사용해야 함

  • 업로드/다운로드 progress 이벤트 지원 X
  • IE 등 구형 브라우저 지원 불가

즉, fetch는 좋은 표준이지만, 실무에서 쓰기에 약간 귀찮은… 면이 여전히 좀 있는 친구라 볼 수 있을 것이다.

이렇게 해서 브라우저의 통신 방식, 즉 XMLHttpRequest, fetch 의 세계를 한 번 알아봤다. 요약하자면, 지금까지는 브라우저가 직접 제공하는 Low-level 표준 도구들의 이야기였다.

그런데 개발자라는 종족(?)은… 늘 “이걸 좀 더 쉽게 쓸 방법 없나?”를 고민하지 않나? (일단 난 머리 아파서 잘 안함 / 농담입니다 죄송합니다) 그래서 등장한 게 바로, XHR 진영의 대표 라이브러리 axios, 그리고 fetch 진영의 신흥 강자 ky다. 자, 이제 이 친구들 이야기로 넘어가 보도록 하자.



👼 XHR 시대(그리고 사실 지금까지도..)의 구원자 - axios

자, 이제 드디어 실무의 세계로 내려가 보자 (아, 실무자 되고 싶다 / 취직시켜 주세요)

앞에서 본 XMLHttpReqeust와 fetch는 모두 브라우저에서 제공하는 Low-level 통신 API였다. 그런데… 우리가 항상 생각하는 문제가 있다. “표준은 좋은데, 귀찮음 ㄹㅇㅋㅋ”

특히 XMLHttpRequest 시절엔 그 귀찮음이 극에 달했다고 한다. 매번 콜백 달고, JSON.parse() 해주고, 에러 처리 따로 하고.. (끔찍하다) 그래서 등장한 구세주… axios 라는 친구였다.

axios는 말한다 - “XHR을 좀 사람답게 쓰자 (Promise와 함께)”

axios는 2014년, 아직 fetch 가 등장하기 전이었던 시절… 즉 XMLHttpRequest가 브라우저의 통신 수단 중 유일한 수단이었던 시절에 등장했다. axios는 쉽게 말하면, XHR를 개발자가 사용하기 쉽게 쓸 수 있도록 만든 HTTP 클라이언트다. 유용한 도구인 셈이다.

그리고 재밌는 점이 하나 있는데, Promise가 정식으로 사양(ES6)으로 도입된 게 2015년인데, axios는 그보다 약간 먼저 등장 했음에도, 초기 설계부터 “Promise 기반 API”를 지원하도록 만들어 졌다는 점이다. (정확한 내용은 자료 조사를 했는데 잘 안나와서, 시기상 정확한 정보가 아닐 수도 있다. 하여튼, axios 자체가 “Promise-based”인 것만 알아두면 될 것 같다.)

axios가 혁명이었던 이유는 단순하다. axios는 개발자 손에서 XHR의 모든 귀찮음을 없애줬기에.. 이건 코드만 봐도 확 체감된다.

1
2
3
4
5
6
import axios from "axios";

axios
  .get("https://api.example.com/data")
  .then((res) => console.log(res.data))
  .catch((err) => console.error("에러:", err));

끝. open, send, onload, JSON.parse()? 이딴 거 안쓴다. 딱, 위의 코드처럼만 하면 된다. (오리지날 XHR 보다가, 이거 보니까 진짜 선녀같네) axios는 내부적으로 XMLHttpRequest를 사용하지만, 그걸 Promise 기반으로 감싸서 fetch처럼 깔끔하게 만들어 줬다.

Promise 기반으로 감싸준 덕분에 비동기 통신이 깔끔하고, 예측 가능하고, “사람이 읽기 편한 코드”가 됐다고 볼 수 있다.

axios가 왜 이렇게까지 사랑 받은건가?

한마디로 말하자면, axios는 “실무형 DX(Developer Experience)의 완성체”라고 볼 수 있다. 즉, 단순히 돌아가기만 하는 게 아니라, 현실적인 개발 환경에서 정말 편하게 쓸 수 있는 구조였다.

표로 왜 이렇게까지 사랑 받았는지.. 이유 좀 정리해 보면 아래처럼 정리해 볼 수 있을 것 같다.

기능설명
Promise 기반.then(), .catch(), async/await 완벽 지원
자동 JSON 파싱res.data 바로 접근 가능
에러 자동 throw4xx, 5xx 응답 자동 예외 처리
요청/응답 인터셉터 (난 이걸 제일 애용했던 것 같다)요청 전/후 공통 로직 관리 가능
타임아웃 설정 가능timeout 옵션 기본 제공
Node.js 지원서버 사이드 렌더링(SSR) 환경까지 지원
전역 설정 가능axios.defaults.headers.common[...] 같은 글로벌 헤더 관리 가능

이런 장점들.. 덕분에 axios는 빠른 시간 안에 사실상의 업게 표준이 되었다. fetch가 등장한 지금까지도, 많은 프로젝트들에서 axios 를 사용하는 걸 보면.. “HTTP 요청 = axios”는 거의 공식이라 느껴졌다. (적어도 나한테까진)

실무 및 많은 프로젝트에서 사용하는 axios 코드를 보면서, 왜 이게 그렇게 좋은 것인지 좀 더 자세히 파보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const api = axios.create({ // 초기 세팅
  baseURL: "https://api.example.com",
  timeout: 5000,
  headers: { "Content-Type": "application/json" }, // 헤더 세팅을 여기서 한 번에 처리
});

// 요청 전 인터셉터
api.interceptors.request.use((config) => {
  console.log("요청 보냄:", config.url);
  return config;
});

// 응답 후 인터셉터
api.interceptors.response.use(
  (res) => res,
  (err) => {
    console.error("에러 발생:", err.message); // 에러 처리를 한 곳에서!
    return Promise.reject(err);
  }
);

async function getUserData() {
  try {
    const res = await api.get("/users");
    console.log("유저 목록:", res.data);
  } catch (err) {
    console.error("API 요청 실패:", err);
  }
}

이 구조, 보기만 해도 마음이 편안해 지지 않는가…(?) 매번 헤더 세팅 안 해도 되고, 에러 처리 로직도 한 곳에서 관리 가능하고, 인터셉터로 토큰 갱신 로직이나 공통 로깅까지 처리할 수 있는 걸 확인 가능하다.

그치만, 지금은 fetch 도 표준 API로 나와있는 상태다. 웹 생태계의 기술 기반이 바뀌었는데, 여전히 axios 는 XHR 기반이다. 그리고, 여전히 수많은 프로젝트들은 axios 를 그대로 유지했다. 왜냐면, axios는 표준보다 편리한 라이브러리였기 때문이다. 많은 사람들이 자연스레 이런 생각을 하게 되었다.

fetch를 사용하기 편하게 해주는 유용한.. 라이브러리는 없는가?

개발자들은 항상 답을 찾는다… 늘 그랬듯이(?) (감사합니다, 항상 잘 가져다 쓰고 있습니다)

그런 고민 속에서 등장한 게 바로 fetch 시대의 새로운 경량 라이브러리, ky 다.



🪶 fetch 시대의 새로운 얼굴 - ky

이제 본격적으로 fetch 세대의 이야기를 해보자. 이 시대의 핵심 키워드는 단 하나다, “가벼움”, 그리고 이러한 철학을 바탕으로 만들어진 경량 라이브러리 ky, 탐구해 보도록 하자.

ky의 등장 배경 - “표준은 좋아, 근데 손맛이 없어(?)”

axios 는 정말 잘 만든 라이브러리다. 국밥 같은 친구다. 근데.. 점점 “무겁다”는 말이 곳곳에서 들리기 시작했다. 패키지 사이즈도 좀 큰 것 같고.. XHR 기반이라 브라우저 표준 흐름과는 살짝 동떨어진 느낌?이 상당히 많은 개발자들 사이에서 논의 되었던 것이다.

그런데, axios 를 제쳐두고, 브라우저 표준인 fetch 를 그냥 쌩으로 쓰자니.. 뭐 이게 표준이고 깔끔하긴 하다만.. 직접 써보면 이런 생각이 든다.

1
2
3
4
5
6
7
fetch("/api/data")
  .then((res) => {
    if (!res.ok) throw new Error("요청 실패!!!!");
    return res.json();
  })
  .then((data) => console.log(data))
  .catch((err) => console.error(err));

…깔끔하긴 한데, 매번 이렇게 res.ok 확인하고 이러는게.. 은근 귀찮다. 타임아웃도 지원 안되고 말이여..

이 둘의 장단점을 동시에 잡아보려는 시도가 있었고, 그 중 하나가 바로 ky 였던 것이다.

ky는 말한다 - “난 fetch 기반의 axios임.”

ky의 깃허브 리포지토리 리드미에 나와있는 공식 문구가 참 멋지다. (나만 그렇게 생각하나)

“Tiny and elegant HTTP client based on the Fetch API.”

근데, 말 그대로다. ky는 fetch를 기반으로, axios처럼 사용하기 쉽게 만들어진 경량 HTTP 클라이언트이다. 내부적으로는 전부 fetch를 활용하지만, fetch만을 사용했을 때 고질적으로 불편했던 점(ex. 에러 처리, 재시도 등)을 자동으로 처리해 준다.

fetch로 작성할 수 있는 코드 중.. 복잡할 수 있는 예제를 하나 떠올려 볼 수 있다. 아래와 같이 말이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function createUser(userData) {
  const res = await fetch("https://api.example.com/users", {
    method: "POST", // POST 요청
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(userData),
  });

  if (!res.ok) {
    // 에러 처리도 직접 해야 함
    const errMsg = await res.text();
    throw new Error(`요청 실패: ${errMsg}`);
  }

  return await res.json();
}

try {
  const newUser = await createUser({ name: "허준호", age: 24 });
  console.log("유저 생성 완료:", newUser);
} catch (err) {
  console.error("에러:", err.message);
}

사실 fetch는 표준이고, 기능도 완벽히 동작한다. 문제는.. 위의 코드 보면 알 수 있듯… 매번 귀찮다는 거다. 표준은 깔끔하지만, 매번 이걸 써야 한다고 생각하면 솔직히 좀 끔찍하다. 이런 걸 프로젝트 전체에서 반복한다고 생각해 보자. (생각만해도 벌써부터 손목 터널 증후군 생길듯)

같은 로직을 ky로 쓰면 이렇게 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import ky from "ky";

const api = ky.create({
  prefixUrl: "https://api.example.com",
  headers: { "Content-Type": "application/json" },
  timeout: 5000, // 기본 타임아웃
  retry: { limit: 2, methods: ["get", "post"] }, // 자동 재시도
});

async function createUser(userData) {
  const newUser = await api.post("users", { json: userData }).json();
  console.log("유저 생성 완료:", newUser);
}

try {
  await createUser({ name: "허준호", age: 24 });
} catch (err) {
  console.error("에러 발생:", err.message);
}

크… request 보낼 때, JSON.stringify()도 자동으로 해주고, 4xx, 5xx 에러도 자동으로 던져준다. 게다가 재시도(retry), 타임아웃(timeout) 기능도 기본 내장이라, axios처럼 axios-retry 같은 패키지를 따로 깔 필요도 없다. 그리고 axios처럼 ky.create() 메소드로 인스턴스를 만들어 prefixUrl, headers 등을 한 번에 세팅할 수 있는 거 너무 좋습니다. fetch의 장점(가벼움, 표준 기반..)은 그대로 두되, 귀찮았던 부분만 자동화 시켜준 것을 볼 수 있다.

필요한 것만, 가볍게 챙겨볼게요

ky는 본질적으로, fetch의 본질은 그대로 두고, 실무에서 진짜 귀찮은 부분만 자동화한 라이브러리라고 볼 수 있다. 아래의 기능들을 보면, 이 ky 라이브러리의 철학을 확실히 확인해 볼 수 있다.

기능설명
fetch 기반내부적으로 표준 fetch API를 그대로 사용. 즉, 최신 브라우저 및 Node 환경 완벽 호환
자동 JSON 직렬화/역직렬화{ json: data } 옵션으로 JSON.stringify() 자동 처리, .json() 메서드로 응답 바로 파싱
자동 에러 throw4xx, 5xx 응답을 자동으로 예외 처리. if (!res.ok) 같은 문법 이제 안 써도 됨
Retry & Timeout 내장retry, timeout 옵션 기본 제공. axios처럼 외부 패키지 안 깔아도 됨
인스턴스 생성 지원ky.create() 로 axios처럼 기본 설정 객체(prefixUrl, header 등) 만들기 가능
가벼운 사이즈6KB 미만의 경량 패키지. fetch 기반이라 의존성도 없음
모던 환경 최적화ESM 기반으로 빌드되어, Vite·Next.js 등 최신 프론트엔드 환경에서 바로 사용 가능

이런 특성 덕분에 ky는 “새로운 개념을 배워야 하는” 라이브러리라고 보기 보단.. 이미 알고 있는 fetch의 문법을 그대로 쓰면서(모르면 공부해라 / 네 선생님), 실무 피로도를 줄여주는 라이브러리라고 볼 수 있다. 쉽게 말하자면.. fetch에게 호감을 느끼게 만들어주는 친구… 정도랄까.



⚖️ axios / ky - 뭐 선택해야 하는 건데요 그래서?

모든 프레임워크, 라이브러리 선택 등.. 이런 기술적인 고민을 할 때처럼, axios랑 ky도 마찬가지인 것 같다. 이 둘 중에서 뭐가 더 낫냐… 에 대한 답은.. 결국 상황과 취향에 따라 갈릴 수 있다. 취향…은 좀 애매한 표현이니까, 상황에 따라 고르는 게 맞다고 본다.

axios - 여전히 현업에서 스테디셀러

axios의 가장 큰 장점은 단연 “완성도”다. 이미 수많은 프로젝트와 Next.js 등의 프레임워크에서 쓰였고, 다양한 환경(브라우저 + Node + SSR)에서도 안정적으로 돌아간다. 인터셉터 지원, 요청 취소/전역 설정, 자동 JSON 파싱, 타임아웃 옵션 등.. 다양한 프로젝트 및 실무에서 DX를 높여주는 많은 기능들이 내장되어 있다.

즉, axios는 실무에서의 “국밥”같은 존재라 할 수 있다. 조금 무겁더라도 든든하고, 이미 많은 사람들에게 익숙한 존재이다. 하지만, 아래와 같은 대표적인 단점들도 있다.

  • 내부적으로 XMLHttpRequest(XHR) 기반이라 fetch보다 레거시함
  • 패키지 사이즈가 크고(30KB 이상), 트리셰이킹 효과도 제한적
  • 최신 브라우저 표준(fetch, AbortController 등)과의 호환성이 낮다

그래서 “간단한 SPA” 서비스나, “경량 프로젝트”에는 오히려 과한 감이 없지 않아 있다. 하지만 복잡한 API 관리, 인증, 인터셉터가 필요한 서비스라면 여전히 최고다. 아래와 같은 상황들에서 axios 쓰는 것을 추천해 본다.

  • 대규모 서비스 (e.g. 대기업, 은행, 쇼핑몰 등)
  • 팀 단위 개발 / 공통 인터셉터 필수 구조
  • SSR, Node.js 기반 서버 통신

ky - 가볍지만 똑똑한 녀석

ky는 fetch 기반이라는 점에서 이미 성격이 다르다. 기능이 적다기보다, “필요한 기능만 남긴” 타입이라고 볼 수 있다. fetch의 철학 그대로, ‘표준을 해치지 않으면서 실용성을 더한 라이브러리’라 볼 수 있다. axios가 제공하는 자동 JSON 직렬화/역직렬화, 자동 에러 throw 기능 뿐 아니라, 아래와 같은 명확한 장점들이 추가적으로 존재한다.

  • 가볍다. (6KB 미만, 의존성 제로)
  • Retry / Timeout 기본 내장

하지만, 아래와 같은 대표적인 단점들 또한 있다.

  • 인터셉터 같은 고급 제어는 지원하지 않는다.
  • 구형 브라우저나 IE 환경에서는 동작하지 않는다.
  • 아주 복잡한 서비스 구조(예: GraphQL + 인증 + 로깅)에는 약하다.
  • SSR 대응이 제대로 되지 않는다 (그래서, 토스에서는 이를 해결하기 위해 @toss/ky라는 라이브러리를 만들어서 slash 라이브러리에서 제공하고 있기도 하다..)

그래서, ky는 “규모보다 속도”를 중시하는 개발자에게 맞다고 볼 수 있다. 빠르고, 단순하고, 표준적인 코드로 끝내고 싶은 때 딱 좋다고 볼… 수 있을 것 같다. 아래와 같은 상황들에서 ky 쓰는 것을 추천해 본다.

  • 중·소규모 SPA, 사이드 프로젝트
  • React/Vite 기반 프론트엔드
  • 빠른 초기 세팅, 간결한 코드 중심의 프로젝트

결국, axios나 ky나 사실 같은 철학으로 시작한 라이브러리다.

“개발자가 더 사람답게 일할 수 있게(?)”

기술의 목적은 늘 그거니까. 무겁든, 가볍든, 중요한 건 도구 그 자체가 아니라, 프로젝트와 사람의 리듬에 맞는 선택을 하는 거다. 이 리듬이, 기술의 발전과 함께 조금씩 더 부드럽고, 사람다운 쪽으로 흘러가고 있는 것 같다. 그래서 난 너무 좋다 (계속 너무 좋은 것들이 나오고 있거든)



정리

ky 라는 내겐… 다소 생소한 라이브러리를 새로운 프로젝트에 도입하려다.. 그것에 대해서 조사를 시작하더니, 이에 더 파고 들어가서 비동기 처리의 역사까지 알아보는 아주 TMI 대폭발의 시간이었다. 그럼에도, 난 이번 시간을 통해서, 왜 그렇게 다양한 프론트엔드 개발자를 뽑는 IT 회사에서, 개발형 코딩테스트에 Promise 객체 문제랑, fetch 문제를 내는지 알겠더라. (기초가 참 중요하네, 특히 다양한 AI Agent들의 도움을 받아서 코딩하는 지금 세상에서는 특히 더욱 더..)

이것들이 결국 브라우저 비동기 처리, 통신 방식의 핵심이었던 것이다. 그리고, 난 이것에 대해 깊이 파고 들지 못하고 그냥 이런 것들이 있구나! 정도만 알아두고… 그것을 사용하기 편하게 만든 껍데기들(axios, 그리고 이번에 새로 알게 된 ky 등)의 사용법만 대강 알고 있던 것이다. 이렇게, 라이브러리들의 베이스가 되는 표준들을 알아보는 시간을 가져보니, 더욱이 이러한 표준들을 사용하기 쉽도록 라이브러리를 개발하고, 여기에 기여하고 하는 개발자들을 더욱 리스펙하게 되었다.

나도 언젠간, 이렇게 대규모 라이브러리/프레임워크에 contribute 하면서, 사람들에게 기술로 기여하며, 행복하게 만들어주는 사람이 되고 싶다. 공부 열심히 해야지.

히히 JS 조아
JS 좋아요, 웹개발 좋아요, 맥북 좋아요. 파이어폭스 너무 좋아요(사실 크롬씀)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.