Cache를 알면 정말 Chill하다..

데브현·5일 전
3

프론트엔드 모음집

목록 보기
11/11
post-thumbnail

이전 글에서 Next.js에서 fetch 말고 react-query를 사용하는 방법에 대해 글을 소개했다.
이번에 이전 글 마지막에 살짝 얘기했던 fetchcachereact-querycache가 어떻게 다른지 정리해보려고 한다.

같은 cache라는 같은 개념을 사용하지만 서로 다른 관점에 대해서 다루고 있다.
chill chill맞게 빼먹은 내용이 있다면 댓글로 남겨주길. 🫤

💾 (Next.js) Fetch의 Cache에 대해서

💿 요청에 대한 메모이제이션(Request Memoization)

라우트를 넘어서 동일한 API 요청을 사용한다면 같은 요청에 대해서는 memoized를 거치게 된다.

공식 문서에서 그림으로 더 자세한 예시를 들고 있다.
렌더링 요청에는 API에 대한 요청이 MISS여서 데이터를 가져와 메모리에 저장하게 된다.
이후에도 동일한 렌더링 요청이 온다면 캐시를 HIT하기 때문에 메모리에 있는 데이터를 바로 리턴하게 되는 것이다.

여기서 중요한 점들을 추가로 알아보면,

  • 이러한 요청들의 Memoization은 Next.js의 스펙이 아닌 React의 스펙이다.
  • GET 요청에 대해서만 메모이제이션을 진행한다.
  • 리액트 컴포넌트 트리에 적용되는 것들만 메모이제이션을 진행한다.
    - 이것을 좀 더 해석하면 라우트 핸들러는 컴포넌트 트리에 속하지 않기 때문에 메모이제이션이 되지 않는다.

💿 데이터 대한 캐시(Data Cache)

Next는 내장 Fetch API를 확장하서 사용하는 것이기 때문에 요청들을 캐싱할 수 있는 옵션들을 제공해준다. 그러나 캐시 옵션에 대해서 피드백을 받아 v15에서 default 값이 바뀌게 되었다.

잠깐 ✋, fetch함수 cache 옵션

이슈로 올라온 내용 예시: Remove statement that cache: force-cache is the default for fetch

14버전의 fetch default 값

15버전의 fetch default 값

Next.js v15 Release Note의 언급
https://nextjs.org/blog/next-15#caching-semantics
여기에 대한 내용을 살짝 정리해보자면,
Next에서는 성능 향상을 위해서 기본값을 캐시로 넣고 원할 때 뺄 수 있도록 설게를 하였다. 그러나 피드백을 기반으로 캐싱에 대한 재평가를 다시 하여 기본 값을 uncached로 적용한 것이다.

fetch(Next에서 확장한 fetch)의 캐시 동작 방식은 다음과 같다.

아까 위에서 설명한 요청 캐시와 매우 흡사하다. 첫 요청에는 데이터 캐시가 없으니 실제 데이터까지 HIT을 통해 데이터를 내려주지만 이후 요청(그림에는 없음)에는 데이터 캐시의 값에서만 내려줄 것이다.

그러나 'no-store'의 값으로 설정한다면 데이터 캐시에 있는지 조차 검사하지 않고 항상 새로운 값을 내려줄 것이다.

두 방식의 차이점이 뭐야?

  • 공통점
    • 캐시된 데이터를 통해 성능을 향상시킴
  • 차이점
    • 데이터 캐시는 요청과 배포에 모두 지속 됨
    • 메모이제이션은 요청의 LifeCycle(수명)에 지속 됨
  • 결론
    • 데이터에 대한 캐시를 통해 원본 데이터 소스에 대한 요청 수를 줄임
    • 요청 메모이제이션을 통해 중복 요청 수를 줄임

이 외에도 Revalidating, Route Cache 등에 대해서 자세히 설명하고 있다. 캐싱에 관해서 여기에서 정독해보는 것을 추천한다.

아까 위에서 잠깐 이야기 했던 라우트 핸들러가 왜 캐시가 이뤄지지 않는지 알려면 라우트 핸들러가 무엇인지 먼저 알아야 한다.

💿 라우트 핸들러(Route Handler)에 대해서?

라우트 핸들러란 특정 경로로 API요청을 보냈을 때 커스텀하게 요청을 만들 수 있는 것이다. 라우트 핸들러의 더 깊은 개념들은 공식 문서를 확인하길 바란다.

내가 공식 문서를 보면서 헷갈리게 느낀 점이 하나 있다.

무엇인지 느꼈는가? 🤔
Data Fetching > Fetching Data on the Server with fetch 에서는 메모이제이션은 되지 않는다고 되어 있다.

그러나 Route Handlers > Caching보면 캐싱에 대한 부분은 제공한다.

// 라우터 핸들러 코드 예시
export async function GET() {
  const res = await fetch('https://data.mongodb-api.com/...', {
    headers: {
      'Content-Type': 'application/json',
      'API-Key': process.env.DATA_API_KEY,
    },
  })
  const data = await res.json()
 
  return Response.json({ data })
}

라우트 핸들러를 통해 데이터를 가져오는 기본적인 구성이다. 이렇게 사용하면 기본적으로 캐시가 된다.

캐싱을 제거할 수 있는 방법이 있다.

  1. GET 메소드와 함께 Request 객체를 사용할 때
  2. 다른 HTTP 메서드(GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS외 나머지)를 사용할 때
  3. cookiesheaders 같은 동적인 함수를 사용할 때
  4. dynamic 모드를 수동으로 설정할 때
// 1. GET 메소드 + request 객체 사용
export async function GET(request: Request) {
 // ~~
}

// 3. headers 사용
export async function GET(request: Request) {
  const headersList = headers()
  // ~~
}

// 4 dynamic를 수동으로 설정
export const dynamic = 'force-dynamic'

즉, 결국 라우트 핸들러에서는 캐시는 사용할 수 있지만, 메모이제이션은 이뤄지지 않는 것이다. 라우트 핸들러의 예시 코드를 보면 알겠지만 컴포넌트 트리와는 전혀 관계가 없기 때문에 메모이제이션이 되지 않는 것이다.

결국 내가 헷갈린 것은 메모이제이션과 캐시를 비슷한 개념으로 계속 생각했던 것.. 😭

💾 React-Query의 Cache에 대해서

리액트 쿼리에서도 캐시의 기능을 제공한다. 그러나 Next.js에서 캐시에 대한 관리 포인트와 전혀 다른 포인트를 다루고 있다.

결론을 먼저 말하자면, 리액트 쿼리는 API 응답 데이터를 받아와 메모리에서 어떻게 관리하여 캐시 처리하는지에 관한 것들을 제공해준다.

💿 staleTime과 gcTime

리액트 쿼리에서는 staleTime과 gcTime을 통해 캐싱을 관리하게 된다.
staleTimegcTime에 대한 개념을 알면 Next.js의 캐싱과 왜 다른지 바로 알 수 있게 된다. (리액트 쿼리를 쓰는 개발자라면 대부분 알고 있지만 그래도 정리해본다.)

staleTime (default: 0)
캐시된 데이터가 fresh 상태라 판단되는 시간을 의미한다. 즉, staleTime이 지나게 되면 fresh하지 않기 때문에 네트워크 요청을 보내고 지나지 않았으면 캐시된 데이터를 바로 보여주는 것이다.

gcTime (default: client - 5m, server - Infinity)
gcTime에서 gc는 가비지 컬렉터(garbage collector)를 의미하고 가비지 컬렉터에 회수되기 까지의 시간을 의미한다. 즉, 캐시된 데이터가 메모리에 유지되는 시간이다.

💿 react-query에서의 상태를 그림으로 표현

📀 추가) React-Query에서 dehydrate/hydrate하는 방법?

전 글에서 얘기하지 못하고 넘어갔던 부분을 여기서 다뤄본다.

// @see https://github.com/TanStack/query/blob/main/packages/query-core/src/hydration.ts#L57

/** mutation에 대해서 객체 형태(dehyrdrated 된)로 만들어줍니다. */
function dehydrateMutation(mutation: Mutation): DehydratedMutation {
  return {
    mutationKey: mutation.options.mutationKey,
    state: mutation.state,
    ...(mutation.options.scope && { scope: mutation.options.scope }),
    ...(mutation.meta && { meta: mutation.meta }),
  }
}

// @see https://github.com/TanStack/query/blob/main/packages/query-core/src/hydration.ts#L70

/** query에 대해서 객체 형태(dehyrdrated 된)로 만들어줍니다. 
 * 이때, 응답 데이터의 경우 직렬화 과정을 거칩니다.
 */
function dehydrateQuery(
  query: Query,
  serializeData: TransformerFn,
): DehydratedQuery {
  return {
    state: {
      ...query.state,
      ...(query.state.data !== undefined && {
        data: serializeData(query.state.data),
      }),
    },
    queryKey: query.queryKey,
    queryHash: query.queryHash,
    ...(query.state.status === 'pending' && {
      promise: query.promise?.then(serializeData).catch((error) => {
        if (process.env.NODE_ENV !== 'production') {
          console.error(
            `A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`,
          )
        }
        return Promise.reject(new Error('redacted'))
      }),
    }),
    ...(query.meta && { meta: query.meta }),
  }
}

// @see https://github.com/TanStack/query/blob/main/packages/query-core/src/hydration.ts#L105
export function dehydrate(
  client: QueryClient,
  options: DehydrateOptions = {},
): DehydratedState {
  /** useMutation에 대해서 dehydrate 해야 하는지 체크하는 필터 */
  const filterMutation =
    options.shouldDehydrateMutation ??
    client.getDefaultOptions().dehydrate?.shouldDehydrateMutation ??
    defaultShouldDehydrateMutation

  /** mutation 캐시 값들에 대해서 
   * 1. flat하게 만든 뒤,
   * 2. dehydrate해야 하는 mutation에 대해서만, 
   * 3. dehydrateMutation를 통해 처리를 합니다. 
   */
  const mutations = client
    .getMutationCache()
    .getAll()
    .flatMap((mutation) =>
      filterMutation(mutation) ? [dehydrateMutation(mutation)] : [],
    )

  /** useQuery에 대해서 dehydrate 해야 하는지 체크하는 필터 (없다면 defaultShouldDehydrateQuery)  */
  const filterQuery =
    options.shouldDehydrateQuery ??
    client.getDefaultOptions().dehydrate?.shouldDehydrateQuery ??
    defaultShouldDehydrateQuery

  /** 직렬화 하는 함수를 가져옵니다. 
   * (없다면 defaultTransformerFn @see https://github.com/TanStack/query/blob/main/packages/query-core/src/hydration.ts#L17)
   */
  const serializeData =
    options.serializeData ??
    client.getDefaultOptions().dehydrate?.serializeData ??
    defaultTransformerFn

  /** query 캐시 값들에 대해서 
   * 1. flat하게 만든 뒤,
   * 2. dehydrate해야 하는 query에 대해서만, 
   * 3. dehydrateQuery를 통해 처리를 합니다. 
   */
  const queries = client
    .getQueryCache()
    .getAll()
    .flatMap((query) =>
      filterQuery(query) ? [dehydrateQuery(query, serializeData)] : [],
    )

  return { mutations, queries }
}

생각보다 간단한 방식으로 dehydrate 처리를 한다.

그럼 이제 클라이언트 단에서 hydrate하는 코드를 살펴보자. hydrate는 약간 더 복잡한데 크게 어려운 코드는 없다.

export function hydrate(
  client: QueryClient,
  dehydratedState: unknown,
  options?: HydrateOptions,
): void {
  if (typeof dehydratedState !== 'object' || dehydratedState === null) {
    return
  }

  /** cache에서 mutation, query를 꺼내온다. */
  const mutationCache = client.getMutationCache()
  const queryCache = client.getQueryCache()
  const deserializeData =
    options?.defaultOptions?.deserializeData ??
    client.getDefaultOptions().hydrate?.deserializeData ??
    defaultTransformerFn

  /** dehydrate 처리를 거친 mutation과 query를 꺼내온다 */
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  const mutations = (dehydratedState as DehydratedState).mutations || []
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  const queries = (dehydratedState as DehydratedState).queries || []

  /** dehydrate 처리된 mutation에 대해서 Cache에 있던 mutation을 통해 hydrate 처리를 한다 */
  mutations.forEach(({ state, ...mutationOptions }) => {
    mutationCache.build(
      client,
      {
        ...client.getDefaultOptions().hydrate?.mutations,
        ...options?.defaultOptions?.mutations,
        ...mutationOptions,
      },
      state,
    )
  })

  /** dehydrate 처리된 query에 대해서 Cache에 있던 query을 통해 hydrate 처리를 한다 */
  queries.forEach(({ queryKey, state, queryHash, meta, promise }) => {
    /** queryHash값을 통해 queryCache에 있는 query를 가져온다.

    */
    let query = queryCache.get(queryHash)

    const data =
      state.data === undefined ? state.data : deserializeData(state.data)

    // Do not hydrate if an existing query exists with newer data
    // 최신 데이터가 포함된 기존 쿼리가 있는 경우 hydrate 처리를 하지 않는다.
    if (query) {
      if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
        // omit fetchStatus from dehydrated state
        // so that query stays in its current fetchStatus
        const { fetchStatus: _ignored, ...serializedState } = state
        query.setState({
          ...serializedState,
          data,
        })
      }
    } else {
      // dehydrate 처리된 query들을 hydrate 처리 합니다.
      // Restore query
      query = queryCache.build(
        client,
        {
          ...client.getDefaultOptions().hydrate?.queries,
          ...options?.defaultOptions?.queries,
          queryKey,
          queryHash,
          meta,
        },
        // Reset fetch status to idle to avoid
        // query being stuck in fetching state upon hydration
        {
          ...state,
          data,
          fetchStatus: 'idle',
        },
      )
    }

    /** pending 처리 중이던 query를 다시 실행시켜준다. */
    if (promise) {
      // Note: `Promise.resolve` required cause
      // RSC transformed promises are not thenable
      const initialPromise = Promise.resolve(promise).then(deserializeData)

      // this doesn't actually fetch - it just creates a retryer
      // which will re-use the passed `initialPromise`
      void query.fetch(undefined, { initialPromise })
    }
  })
}

🔚 마무리

전 글에도 말했지만 fetch와 react-query의 용도는 서로 다르다. 캐시 처리하는 방식도 서로 다르기 때문에 상황에 맞게 잘 사용하는 것이 매우 중요하다고 느낀다.

fetch를 통해 캐시 처리가 성능에 이점을 준다면, react-query와 같이 사용하는 것도 문제가 되지 않을 것이다.

profile
I am a front-end developer with 4 years of experience who believes that there is nothing I cannot do.

2개의 댓글

comment-user-thumbnail
어제

정말 유용한 글이네요 👍 연휴에도 열심히 하시는 모습 존경합니다...

1개의 답글