TanStack Query, 아직도 로딩 스피너 쓰세요? (타입스크립트와 프리페칭으로 만드는 즉각적인 UX)

TanStack Query(구 React Query)는 이제 프론트엔드 데이터 페칭의 표준이라고 해도 과언이 아니죠.

서버 상태를 아주 우아하게 관리해주지만, 혹시 이 도구의 잠재력을 100% 끌어내고 계신가요?

오늘은 TanStack Query를 그냥 '잘 쓰는' 수준을 넘어, '마스터하는' 두 가지 고급 패턴에 대해 이야기해 보려고 하는데요.

첫 번째는 타입스크립트를 활용해 우리의 API 훅을 총알도 막아낼 만큼 견고하게 만드는 방법이고요.

두 번째는 '프리페칭'을 통해 사용자가 로딩 스피너를 볼 틈도 없이 데이터를 보여주는 마법 같은 사용자 경험을 만드는 기술입니다.

이 두 가지만 제대로 익혀도 여러분의 애플리케이션은 코드 품질과 사용자 경험 양쪽에서 한 단계 도약할 수 있을 거예요.

타입스크립트로 TanStack Query 날개 달아주기

TanStack Query를 타입스크립트와 함께 쓰면 자동 완성, 컴파일 타임 에러 체크 등 정말 많은 이점을 누릴 수 있는데요.

이걸 제대로 활용하면 아주 견고하고 재사용성 높은 데이터 훅을 만들 수 있습니다.

기본부터 탄탄하게, 제네릭으로 타입 지정하기

가장 기본적인 useQuery 사용법부터 타입스크립트를 적용해 보죠.

import { useQuery } from '@tanstack/react-query';

type User = {
  id: string;
  name: string;
};

const fetchUser = async (): Promise<User> => {
  const res = await fetch('/api/user');
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
};

export function useUserQuery() {
  return useQuery<User>(['user'], fetchUser);
}

여기서 핵심은 useQuery<User> 부분인데요.

이렇게 제네릭으로 User 타입을 명시해주면, TanStack Query는 이 쿼리가 성공했을 때 반환될 data의 타입이 User라는 것을 알게 됩니다.

덕분에 data.name 같은 속성에 접근할 때 자동 완성이 지원되고, 만약 오타라도 나면 컴파일러가 바로 에러를 뱉어주죠.

옵션을 타입으로 정의해서 재사용하기

쿼리 옵션을 별도의 타입으로 추출해서 재사용성을 높일 수도 있는데요.

UseQueryOptions 타입을 활용하면 됩니다.

import { UseQueryOptions } from '@tanstack/react-query';

type UserQueryOptions = UseQueryOptions<
  User,         // TQueryFnData: queryFn이 반환하는 데이터 타입
  Error,        // TError: 에러 발생 시 에러 객체의 타입
  User,         // TData: 최종적으로 컴포넌트에 전달될 데이터 타입
  ['user']      // TQueryKey: 쿼리 키의 타입
>;

export const useUserQuery = (options?: UserQueryOptions) => {
  return useQuery(['user'], fetchUser, options);
};

이렇게 UserQueryOptions라는 타입을 만들어두면, 이 쿼리에 특화된 옵션들을 타입 안전하게 관리하고 다른 곳에서도 쉽게 재사용할 수 있게 되죠.

제네릭 훅으로 궁극의 재사용성 달성하기

여기서부터가 진짜 재미있는 부분인데요.

아예 어떤 데이터 타입이든 처리할 수 있는 범용적인 useTypedQuery 훅을 만들어서 반복 작업을 극적으로 줄일 수 있습니다.

마치 우리만의 미니 API 팩토리를 만드는 것과 같죠.

import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';

export function useTypedQuery<
  TData, 
  TError = Error, 
  TQueryKey extends readonly unknown[] = any
>(
  key: TQueryKey,
  queryFn: () => Promise<TData>,
  options?: UseQueryOptions<TData, TError, TData, TQueryKey>
): UseQueryResult<TData, TError> {
  return useQuery(key, queryFn, options);
}

useTypedQuery는 데이터 타입(TData), 에러 타입(TError), 쿼리 키 타입(TQueryKey)을 제네릭으로 받아서 useQuery를 한번 감싼 래퍼 훅인데요.

이걸 사용하면 새로운 쿼리 훅을 만드는 게 정말 간단해집니다.

type Product = { id: number; name: string };

function useProductQuery(id: number) {
  return useTypedQuery<Product>(
    ['product', id], 
    () => fetch(`/api/products/${id}`).then(res => res.json())
  );
}

보세요.

이제 useProductQuery를 만들 때 useTypedQuery를 호출하면서 데이터 타입과 쿼리 키, 그리고 queryFn만 넘겨주면 됩니다.

복잡한 타입 정의는 useTypedQuery 안에 모두 캡슐화되어 있죠.

이런 패턴은 특히 여러 엔티티를 다루는 큰 프로젝트에서 빛을 발합니다.

잠깐, 타입스크립트의 추론 능력을 믿어보세요

한 가지 더 팁을 드리자면, queryFn을 인라인 함수로 작성하고 반환 타입을 Promise<T>로 명시해주면 타입스크립트가 알아서 데이터 타입을 추론해주거든요.

useQuery(['user'], async (): Promise<User> => {
  return fetch('/api/user').then(res => res.json());
});

덕분에 굳이 useQuery<User>처럼 제네릭을 명시하지 않아도 타입 안전성을 누릴 수 있는 거죠.

로딩 스피너를 추방하는 프리페칭의 마법

이제 우리 쿼리가 타입스크립트로 안전해졌으니, 다음은 사용자 경험을 극적으로 끌어올릴 차례인데요.

'프리페칭(Prefetching)'은 사용자가 그 데이터를 요청하기도 '전에' 미리 가져와서 캐시에 살짝 숨겨두는 기술입니다.

사용자가 링크를 클릭하는 순간, 데이터는 이미 준비되어 있기 때문에 로딩 스피너 없이 즉시 화면이 나타나는 마법 같은 경험을 선사할 수 있죠.

가장 기본적인 프리페칭 마우스 호버

프리페칭을 트리거하는 가장 흔하고 효과적인 방법은 사용자가 링크에 마우스를 올렸을 때인데요.

사용자가 링크에 마우스를 올렸다는 건, 그 페이지로 이동할 '의도'가 있다는 강력한 신호거든요.

import { useQueryClient } from '@tanstack/react-query';

const fetchPost = async (id: number) => {
  // ... 게시글 데이터를 가져오는 로직
};

export default function PostLink({ id }: { id: number }) {
  const queryClient = useQueryClient();

  return (
    <a
      href={`/posts/${id}`}
      onMouseEnter={() => {
        queryClient.prefetchQuery(['post', id], () => fetchPost(id));
      }}
    >
      게시글 #{id} 보기
    </a>
  );
}

useQueryClient 훅으로 쿼리 클라이언트 인스턴스를 가져온 뒤, <a> 태그의 onMouseEnter 이벤트 핸들러 안에서 queryClient.prefetchQuery를 호출하면 끝입니다.

이제 사용자가 이 링크에 마우스를 올리는 순간, 백그라운드에서 fetchPost가 실행되고 그 결과가 ['post', id]라는 키로 캐시되죠.

프리페칭 에러 처리, 잊지 마세요

그런데 이 prefetchQuery는 기본적으로 '실행하고 잊어버리는(fire-and-forget)' 방식으로 동작하거든요.

그래서 만약 프리페칭 중에 네트워크 에러가 발생해도 조용히 무시해버립니다.

만약 에러를 로깅하거나 다른 처리를 하고 싶다면, 반드시 await 키워드를 사용해서 비동기적으로 처리해야 합니다.

try {
  await queryClient.prefetchQuery(['post', id], () => fetchPost(id));
} catch (err) {
  console.error('프리페칭 실패:', err);
}

이렇게 try-catch 블록으로 감싸주면 프리페칭 실패에 대한 예외 처리가 가능해집니다.

useQuery와의 아름다운 협업

이게 바로 프리페칭의 하이라이트죠.

사용자가 마침내 링크를 클릭해서 해당 게시글 페이지로 이동했다고 해봅시다.

그 페이지의 컴포넌트는 아마 이렇게 useQuery를 사용하고 있을 텐데요.

const { data } = useQuery(['post', id], () => fetchPost(id));

TanStack Query는 fetchPost를 실행하기 전에, 먼저 캐시에 ['post', id] 키에 해당하는 데이터가 있는지 확인합니다.

그런데 우리가 이미 프리페칭을 해뒀기 때문에, 캐시에는 신선한 데이터가 딱 준비되어 있죠.

그러면 TanStack Query는 네트워크 요청을 보내는 대신, 캐시에서 데이터를 즉시 꺼내 반환합니다.

결과는? 로딩 스피너 없는 즉각적인 화면 전환이죠.

사용자 입장에서는 정말 놀랍도록 빠른 속도를 체감하게 되는 겁니다.

또 다른 전략들 페이지 로드 시 프리페칭, 그리고 ensureQueryData

마우스를 올릴 때뿐만 아니라, 페이지가 처음 로드될 때 useEffect를 사용해서 데이터를 미리 가져와 캐시를 채워두는 전략도 유용한데요.

대시보드나 설정 페이지처럼 사용자가 높은 확률로 접근할 데이터를 미리 준비해두는 거죠.

useEffect(() => {
  queryClient.prefetchQuery(['settings'], fetchSettings);
}, [queryClient]);

또한 TanStack Query v5부터는 ensureQueryData라는 아주 편리한 API도 추가되었는데요.

이 함수는 캐시에 데이터가 있으면 즉시 반환하고, 없으면 queryFn을 실행해서 데이터를 가져온 뒤 그 결과를 반환하고 캐싱까지 해줍니다.

사용자 인증 정보처럼, 레이아웃 단에서 반드시 필요한 데이터를 미리 로드하거나 보장하는 데 아주 유용하죠.

마치며

오늘 우리는 TanStack Query를 한 단계 더 깊이 있게 사용하는 두 가지 강력한 패턴을 살펴봤는데요.

타입스크립트를 활용한 '제네릭 쿼리 훅'은 코드의 안정성과 재사용성을 극대화해서 우리의 개발 경험을 향상시켜주고요.

prefetchQueryensureQueryData를 활용한 '스마트 프리페칭'은 로딩 시간을 최소화해서 사용자의 경험을 극적으로 끌어올려 줍니다.

이 두 가지 패턴을 잘 조합해서 사용한다면, 여러분은 분명 더 견고하고, 더 빠르고, 더 즐거운 웹 애플리케이션을 만들 수 있을 거예요.