Low Love - King Gnu

cd

obvoso

star

React의 fetch가 어떻게 Next.js의 request memoization에서 사용되는가

리액트에서 Web API인 fetch를 어떻게 확장했나

개발
React
Nextjs
thumbnail

서론

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과 옵션이 있는 요청을 자동으로 메모하기 위해 fetchAPI를 확장했습니다.

React 구성 요소 트리의 여러 곳에서 동일한 데이터에 대한 페치 함수를 호출하면서도 한 번만 실행할 수 있습니다

1473cd4b-ffcd-807d-8bac-fd5c68c1e944--0.png

예를 들어, 경로 전체에서 동일한 데이터를 사용해야 하는 경우 트리 맨 위에서 데이터를 페치하고 구성 요소 간에 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

1473cd4b-ffcd-807d-8bac-fd5c68c1e944--1.png

  • 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의 다양한 옵션을 직렬화하여 유니크한 키를 생성합니다.

캐시 키를 생성할 때, Requestcache 필드는 제외되는데, 최초 요청에서 발생한 캐싱 결과를 이후에도 사용하기 위해서입니다.

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 함수가 호출되면 originalFetchfetch 함수의 메모리 주소를 할당합니다.

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를 사용하지 않았지만 궁금해 찾아보며 정리한 글입니다.

잘못된 부분이 있다면 지적 부탁드립니다😂

레퍼런스

star

이전 포스트

Vercel 빌드시 발생한 sharp 라이브러리 에러

Fatal glibc error malloc.c:3211

2024년 11월 14일