React의 fetch가 어떻게 Next.js의 request memoization에서 사용되는가
리액트에서 Web API인 fetch를 어떻게 확장했나
서론
Next.js의 캐싱 메커니즘인 request memoization
은 react의 fetch를 사용합니다.
React extends the fetch API to automatically memoize requests that have the same URL and options. This means you can call a fetch function for the same data in multiple places in a React component tree while only executing it once.
Next.js 14에서의 캐싱 메커니즘인 Request Memoization은 React가 확장한 fetch
를 사용하며, 여기에 Next.js가 추가로 fetch
를 확장하여 캐싱 및 재검증 기능을 제공합니다
react
에서 Web API인 fetch
를 어떻게 확장하여 사용하는지 궁금해 찾아본 글입니다.
request memoization
React의 fetch에 관한 글이기에 요청 메모이제이션에 대해선 간단하게만 정리하겠습니다.
React는 동일한 URL과 옵션이 있는 요청을 자동으로 메모하기 위해 fetch
API를 확장했습니다.
React 구성 요소 트리의 여러 곳에서 동일한 데이터에 대한 페치 함수를 호출하면서도 한 번만 실행할 수 있습니다
예를 들어, 경로 전체에서 동일한 데이터를 사용해야 하는 경우 트리 맨 위에서 데이터를 페치하고 구성 요소 간에 props를 전달할 필요가 없습니다.
대신 네트워크에서 동일한 데이터에 대한 여러 요청을 하는 것의 성능 영향에 대해 걱정하지 않고 필요한 구성 요소에서 데이터를 페치할 수 있습니다.
async function getItem() {
// The `fetch` function is automatically memoized and the result
// is cached
const res = await fetch('https://.../item/1')
return res.json()
}
// This function is called twice, but only executed the first time
const item = await getItem() // cache MISS
// The second call could be anywhere in your route
const item = await getItem() // cache HIT
- React 18부터 도입된 Server Components는 컴포넌트 트리 내에서 동일한
fetch
요청이 여러 번 발생하면 첫 번째 요청의 결과를 재사용하도록 메모이제이션합니다. - 이는 React 자체에서 제공하는 기능으로, React가 웹 API인
fetch
를 확장하여 구현됩니다. - 이 메모이제이션은 React 컴포넌트 트리 내에서만 동작하며,
generateMetadata
,generateStaticParams
, 레이아웃, 페이지 등에서 적용됩니다. - 그러나 Route Handlers와 같이 React 컴포넌트 트리 외부에서는 이 메모이제이션이 적용되지 않습니다.
아하! 동일한 렌더 패스에서 요청에 대한 후속 함수 호출은 캐시가 되고, 함수를 실행하지 않고도 메모리에서 데이터가 반환되는구나
React의 fetch
소스코드는 이곳에서 확인했습니다.
import {
enableCache,
enableFetchInstrumentation,
} from 'shared/ReactFeatureFlags';
import ReactCurrentCache from './ReactCurrentCache';
function createFetchCache(): Map<string, Array<any>> {
return new Map();
}
const simpleCacheKey = '["GET",[],null,"follow",null,null,null,null]';
function generateCacheKey(request: Request): string {
return JSON.stringify([
request.method,
Array.from(request.headers.entries()),
request.mode,
request.redirect,
request.credentials,
request.referrer,
request.referrerPolicy,
request.integrity,
]);
}
if (enableCache && enableFetchInstrumentation) {
if (typeof fetch === 'function') {
const originalFetch = fetch;
const cachedFetch = function fetch(
resource: URL | RequestInfo,
options?: RequestOptions,
) {
const dispatcher = ReactCurrentCache.current;
if (!dispatcher) {
return originalFetch(resource, options);
}
if (
options &&
options.signal &&
options.signal !== dispatcher.getCacheSignal()
) {
return originalFetch(resource, options);
}
let url: string;
let cacheKey: string;
if (typeof resource === 'string' && !options) {
cacheKey = simpleCacheKey;
url = resource;
} else {
const request =
typeof resource === 'string' || resource instanceof URL
? new Request(resource, options)
: resource;
if (
(request.method !== 'GET' && request.method !== 'HEAD') ||
request.keepalive
) {
return originalFetch(resource, options);
}
cacheKey = generateCacheKey(request);
url = request.url;
}
const cache = dispatcher.getCacheForType(createFetchCache);
const cacheEntries = cache.get(url);
let match;
if (cacheEntries === undefined) {
match = originalFetch(resource, options);
cache.set(url, [cacheKey, match]);
} else {
for (let i = 0, l = cacheEntries.length; i < l; i += 2) {
const key = cacheEntries[i];
const value = cacheEntries[i + 1];
if (key === cacheKey) {
match = value;
return match.then(response => response.clone());
}
}
match = originalFetch(resource, options);
cacheEntries.push(cacheKey, match);
}
return match.then(response => response.clone());
};
Object.assign(cachedFetch, originalFetch);
try {
fetch = cachedFetch;
} catch (error1) {
try {
globalThis.fetch = cachedFetch;
} catch (error2) {
console.warn(
'React was unable to patch the fetch() function in this environment. ' +
'Suspensey APIs might not work correctly as a result.',
);
}
}
}
}
1. createFetchCache()
react cache는 Map
으로 이루어졌습니다.
우선 fetch
요청을 캐싱하기 위해 데이터를 저장할 Map
객체를 생성합니다.
Map
은 URL을 key로, 해당 URL에 대한 요청 결과를 배열의 형태로 저장합니다.
function createFetchCache(): Map<string, Array<any>> {
return new Map();
}
2. simpleCacheKey
Key
는 간단한 요청을 위한 키(simpleCacheKey)와 복잡한 요청의 키(generateCacheKey) 2가지로 나누어집니다.
간단한 GET
요청을 위한 캐시 키는 아래와 같이 정의되어 있습니다.
const simpleCacheKey = '["GET",[],null,"follow",null,null,null,null]';
3. generateCacheKey()
복잡한 GET
요청을 위한 캐시 키는 아래의 함수를 통해 생성됩니다.
이 함수는 인자로 받은 Request
객체를 기반으로 캐시 키를 생성합니다.
Request
의 다양한 옵션을 직렬화하여 유니크한 키를 생성합니다.
캐시 키를 생성할 때, Request
의 cache
필드는 제외되는데, 최초 요청에서 발생한 캐싱 결과를 이후에도 사용하기 위해서입니다.
function generateCacheKey(request: Request): string {
return JSON.stringify([
request.method,
Array.from(request.headers.entries()),
request.mode,
request.redirect,
request.credentials,
request.referrer,
request.referrerPolicy,
request.integrity,
]);
}
주의할 점은, 만약 동일한 URL에 대해 서로 다른
options
가 전달되었다면,generateCacheKey
함수가 각 요청에 대해 서로 다른cacheKey
를 생성한다는 것입니다.
동일한 엔드포인트를 여러 번 요청해도 각기 다른 옵션을 사용하면, 새로 요청한 결과가 각각의 캐시에 저장됩니다.
4. cachedFetch()
요청에 대한 유니크한 키를 생성했으니 캐시된 결과가 어떻게 사용되는지 살펴보겠습니다.
cachedFetch
는 실제로 fetch 요청을 가로채고 캐싱 기능을 적용하는 래퍼 함수입니다.
fetch 함수가 호출되면 originalFetch
에 fetch
함수의 메모리 주소를 할당합니다.
ReactCurrentCache.current
에서 현재의 캐시dispatcher
를 가져와 이를 기반으로 캐싱 로직이 수행됩니다.
dispatcher
는 React의Suspense
를 지원하기 위한 캐시 컨텍스트를 의미합니다.
특정 컴포넌트 트리 내에서만 적용되는 캐시로, 각 컴포넌트가 독립적인 캐시 컨텍스트를 가질 수 있고,ReactCurrentCache.current
에 따라 다른 캐시가 사용될 수 있습니다.
요청이 캐시되어 있지 않은 경우
만약 dispatcher
가 없는 경우, 즉 캐시가 없는 컨텍스트에서는 originalFetch
를 호출하고 결과를 리턴합니다.
if (typeof fetch === 'function') {
const originalFetch = fetch;
const cachedFetch = function fetch(resource: URL | RequestInfo, options?: RequestOptions) {
const dispatcher = ReactCurrentCache.current;
if (!dispatcher) {
return originalFetch(resource, options);
}
// ...
}
}
요청이 캐시되어 있는 경우
컨텍스트 내 캐시가 되어있다면, 옵션의 유무를 확인하여 해당 요청의 키를 가져옵니다.
dispatcher
에서 가져온 cache 중 url
이 일치하는 entry만 추출합니다.
const cacheKey = options ? generateCacheKey(request) : simpleCacheKey;
const cache = dispatcher.getCacheForType(createFetchCache);
const cacheEntries = cache.get(url);
cachedFetch
는 동일한 요청을 하지 않기 위해 요청의 URL을 기반으로 캐시를 확인합니다.
만약 해당 요청이 cacheEntries
에 없다면 originalFetch
를 호출하여 실제 요청을 수행하고, 결과를 캐시에 저장합니다.
if (cacheEntries === undefined) {
match = originalFetch(resource, options);
cache.set(url, [cacheKey, match]);
}
만약 cacheEntries
에 동일한 요청이 있다면, 캐시된 결과를 사용하여 응답을 반환합니다.
response.clone()
은 응답 데이터를 여러 번 사용할 수 있도록 복제하여 반환하는 역할을 합니다.
순차적으로 모든 cacheEntries
를 확인하였지만 cacheKey
와 일치하는 key
가 없을 경우, 엔트리에 캐시가 없는 경우와 동일하게 실제 요청을 수행하고 결과를 저장합니다.
else {
for (let i = 0, l = cacheEntries.length; i < l; i += 2) {
const key = cacheEntries[i];
const value = cacheEntries[i + 1];
if (key === cacheKey) {
return match.then(response => response.clone());
}
}
match = originalFetch(resource, options);
cacheEntries.push(cacheKey, match);
}
5. 캐시되지 않는 경우
GET
,HEAD
가 아닌 HTTP 메서드는 캐싱에서 제외됩니다. 이런 요청은 일반적으로 서버 상태를 변경하는 동작을 수행할 수 있으므로 캐시하지 않고 바로 네트워크 요청을 보냅니다,options.signal
이 존재하고,dispatcher
의 캐시 시그널과 다를 경우도 캐시에서 제외됩니다. 이는 외부에서 전달된 시그널을 통해 요청의 생명주기를 관리하는 경우로, 이러한 경우는 캐시에서 벗어나게 됩니다.
6. fetch함수 패치
try {
fetch = cachedFetch;
} catch (error1) {
try {
globalThis.fetch = cachedFetch;
} catch (error2) {
console.warn('React was unable to patch the fetch() function in this environment.');
}
}
마지막으로 fetch함수를 cachedFetch로 수정합니다.
이를 통해 React에서 네트워크 요청을 가로채로 캐싱 메커니즘을 사용할 수 있도록 합니다.
만약 fetch함수 수정에 실패할 경우, globalThis.fetch를 수정합니다. 이 조차도 실패한다면 메세지를 출력합니다. gg
fetch
는 브라우저에서 사용하는 Web API
이고 globalThis.fetch
는 서버를 포함한 실행환경 내의 fetch
입니다.
회고
블로그 프로젝트를 내에선 CMS client를 사용했기 때문에 fetch를 사용하지 않았지만 궁금해 찾아보며 정리한 글입니다.
잘못된 부분이 있다면 지적 부탁드립니다😂