특정 상황에서 서비스 중 토큰 리프레시 API가 두 번씩 호출되는 것 같다는 백엔드 엔지니어 분의 리포트가 있었습니다. 이를 해결하기 위해 토큰 리프레시 로직에 스케쥴링을 한다던가 하는 여러 방법들을 생각해보았지만, Next.js
의 캐싱 기능을 활용하는 것이 가장 좋을 것 같다는 결론이 나왔습니다.
다만 토큰 리프레시 API를 axios
로 요청하고 있었기 때문에 Next.js
의 캐싱 기능을 제대로 지원받지 못하고 있었는데요. 오늘은 Next.js
의 캐싱 기능을 axios
를 사용해도 지원받을 수 있는지에 대해 이야기해볼까 합니다. (글 내용과 관련해서 도움을 주신 준일님, 현석님, 준환님, 승준님 감사합니다!)
👀 TL;DR
Next.js
의 일부 캐싱 기능은fetch API
를 통해서만 사용할 수 있습니다. 기존axios
는XML
기반으로 캐싱 기능이 지원되지 않습니다.
24년 5월axios
에 배포된fetch adapter
를 적용하면axios
가 내부적으로fetch API
를 통해 통신하도록 설정이 가능하기 때문에,axios
의 편의성과Next.js
의 캐싱 지원 모두 챙길 수 있습니다.
Next.js
에서는 여러 가지 캐싱 기능을 제공하고 있습니다. ⛓️ 관련 공식문서에서는 기본적으로 Request Memoization(React Cache)
, Data Cache
, Full Route
, Router Cache
의 4가지 캐싱 모델을 제공하고 있다고 설명하고 있는데요.
오늘은 그 중에서 SSR 과정에서 데이터를 가져올 때의 캐싱 기능인 Request Memoization(React Cache)
과 Data Cache
에 대해서 이야기해볼까 합니다.
왜 이 두 가지냐구요? 그것은 이 두 가지 캐싱 기능이 JavaScript
의 기본 제공 함수인 fetch API
를 통해서만 제공되기 때문입니다. 문서 중간중간에는 ⛓️ React
와 Next.js
가 기본 제공되는 fetch API
를 확장(extend)하여 제공하기 때문에, fetch API
를 사용하면 Next.js
에서 제공하는 캐싱 기능에 대한 인터페이스를 설정할 수 있다고 설명하고 있습니다.
또한 이러한 이유로 Next.js
에서 제공하는 캐싱 기능을 제대로 활용하려면 fetch API
를 사용해야 한다.고 설명하고 있습니다.
무슨 이야기인지 알겠습니다. 저도 캐싱 기능을 지원받고 싶으니 fetch API
를 써야겠습니다.
근데 지금까지 써온 axios
는 어떡하죠? axios
는 자체적인 XML
통신 기반이기 때문에 fetch API
를 통한 캐싱 기능이 적용되지 못합니다. Base Url
이나 default config
등의 설정을 자유롭게 할 수 있도록 도와주는 axios Instance
패턴, 저희를 위해 request
를 가로채서 JWT 토큰
을 헤더에 주입해주고 있는 request interceptor
패턴 등 유용한 기능들을 전부 포기해야하는 건가요?
Next.js
의 캐싱 기능을 적극적으로 활용하는 것은 좋지만, fetch
기반으로 interceptor
를 직접 전부 구현하는 건 음...(하기 직전까지 갔지만) 별로 하고 싶은 작업은 아닙니다. 그래서 axios
를 그대로 사용하면서 Next.js
의 캐싱 기능을 지원 받는 방법은 없을까 조사해보았습니다.
다행히도 axios
에서 비교적 최근(올해 중)에 ⛓️ fetch API
에 대한 adapter
업데이트를 배포했습니다.
⛓️ PR의 커밋 내역을 보니 올해 4월부터 업데이트를 위한 작업이 이루어진 것 같습니다. ⛓️ 이런 이슈와 같이 오래된 fetch
지원에 대한 니즈가 해소된 것 같네요. ⛓️ axios
의 레포지토리 README에서도 확인이 가능합니다.
// fetch로 작성한 Next.js 캐싱이 켜진 호출
fetch(URL, { cache: "force-cache" })
// axios adapter로 작성한 Next.js 캐싱이 켜진 호출
axios.get(URL, {
adapter: "fetch",
fetchOptions: { cache: "force-cache" }
})
코드에는 위와 같은 식으로 적용할 수 있습니다. adapter
는 기본적으로 XML
로 설정되어 있으며, 위와 같이 명시할 경우 명시한 adapter
에 맞는 방식으로 동작하게 됩니다. fetchOptions
프로퍼티를 통해 fetch
의 두 번째 인자인 options
도 입력해줄 수 있죠.
이 방법이라면 Next.js
의 fetch API
기반 캐싱도 활용하고, 제가 익숙하게 쓰던 axios interceptor
도 포기하지 않아도 되겠죠.
axios
에 fetch adapter
를 적용해서 Next.js
에서 사용하고 있다는 레퍼런스가 열심히 찾아봐도 찾기가 힘듭니다. 앞서서 적용하신 분들의 경험들을 날먹하려고 했는데 실패했습니다. 제가 원하는 동작을 하는지 직접 검증해봐야겠습니다.
크게 두 가지로 간단하게 확인해봤습니다. 첫 번째로 axios adapter
의 fetch
와 Next.js
의 fetch
가 동일한지 코드를 간단하게 까봤고요. 두 번째로 fetch API
와 axios adapted fetch
의 런타임 동작을 비교해봤습니다.
첫 번째 분석의 목적은 axios
에서 adapter
를 적용했을 때 내부적으로 동작하는 fetch
가, Next.js
에서 확장해서 제공한다는 그 fetch
와 같은지 확인하는 것입니다. 일단 Next.js
에서 fetch
를 확장하는 과정을 살펴봅시다.
⛓️ Next.js v15.1.0 tag에서 patch-fetch라는 코드를 봅시다. 1,000 줄에 육박하는 코드이지만 전부 볼 필요는 없고, 맨 마지막 코드만 보면 될 것 같습니다.
export function patchFetch(options: PatchableModule) {
// If we've already patched fetch, we should not patch it again.
if (isFetchPatched()) return
// Grab the original fetch function. We'll attach this so we can use it in
// the patched fetch function.
const original = createDedupeFetch(globalThis.fetch)
// Set the global fetch to the patched fetch.
globalThis.fetch = createPatchedFetcher(original, options)
}
Next.js
는 globalThis
를 통해 globalThis.fetch
와 같은 식으로 기존의 fetch
객체에 접근합니다. globalThis
에 대해 궁금하신 분들은 ⛓️ 자바스크립트에서 globalThis
의 소름끼치는 폴리필을 읽어보시면 도움이 될 것 같습니다.
아무튼 코드를 살펴보면, 원본 fetch
함수를 original
에 저장한 뒤, 이를 통해 새로운(patched
) fetch
함수를 생성하여 다시 globalThis.fetch
에 할당해주고 있습니다. 간단한 오버라이드입니다. 브라우저에서는 전역 객체인 window
에, node
환경에서는 global
에 이것이 담길 것이고, Next.js
환경에서는 전역 컨텍스트에서 추가적인 설정 없이 fetch
를 실행하면 이 함수가 호출될 것입니다.
이번엔 ⛓️ axios
의 fetch adapter
코드를 살펴보겠습니다.
export default isFetchSupported && (async (config) => {
// ...
let response = await fetch(request); // line 170
// ...
return await new Promise((resolve, reject) => { // line 203
settle(resolve, reject, {
data: responseData,
headers: AxiosHeaders.from(response.headers),
status: response.status,
statusText: response.statusText,
config,
request
})
// ...
}
코드의 170번째 줄을 보면, fetch
를 호출하고 있습니다. 그리고 그 반환값을 settle
함수로 axios response
형태로 wrapping하여 반환하고 있습니다. 별 특별한 내용 없이 fetch
를 내부적으로 사용하고 있는 것입니다. (+ axios
에서 fetch
와 globalThis
를 검색해보니, 추가적인 오버라이딩 작업은 없었던 것으로 확인됩니다.)
axios
와 Next.js
의 코드를 간단하게 살펴본 결과, 둘이 사용하는 fetch
함수는 같은 것으로 보입니다.
코드상으로 같은 아이인 것 같으니 동작도 동일하게 하겠죠? 테스트해봅시다. 제가 궁금한 것은 axios
의 fetch adapter
를 사용하면 Next.js
의 Request Memoization
과 Data Cache
를 지원받을 수 있는가입니다. 이를 확인하기 위해서 Open API
인 ⛓️ jsonplaceholder에서 포스트 목록을 불러오는 getPosts
함수를, fetch
와 axios
의 사용 여부, cache
에 대한 설정 여부를 두고 총 4가지 조건으로 각 캐싱 기능을 테스트해보았습니다. 조건별로 아래와 같은 함수로 선언해주었습니다.
fetch
(no cache)const getPosts = async () =>
fetch("https://jsonplaceholder.typicode.com/posts")
.then((res) => res.json());
fetch
(with cache)const getPosts = async () =>
fetch("https://jsonplaceholder.typicode.com/posts", {
cache: "force-cache",
}).then((res) => res.json());
axios
const getPosts = async () =>
axios
.get("https://jsonplaceholder.typicode.com/posts")
.then((res) => res.data);
axios
(with fetch adapter & cache)const getPosts = async () =>
axios
.get("https://jsonplaceholder.typicode.com/posts", {
adapter: "fetch",
fetchOptions: { cache: "force-cache" },
})
.then((res) => res.data);
함수를 호출하는 컴포넌트의 경우 테스트 목적이 되는 캐싱 기능에 따라 다르게 작성했습니다.
Next.js
의 Data Cache
는 이미 요청된 데이터에 대해 다시 요청할 경우 Next.js
서버 내의 캐시 레이어를 통해 이전 요청의 결과를 반환하도록 해줍니다.
const PostList1 = async () => {
const posts: Post[] = await (async () => {
const now = new Date();
return getPosts().then((res) => {
console.log(
`📊 data fetched in ${new Date().getTime() - now.getTime()}ms.`
);
return res;
});
})();
return <div />;
};
동일한 요청을 여러 번 하는 환경을 구축하고 싶었기 때문에, 위와 같이 SSR
과정에서 데이터를 호출하도록 하였어요. 클라이언트에게 페이지를 렌더링해서 전송하는 과정에서 서버가 getPosts
를 통해 데이터를 불러오고 이를 화면에 반영합니다. 위 컴포넌트의 콘솔 출력은 서버 콘솔로 이루어지게 됩니다.
이제 내부의 getPosts
함수를 네 가지 조건에 맞춰서 바꿔가면서 서버를 재시작한 뒤 초기 렌더링을 포함한 3회의 새로고침을 해주었습니다.
fetch
(no cache)fetch
(with cache)axios
axios
(with fetch adapter & cache)Request Memoization
은 한 번 캐싱한 내용을 다음 호출 시기에도 불러오는 Data Cache
와 다르게, 한 번의 호출 과정에서 생기는 중복된 호출을 한 가지로 통일해줍니다. 예를 들어, 컴포넌트 트리에서 동일한 데이터 요청을 다른 컴포넌트에서 할 경우 각각의 호출이 실행되지 않고 하나의 호출 결과를 여러 컴포넌트가 공유하게 됩니다.
const PostList2 = async () => {
const posts1: Post[] = await (async () => {
const now = new Date();
return getPosts().then((res) => {
console.log(
`📊 data 1 fetched in ${new Date().getTime() - now.getTime()}ms.`
);
return res;
});
})();
const posts2: Post[] = await (async () => {
const now = new Date();
return getPosts().then((res) => {
console.log(
`📊 data 2 fetched in ${new Date().getTime() - now.getTime()}ms.`
);
return res;
});
})();
return <div />;
};
이를 테스트하기 위해 위와 같이 한 컴포넌트 내에서 같은 함수로 두 개의 데이터를 호출하도록 하고, 각각의 호출에 얼마만큼의 시간이 소요됐는지 출력되도록 작성해주었습니다.
fetch
(no cache)fetch
(with cache)axios
axios
(with fetch adapter & cache)유형 | fetch (no cache) | fetch (with cache) | axios | axios fetch adapter(with cache) |
---|---|---|---|---|
Data Cache | X | O | X | O |
Reqeust Memoization | O? | O | X | O |
결과는 자연스러웠습니다. 코드상으로 살펴본 것과 같이 axios
에 fetch adapter
를 추가하여 설정한 것은 fetch API
를 사용한 것과 (적어도 제가 확인해본 범위에서는) 동일한 동작을 보였습니다.
axios
에 fetch adapter
를 설정하여 Next.js
의 캐싱을 지원받을 수 있다는 것을 코드 상으로도 확인하였고, 테스트에서도 확인할 수 있었습니다.
따라서 저는 익숙한 axios
의 기능과 Next.js
의 캐싱을 모두 활용할 수 있는 이 방법을 우선 선택하며, 필요 시 다른 대안을 검토할 것 같습니다.
저는 JavaScript
생태계에서 개발을 시작한 지 오래되지 않아서, 이렇게 라이브러리 생태계가 서로 의존하면서 발전하는 과정이 새롭게 다가옵니다. 라이브러리 레벨에서 일종의 문법 설탕을 지원하면 이렇게 쉽게 해결할 수 있는 문제들을 복잡하게 인터페이스를 구축하게 될 때가 있는데요.
이번 글과 같은 경우에는 axios
의 fetch adapter
지원여부를 모르고 넘어갔을 수도 있을 것 같아요. 그렇다면 아마 (직접 짰으니) 비교적 안정성이 낮은 커스텀 유틸을 작성해 사용하게 되었겠지요. 가능하다면 많은 정보를 수용해 이 중에서 필요한 방법으로 문제를 해결하는 것이 생산적인 것 같습니다.
아무튼 도움이 되셨길 바라며, 긴 글 읽어주셔서 감사합니다!
글의 내용뿐 아니라, 가독성 등에 대한 피드백 또한 언제나 감사드립니다.
글 너무 재미있게 잘 읽었습니다!