Notice
Recent Posts
Recent Comments
Link
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

mimizae 님의 블로그

Tanstack Query 초기 세팅 본문

FE

Tanstack Query 초기 세팅

mimizae 2026. 1. 29. 11:42

37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트의 초기세팅 과정을 담은 글입니다! 🙌🏻


🤔 Tanstack Query 사용 이유

우리 서비스는 건강 검진 기록이라는 상대적으로 민감한 정보를 다루는 서비스다. 그래서 처음에는 자연스럽게 이런 고민이 들었다.

🤨 “음… 이거 쿼리 써도 되나? 민감한 정보를 캐시해도 괜찮은가? 캐시라는 행위 자체가 보안적으로 불리한 거 아닌가…?”

 

특히 TanStack Query는 캐싱 라이브러리라는 인식이 강하다 보니, 민감한 데이터를 오래 클라이언트에 들고 있는 구조가 보안 측면에서 적절하지 않다고 느껴졌다.

하지만 내가 TanStack Query를 고민했던 이유는 캐시를 한다 / 안 한다의 문제가 아니라, 캐시에 너무 집중한 나머지 다른 장점들을 충분히 보지 못했던 것에 가까웠다.

 

TanStack Query는 단순히 데이터를 캐시해 두는 도구라기보다는 서버 상태를 안정적으로 관리하고, 요청 중복을 방지하며, 데이터를 언제 다시 가져올지에 대한 타이밍을 제어할 수 있는 서버 상태 관리 도구에 가깝다.

즉, 캐시는 무조건 오래 유지해야 하는 개념이 아니라, 서비스 특성에 맞게 조절할 수 있는 여러 옵션 중 하나였다.

 

이에 따라 TanStack Query의 성능과 UX 측면의 장점은 적극적으로 활용하되, 모든 데이터를 동일한 기준으로 다루기보다는 데이터의 민감도에 따라 캐시 전략을 분리하는 방식을 선택했다.

 

기본적으로는 TanStack Query의 캐싱 이점을 활용해 불필요한 네트워크 요청을 줄이고 부드러운 사용자 경험을 제공하되, 민감한 정보가 포함된 데이터에 한해서는 캐시된 데이터를 그대로 신뢰하지 않고 항상 서버를 기준으로 다시 동기화하도록 구성하는 것이다.

 

즉!!!!!

  • 기본 query는 적절한 staleTime을 설정해 캐시를 활용하고
  • 건강 검진 기록과 같이 민감한 데이터가 포함된 query는 staleTime을 매우 짧게 설정해 캐시된 데이터를 즉시 stale 상태로 판단하며
  • 화면 진입, 포커스 변경, 네트워크 복구 시점마다 자동으로 refetch가 일어나도록 개별 설정한다.
  • 또한 민감한 데이터의 경우 gcTime을 짧게 설정해 사용이 종료된 캐시가 클라이언트 메모리에 오래 남지 않도록 한다.

⚙️ Tanstack Query 및 devtools 설치

💭 패키지 매니저는 pnpm을 사용한다! 이유는 이 글을 참고해 주길 바란다.

pnpm add @tanstack/react-query @tanstack/react-query-devtools -D

@tanstack/react-query-devtools 사용 이유

TanStack Query Devtools는 애플리케이션에서 사용 중인 query와 mutation의 상태를 실시간으로 확인하기 위한 개발 도구다!!

  • 현재 어떤 query가 존재하는지
  • 각 query의 상태 (idle, loading, success, error)
  • stale 여부 및 refetch 여부
  • 캐시 데이터와 옵션 설정

등을 한 눈에 확인할 수 있다.

 

특히 서버 상태를 다루는 로직은 코드만으로 흐름을 파악하기 어려운 경우가 많기 때문에, Devtools를 통해 데이터 흐름과 상태 변화를 시각적으로 확인할 수 있다는 점에서 유용하다.


💡 QueryClient 생성

🔻 QueryProvider.tsx 코드

import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import type { ReactNode } from "react";
import { useState } from "react";
import { createQueryClient } from "@/shared/apis/query-client";

export function QueryProvider({ children }: { children: ReactNode }) {
	const [queryClient] = useState(() => createQueryClient());

	return (
		<QueryClientProvider client={queryClient}>
			{children}
			{import.meta.env.DEV ? (
				<ReactQueryDevtools initialIsOpen={false} />
			) : null}
		</QueryClientProvider>
	);
}

 

QueryClient란TanStack Query 라이브러리에서 전역적으로 서버 상태를 관리하고 상호작용하는 핵심 객체로, 데이터를 가져오고(fetch), 캐싱하며(cache), 동기화(sync)하는 모든 작업을 제어하는 중심 역할을 한다!

 

즉, Tanstack Query의 전역 상태 관리자이자 캐시 저장소인 셈이다.

QueryClient 내부에는 다음과 같은 정보와 로직이 포함되어 있다.

  • 모든 query의 캐시 데이터
  • query의 상태 (loading, error, success)
  • 데이터의 stale / fresh 여부 판단 로직
  • 자동 refetch 및 retry 정책
  • mutation의 상태 및 결과
  • query 무효화(invalidate) 및 재요청(refetch) 제어

이러한 기능을 통해 서버 상태를 일관되게 관리하고, 불필요한 네트워크 요청을 줄이며, 사용자 경험을 개선한다.

 

🔻 createQueryClient.ts 코드 전문 

더보기
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";

export const createQueryClient = () => {
	return new QueryClient({
		queryCache: new QueryCache({
			onError: () => {
				// TODO: 추후 query 요청 실패 시 공통 에러 처리 로직 추가 예정
				// ex) 에러 코드별 메시지 매핑, 전역 토스트 노출 등
			},
		}),
		mutationCache: new MutationCache({
			onError: () => {
				// TODO: mutation 실패 시 공통 에러 처리 로직 추가 예정
			},
		}),
		defaultOptions: {
			queries: {
				staleTime: 60 * 1000,
				retry: 1,
				refetchOnWindowFocus: false,
				throwOnError: true,
			},
			mutations: {
				retry: 0,
				throwOnError: true,
			},
		},
	});
};

 

createQueryClient 함수 분리

QueryClient 내부에 캐시 데이터, 상태, 옵저버 등 다양한 정보를 함께 관리한다고 했다.

즉, 상대적으로 무거운 객체이며 한 번 생성되면 애플리케이션 전반에서 계속 재사용되는 것이 전제이다.

 

이러한 특성 때문에 QueryClient를 아래와 같이 컴포넌트 내부에서 바로 생성하는 방식은 적절하지 않다.

const queryClient = new QueryClient(); // ❌

 

이 방식으로 생성할 경우,

  • 컴포넌트가 리렌더링될 때마다 QueryClient가 새로 생성될 수 있고
  • 캐시가 초기화되고, 기존 query 상태가 모두 사라지며
  • React StrictMode 환경에서는 의도치 않게 여러 번 생성될 수 있다.

그래서 아래와 같은 패턴을 사용했다!

const [queryClient] = useState(() => createQueryClient());

 

이 패턴은 React의 lazy initializer를 활용한 것으로, 최초 렌더링 시에만 QueryClient를 한 번 생성하고 이후 리렌더링에서는 동일한 인스턴스를 계속 재사용한다!

이를 통해, QueryClient불필요하게 재생성되는 것을 방지하고 TanStack Query의 캐시와 상태를 안정적으로 유지할 수 있다.

 

또한 createQueryClient라는 생성 함수로 분리함으로써, 아래와 같은 이점을 얻을 수 있다.

  • QueryClient 생성 책임을 명확히 분리
  • 설정 로직을 한 곳에서 관리 가능
  • 추후 옵션 확장이나 환경별 분기에도 유연하게 대응 가능

 defaultOptions

프로젝트 전반에서 동일한 데이터 요청 정책과 캐싱 전략을 유지하기 위해 QueryClient를 생성할 때 기본 옵션(defaultOptions)을 설정한다.

이를 통해 모든 query가 공통된 기준으로 동작하고, 개별 query마다 중복 설정을 줄이며, 팀 단위로 일관된 데이터 패칭 전략을 유지할 수 있다!! 우리 서비스에서는 아래와 같은 옵션들을 추가했다.

defaultOptions: {
	queries: {
		staleTime: 60 * 1000,
		retry: 1,
		refetchOnWindowFocus: false,
		throwOnError: true,
	},
	mutations: {
		retry: 0,
		throwOnError: true,
	},
},

 

queries 옵션

  • staleTime: 60 * 1000
    데이터를 1분 동안 fresh 상태로 유지한다. 해당 시간 동안은 같은 query가 다시 실행되더라도 네트워크 요청 없이 캐시된 데이터를 재사용한다. 불필요한 API 호출 최소화하고 페이지 이동이 잦은 환경에서도 부드러운 UX를 제공하기 위함이다.
  • retry: 1
    query 요청이 실패했을 경우 1회 재시도한다. 일시적인 네트워크 오류를 고려한 설정이며 사용자에게 즉시 에러를 노출하지 않고 일시적인 실패에 대한 복원력을 확보하기 위함이다!!
  • refetchOnWindowFocus: false
    브라우저 탭이 다시 포커스될 때 자동으로 refetch 되는 동작을 비활성화한다! 사용자가 다른 탭을 잠깐 다녀올 때마다 데이터가 다시 로드되는 현상 방지해 예기치 않은 UI 변경 및 깜빡임 최소화 하기 위함이다.
mutations 옵션
  • retry: 0
    mutation 요청은 자동 재시도를 하지 않는다. mutation은 서버 상태변경하는 요청이기 때문에 재시도 시 중복 요청 / 중복 데이터 생성 위험이 있기 때문이다. 따라서, mutation 실패 시 즉시 에러를 처리하고 필요한 경우 사용자가 명시적으로 다시 시도하도록 한다.

💡 QueryProvider로 감싸기

QueryClient를 생성했다면, 다음은 해당 QueryClient애플리케이션 전반에서 사용할 수 있도록 주입해야 한다!

 

이를 위해 TanStack Query에서는 QueryClientProvider를 제공하며, 이 Provider를 통해 QueryClient 인스턴스를 React Context로 공유한다.

export const AppProvider = () => {
	return (
		<AuthInitializerProvider>
			<QueryProvider>
				<MainToaster />
				<OverlayProvider>
					<RouterProvider />
				</OverlayProvider>
			</QueryProvider>
		</AuthInitializerProvider>
	);
};

export const App = () => {
	return (
		<ErrorBoundary FallbackComponent={GlobalErrorFallback}>
			<AppProvider />
		</ErrorBoundary>
	);
};

 

QueryClientProvider를 감싼 QueryProvider 컴포넌트를 생성하고, 애플리케이션의 최상단에서 사용한다.

 

이렇게 구성함으로써, 애플리케이션 전반에서 동일한 QueryClient 인스턴스를 공유할 수 있고 모든 하위 컴포넌트에서 useQuery, useMutation, useQueryClient 등의 훅을 별도의 설정 없이 사용할 수 있다!!


🤨 ETC

작업 PR 링크

 

🔻 NextJS에서의 Tanstack Query 초기 세팅?!

더보기
'use client';

import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: false,
        refetchOnMount: true,
        refetchOnReconnect: true,
        retry: 1,
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (isServer) {
    return makeQueryClient();
  } else {
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

export function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

 

잘 보면 React의 TQ 초기세팅과 다를 바 없어보이지만 NextJS의 초기세팅에는 getQueryClient라는 함수가 하나 추가되어있는 걸 볼 수 있다.

 

왜 Next.js에서는 QueryClient를 이렇게 관리해야 할까?

 

Next.js는 Server + Client가 섞여 있다.

즉, Next.js에서는 같은 코드가 서버에서 실행될 수도 있고 브라우저에서 실행될 수도 있다.

그래서 TanStack Query에서도 지금 이 코드가 서버에서 도는지, 클라이언트에서 도는지를 구분해야만 한다.

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

 

서버에서는 요청마다 QueryClient가 새로 필요하다.

서버에서는 다음과 같은 특징을 갖는데,

  • 요청 A, 요청 B는 완전히 다른 사용자
  • QueryClient를 공유하면 데이터가 섞이는 치명적인 문제 발생... ☠️

그래서 서버에서는 항상 새 QueryClient를 생성해야 한다.

if (isServer) {
  return makeQueryClient();
}

 

하지만, 위에서 설명했듯이, 브라우저에서는 QueryClient를 하나만 유지해야 한다.

 

그래서 전역 변수로 한 번만 생성하는 패턴을 사용한다. 브라우저 세션 동안에는 싱글톤처럼 동작하도록 만든 것이다!

let browserQueryClient: QueryClient | undefined = undefined;

if (!browserQueryClient) {
  browserQueryClient = makeQueryClient();
}

 

결론!!! (정리)

 

Next.js에서는 같은 코드가 서버와 클라이언트에서 모두 실행되기 때문에, 서버에서는 요청 간 캐시 오염을 막기 위해 QueryClient를 매번 새로 생성하고, 클라이언트에서는 캐시 유지를 위해 단 하나의 QueryClient를 재사용해야 한다.

function getQueryClient() {
  if (isServer) {
    // 서버: 요청마다 새로 생성
    return makeQueryClient();
  } else {
    // 클라이언트: 한 번만 생성해서 재사용
    if (!browserQueryClient) {
      browserQueryClient = makeQueryClient();
    }
    return browserQueryClient;
  }
}