Next.js App Router로 제품 만들기: 이제 더 이상 Axios를 쓰지 않기로 했습니다

Next.js App Router로 제품 만들기: 이제 더 이상 Axios를 쓰지 않기로 했습니다

안녕하세요, 디어코퍼레이션의 김명재입니다.

토이프로젝트가 아닌 실제 제품에 Next.js App Router를 사용해보셨나요? 이번에 저희 회사에서 웹 애플리케이션을 새로 세팅할 필요가 생겼고 저는 과감하게 Next.js의 App Router를 선택했습니다(회사에서는 기존 제품에도 Next.js를 사용하고 있었고 저도 5년째 사용하고 있습니다). 이 글에서는 App Router를 선택한 배경과 App Router에서 권장하는 fetch를 사용하기 위해 어떻게 정든 Axios를 떠나보냈는지를 중심 주제로 다룹니다.

Next.js App Router와 fetch

Next.js는 2022년 10월 25일 컨퍼런스에서 app directory를 발표했습니다. App directory는 기존의 Page Router를 대체합니다. 2023년 5월 4일 Next.js 13.4.0버전에서 app direoctry는 beta 딱지를 벗고 stable이 되었습니다. 저희 회사에서 도입한 시점인 7월달엔 13.4.6버전이 릴리즈되어 있었고, stable 변경 이후 6번의 업데이트가 있었으니 이제 제품에 써봐도 괜찮을 것 같다는 느낌이었습니다(App directory는 13.2버전부터 App Router라는 이름으로 불리기 시작했습니다. 이하에서는 정식 명칭인 App Router로 적겠습니다).

그러나 느낌만으로 결정할 수는 없으니... 제품에 적용하기 전에 먼저 제가 9년째 유지보수하고 있는 제 블로그를 App Router로 다시 구현하면서 학습했습니다. 디렉토리 구성, 서버 사이드 렌더링, 비동기 호출(API 요청), 에러처리, 캐시 등 실제 제품을 구현할 때 고려해야 할 것들을 신경 쓰며 학습했습니다. App Router를 사용한 블로그의 모든 기능이 문제없이 작동했고 App Router에 익숙해지면서 자신감이 생겼습니다.

App Router를 사용하면서 가장 강하게 들었던 느낌은 AppRouter가 Page Router보다 훨씬 더 서버 친화적으로 코드를 작성하도록 유도한다는 것이었습니다. 이전엔 클라이언트 렌더링을 위한 프레임워크에 서버쪽 기능을 추가한 느낌이었다면, App Router부터는 렌더링의 주도권이 완전히 서버쪽으로 넘어갔다는 느낌을 받았습니다. 옛날의 클래식한 웹 프레임워크의 향수가 느껴지는 그런 느낌. 하지만 모던한.

App Router 문서를 읽어보니 fetch사용을 권장하고 있었습니다. Next.js 서버쪽에서 캐싱 관련 기능을 fetch를 확장해서 제공하고 있고, fetch가 아닌 다른 라이브러리를 사용한다면 직접 캐싱 관련 설정을 해주어야 합니다.

현재 회사는 물론 일해왔던 회사들에서도 제품에 fetch가 아닌 Axios를 사용했었습니다. 지금까지 개발했던 제품들이 Axios의 번들 크기(11.2KB, minified + gzipped)도 허용하지 못할 만큼 용량 최적화가 필요하진 않았기 때문입니다. 익숙한 Axios를 사용해서 빠르게 개발하는 게 이제까지는 좋은 선택이었지만, 프레임워크에서 fetch를 쓰라고 하니 드디어 미루고 미뤘던 fetch를 학습해야 할 때가 온 것 같았습니다. Next.js에서 권장하는 fetch를 거부하고 굳이 Axios를 고집하면서 Next.js에 캐시 관련 설정을 수동으로 관리할 명분도 없었습니다.

fetch는 이런저런 게 아쉬워요!

아래 항목들은 Axios에서 제공하지만 fetch에는 없어서 아쉽다고 느꼈던 기능들입니다.

  1. baseUrl을 설정할 수 없다.
  2. default header를 설정할 수 없다.
  3. request, response interceptor가 없다.
  4. json serialize, deserialize를 직접 해야 한다.

Axios는 인스턴스를 생성할 때 baseUrl과 default header를 설정하면 모든 요청에 대해서 url의 origin이 비어있는 경우 baseUrl을 origin으로 사용하고 default header를 넣어줍니다.

import axios from 'axios';

const axiosInstance = axios.create({
    baseURL: 'https://your-api-endpoint.com'
    headers: {
        'Accept': 'application/json',
    }
})

// https://your-api-endpoint.com/article 에
//  'Accept: application/json' 헤더가 붙어있는 get요청을 보낸다
axios.get('/article')

request interceptor는 요청을 보내기 전에, response interceptor는 요청을 받은 후에 실행합니다.

axiosInstance.interceptors.request.use(config => {
  // 매 요청마다 localStorgae의 토큰을 조회해서 헤더에 추가한다.
  config['Authorization'] = localStorage.getItem('token');
  return config;
})

axiosInstance.interceptors.request.use(undefined, error => {
  // Unauthorized 응답을 받으면 가지고 있던 토큰을 제거한다.
  if (error.isAxiosError && e.response?.status === 401) {
    localStorage.removeItem('token');
  }
})

Axios는 request config의 data에 object를 넣을 수 있고, response도 json이라면 자동으로 object로 만들어줍니다.

axiosInstance.post<{id: number}>('/create', {
  title: 'title',
  content: 'content'
})
  .then(response => response.data)
  .then(console.log)

// 출력 예시: {id: 1}. 문자열이 아니라 객체가 나온다.

Axios에 다른 여러가지 기능들도 있지만, 특히 이 4가지 기능이 없다면 모든 요청에 매번 같은 설정값을 넣어줘야 합니다. 변경사항이 생기기라도 하면 fetch함수를 호출할 때마다 같은 작업을 해야 하고, 혹시 뭔가를 빠뜨린다면 장애로 이어질 수도 있습니다.

누가 만들어 놓지 않았을까?

저 혼자 Next.js를 쓰는 것도 아니고 저 혼자 fetch를 쓰는 것도 아니니 당연히 누군가 위 기능을 제공하는 라이브러리를 만들었을 텐데요, 아래는 제가 찾은 솔루션들과 장단점들입니다.

1. fetch-intercept

https://www.npmjs.com/package/fetch-intercept

fetch와 interceptor 관련 내용으로 검색을 하면 가장 먼저 찾을 수 있는 라이브러리입니다. 하지만 baseUrl과 default headers 기능을 제공하지 않고 전역에 선언되어있는 fetch를 몽키패치 해버리기 때문에 이 라이브러리를 선택할 수는 없었습니다.

Next.js에서 fetch를 사용하는 3가지 맥락

Next.js에서는 fetch를 다양한 맥락에서 사용하게 됩니다. 제 멘탈 모델에서는 크게 3가지가 있습니다.

  1. 클라이언트 사이드(웹브라우저)에서 AJAX 요청
  2. 서버 사이드에서 렌더링하기 위한 AJAX 요청
  3. 서버 사이드에서 렌더링이 아니라 프록시 역할로 AJAX 요청을 하고 응답을 그대로 내보냄(route.ts 파일)

하나의 fetch에서 이 3가지 상황을 모두 대응하는건 좋은 선택이 아니므로 fetch-intercept는 기각입니다.

장점
  • 가벼운 번들 사이즈 (762 Byte, minified + gzipped)
단점
  • baseUrl 기능이 없다.
  • default headers 기능이 없다.
  • 전역에 선언된 fetch 에 몽키패치를 해버리기 때문에 모든 fetch 호출에 request, response interceptor가 적용된다. fetch를 다양한 맥락에서 사용해야 하는 Next.js에는 어울리지 않는다.

2. wretch

https://github.com/elbywan/wretch

wretch는 fetch-intercept 보다 많은 기능을 제공합니다. baseUrl과 default header도 설정할 수 있습니다. interceptor도 middleware라는 이름으로 제공하고 있습니다. 전역 fetch를 건드리지 않고 wretch instance를 생성해서 사용하는 방식이기 때문에 side effect도 없어서 안심하고 사용할 수 있습니다.

하지만 Axios처럼 400이상의 응답 상태에 대해서 모두 에러를 던지기 때문에 Next.js 서버쪽에서 프록시 역할을 할 때 wretch는 적합하지 않습니다. 외부 서비스 API를 호출하고 응답을 그대로 반환하는 프록시 역할에 wretch를 사용한다면, 400이상의 응답 상태 때문에 던진 예외를 다시 잡아서 정상적인 Response 객체로 만들어서 return해야 하는데 그럴거면 그냥 원본의 fetch를 사용하는게 낫습니다.

// file: src/app/sample/api/[[...path]]/route.ts
// Next.js의 route.ts에서 fetch로 외부 서비스를 호출한다.
// 상태 코드에 따른 예외를 던지지 않기 때문에 Response를 그대로 클라이언트에 전달하기만 하면 된다.

import { NextRequest } from "next/server";  
  
const pathPrefix = "/sample/api";
  
export async function GET(request: NextRequest) {  
  const { nextUrl, method, headers } = request;  
  
  return fetch("https://postman-echo.com" + nextUrl.pathname.replace(pathPrefix, ""), {  
    method,  
    headers,  
  });  
}

게다가 wretch는 fetch와 호환되지 않는 자신만의 인터페이스를 가지고 있기 때문에 wretch 인스턴스를 생성할 때 뿐만 아니라 사용할 때도 wretch만의 인터페이스를 학습해야 한다는 점도 아쉬웠습니다. 위에서 이야기한 Next.js의 3가지 맥락을 하나의 라이브러리로 통합하지 못하고 파편화되는 건 만족스럽지 않기 때문에 wretch도 선택할 수 없었습니다.

장점
  • 다양한 기능 (baseUrl, default header, interceptor 모두 지원)
  • 상대적으로 가벼운 번들 사이즈 (1.9KB, minified + gzipped)
단점
  • Axios처럼 response status에 따라 에러를 던지기 때문에 Next.js의 route.ts에서 사용하려면 추가 조치가 필요하다. 그럴바엔 그냥 fetch를 쓰는게 낫다.
  • wretch 인스턴스를 생성할 때 뿐만 아니라 사용할 때도 fetch와 호환되지 않는 자신만의 인터페이스를 가지고 있기 때문에 추가로 학습이 필요하다.

3. ofetch

https://github.com/unjs/ofetch

ofetch는 Nuxt.js에서 기본으로 사용하는 라이브러리입니다. wretch와 마찬가지로 다양한 기능을 제공하고 있기 때문에 기능만으로는 부족함이 없었고, 상태 코드에 따른 에러를 던지지 않게 설정할 수 있는 옵션도 제공하고 있습니다(https://github.com/unjs/ofetch/issues/207). wretch와는 다르게 자신만의 인터페이스를 정의한 게 아니라 fetch 인터페이스의 호환을 유지하면서 추가 기능을 붙였기 때문에 wretch보다는 필요한 학습량도 적고, proxy 역할을 할 때도 특별한 변환작업 없이 사용할 수 있습니다.

하지만 검색해 봐도 ofetch를 Next.js의 App Router에서 사용해 봤다는 레퍼런스가 없었기 때문에 만약 ofetch를 선택했더라면 Next.js와 잘 맞을지, 그리고 문제가 생겼을 때 트러블슈팅을 직접 해야 한다는 것도 부담이었습니다.

번들 사이즈도 fetch-intercept, wretch에 비해 많이 컸습니다 (5KB). 제가 필요한 건 그저 baseUrl과 default header 설정, 그리고 interceptor인데... 번들 사이즈는 큰 이슈는 아니었지만 그래도 아쉽긴 마찬가지였습니다.

장점
  • 다양한 기능 (baseUrl, default header, interceptor 모두 지원)
  • Next.js의 route.ts파일에서도 사용할 수 있도록 response 상태와 상관 없이 예외를 던지지 않고 응답을 전달할 수 있다.
  • fetch와 호환되는 인터페이스를 가졌다.
단점
  • Next.js에서 ofetch를 사용하는 레퍼런스가 부족하다. App Router도 새로운 기술인데 여기에 ofetch까지 얹어서 사용하기는 부담스럽다.
  • 필요한 기능에 비해 번들 사이즈가 크다 (5KB)

쓸만한 라이브러리가 없다!

도저히 마음에 드는 라이브러리가 없어서 그냥 직접 만들었습니다(??)

사실은 라이브러리를 홍보하기 위해 작성한 글입니다

제가 원하던 기능은 딱 3가지(baseUrl, default headers, interceptors)였고 Next.js에서 제공하는 fetch 구현체를 사용할 수 있어야 했습니다. fetch관련 라이브러리중에 그나마 찾을 수 있었던게 위 3개의 라이브러리였고 모두 만족스럽지 않아서 그냥 직접 구현해서 쓰기로 했습니다. 복잡한 기능이 필요한게 아니었고, 처음부터 외부에 공개할 라이브러리를 만들려고 한 것도 아니었기 때문에 빠르게 만들었습니다.

라이브러리로 공개할 예정에 없던 코드

(fyi. 저희 회사는 반말을 사용하고 있습니다 😄)

(스크린샷의 주석 2번 항목은 잘못 적혀있습니다. 'generic으로 response body type을 정하는 기능'도 함수 안에서 구현하려고 하다가 바깥에서 처리하도록 결정했는데 PR 올릴 때는 주석이 아직 남아 있었네요)

그저 내부용으로 사용하려고 했던, 1시간 만에 만든 간단한 코드였습니다. 초기버전에서는 상태에 따른 예외처리 옵션도 설정할 수 있었지만, 공개한 라이브러리에는 상태 코드에 따른 예외 처리 기능은 제거했습니다. 사용하시는 분들이 interceptor로 충분히 직접 구현하실 수 있기 때문입니다.

스크린샷의 returnCustomizedFetch는 간단한 고계함수(함수를 인수로 받거나 결과 값으로 반환하는 함수)입니다. 매개변수로 받은 defaultOptions가 적용된 fetch함수를 return합니다. fetch를 호출하기 전에 request interceptor에서 전처리를 하고 호출해서 받은 response를 response interceptor에서 후처리 합니다. return하는 함수는 fetch 함수와 완전히 같은 인터페이스를 갖습니다.

만들어 놓고 보니 조금만 손보면 범용적으로 사용할 수 있어 보였습니다. 초기 버전에서는 전역에 선언되어 있는 fetch를 직접 사용했지만, fetchdefaultOptions 으로 받을 수 있다면 모든 환경에서 사용할 수 있는 코드를 만들 수 있을 것 같았습니다.

재귀적인 타입 선언, 무한으로 즐기는 interceptor

  1. defaultOptions로 받은 fetch에 baseUrl, default headers, interceptor를 적용한 fetch함수를 return합니다.
  2. fetch함수를 다시 defaultOptions에 넣어서 새로운 기능을 추가합니다.
  3. 2번에서 만든 함수를 다시 defaultOptions에 넣어서 새로운 기능을 추가합니다.
  4. 3번에서 만든 함수를 다시...

전역에 선언된 fetch를 사용하는 게 아니라 매개변수로 fetch를 받아서 사용한다면 코드는 외부 세계를 참조하지 않는 순수한 함수가 됩니다. 여기서 '순수한'이라는 의미는 함수형 프로그래밍의 관점에서 순수하다는 의미인데, 함수가 스코프 바깥 세계를 참조하지 않기 때문에 같은 매개변수를 넣으면 언제나 동일한 fetch함수를 return한다는 뜻입니다.

fetch를 return 하는 함수가 매개변수로 fetch를 받는 재귀적인 타입 구조는 사용자가 원하는 만큼 필요할 때마다 fetch를 쉽게 확장할 수 있게 해줍니다. 뭔가 아름다운 구조를 만든 것 같아서 저는 신나서 PR을 올렸습니다.

스크린샷의 fetchOnClient는 3가지 기능을 합성한 fetch함수입니다.

export const fetchOnClient = returnFetchForDeerBackend({
  fetch: returnFetchThrowingErrorByStatusCode({
    fetch: returnFetchWithLoader({
      headers: defaultHeaders,
      baseUrl: ENV.NEXT_PUBLIC_BASE_URL,
    }),
  }),
});
  1. 비동기 요청을 보내기 전에 로딩화면을 보여주고 요청이 끝나면 로딩화면을 제거합니다.
  2. 400이상의 상태를 받았을 때 예외를 던집니다.
  3. response body에서 특정한 필드를 추출합니다.

만들어 놓고 보니 returnFetch함수는 지금까지 배우고 경험했던 코드에 비추어 봤을 때 부끄럽지 않은 코드였고, 아직 한국에서 Next.js의 App Router가 대중화된 상황은 아니니 라이브러리로 만들어 공개한다면 한국의 Next.js 생태계에 기여할 수 있을 것 같았습니다. 그래서 공개합니다: return-fetch

return-fetch, A simple and powerful high order function to extend fetch

return-fetch 한번 맛보세요!

위에서 말씀드렸듯이 return-fetch는 Next.js의 클라이언트 사이드와 서버 사이드에서 모두 사용할 수 있는 fetch 확장 라이브러리를 찾다 찾다 못 찾아서 직접 구현한 라이브러리입니다. 딱 필요한 3가지 기능만을 구현하면서 다른 라이브러리들이 가진 단점을 모두 제거했습니다.

실제 코드는 여기서 볼 수 있습니다: https://github.com/deer-develop/return-fetch/blob/main/src/index.ts#L148. 짧고 간단한 코드지만 이 코드 덕분에 저는 Axios 없이도 빠르게 제품 개발을 할 수 있었습니다. 이 코드를 작성하면서 중요하게 생각했던 것들과 어떻게 응용할 수 있는지 자세하게 말씀드리겠습니다.

return-fetch의 핵심 철학

  1. 딱 3가지 기능만 구현합니다
    - baseUrl, default headers, interceptors
  2. 아무런 라이브러리를 의존하지 않고 오직 타입스크립트만 사용합니다.
  3. LSP(Liskov substitution principle) 를 지킵니다.
    - 기존에 fetch를 사용하고 있던 모든 곳을 return-fetch로 교체하더라도 문제없이 작동합니다.
  4. 사용자가 interceptor를 쉽게 추가할 수 있어야 합니다.
  5. 사용자가 SRP(Single responsibility principle) 를 지킬 수 있어야 합니다
    - 사용자가 직접 한 번에 하나의 기능을 제공하는 interceptor를 작성하고, interceptor가 적용된 fetch를 조합하면서 원하는 기능이 적용된 fetch를 생성할 수 있습니다.

return-fetchfetch함수를 return하는 함수입니다. 매개변수로 baseUrl, default header와 interceptor를 받아서 기본값이 적용된 fetch함수를 만듭니다.

import returnFetch from "return-fetch";

const fetchExtended = returnFetch({
  baseUrl: "https://jsonplaceholder.typicode.com",
  headers: { Accept: "application/json" },
  interceptors: {
    request: async (args) => {
      console.log("********* before sending request *********");
      console.log("url:", args[0].toString());
      console.log("requestInit:", args[1], "\n\n");
      return args;
    },

    response: async (response, requestArgs) => {
      console.log("********* after receiving response *********");
      console.log("url:", requestArgs[0].toString());
      console.log("requestInit:", requestArgs[1], "\n\n");
      return response;
    },
  },
});

fetchExtended("/todos/1", { method: "GET" })
  .then((it) => it.text())
  .then(console.log);

returnFetch의 return값인 fetchExtendedfetch대신 사용하면 됩니다. fetchExtendedfetch와 완전히 동일한 인터페이스(매개변수와 리턴타입)를 갖기 때문에 기존에 사용하던 fetch처럼 그대로 사용하면 됩니다.

위 예제는 API요청을 보내기 전에 request interceptor에서 로그를 출력하고, API요청의 응답을 받은 후에 repsonse interceptor에서 로그를 출력합니다. request interceptor의 매개변수는 baseUrl과 default header가 적용된 상태입니다.

request interceptor의 return값을 API요청할 때 사용하기 때문에 요청을 보내기 전에 request interceptor에서 요청에 대한 전처리를 할 수 있고, response interceptor의 return값이 fetchExtended함수의 return값이 되기 때문에 response interceptor에서 응답에 대한 후처리를 할 수 있습니다.

https://stackblitz.com/edit/return-fetch 여기에서 실제로 라이브러리를 사용해보실 수 있습니다.

return-fetch의 장단점

장점

  • 가볍습니다 (733Byte, minified + gzipped)
  • fetch가 있는 어떤 실행환경에서도 사용할 수 있습니다 (Node.js, 웹브라우저, React Native, Web Worker 등등).
  • 모든 fetch polyfill과 호환됩니다.
  • Side effect가 없습니다. 전역 fetch를 건드리지 않습니다. 외부 세계의 상태를 바꾸지 않으므로 안심하고 사용할 수 있습니다.
  • 재귀적인 타입 선언 덕분에 특별한 처리 없이도 interceptor를 원하는 만큼 추가할 수 있습니다.
  • 테스트 커버리지 100%를 유지합니다. 모든 기능을 테스트했습니다.
  • Next.js App Router환경에서 잘 작동합니다. Next.js를 사용하시는 분들이라면 마음놓고 쓰셔도 됩니다.

단점

  • 뼈대만 제공합니다. 필요한 기능이 있다면 직접 구현해야 합니다.
  • baseUrl과 default header 기능도 request interceptor로 직접 구현할 수 있지만, 이 두 가지 기능은 정말 많이 쓰이기 때문에 편의상 구현해놨습니다.
  • 신규 라이브러리라서 쓰는 사람이 별로 없습니다(😢).

필요한 기능이 있나요? 제가 만들어 놨습니다

이렇게 작고 간단한 라이브러리로 뭘 할 수 있을까요? 제가 원하는 기능은 모두 다 만들 수 있었습니다.

예를 들어서

  1. 요청 보내기 전에 로딩 화면을 띄우고 요청 보낸 후에 로딩 화면을 제거하기
  2. 응답으로 400이상의 상태를 받았을 때 에러를 던지기
  3. request body를 string이 아니라 json으로 받고, response body도 json으로 return하는 fetch함수 만들기
  4. 401응답을 받았을 때 쿠키를 제거하고 요청을 다시 한 번 보내기
  5. returnFetch 를 사용하는 함수를 조합해서 기능 합치기
  6. 전역 fetch 교체하기

이 정도가 있습니다. 필요한 게 있으시면 https://github.com/deer-develop/return-fetch/issues 이슈에 올려주세요! 제가 한 번 만들어 보겠습니다 😎

1. 요청 보내기 전에 로딩 화면을 띄우고 요청 보낸 후에 로딩 화면을 제거하기

간단한 예제입니다. returnFetch와 동일한 타입의 함수인 returnFetchWithLoadingIndicator가 return하는 함수를 fetch대신 사용하면 됩니다.

import returnFetch, { ReturnFetch } from "return-fetch";
import { displayLoadingIndicator, hideLoadingIndicator } from "@/your/adorable/loading/indicator";

// Write your own high order function to display/hide loading indicator
const returnFetchWithLoadingIndicator: ReturnFetch = (args) => returnFetch({
  ...args,
  interceptors: {
    request: async (args) => {
      setLoading(true);
      return args;
    },
    response: async (response) => {
      setLoading(false);
      return response;
    },
  },
})

// Create an extended fetch function and use it instead of the global fetch.
export const fetchExtended = returnFetchWithLoadingIndicator({
  // default options
});

https://return-fetch.myeongjae.kim/#1-displayhide-loading-indicator 여기에서 위 코드를 직접 실행해보실 수 있습니다.

다만 실제로 저 코드를 사용하게 되면 API를 호출할 때마다 로딩화면이 깜빡거려서 거슬리는 느낌을 받습니다. 저희 제품에는 응답 지연시간이 200ms 이상일 때만 로딩화면이 보이도록 조치해서 사용하고 있습니다(이 기능도 역시 returnFetch로 구현했습니다).

2. 응답으로 400이상의 상태를 받았을 때 에러를 던지기

import returnFetch, { ReturnFetch } from "return-fetch";

// Write your own high order function to throw an error if a response status is more than or equal to 400.
const returnFetchThrowingErrorByStatusCode: ReturnFetch = (args) => returnFetch({
  ...args,
  interceptors: {
    response: async (response) => {
      if (response.status >= 400) {
        throw await response.text().then(Error);
      }

      return response;
    },
  },
})

// Create an extended fetch function and use it instead of the global fetch.
export const fetchExtended = returnFetchThrowingErrorByStatusCode({
  // default options
});

https://return-fetch.myeongjae.kim/#2-throw-an-error-if-a-response-status-is-more-than-or-equal-to-400 여기에서 위 코드를 직접 실행해보실 수 있습니다.

3. request body를 string이 아니라 json으로 받고, response body도 json으로 return하는 fetch함수 만들기

Axios처럼 response body의 타입을 generic으로 지정할 수 있습니다. 아래 코드는 별도의 패키지 공개했습니다: return-fetch-json

import returnFetch, { FetchArgs, ReturnFetchDefaultOptions } from "return-fetch";

// Use as a replacer of `RequestInit`
type JsonRequestInit = Omit<NonNullable<FetchArgs[1]>, "body"> & { body?: object };

// Use as a replacer of `Response`
export type ResponseGenericBody<T> = Omit<
  Awaited<ReturnType<typeof fetch>>,
  keyof Body | "clone"
> & {
  body: T;
};

export type JsonResponse<T> = T extends object
  ? ResponseGenericBody<T>
  : ResponseGenericBody<unknown>;


// this resembles the default behavior of axios json parser
// https://github.com/axios/axios/blob/21a5ad34c4a5956d81d338059ac0dd34a19ed094/lib/defaults/index.js#L25
const parseJsonSafely = (text: string): object | string => {
  try {
    return JSON.parse(text);
  } catch (e) {
    if ((e as Error).name !== "SyntaxError") {
      throw e;
    }

    return text.trim();
  }
};

// Write your own high order function to serialize request body and deserialize response body.
export const returnFetchJson = (args?: ReturnFetchDefaultOptions) => {
  const fetch = returnFetch(args);

  return async <T>(
    url: FetchArgs[0],
    init?: JsonRequestInit,
  ): Promise<JsonResponse<T>> => {
    const response = await fetch(url, {
      ...init,
      body: init?.body && JSON.stringify(init.body),
    });

    const body = parseJsonSafely(await response.text()) as T;

    return {
      headers: response.headers,
      ok: response.ok,
      redirected: response.redirected,
      status: response.status,
      statusText: response.statusText,
      type: response.type,
      url: response.url,
      body,
    } as JsonResponse<T>;
  };
};

// Create an extended fetch function and use it instead of the global fetch.
export const fetchExtended = returnFetchJson({
  // default options
});

//////////////////// Use it somewhere ////////////////////
export type ApiResponse<T> = {
  status: number;
  statusText: string;
  data: T;
};

fetchExtended<ApiResponse<{ message: string }>>("/sample/api/echo", {
  method: "POST",
  body: { message: "Hello, world!" }, // body should be an object.
}).then(it => it.body);

약간 길지만 기본적인 구현은 이전 예제들과 동일합니다. https://return-fetch.myeongjae.kim/#3-serialize-request-body-and-deserialize-response-body 여기에서 실행해볼 수 있습니다.

4. 401응답을 받았을 때 쿠키를 제거하고 요청을 다시 한 번 보내기

let retryCount = 0;

const returnFetchRetry: ReturnFetch = (args) => returnFetch({
  ...args,
  interceptors: {
    response: async (response, requestArgs, fetch) => {
      if (response.status !== 401) {
        return response;
      }

      console.log("not authorized, trying to get refresh cookie..");
      const responseToRefreshCookie = await fetch(
        "https://httpstat.us/200",
      );
      if (responseToRefreshCookie.status !== 200) {
        throw Error("failed to refresh cookie");
      }

      retryCount += 1;
      console.log(`(#${retryCount}) succeeded to refresh cookie and retry request`);
      return fetch(...requestArgs);
    },
  },
});

const fetchExtended = returnFetchRetry({
  baseUrl: "https://httpstat.us",
});

fetchExtended("/401")
  .then((it) => it.text())
  .then((it) => `Response body: "${it}"`)
  .then(console.log)
  .then(() => console.log("\n Total counts of request: " + (retryCount + 1)))

response interceptor에서 응답을 확인하고 상태가 401이라면 retry를 합니다. https://return-fetch.myeongjae.kim/#8-retry-a-request 여기에서 실행해보실 수 있습니다.

5. returnFetch 를 사용하는 함수를 조합해서 기능 합치기

제가 returnFetch에서 가장 좋아하는 기능입니다. 이전 예제들은 각각 하나의 기능만 구현했는데, 만약 로딩 화면도 보여주고싶고 상태가 400이상일 때 에러도 던지고싶고 request, response body를 객체로 사용하고 싶으면 어떻게 해야 할까요?

코드를 다시 작성할 필요 없이 위 함수들을 재활용해서 조합하면 됩니다.

import {
  returnFetchJson,
  returnFetchThrowingErrorByStatusCode,
  returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";

/*
  Compose high order functions to create your awesome fetch.
   1. Add loading indicator.
   2. Throw an error when a response's status code is 400 or more.
   3. Serialize request body and deserialize response body as json and return it.
*/
export const fetchExtended = returnFetchJson({
  fetch: returnFetchThrowingErrorByStatusCode({
    fetch: returnFetchWithLoadingIndicator({
      // default options
    }),
  }),
});

//////////////////// Use it somewhere ////////////////////
fetchExtended("/sample/api/echo", {
  method: "POST",
  body: { message: "Hello, world!" }, // body should be an object.
}).catch((e) => { alert(e.message); });

https://return-fetch.myeongjae.kim/#4-compose-above-three-high-order-functions-to-create-your-awesome-fetch- 여기에서 실행해보실 수 있습니다.

returnFetch는 매개변수로 fetch함수를 받을 수 있습니다. returnFetch의 return값은 fetch함수의 인터페이스와 동일하기 때문에 fetch대신 사용할 수 있으므로 returnFetch의 매개변수에 자리에도 들어갈 수 있습니다. returnFetch는 기능이 추가된 fetch를 계속 감싸면서 기능을 덧붙일 수 있습니다.

특별한 처리 없이 재귀적인 타입 선언만으로 복수의 interceptor를 처리할 수 있는 것이 returnFetch의 최대 장점입니다. 위에서 보시는 것처럼 하나의 기능만 추가하는 함수를 작성해서 하나의 책임만 지도록 할 수 있기 때문에 returnFetch의 사용자는 단일 책임 원칙(Single Responsibility Principle)을 지킬 수 있습니다. 라이브러리에서 별도의 interceptor 중첩 처리를 하지 않기 때문에 번들 사이즈도 작게 유지할 수 있었습니다.

6. 전역 fetch 교체하기

returnFetch는 리스코프 치환 원칙(Liskov substitution principle)을 만족하기 때문에 returnFetch가 생성한 함수를 전역 fetch대신 사용할 수 있습니다. 전역 fetch를 사용하는 모든 곳에서 로딩 화면을 보여주고 400이상의 응답을 받았을 때 예외를 던지고 싶다면 아래처럼 함수를 조합하고 fetch를 덮어씌우면 됩니다.

import {
  returnFetchJson,
  returnFetchThrowingErrorByStatusCode,
  returnFetchWithLoadingIndicator
} from "@/your/customized/return-fetch";

// save global fetch reference.
const globalFetch = globalThis.fetch;
export const fetchExtended = returnFetchThrowingErrorByStatusCode({
  fetch: returnFetchWithLoadingIndicator({
    fetch: globalFetch, // use global fetch as a base.
  }),
});

// replace global fetch with your customized fetch.
globalThis.fetch = fetchExtended;

전역 공간을 건드리는 행위는 절대 추천하지 않습니다. 하지만 꼭 필요한 순간도 있을테니, 그 때도 returnFetch를 문제없이 사용할 수 있습니다.

Next.js에서 return-fetch를 어떻게 사용하면 좋을까요?

위에서 말씀드린 것처럼 Next.js에서 API요청을 하는 3가지 맥락이 있습니다.

  1. 클라이언트 사이드(웹브라우저)에서 AJAX 요청
  2. 서버 사이드에서 렌더링하기 위한 AJAX 요청
  3. 서버 사이드에서 렌더링이 아니라 프록시 역할로 AJAX 요청을 하고 응답을 그대로 내보냄(route.ts 파일)

1. 클라이언트 사이드(웹브라우저)에서 AJAX 요청

클라이언트 사이드에서 AJAX 요청을 할 때는 3가지 기능을 추가해서 사용하고 있습니다.

  1. 요청을 보내기 전에 로딩화면을 보여주고 응답을 받으면 로딩화면을 제거한다.
  2. 응답의 상태 코드가 400이상이라면 에러를 던진다.
  3. request, response body를 객체로 넣으면 자동으로 직렬화/역직렬화를 한다. repsonse body의 타입은 generic으로 지정할 수 있다.

2. 서버 사이드에서 렌더링하기 위한 AJAX 요청

서버 사이드 렌더링용 데이터를 가져올 때는 2가지 기능을 추가해서 사용하고 있습니다.

  1. 응답의 상태 코드가 400이상이라면 에러를 던진다.
  2. request, response body를 객체로 넣으면 자동으로 직렬화/역직렬화를 한다. repsonse body의 타입은 generic으로 지정할 수 있다.

3. 서버 사이드에서 렌더링이 아니라 프록시 역할로 AJAX 요청을 하고 응답을 그대로 내보냄(route.ts 파일)

서버 사이드에서 프록시 역할을 해야 할 때(route.ts 파일)는 기능을 추가하지 않고 baseUrl과 defaultHeader만 지정해서 사용하고 있습니다. 받은 response를 별도의 처리 없이 그대로 client에 전달하기 때문입니다.

마무리

fetch를 다양한 환경에서 사용해야하는 Next.js와 return-fetch는 잘 어울립니다. Axios에 익숙하지만 Next.js의 App Router를 사용할 예정이라면 Axios와 fetch사이에서 갈등할 수밖에 없습니다. 갈등의 순간에 제 라이브러리가 현명한 선택을 도울 수 있으면 좋겠습니다.

request, response body를 객체로 받을 수 있게 해주는 코드는 많이 쓰일 것 같아서 쉽게 받아서 사용하실 수 있도록 npm에 새로운 패키지로 등록했습니다. return-fetch 사랑해주세요! (신규 라이브러리라 사용하기가 부담스럽다면 ofetch를 추천합니다, 번들 사이즈가 7배 정도 크지만...)

감사합니다.

부록: fetch library 비교

1. fetch-intercept

https://www.npmjs.com/package/fetch-intercept

장점

  • 가벼운 번들 사이즈 (762 Byte, minified + gzipped)

단점

  • baseUrl을 설정할 수 없다.
  • default headers를 설정할 수 없다.
  • 전역에 선언된 fetch 에 몽키패치를 해버리기 때문에 모든 fetch 호출에 request, response interceptor가 적용된다. fetch를 다양한 맥락에서 사용해야 하는 Next.js에는 어울리지 않는다.

2. wretch

https://www.npmjs.com/package/wretch

장점

  • 다양한 기능 (baseUrl, default header, interceptor 모두 지원)
  • 상대적으로 가벼운 번들 사이즈 (1.9KB, minified + gzipped)

단점

  • Axios처럼 response status에 따라 에러를 던지기 때문에 Next.js의 route.ts에서 사용하려면 추가 조치가 필요하다.
  • wretch 인스턴스를 생성할 때 뿐만 아니라 사용할 때도 fetch와 호환되지 않는 자신만의 인터페이스를 가지고 있기 때문에 추가로 학습이 필요하다.

3. ofetch

https://www.npmjs.com/package/ofetch

장점

  • 다양한 기능 (baseUrl, default header, interceptor 모두 지원)
  • Next.js의 route.ts에서도 사용할 수 있도록 response 상태와 상관 없이 예외를 던지지 않고 응답을 전달할 수 있다.
  • fetch와 호환되는 인터페이스를 가졌다.

단점

  • Next.js에서 ofetch를 사용하는 레퍼런스가 부족하다. App Router도 새로운 기술인데 여기에 ofetch까지 얹어서 사용하기는 부담스럽다.
  • 필요한 기능에 비해 번들 사이즈가 크다 (5KB)

4. return-fetch

https://www.npmjs.com/package/return-fetch

장점

  • 가볍다 (733Byte, minified + gzipped)
  • fetch만 있다면 어떤 실행환경에서도 사용할 수 있다 (Node.js, 웹브라우저, React Native, Web Worker 등등).
  • 모든 fetch polyfill과 호환된다.
  • Side effect가 없다. 전역 fetch를 건드리지 않는다. 외부 세계의 상태를 바꾸지 않으므로 안심하고 사용할 수 있다.
  • 재귀적인 타입 선언 덕분에 특별한 처리 없이도 interceptor를 원하는 만큼 추가할 수 있다.
  • 테스트 커버리지가 100%다. 모든 기능을 테스트했다.
  • Next.js App Router환경에서 잘 작동한다. Next.js 사용자는 마음놓고 써도 된다.

단점

  • 뼈대만 제공한다. 필요한 기능이 있다면 직접 구현해야 한다.
  • baseUrl, default header 기능도 request interceptor만 있으면 직접 구현할 수 있지만, 다행히 이 두 가지 기능은 탑재되어 있다.
  • 신규 라이브러리라서 쓰는 사람이 별로 없다(😢).
-->