Next.js 프로젝트에서 Tanstack query의 prefetch를 사용하면서 클라이언트와 서버에서 네트워크 요청을 처리해야하는 상황
Axios는 node.js와 브라우저를 위한 Promise 기반 HTTP 클라이언트 입니다. 그것은 동형 입니다(동일한 코드베이스로 브라우저와 node.js에서 실행할 수 있습니다). 서버 사이드에서는 네이티브 node.js의 http 모듈을 사용하고, 클라이언트(브라우저)에서는 XMLHttpRequests를 사용합니다.
(출처 : Axios 라이브러리 공식문서)
Fetch API는 네트워크 통신을 포함한 리소스 취득을 위한 인터페이스를 제공하며, XMLHttpRequest보다 강력하고 유연한 대체제입니다.
(출처 : mdn fetch API)
default
브라우저는 HTTP 캐시에서 일치하는 요청을 찾는다. 일치하는 항목이 있고, 새 항목인 경우 캐시에서 반환한다. 일치하는 항목이 있고, 오래된 항목이면 브라우저는 원격 서버에 조건부 요청을 한다. 서버가 리소스가 변경되지 않았다고 표시하면 캐시에서 리소스 반환하고, 변경되었다면 리소스가 서버에서 다운로드되고 캐시가 업데이트 된다. 일치하는 항목이 없으면, 브라우저는 정상 요청 후 다운로드된 리소스로 캐시를 업데이트 한다.
no-store
브라우저는 캐시를 살펴보지 않고 원격 서버에서 리소스를 가져오며, 다운로드된 리소스로 캐시를 업데이트하지 않는다.
reload
브라우저는 캐시를 살펴보지 않고 원격 서버에서 리소스를 가져오며, 다운로드 된 리소르로 캐시를 업데이트 한다.
no-cache
브라우저는 HTTP 캐시에서 일치하는 요청을 찾는다. 새 요청이든 오래된 요청이든 일치하는 항목이 있으면 원격 서버에 조건부 요청을 보낸다. 서버가 리소스 변경이 되지 않았다고 하면 캐시에서 리소스가 반환된다. 변경이 되었다면 리소스가 서버에서 다운로드되고 캐시가 업데이트 된다.
SSG (Static Site Generation)
export const getPlaylistsStatic = async () => {
const response = await fetch("https://url", {
cache: 'force-cache' // 또는 next: { revalidate: false }
});
return response.json();
};
ISR (Incremental Static Regeneration)
export const getPlaylistsISR = async () => {
const response = await fetch("https://url", {
next: { revalidate: 3600 } // 1시간마다 재생성
});
return response.json();
};
SSR (Server-Side Rendering)
export const getPlaylistsSSR = async () => {
const response = await fetch('"https://url", {
cache: 'no-store' // 또는 next: { revalidate: 0 }
});
return response.json();
};
CSR (Client-side Rendering)
export const getPlaylistsClient = async () => {
'use client';
const response = await fetch('api/spotify/playlists');
return response.json();
};
import axios from 'axios';
import { getAccessToken } from './getAccessToken';
const spotifyApiAxios = axios.create({
baseURL: process.env.NEXT_PUBLIC_SPOTIFY_API_URL,
params: {
locale: 'ko_KR',
},
});
spotifyApiAxios.interceptors.request.use(
async (config) => {
const token = await getAccessToken();
config.headers['Authorization'] = `Bearer ${token}`;
return config;
},
(error) => {
return Promise.reject(error);
},
);
export default spotifyApiAxios;
axios interceptor를 사용해 코드를 간소화하고 로직을 중앙화하는데는 성공했지만, 페이지 로드 속도를 개선해야할 필요성을 느꼈고 이에 fetch caching 옵션을 사용해서 네트워크 요청을 최적화하는 것이 필요했다.
Next.js 공식문서에서는 fetch 사용을 권장하는데
그렇다면, fetch를 사용하면서 interceptor를 사용하는 방법은 없을까?
이에 대한 고민으로 아래 방법을 찾을 수 있었다.
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 (requestArgs, response) => {
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);
Output
********* before sending request *********
url: https://jsonplaceholder.typicode.com/todos/1
requestInit: { method: 'GET', headers: { Accept: 'application/json' } }
********* after receiving response *********
url: https://jsonplaceholder.typicode.com/todos/1
requestInit: { method: 'GET', headers: { Accept: 'application/json' } }
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
axios를 사용할때 기본 옵션들을 지정하여 여러 개의 axiosClient를 만들듯이, fetch도 기본 옵션을 다르게 설정한 호출 객체를 만들어 사용하는 방법이다.
return-fetch 라이브러리를 사용하는게 더 복잡해보이기도 했고 어떤 방식으로 동작하는지 이해해보고자 원하는 요구사항대로 커스텀해 만들어보기로 했다.
import { getAccessToken } from './getAccessToken';
interface IDefaultOptions {
baseURL: string;
headers?: HeadersInit;
mode?: RequestMode;
defaultParams?: Record<string, string | number>;
interceptors: {
request: (config: IRequestOptions) => IRequestOptions | Promise<IRequestOptions>;
response?: (response: Response) => Response | Promise<Response>;
};
}
interface IRequestOptions extends RequestInit {
params?: Record<string, string | number>;
body?: any;
}
// 팩토리 함수: 기본 설정을 받아 fetch 함수를 반환
export const fetcher = (defaultOptions: IDefaultOptions) => {
return async (url: string, options: IRequestOptions = {}) => {
// URL 처리: baseURL과 endpoint 조합, 쿼리 파라미터 추가
const fullUrl = new URL(defaultOptions.baseURL + url);
// 기본 params 추가
if (defaultOptions.defaultParams) {
Object.entries(defaultOptions.defaultParams).forEach(([key, value]) => {
fullUrl.searchParams.append(key, String(value));
});
}
// 요청별 params 추가
if (options.params) {
Object.entries(options.params).forEach(([key, value]) => {
fullUrl.searchParams.append(key, String(value));
});
}
// 요청 옵션 설정: 기본값과 사용자 옵션 병합
const requestOptions: IRequestOptions = {
method: options.method || 'GET',
headers: new Headers({
...defaultOptions.headers,
...options.headers,
}),
mode: defaultOptions.mode,
...options,
};
// 요청 인터셉터
const processedOptions = await defaultOptions.interceptors.request(requestOptions);
// POST 요청 처리
if (processedOptions.method === 'POST' && processedOptions.body) {
processedOptions.headers = new Headers(processedOptions.headers);
processedOptions.headers.set('Content-type', 'application/json');
processedOptions.body = JSON.stringify(processedOptions.body);
}
try {
let response = await fetch(fullUrl, processedOptions);
// 응답 인터셉터
if (defaultOptions.interceptors.response) {
response = await defaultOptions.interceptors.response(response);
}
if (!response.ok) {
throw new Error(`HTTP error! Status : ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Fetch error:', error);
throw error;
}
};
};
fetcher는 팩토리 함수로 실제 fetch 함수를 반환한다
export const spotifyFetcher = fetcher({
baseURL: process.env.NEXT_PUBLIC_SPOTIFY_API_URL!,
headers: {
'Content-Type': 'application/json',
},
defaultParams: {
locale: 'ko_KR',
},
interceptors: {
request: async (config) => {
const token = await getAccessToken();
config.headers = new Headers(config.headers);
config.headers.set('Authorization', `Bearer ${token}`);
return config;
},
},
});
spotifyFetcher는 기본 설정이 적용된 특정 인스턴스를 생성한다.
const [playlistResponse, tracksResponse] = await Promise.all([
spotifyFetcher(`/playlists/${playlistId}`, {
method: 'GET',
next: { revalidate: 3600 },
}),
spotifyFetcher(`/playlists/${playlistId}/tracks`, {
method: 'GET',
params: {
fields:
'items(track(id,name,preview_url,external_urls,duration_ms,artists(id,name),album(id,name,images)))',
limit: 8,
},
next: { revalidate: 3600 },
}),
]);
// POST 요청 예시
await spotifyFetcher('/endpoint', {
method: 'POST',
body: {
key: 'value',
data: 'test'
}
});
함수를 사용할 때는 반환된 함수를 호출한다.