prefetch는 무조건 성능개선이 되는것이 아니었다

Article

들어가며

prefetch라는 기술에 대해서 알게 되었습니다.

사용자가 이용할것으로 예상되는 데이터를 미리 요청하여 빠르게 보여주는 전략이었습니다. 그래서 피드에서 hover하는 게시글의 데이터를 미리 불러와서 클릭시에 더 빠르게 보여주도록 적용했습니다.

뿌듯해하며 테스트를 하던 그때, 데이터가 중복으로 요청되고 있다는 사실을 알게되었습니다.

제가 Tanstack query의 prefetch에 대해 깊게 탐구하는 과정과 마주한 에러들을 어떻게 고쳐나갔는지 담아봤습니다.

추가로 캐시의 라이프사이클과 next link에서 지원되는 prefetch는 어떻게 다른지도 함께 찾아봤습니다.

prefetchQuery란?

await queryClient.prefetchQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});
await queryClient.prefetchQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});
  • prefetchQuery는 useQuery와 관련된 훅이 사용되기 전에 쿼리를 미리 가져오기 위해 사용되는 비동기 메서드입니다.
  • useQuery가 나중에 해당 쿼리를 구독하면, 이미 캐시에 있으므로 네트워크 요청 없이 즉시 데이터 제공하는 특징이 있습니다.
  • 주로 페이지 전환 전에 미리 캐시를 채우는 용도로 사용해왔습니다.

prefetch 적용 - 피드에서 hover시 요청

피드에서 사용자가 게시글을 둘러보다가 클릭했을때 더 빠르게 렌더링하기 위해서 prefetch를 고려했습니다.

게시글 카드에 hover했을때 미리 데이터를 요청하도록 구현했습니다.

문제 발생 - prefetch를 했는데도 데이터 재요청

prefetch는 사용자 경험을 개선해줄 은총알이라고 생각했습니다.

적용하고 테스트를 하던중 다음과 같은 문제가 발생했습니다.

  • 페이지 이동후에도 캐시된 데이터에 대해서 요청 발생
  • hover할때마다 데이터 요청 발생

빠르게 보여주는 용도도 있긴하지만 데이터 요청 최적화의 목적도 있었기 때문에 이렇게 되면 오히려 요청수만 증가시키는 안좋은 결과만 발생했습니다.

개선시도 1 - 캐시데이터 미리 확인하기

저는 문제 상황에 대해서, “prefetchQuery 는 캐시데이터를 확인하지 않는구나”라고 생각했고

queryClient.getQueryData를 이용해서 사전에 확인후 사용해야겠다고 생각했습니다.

// prefetchTodoData

const todoDate = queryClient.getQueryData(TODO_QUERY_KEY);
if (!todoDate) {
  await queryClient.prefetchQuery({
    queryKey: TODO_QUERY_KEY,
    queryFn: fetchTodos,
  });
}
// prefetchTodoData

const todoDate = queryClient.getQueryData(TODO_QUERY_KEY);
if (!todoDate) {
  await queryClient.prefetchQuery({
    queryKey: TODO_QUERY_KEY,
    queryFn: fetchTodos,
  });
}

이 시도는 성공적이었습니다.

더이상 hover할때마다 불필요한 중복 요청을 보내지도 않았고 prefetch전에 캐시를 확인하여 요청하는 과정을 따랐습니다.

개선시도 2 - staleTime 설정하기

하지만 여전히 페이지 이동후에 재요청은 발생하고 있었고 완벽한 해결사항은 아니라고 생각했습니다.

공식문서를 찾아보던중

set staleTime to e.g. 2 _ 60 _ 1000 to make sure data is read from the cache, without triggering any kinds of refetches, for 2 minutes, or until the Query is invalidated manually.

Tanstack Query 공식문서

staleTime 에 대한 내용을 발견했습니다.

기본 값이 0으로 지정되어있어서 캐시된 데이터가 있더라도 기본적으로 오래된것으로 간주한다는 것을 알게되었고 캐시를 활용하기 위해서는 “신선”한 상태를 유지하는 시간설정이 필요했습니다.

const { data: todoData } = useQuery({
  queryKey: TODO_QUERY_KEY,
  queryFn: fetchTodos,
  staleTime: 5 * 60 * 1000,
});
const { data: todoData } = useQuery({
  queryKey: TODO_QUERY_KEY,
  queryFn: fetchTodos,
  staleTime: 5 * 60 * 1000,
});

신선한 시간은 staleTime 을 통해 설정할 수 있었고

staleTime을 설정하는 것으로 앞선 문제까지 해결할 수 있었습니다.

추가 개선 - 상세페이지로 바로 접속한 경우

저는 사용자의 다양한 플로우에 대해서 동일한 경험을 하기를 원했습니다.

[피드 → 상세페이지] 사용자가 피드를 통해서 진입시에는 빠르게 들어갈 수 있었지만,

[→ 상세페이지] 해당 페이지로 바로 접속했을때는 prefetch가 되지 않아서 로딩을 기다려야하는 상황에 발생했습니다.

그래서 서버사이드 환경에서 prefetch를 적용했습니다.

export async function getServerSideProps() {
  const queryClient = new QueryClient();

  // 서버에서 미리 데이터를 가져와 캐시에 저장
  await queryClient.prefetchQuery({
    queryKey: TODO_QUERY_KEY,
    queryFn: fetchTodos,
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}
export async function getServerSideProps() {
  const queryClient = new QueryClient();

  // 서버에서 미리 데이터를 가져와 캐시에 저장
  await queryClient.prefetchQuery({
    queryKey: TODO_QUERY_KEY,
    queryFn: fetchTodos,
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

이렇게 구현하니 클라이언트에서 재요청이 발생하지 않았고 빠르게 화면을 보여줄 수 있었습니다.

문제 발생 - 서버사이드 prefetch 시 캐시가 비어있는 현상

문제가 발생한건

[피드 → 상세페이지] 사용자가 피드를 통해서 진입시에

hover 했을때에도 데이터 요청이 발생했고 서버에서도 데이터 요청이 발생하여 중복으로 요청되는 것을 확인했습니다.

개선시도 - 전역에 queryClient를 하나만 생성하는 싱글톤 패턴 적용

const todoDate = queryClient.getQueryData(TODO_QUERY_KEY);
console.log(todoDate); // undefined
const todoDate = queryClient.getQueryData(TODO_QUERY_KEY);
console.log(todoDate); // undefined

왜 중복 요청을 하는 걸까? 고민하면서 prefetch 전에 캐시 데이터를 요청해서 로그로 확인해보았습니다.

분명 이전 페이지에서 prefetch 후에 넘어온것인데 왜 캐시가 없는지 이해가 안되었습니다.

제가 문제라고 생각했던 부분은,

const queryClient = new QueryClient();
const queryClient = new QueryClient();

서버에서 매번 쿼리클라이언트 인스턴스를 생성한다는 점이었습니다.

매번 새로 생성하니 캐시가 공유되지 않아서 당연히 캐시가 없는 것이라고 생각했습니다. 그래서 하지말아야할, 전역에 쿼리클라이언트를 하나만 선언하고 모든곳에서 공유하기 시작했습니다.

import { QueryClient } from "@tanstack/react-query";

const createQueryClient = () => {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000 * 60 * 5,
      },
    },
  });
};

let globalQueryClient: QueryClient | undefined;

export const getQueryClient = () => {
  if (!globalQueryClient) globalQueryClient = createQueryClient();
  return globalQueryClient;
};
import { QueryClient } from "@tanstack/react-query";

const createQueryClient = () => {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000 * 60 * 5,
      },
    },
  });
};

let globalQueryClient: QueryClient | undefined;

export const getQueryClient = () => {
  if (!globalQueryClient) globalQueryClient = createQueryClient();
  return globalQueryClient;
};

물론 결과는 성공적이었습니다.

서버를 비롯한 클라이언트에서도 하나의 인스턴스만을 공유하니

서버에서도 캐시 확인이 가능했고 중복요청은 발생하지 않았습니다.

결론 - 매번 인스턴스를 생성하는 로직으로 변경

다시 로직을 되돌린것은 테스트를 하던중

모든 사용자가 동일한 데이터를 공유한다는 것을 알게된 다음이었습니다.

When doing server rendering, it's important to create the queryClient instance inside of your app, in React state (an instance ref works fine too). This ensures that data is not shared between different users and requests, while still only creating the queryClient once per component lifecycle.

Tanstack Query 공식문서

공식 문서를 확인해보니 서버에서는 새로 인스턴스를 생성하는 것을 권장하고 있었습니다.

알게된 내용

미리 이미지 업로드 요청

인스타그램의 경우는 여러장의 사진을 올릴때 시간이 오래걸리므로,

사용자가 게시글을 작성하면서 이미지를 추가하면 그때 업로드 요청이 함께 이루어진다.

그래서 이미지 업로드 후 글을 작성할때 이미지는 업로드된 후라서 게시속도가 빨라진다.

next에서 제공하는 Link 컴포넌트는 해당 페이지의 정적인 리소스를 미리 요청한다.

이미지나 JS파일을 미리 받을 수 있어서 데이터를 미리 요청하는 tanstack query의 prefetch와 함께 사용하면 시너지가 더욱 좋다.

prefetch 트리거

마우스 hover의 경우는 민감하게 반응하면 불필요한 요청수가 많아지므로 임계치 (200ms)를 설정해 디바운스를 적용하는 것이 좋다.

가급적이면 성능 최적화가 필요한 페이지이면서, 사용자의 대부분이 이동하는 플로우에 동작시키는 것이 좋다.

스크롤 이벤트를 통해 특정 위치에 도달했을때나 특정 컴포넌트가 뷰포트내에 들어왔을때 요청하는 방법도 있다.

브라우저 종료후에도 유지시키기

import { createWebStoragePersistor } from '@tanstack/query-persist-client-experimental';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';

const queryClient = new QueryClient();

const localStoragePersistor = createWebStoragePersistor({
  storage: window.localStorage,
});

persistQueryClient({
  queryClient,
  persistor: localStoragePersistor,
});
import { createWebStoragePersistor } from '@tanstack/query-persist-client-experimental';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';

const queryClient = new QueryClient();

const localStoragePersistor = createWebStoragePersistor({
  storage: window.localStorage,
});

persistQueryClient({
  queryClient,
  persistor: localStoragePersistor,
});

로컬스토리지에 저장해서 브라우저 종료시에도 데이터를 유지시킬수 있다.

staleTime하고 함께 적용해서 refetch 를 통해 새로운 데이터로 바꿀수 있다.

자주 값이 변하지 않으면서, 첫 페이지 뷰포트에 위치해서 빠르게 로드해야하는 정보일 경우 유용해보였다.

배운점

1. staleTime 설정 중요성

tanstack query를 사용하는 이유중에 하나는 캐싱을 통한 요청 감소가 있을것이다.

근데 staleTime을 설정하지 않아서 매번 오래된 상태의 데이터로 인식되고 새롭게 요청하는 일은, tanstack query를 반만 이용하는것과도 같다.

반드시 staleTime을 설정하여 요청을 최적화하는것이 중요하다 !!

하지만 사람이라서 useQuery 와 같은 곳에 설정하는 것을 잊어버릴수 있는데,

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )

초기 세팅에서 기본 옵션으로 설정하여 일괄로 적용할수 있다.

2. prefetch는 전능한 기능이 아니며 캐시 전략과 함께 고려하는것이 중요

무조건 미리 요청하는 것은 오히려 서버 요청 증가로 이루어질수 있다는 것을 알게되었다.

사용자가 이동하지 않음에도 해당 게시글에 마우스를 올릴수도 있고 상세페이지에 바로 접속했을때 로딩이 발생하는 건 당연한 수순이다. (페이지 리소스 및 인증 과정도 발생하기 때문)

또한 hover에만 prefetch를 동작시키는건 모바일 사용자를 고려하지 않은 전략일수 있다. 따라서 퍼널과 같이 반드시 사용자가 다음 페이지를 방문하는 플로우면서, 데이터 요청에 시간이 오래 걸릴 경우 활용하면 좋다.

tanstack query에서는 다양한 캐시 전략을 제공한다.

데이터를 신선상태로 유지시키는 시간을 뜻하는 staleTime외에도 메모리에 유지시키는 gcTime도 존재하고 refetch, invalidateQueries, resetQueries, removeQueries 와 같은 캐시 무효화 전략도 다양한 옵션을 제공하니 전략에 맞게 적용하는 것이 중요하다.

3. 뇌피셜을 자제하자

사실 가장 크게 느낀점은, 문제를 발견하고 혼자 해결하려는 것만으로는 한계가 있다는 것을 느꼈다.

내가 알고 있는 선에서 해결책을 찾았지만 근본적인 해결책이 아니었으며 다른 부작용을 야기했다.

결국 답은 공식문서에 있었다. 공식문서에서는 설계의도와 유의할점등 라이브러리를 사용함에 있어서 제일 중요한 정보를 담고 있었다. 공식문서를 파악하고 사용하자!

참고자료

읽어주셔서 감사합니다