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

August 23, 20255 minutes

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를 활용한 ‘스마트 프리페칭’은 로딩 시간을 최소화해서 사용자의 경험을 극적으로 끌어올려 줍니다.

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