Skip to content

Instantly share code, notes, and snippets.

@YangSiJun528
Last active June 9, 2025 01:26
Show Gist options
  • Save YangSiJun528/678fe789f427cb8bd19422dbca250f7f to your computer and use it in GitHub Desktop.
Save YangSiJun528/678fe789f427cb8bd19422dbca250f7f to your computer and use it in GitHub Desktop.
Manus 피셜 - TanStack Query v5 사용 가이드

TanStack Query v5 사용 가이드

목차

  1. 소개
  2. 기본 사용법
  3. 동적 파라미터와 QueryKey 관리
  4. 상태 관리와 캐싱
  5. 페이지네이션과 무한 스크롤
  6. 최적화된 Mutation 패턴
  7. 성능 최적화 패턴
  8. 향상된 에러 처리
  9. HTTP 캐싱과의 관계
  10. 모범 사례 및 권장 패턴

소개

TanStack Query(이전 명칭: React Query)는 서버 상태 관리를 위한 강력한 라이브러리입니다. 이 가이드에서는 TanStack Query v5의 주요 기능과 사용법을 Q&A 형식으로 설명합니다.

Q: TanStack Query란 무엇인가요?

A: TanStack Query는 서버 상태를 가져오고, 캐싱하고, 동기화하고, 업데이트하는 작업을 간소화하는 라이브러리입니다. 복잡한 상태 관리 로직을 추상화하여 개발자가 데이터 페칭과 관련된 문제를 쉽게 해결할 수 있도록 도와줍니다.

Q: TanStack Query v5의 주요 특징은 무엇인가요?

A: TanStack Query v5의 주요 특징은 다음과 같습니다:

  • 선언적 쿼리: 컴포넌트 내에서 데이터 요청을 선언적으로 정의
  • 자동 캐싱: 쿼리 결과를 자동으로 캐싱하여 중복 요청 방지
  • 백그라운드 업데이트: 사용자 경험을 방해하지 않고 데이터를 최신 상태로 유지
  • 페이지네이션 및 무한 스크롤 지원: 대량의 데이터를 효율적으로 처리
  • 데이터 동기화: 여러 컴포넌트 간에 데이터 상태 동기화
  • 낙관적 업데이트: 서버 응답을 기다리지 않고 UI를 즉시 업데이트
  • 타입스크립트 지원: 완전한 타입 안정성 제공
  • 서버 사이드 렌더링 지원: SSR 환경에서도 원활하게 작동

기본 사용법

Q: TanStack Query를 어떻게 설치하고 설정하나요?

A: TanStack Query v5는 다음과 같이 설치하고 설정할 수 있습니다:

# npm을 사용하는 경우
npm install @tanstack/react-query

# yarn을 사용하는 경우
yarn add @tanstack/react-query

# pnpm을 사용하는 경우
pnpm add @tanstack/react-query

그리고 애플리케이션에 QueryClient와 QueryClientProvider를 설정합니다:

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

// QueryClient 인스턴스 생성
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분
      gcTime: 1000 * 60 * 60 * 24, // 24시간
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* 애플리케이션 컴포넌트 */}
      <YourAppComponent />
      
      {/* 개발 환경에서만 DevTools 표시 */}
      {process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
    </QueryClientProvider>
  );
}

Q: 기본적인 쿼리는 어떻게 사용하나요?

A: TanStack Query v5에서 기본적인 쿼리는 useQuery 훅을 사용하여 다음과 같이 구현합니다:

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

// 데이터를 가져오는 함수
const fetchProducts = async () => {
  const response = await fetch('https://api.example.com/products');
  if (!response.ok) {
    throw new Error('네트워크 응답이 올바르지 않습니다');
  }
  return response.json();
};

function ProductList() {
  const { 
    data, 
    isLoading, 
    isError, 
    error 
  } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  if (isLoading) return <div>로딩 중...</div>;
  if (isError) return <div>에러: {error.message}</div>;

  return (
    <div>
      <h2>상품 목록</h2>
      <ul>
        {data.map(product => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </div>
  );
}

Q: useQuery 훅이 반환하는 주요 상태값은 무엇인가요?

A: useQuery 훅은 다음과 같은 주요 상태값을 반환합니다:

  • data: 쿼리 함수가 성공적으로 해결된 경우의 데이터
  • isLoading: 쿼리가 현재 로딩 중인지 여부
  • isError: 쿼리에 오류가 발생했는지 여부
  • error: 발생한 오류 객체
  • status: 쿼리의 현재 상태 ('loading', 'error', 'success')
  • isFetching: 쿼리가 현재 데이터를 가져오는 중인지 여부
  • refetch: 쿼리를 수동으로 다시 가져오는 함수
  • isStale: 쿼리 데이터가 오래되었는지(stale) 여부
  • isSuccess: 쿼리가 성공적으로 완료되었는지 여부

Q: TanStack Query v5에서 달라진 점은 무엇인가요?

A: TanStack Query v5에서는 다음과 같은 주요 변경사항이 있습니다:

  1. 객체 기반 API: 함수 인자 대신 객체 기반 API를 사용합니다.

    // v4
    useQuery(['products'], fetchProducts, { staleTime: 5000 });
    
    // v5
    useQuery({
      queryKey: ['products'],
      queryFn: fetchProducts,
      staleTime: 5000
    });
  2. 개선된 타입스크립트 지원: 더 정확한 타입 추론과 타입 안정성을 제공합니다.

  3. 성능 최적화: 내부 구현이 최적화되어 더 나은 성능을 제공합니다.

  4. 확장된 DevTools: 더 강력한 디버깅 도구를 제공합니다.

  5. 프레임워크 독립적: React 외에도 다양한 프레임워크를 지원합니다.

동적 파라미터와 QueryKey 관리

Q: API에 파라미터가 추가되어 조건에 맞는 데이터만 캐싱해야 하는 경우, QueryKey를 어떻게 설정해야 하나요?

A: QueryKey를 배열 형태로 구성하여 파라미터를 포함시키세요. 모던한 방법은 QueryKey 팩토리 패턴을 사용하는 것입니다.

// QueryKey 팩토리 사용 (권장)
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id) => [...productKeys.details(), id] as const,
}

// 사용법
function useProducts(filters) {
  return useQuery({
    queryKey: productKeys.list(filters),
    queryFn: () => fetchProducts(filters)
  });
}

function useProduct(id) {
  return useQuery({
    queryKey: productKeys.detail(id),
    queryFn: () => fetchProduct(id)
  });
}

QueryKey 팩토리 패턴의 장점:

  • 타입 안정성: TypeScript에서 완전한 타입 추론
  • 일관성: 팀 전체에서 동일한 키 구조 사용
  • 무효화 용이성: 계층적 구조로 부분 무효화 쉬움

Q: 쇼핑몰에서 가격이나 기타 옵션 필터링해서 쿼리 파라미터로 던지는 경우는 어떻게 처리하나요?

A: 필터링 조건을 QueryKey에 포함시켜 각 조건별로 서로 다른 캐시 공간이 자동으로 관리되도록 합니다.

// 예시) 쇼핑몰 상품 목록을 가격 필터링 조건으로 가져올 때
function useProducts({ minPrice, maxPrice, category }) {
  return useQuery({
    queryKey: ['products', { minPrice, maxPrice, category }],
    queryFn: () => fetchProducts({ minPrice, maxPrice, category })
  });
}

위 예시에서 ['products', { minPrice, maxPrice, category }]와 같이 배열로 QueryKey를 설정하면:

  • minPrice, maxPrice, category가 달라질 때마다 서로 다른 캐시로 간주합니다.
  • 내부적으로는 JSON.stringify를 통해 Key를 직렬화하므로, 같은 값 조합이면 이전에 캐시된 데이터를 바로 재사용합니다.

Q: QueryKey에 객체를 직접 넣어도 되나요?

A: 내부적으로 배열을 JSON.stringify해서 고유 키를 만들기 때문에, 객체 리터럴을 QueryKey에 바로 넣으면 객체의 프로퍼티 순서나 참조가 달라질 때 캐시 미스가 발생할 수 있습니다. 따라서 보통은 단순 값(문자열·숫자)이나 "문자열 + JSON.stringify"를 조합해서 사용하는 것을 권장합니다.

// 권장 방법
const filters = JSON.stringify({ minPrice, maxPrice, category });

useQuery({
  queryKey: ['products', filters],
  queryFn: () => fetchProducts({ minPrice, maxPrice, category })
});

이렇게 하면 객체 순서가 바뀔 위험을 줄이고, 같은 필터 조합에서는 반드시 같은 Key를 생성하게 됩니다.

Q: ID 기반으로 여러 번 호출하고, 나중에 일부가 중복되는 경우는 어떻게 처리하나요?

A: ID를 QueryKey에 포함시키면 중복 호출된 ID가 있을 경우 이미 캐싱된 데이터를 재활용합니다.

function useProductById(productId) {
  return useQuery({
    queryKey: ['product', String(productId)],
    queryFn: () => fetchProductById(productId)
  });
}

이때 productId가 1, 2, 3... 식으로 바뀔 때마다 캐시 키(['product','1'], ['product','2'] 등)가 달라지므로, 중복 호출된 ID가 있을 경우(예: 나중에 ID=5 호출한 적이 있고, 또다시 ID=5를 요청하면) 이미 캐싱된 데이터를 재활용하게 됩니다.

Q: 파라미터 조합이 매우 많아서 캐시 크기가 걱정된다면 어떻게 해야 하나요?

A: 다음 두 가지 방법을 고려해볼 수 있습니다:

  1. staleTime/gcTime을 짧게 설정: 불필요한 캐시를 빨리 정리합니다.

    useQuery({
      queryKey: ['products', filters],
      queryFn: fetchProducts,
      staleTime: 1000 * 60 * 5, // 5분
      gcTime: 1000 * 60 * 10 // 10분
    });
  2. 필요 없는 조합 직접 제거: 액세스 빈도가 매우 낮은 필터는 캐시 관리 로직에서 직접 제거합니다.

    queryClient.removeQueries({
      predicate: (query) => {
        // 특정 조건에 맞는 쿼리만 제거
        const key = query.queryKey;
        return Array.isArray(key) && 
               key[0] === 'products' && 
               // 특정 조건 확인
               key[1].someRareCondition === true;
      }
    });

상태 관리와 캐싱

Q: 캐싱 중인 데이터가 변경되는 API 호출은 어떤 것으로 하나요?

A: 캐싱된 데이터를 변경하는 API 호출은 useMutation 훅을 사용합니다. 이를 통해 서버에 데이터를 변경하는 요청을 보내고, 성공 시 캐시를 업데이트할 수 있습니다.

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

function useUpdateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (updatedProduct) => 
      fetch(`/api/products/${updatedProduct.id}`, {
        method: 'PUT',
        body: JSON.stringify(updatedProduct),
        headers: { 'Content-Type': 'application/json' },
      }).then(res => res.json()),
    onSuccess: (data, variables) => {
      // 1) 변경된 단일 상품 데이터를 바로 cache에 반영
      queryClient.setQueryData(
        ['product', String(data.id)],
        data
      );

      // 2) 상품 목록에도 영향을 줄 수 있으므로,
      //    목록 캐시를 무효화해 재요청하도록 설정
      queryClient.invalidateQueries({ queryKey: ['products'] });
    }
  });
}

Q: 변경된 데이터를 캐시에 어떻게 반영하나요?

A: 변경된 데이터를 캐시에 반영하는 방법은 크게 두 가지가 있습니다:

  1. queryClient.setQueryData: 특정 QueryKey에 매핑된 캐시를 새 데이터로 직접 업데이트합니다.

    queryClient.setQueryData(['product', String(updatedProduct.id)], updatedProduct);
  2. queryClient.invalidateQueries: 해당 Key 패턴에 맞는 모든 쿼리를 "stale" 상태로 표시해, 다음번에 useQuery 훅이 마운트되거나 refetch를 호출하면 자동으로 재요청하게 합니다.

    queryClient.invalidateQueries({ queryKey: ['products'] });

Q: 여러 캐시 간의 관계는 어떻게 관리하나요?

A: 예를 들어, "상품 A"를 수정(PUT)할 때 ['product','A'] 외에도 다양한 "상품 목록" 캐시도 무효화(invalidate)해야 할 수 있습니다. 이런 경우 다음과 같이 처리할 수 있습니다:

// 'products'를 포함하는 모든 캐시를 무효화
queryClient.invalidateQueries({ queryKey: ['products'], exact: false });

// 또는 더 간단하게
queryClient.invalidateQueries('products'); // prefix 매칭

위 코드는 'products'를 포함하는 모든 캐시가 재요청 대상으로 표시됩니다.

Q: 낙관적 업데이트(Optimistic Update)란 무엇인가요?

A: 낙관적 업데이트는 서버 반영을 기다리지 않고, "UI 상에서 미리" 캐시를 업데이트해 주는 방법입니다. useMutation의 onMutate, onError, onSettled를 활용하여 구현합니다:

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

function useUpdateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateProduct,
    // 🆕 개선: 더 정확한 타입과 함께 사용
    onMutate: async (variables) => {
      // 진행 중인 refetch 취소
      await queryClient.cancelQueries({ 
        queryKey: productKeys.detail(variables.id) 
      });

      // 이전 상태 저장
      const previousProduct = queryClient.getQueryData(
        productKeys.detail(variables.id)
      );

      // 낙관적 업데이트
      queryClient.setQueryData(
        productKeys.detail(variables.id), 
        variables
      );

      return { previousProduct };
    },
    onError: (err, variables, context) => {
      // 에러 발생 시 이전 상태로 롤백
      if (context?.previousProduct) {
        queryClient.setQueryData(
          productKeys.detail(variables.id),
          context.previousProduct
        );
      }
    },
    onSettled: (data, error, variables) => {
      // 성공/실패 여부와 관계없이 쿼리 무효화
      queryClient.invalidateQueries({ 
        queryKey: productKeys.detail(variables.id) 
      });
    }
  });
}

이 패턴의 장점은 사용자에게 즉각적인 피드백을 제공하면서도, 서버 응답에 따라 적절히 롤백하거나 최종 데이터로 업데이트할 수 있다는 점입니다.

페이지네이션과 무한 스크롤

Q: 페이지네이션이나 무한 스크롤을 사용하면 캐싱은 어떻게 되나요?

A: TanStack Query는 페이지네이션과 무한 스크롤을 위한 두 가지 주요 접근 방식을 제공합니다.

Q: 전통적인 페이지네이션은 어떻게 구현하나요?

A: useQuery를 활용한 전통적 페이지네이션은 다음과 같이 구현합니다:

function usePaginatedProducts(page, filters) {
  return useQuery({
    queryKey: ['products', filters, page],
    queryFn: () => fetchProducts({ page, ...filters })
  });
}

이 방식의 특징:

  • page 값을 QueryKey에 포함시켜 페이지별로 캐싱합니다.
  • 페이지가 변경될 때마다(예: page: 1 → 2) 새로운 캐시 슬롯이 생성됩니다.
  • 이전 페이지를 다시 요청하면 캐시된 데이터를 그대로 사용합니다.

단점으로는 페이지 전환 시 "이전 페이지 데이터가 사라졌다가 새 페이지 로딩 스피너가 잠깐 보임" 등의 UX 문제가 있을 수 있습니다. 이를 해결하기 위해 keepPreviousData: true 옵션을 사용할 수 있습니다:

function usePaginatedProducts(page, filters) {
  return useQuery({
    queryKey: ['products', filters, page],
    queryFn: () => fetchProducts({ page, ...filters }),
    keepPreviousData: true // 새 데이터를 가져오는 동안 이전 데이터 유지
  });
}

이렇게 하면 "이전 페이지 데이터를 보여주면서" 새 페이지를 비동기적으로 받아오게 됩니다.

Q: 무한 스크롤은 어떻게 구현하나요?

A: useInfiniteQuery를 활용한 무한 스크롤은 다음과 같이 구현합니다:

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

function useInfiniteProducts(filters) {
  return useInfiniteQuery({
    queryKey: ['products', 'infinite', filters],
    queryFn: ({ pageParam = 0 }) => fetchProducts({ page: pageParam, ...filters }),
    getNextPageParam: (lastPage, allPages) => {
      // lastPage: fetchProducts가 리턴한 { data, nextCursor } 형태라 가정
      return lastPage.nextCursor ?? undefined;
    }
  });
}

// 사용 예시
function ProductList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status
  } = useInfiniteProducts(filters);

  return (
    <div>
      {status === 'pending' ? (
        <p>로딩 중...</p>
      ) : status === 'error' ? (
        <p>에러 발생</p>
      ) : (
        <>
          {data.pages.map((page, i) => (
            <React.Fragment key={i}>
              {page.data.map(product => (
                <ProductItem key={product.id} product={product} />
              ))}
            </React.Fragment>
          ))}
          <div>
            <button
              onClick={() => fetchNextPage()}
              disabled={!hasNextPage || isFetchingNextPage}
            >
              {isFetchingNextPage
                ? '더 불러오는 중...'
                : hasNextPage
                ? '더 보기'
                : '더 이상 상품이 없습니다'}
            </button>
          </div>
        </>
      )}
    </div>
  );
}

이 방식의 특징:

  • pageParam은 내부에서 자동 관리되는 "다음 페이지 인덱스"입니다.
  • API가 cursor를 리턴하도록 설계됐다면, lastPage.nextCursor를 반환해 자동으로 캐싱 및 페칭합니다.
  • 결과는 data.pages 배열에 각 페이지별로 데이터를 누적 저장합니다.
  • 뒤로 갔다가 같은 필터로 돌아오면, 이미 가져온 페이지들은 모두 캐시에서 즉시 렌더링합니다.
  • 백그라운드 리패치 시, 새로운 페이지가 있다면 이어 붙입니다.

Q: 페이지네이션/무한 스크롤 시 주의할 점은 무엇인가요?

A: 다음 사항들을 주의해야 합니다:

  1. 필터 변경 시 캐시 분리: 필터(쿼리 파라미터) 변경 시, ['products', filters] 자체가 변경되므로 "새로운 키"로 캐시가 분리됩니다. 예를 들어, 카테고리만 변경되면 다음과 같은 캐시 슬롯들이 공존 가능합니다:

    ['products', {category: 'electronics'}, page 1]
    ['products', {category: 'electronics'}, page 2]
    ['products', {category: 'books'}, page 1]
    
  2. staleTime 설정: staleTime을 충분히 길게 설정하면, 뒤로 돌아갔을 때 백그라운드에서 바로 재요청하지 않으므로 깜빡임 없는 UX를 만들 수 있습니다.

  3. prefetching(미리 불러오기): 현재 보고 있는 페이지의 다음 페이지를 미리 fetch해서 스크롤 직전에 데이터가 이미 준비되도록 할 수 있습니다.

    // 다음 페이지 미리 가져오기
    useEffect(() => {
      if (data && !isFetching && hasNextPage) {
        queryClient.prefetchQuery({
          queryKey: ['products', filters, page + 1],
          queryFn: () => fetchProducts({ page: page + 1, ...filters })
        });
      }
    }, [data, isFetching, page, filters, hasNextPage]);

최적화된 Mutation 패턴

Q: 최적화된 Mutation 패턴은 어떻게 구현하나요?

A: TanStack Query v5에서는 다음과 같이 최적화된 Mutation 패턴을 구현할 수 있습니다:

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

function useUpdateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateProduct,
    // 🆕 개선: 더 정확한 타입과 함께 사용
    onMutate: async (variables) => {
      // 진행 중인 refetch 취소
      await queryClient.cancelQueries({ 
        queryKey: productKeys.detail(variables.id) 
      });

      // 이전 상태 저장
      const previousProduct = queryClient.getQueryData(
        productKeys.detail(variables.id)
      );

      // 낙관적 업데이트
      queryClient.setQueryData(
        productKeys.detail(variables.id), 
        variables
      );

      return { previousProduct };
    },
    onError: (err, variables, context) => {
      // 에러 발생 시 이전 상태로 롤백
      if (context?.previousProduct) {
        queryClient.setQueryData(
          productKeys.detail(variables.id),
          context.previousProduct
        );
      }
    },
    onSettled: (data, error, variables) => {
      // 성공/실패 여부와 관계없이 쿼리 무효화
      queryClient.invalidateQueries({ 
        queryKey: productKeys.detail(variables.id) 
      });
    }
  });
}

Q: useMutation 훅의 주요 콜백 함수들은 무엇인가요?

A: useMutation 훅의 주요 콜백 함수들은 다음과 같습니다:

  1. onMutate: mutation이 실행되기 전에 호출됩니다. 낙관적 업데이트를 구현하는 데 사용됩니다.

    • 진행 중인 refetch를 취소합니다.
    • 이전 상태를 저장합니다.
    • 낙관적 업데이트를 수행합니다.
    • context 객체를 반환하여 다른 콜백에서 사용할 수 있게 합니다.
  2. onError: mutation이 실패했을 때 호출됩니다.

    • 에러 처리 로직을 구현합니다.
    • onMutate에서 반환한 context를 사용하여 이전 상태로 롤백합니다.
  3. onSuccess: mutation이 성공했을 때 호출됩니다.

    • 서버에서 반환된 데이터로 캐시를 업데이트합니다.
    • 관련된 쿼리를 무효화합니다.
  4. onSettled: mutation이 성공하거나 실패한 후 항상 호출됩니다.

    • 성공/실패 여부와 관계없이 수행해야 하는 작업을 구현합니다.
    • 일반적으로 관련 쿼리를 무효화하는 데 사용됩니다.

Q: 여러 관련 쿼리를 한 번에 무효화하는 방법은 무엇인가요?

A: 여러 관련 쿼리를 한 번에 무효화하는 방법은 다음과 같습니다:

  1. 문자열 또는 배열 첫 번째 요소로 매칭:

    // 'users'를 prefix(혹은 key 배열의 첫 번째 요소)로 갖는 모든 쿼리를 무효화
    queryClient.invalidateQueries('users');

    이 방법은 다음과 같은 모든 쿼리를 무효화합니다:

    • 문자열 'users'(정확히 같은 값)
    • 배열이고 첫 번째 요소가 'users'인 모든 키 (예: ['users', {by: 'name'}], ['users', {by: 'type'}] 등)
  2. predicate 함수를 사용한 정교한 매칭:

    queryClient.invalidateQueries({
      predicate: (query) => {
        // query.queryKey는 배열 또는 문자열 형태
        const key = query.queryKey;
        // 예시: 첫 요소가 'users'이면서, key[1].by === 'type'인 쿼리만 무효화
        if (Array.isArray(key) && key[0] === 'users' && key[1].by === 'type') {
          return true;
        }
        return false;
      }
    });

    이 방법은 더 복잡한 조건(예: 특정 파라미터 조합만 무효화, 특정 조건인 쿼리만 무효화)에 유용합니다.

성능 최적화 패턴

Q: TanStack Query에서 제공하는 성능 최적화 패턴에는 어떤 것들이 있나요?

A: TanStack Query v5에서는 다음과 같은 성능 최적화 패턴을 제공합니다:

Q: 선택적 구독은 어떻게 구현하나요?

A: 선택적 구독은 select 옵션을 사용하여 필요한 데이터만 구독하는 방법입니다:

// 1. 선택적 구독 (필요한 데이터만 구독)
function useProductName(productId) {
  return useQuery({
    queryKey: productKeys.detail(productId),
    queryFn: () => fetchProduct(productId),
    select: (data) => data.name, // name만 선택
    // name이 변경될 때만 리렌더링됨
  });
}

이 방식의 장점:

  • 전체 데이터 중 필요한 부분만 선택하여 컴포넌트에 제공합니다.
  • 선택한 데이터가 변경될 때만 리렌더링이 발생하므로 성능이 향상됩니다.
  • 여러 컴포넌트에서 동일한 쿼리를 사용하지만 다른 부분의 데이터가 필요한 경우 유용합니다.

Q: 조건부 쿼리는 어떻게 구현하나요?

A: 조건부 쿼리는 enabled 옵션을 사용하여 특정 조건이 충족될 때만 쿼리를 실행하는 방법입니다:

// 2. 조건부 쿼리
function useProduct(productId) {
  return useQuery({
    queryKey: productKeys.detail(productId),
    queryFn: () => fetchProduct(productId),
    enabled: !!productId, // productId가 있을 때만 실행
  });
}

이 방식의 장점:

  • 필요한 데이터가 있을 때만 쿼리를 실행합니다.
  • 불필요한 네트워크 요청을 방지합니다.
  • 의존성이 있는 쿼리를 순차적으로 실행할 수 있습니다.

Q: 병렬 쿼리는 어떻게 최적화하나요?

A: 병렬 쿼리는 useQueries 훅을 사용하여 여러 쿼리를 동시에 실행하는 방법입니다:

// 3. 병렬 쿼리 최적화
function useProductWithReviews(productId) {
  const results = useQueries({
    queries: [
      {
        queryKey: productKeys.detail(productId),
        queryFn: () => fetchProduct(productId)
      },
      {
        queryKey: ['reviews', productId],
        queryFn: () => fetchReviews(productId)
      }
    ]
  });

  const [productQuery, reviewsQuery] = results;

  return {
    product: productQuery.data,
    reviews: reviewsQuery.data,
    isLoading: productQuery.isLoading || reviewsQuery.isLoading,
    isError: productQuery.isError || reviewsQuery.isError
  };
}

이 방식의 장점:

  • 여러 관련 데이터를 병렬로 가져와 로딩 시간을 단축합니다.
  • 각 쿼리의 상태를 개별적으로 관리할 수 있습니다.
  • 데이터 간의 의존성이 없을 때 효율적입니다.

Q: 데이터 변환(Transform)은 어떻게 구현하나요?

A: 데이터 변환은 select 옵션을 사용하여 서버에서 받은 데이터를 컴포넌트에 필요한 형태로 변환하는 방법입니다:

// 4. 데이터 변환
function useFormattedProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    select: (data) => data.map(product => ({
      ...product,
      formattedPrice: `${product.currency} ${product.price.toFixed(2)}`,
      discountPercentage: ((product.originalPrice - product.price) / product.originalPrice * 100).toFixed(0)
    }))
  });
}

이 방식의 장점:

  • 서버 데이터를 UI에 적합한 형태로 변환합니다.
  • 변환 로직을 쿼리 훅 내부에 캡슐화하여 컴포넌트 코드를 간결하게 유지합니다.
  • 동일한 데이터를 사용하는 여러 컴포넌트에서 일관된 형식을 보장합니다.

Q: 쿼리 결합(Query Combining)은 어떻게 구현하나요?

A: 쿼리 결합은 여러 쿼리의 결과를 결합하여 새로운 데이터를 생성하는 방법입니다:

// 5. 쿼리 결합
function useProductWithDetails(productId) {
  const productQuery = useQuery({
    queryKey: productKeys.detail(productId),
    queryFn: () => fetchProduct(productId)
  });
  
  const manufacturerQuery = useQuery({
    queryKey: ['manufacturer', productQuery.data?.manufacturerId],
    queryFn: () => fetchManufacturer(productQuery.data?.manufacturerId),
    enabled: !!productQuery.data?.manufacturerId
  });
  
  const categoryQuery = useQuery({
    queryKey: ['category', productQuery.data?.categoryId],
    queryFn: () => fetchCategory(productQuery.data?.categoryId),
    enabled: !!productQuery.data?.categoryId
  });
  
  return {
    product: productQuery.data,
    manufacturer: manufacturerQuery.data,
    category: categoryQuery.data,
    isLoading: productQuery.isLoading || manufacturerQuery.isLoading || categoryQuery.isLoading,
    isError: productQuery.isError || manufacturerQuery.isError || categoryQuery.isError
  };
}

이 방식의 장점:

  • 관련된 여러 데이터를 하나의 훅으로 제공합니다.
  • 의존성이 있는 쿼리를 순차적으로 실행합니다.
  • 각 데이터의 캐싱을 개별적으로 관리하면서도 통합된 인터페이스를 제공합니다.

향상된 에러 처리

Q: TanStack Query에서 에러 처리는 어떻게 하나요?

A: TanStack Query v5에서는 다음과 같이 향상된 에러 처리 방법을 제공합니다:

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

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    throwOnError: true, // React Error Boundary와 함께 사용
    meta: {
      errorMessage: '상품을 불러오는데 실패했습니다.' // 에러 메타데이터
    }
  });
}

Q: React Error Boundary와 함께 사용하는 방법은 무엇인가요?

A: React Error Boundary와 함께 사용하면 에러 처리를 더 선언적으로 할 수 있습니다:

import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';

function ProductListPage() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ error, resetErrorBoundary }) => (
            <div>
              <p>상품을 불러오는데 문제가 발생했습니다: {error.message}</p>
              <button onClick={resetErrorBoundary}>다시 시도</button>
            </div>
          )}
        >
          <ProductList />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

function ProductList() {
  const { data } = useProducts(); // 에러 시 자동으로 Error Boundary로 throw
  
  return (
    <div>
      {data?.map(product => <ProductItem key={product.id} product={product} />)}
    </div>
  );
}

이 방식의 장점:

  • 에러 처리 로직을 컴포넌트 트리의 상위 레벨로 분리합니다.
  • 선언적인 에러 처리를 가능하게 합니다.
  • 에러 발생 시 사용자에게 적절한 UI를 제공하고 복구 메커니즘을 제공합니다.

Q: 에러 재시도(Retry) 기능은 어떻게 구현하나요?

A: 에러 재시도 기능은 retry 옵션을 사용하여 구현합니다:

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    retry: 3, // 최대 3번 재시도
    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // 지수 백오프
  });
}

이 방식의 장점:

  • 일시적인 네트워크 오류에 대한 복원력을 제공합니다.
  • 지수 백오프 전략으로 서버 부하를 줄입니다.
  • 사용자 경험을 방해하지 않고 자동으로 복구를 시도합니다.

Q: 에러 콜백은 어떻게 사용하나요?

A: 에러 콜백은 onError 옵션을 사용하여 구현합니다:

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    onError: (error) => {
      // 에러 로깅
      console.error('상품을 불러오는데 실패했습니다:', error);
      
      // 에러 알림
      toast.error('상품을 불러오는데 실패했습니다. 나중에 다시 시도해주세요.');
      
      // 분석 이벤트 전송
      analytics.track('query_error', {
        queryKey: 'products',
        errorMessage: error.message
      });
    }
  });
}

이 방식의 장점:

  • 에러 발생 시 추가 작업(로깅, 알림, 분석 등)을 수행할 수 있습니다.
  • 사용자에게 적절한 피드백을 제공할 수 있습니다.
  • 에러 추적 및 디버깅에 유용합니다.

HTTP 캐싱과의 관계

Q: HTTP 단의 캐싱이나 그런 것과는 별개로 동작하나요?

A: 브라우저의 HTTP 캐싱(304 Not Modified, ETag, Cache-Control 등)과 TanStack Query의 내부 캐시는 서로 보완적으로 작동하지만, 완전히 독립된 레이어이기도 합니다.

Q: 브라우저/네트워크 레벨 캐싱은 어떻게 동작하나요?

A: 서버가 응답 헤더 Cache-Control, ETag, Last-Modified 등을 설정하면, 브라우저(또는 프록시)는 동일 URL 요청에 대해 "서버에 조건부 요청(Conditional Request)"을 보내 304 Not Modified를 받을 수 있습니다. 이 경우, 브라우저는 네트워크 응답을 재사용하거나 최소한 변경 여부만 확인하므로 속도·트래픽을 절약합니다.

Q: TanStack Query 레벨 캐싱은 어떻게 동작하나요?

A: TanStack Query는 기본적으로 JS 메모리에 "QueryKey → 데이터" 매핑을 저장합니다. 따라서 같은 QueryKey로 요청할 때:

  • staleTime 안에서는 fetch 함수를 호출하지 않고, "캐시된 데이터"를 즉시 반환합니다.
  • staleTime이 지났거나, invalidateQueries로 stale 처리되었으면 내부적으로 fetch 함수를 실행합니다. 이때 fetch 함수가 HTTP 요청을 보내면, 실제 네트워크 레벨에서는 브라우저가 HTTP 캐시 메커니즘을 확인합니다(304 응답 등).

즉, TanStack Query의 cache → 네트워크 요청 여부 결정(enabled, staleTime 등) → 브라우저 HTTP 캐싱 → 서버 응답 순서로 동작합니다.

Q: 서버 측 HTTP 캐시된 데이터와 TanStack Query 재요청은 어떻게 상호작용하나요?

A: 만약 서버가 오래된 데이터를 HTTP 캐시로 제공하고 있다면, TanStack Query가 "새로 요청을 보냈다" 하더라도 그 요청이 304 또는 캐시된 응답을 받을 수 있습니다.

결과적으로는 TanStack Query가 "JS 메모리 캐시"에서 무효화 시점(stale)이라 판단해 fetch를 했더라도, 브라우저가 "서버(또는 중간 프록시)에 실제로 새로운 데이터가 있는지 확인"한 뒤 304로 되돌려준다면, 실질적으로는 업데이트되지 않은(오래된) 데이터를 받아오게 됩니다.

Q: 이런 문제를 해결하기 위한 방법은 무엇인가요?

A: 다음과 같은 해결책을 사용할 수 있습니다:

  1. 캐시 무효화(Invalidate): 서버 쪽에서 데이터를 강제로 갱신해야 한다면, fetch 요청 시 HTTP 헤더에 Cache-Control: no-cache 또는 Pragma: no-cache 옵션을 추가해 브라우저가 로컬 캐시를 무시하도록 만들 수 있습니다.

    const fetchProducts = () => 
      fetch('/api/products', {
        headers: { 'Cache-Control': 'no-cache' }
      }).then(res => res.json());

    또는 fetch 옵션을 사용할 수도 있습니다:

    const fetchProducts = () => 
      fetch('/api/products', { cache: 'reload' })
        .then(res => res.json());
  2. ETag/Last-Modified 설계: 서버 API 레벨에서 ETag나 Last-Modified를 적절히 사용해 "정말 데이터가 변경된 경우에만(response 200)" 새로운 payload를 던져주는 구조가 좋습니다. 이러면 브라우저 네트워크 캐시는 물론 TanStack Query의 재요청도 불필요한 데이터 전달을 줄일 수 있습니다.

  3. TanStack Query 옵션:

    • refetchOnWindowFocus: false: 기본값은 true여서, 윈도우가 다시 포커스될 때마다 re-fetch를 시도합니다. 이때 HTTP 캐시가 오래된 데이터를 주면 그대로 받아오므로, 실제로 화면에는 갱신되지 않는 경우가 생깁니다. false로 끄면 불필요한 네트워크 요청이 줄어듭니다.
    • staleTimegcTime 설정: staleTime을 길게 잡으면, "열흘 전" 요청한 데이터라도 그 기간 동안은 fetch를 아예 시도하지 않으므로, HTTP 캐시 레벨과 섞이지 않고 일정 기간 메모리 캐시만 사용하도록 강제 가능합니다.
    • 반대로 "항상 최신"이 필요하다면, staleTime: 0으로 두고, refetchOnMount: true로 설정해서 컴포넌트 마운트마다 무조건 fetch(→브라우저 HTTP 캐시 경로)하도록 할 수 있습니다.

모범 사례 및 권장 패턴

Q: QueryKey 작성 요령은 무엇인가요?

A: QueryKey 작성 시 다음 요령을 따르는 것이 좋습니다:

  1. 동적 파라미터 포함: 동적 파라미터(필터, ID, 페이지 등)는 반드시 QueryKey(보통 배열) 안에 포함시킵니다.

  2. 객체 직렬화: 객체를 직접 넣을 땐 프로퍼티 순서를 보장하거나, JSON.stringify로 직렬화합니다.

    // 권장 방법
    ['products', JSON.stringify({minPrice, maxPrice, category})]
    // 또는
    ['product', String(productId)]
  3. QueryKey 팩토리 패턴 사용: 일관된 키 구조를 위해 QueryKey 팩토리 패턴을 사용합니다.

    // QueryKey 팩토리 패턴
    export const productKeys = {
      all: ['products'] as const,
      lists: () => [...productKeys.all, 'list'] as const,
      list: (filters) => [...productKeys.lists(), filters] as const,
      details: () => [...productKeys.all, 'detail'] as const,
      detail: (id) => [...productKeys.details(), id] as const,
    }
  4. 계층적 구조 설계: 무효화를 쉽게 하기 위해 계층적 구조로 설계합니다.

    // 계층적 구조 예시
    ['users']                     // 모든 사용자 관련 쿼리
    ['users', 'list']             // 사용자 목록
    ['users', 'list', filters]    // 필터링된 사용자 목록
    ['users', 'detail']           // 모든 사용자 상세 정보
    ['users', 'detail', userId]   // 특정 사용자 상세 정보

Q: Mutation 후 캐시 업데이트는 어떻게 하나요?

A: Mutation 후 캐시 업데이트는 다음과 같은 방법으로 할 수 있습니다:

  1. 단일 리소스 변경(예: 상품 수정):

    mutation.mutate(updatedProduct, {
      onSuccess: () => {
        // 1) 변경된 단일 상품 데이터를 바로 cache에 반영
        queryClient.setQueryData(
          productKeys.detail(updatedProduct.id),
          updatedProduct
        );
        
        // 2) 상품 목록에도 영향을 줄 수 있으므로,
        //    목록 캐시를 무효화해 재요청하도록 설정
        queryClient.invalidateQueries({ queryKey: productKeys.lists() });
      }
    });
  2. 낙관적 업데이트(Optimistic Update)로 UX 개선:

    const mutation = useMutation({
      mutationFn: updateProduct,
      onMutate: async (newProduct) => {
        // 진행 중인 refetch 취소
        await queryClient.cancelQueries({
          queryKey: productKeys.detail(newProduct.id)
        });
        
        // 이전 상태 저장
        const prevProduct = queryClient.getQueryData(
          productKeys.detail(newProduct.id)
        );
        
        // 낙관적 업데이트
        queryClient.setQueryData(
          productKeys.detail(newProduct.id),
          newProduct
        );
        
        return { prevProduct };
      },
      onError: (err, newProduct, context) => {
        // 에러 발생 시 이전 상태로 롤백
        queryClient.setQueryData(
          productKeys.detail(newProduct.id),
          context.prevProduct
        );
      },
      onSettled: (data, error, newProduct) => {
        // 성공/실패 여부와 관계없이 쿼리 무효화
        queryClient.invalidateQueries({
          queryKey: productKeys.detail(newProduct.id)
        });
        queryClient.invalidateQueries({
          queryKey: productKeys.lists()
        });
      }
    });
  3. 관련된 모든 캐시(Key Prefix)를 invalidateQueries로 처리:

    // 'products'를 prefix로 갖는 모든 쿼리를 무효화
    queryClient.invalidateQueries({ queryKey: ['products'] });

Q: 만약 유저 목록(users)을 조회하는 API가 여러 개라서 다양한 QueryKey가 있을 때, Mutation 후 캐시 업데이트는 어떻게 하나요?

A: 여러 관련 쿼리를 한 번에 무효화하는 방법은 다음과 같습니다:

  1. prefix 기반 캐시 무효화:

    // 'users'를 prefix(혹은 key 배열의 첫 번째 요소)로 갖는 모든 쿼리를 무효화
    queryClient.invalidateQueries('users');

    이 방법은 다음과 같은 모든 쿼리를 무효화합니다:

    • 문자열 'users'(정확히 같은 값)
    • 배열이고 첫 번째 요소가 'users'인 모든 키
      ['users', {by: 'name', name, keyword}]
      ['users', {by: 'type', type, subType}]
      ['users', {by: 'gender', gender}]
      
  2. predicate 함수를 사용한 정교한 매칭:

    queryClient.invalidateQueries({
      predicate: (query) => {
        // query.queryKey는 배열 또는 문자열 형태
        const key = query.queryKey;
        // 예시: 첫 요소가 'users'이면서, key[1].by === 'type'인 쿼리만 무효화
        if (Array.isArray(key) && key[0] === 'users' && key[1].by === 'type') {
          return true;
        }
        return false;
      }
    });

Q: 엔티티 간 관계(예: Product → User)와 캐시 일관성은 어떻게 관리하나요?

A: "Product API 호출 시 user 정보도 같이 반환"하는 구조라면, Product 쿼리 캐시에 'user'가 내장되어 있을 수 있습니다. 이런 경우 다음과 같은 방법으로 캐시 일관성을 관리할 수 있습니다:

  1. 관련 캐시 함께 무효화:

    onSuccess: (updatedUser) => {
      // 1) user 캐시
      queryClient.invalidateQueries('user');
      
      // 2) product 캐시 – 예를 들어, 특정 상품 목록에 해당 유저 데이터가 들어간다면
      //    products 조회 키 전부를 무효화
      queryClient.invalidateQueries('products');
      
      // 3) 단일 product 캐시 중에도 이 유저가 내장되어 있는 경우가 있으므로,
      //    product prefix 전부를 무효화
      queryClient.invalidateQueries('product');
    }

    장점: 구현이 비교적 간단합니다.

    단점: Product 데이터가 많으면 "불필요하게 대부분의 Product 쿼리가 모두 다시 페치"될 수 있어 네트워크 비용이 상승합니다.

  2. cache 업데이트(수동으로 setQueryData)로 일관성 유지:

    onSuccess: (updatedUser) => {
      // 1) user 캐시 덮어쓰기 (단일 유저)
      queryClient.setQueryData(['user', 'byId', String(updatedUser.id)], updatedUser);
      queryClient.setQueryData(['user', 'byName', updatedUser.name], updatedUser);
      
      // 2) Product 캐시 중, "내장된 user.id === updatedUser.id"인 데이터들을 찾아서
      //    user 부분만 업데이트
      // 예시: products 리스트 캐시에 들어있는 user 배열을 덮어쓰기
      queryClient.setQueryData(['products'], oldData => {
        if (!oldData) return oldData;
        return oldData.map(product => {
          if (product.owner.id === updatedUser.id) {
            return {
              ...product,
              owner: updatedUser
            };
          }
          return product;
        });
      });
      
      // 3) 단일 product 캐시 중에서도 해당 user가 포함된 것들을 업데이트
      // 이 부분은 모든 product 캐시를 순회해야 하므로 복잡할 수 있음
      // 실제 구현 시에는 제품 ID 목록을 알고 있는 경우에만 가능
      knownProductIds.forEach(productId => {
        queryClient.setQueryData(['product', productId], oldProduct => {
          if (!oldProduct || oldProduct.owner.id !== updatedUser.id) return oldProduct;
          return {
            ...oldProduct,
            owner: updatedUser
          };
        });
      });
    }

    장점: 필요한 캐시만 정확히 업데이트하므로 네트워크 요청을 최소화합니다.

    단점: 구현이 복잡하고, 모든 관련 캐시를 찾아 업데이트하기 어려울 수 있습니다.

Q: TanStack Query v5에서 권장하는 전반적인 모범 사례는 무엇인가요?

A: TanStack Query v5에서 권장하는 전반적인 모범 사례는 다음과 같습니다:

  1. QueryKey 관리:

    • QueryKey 팩토리 패턴을 사용하여 일관된 키 구조를 유지합니다.
    • 동적 파라미터는 항상 QueryKey에 포함시킵니다.
    • 계층적 구조로 설계하여 부분 무효화를 쉽게 합니다.
  2. 데이터 페칭 최적화:

    • select 옵션을 사용하여 필요한 데이터만 구독합니다.
    • enabled 옵션을 사용하여 조건부 쿼리를 구현합니다.
    • useQueries를 사용하여 병렬 쿼리를 최적화합니다.
    • prefetchQuery를 사용하여 사용자 경험을 개선합니다.
  3. 캐시 관리:

    • staleTimegcTime을 적절히 설정하여 캐시 수명을 관리합니다.
    • invalidateQueries를 사용하여 관련 쿼리를 무효화합니다.
    • setQueryData를 사용하여 캐시를 직접 업데이트합니다.
    • 낙관적 업데이트를 구현하여 사용자 경험을 개선합니다.
  4. 에러 처리:

    • React Error Boundary와 함께 사용하여 선언적 에러 처리를 구현합니다.
    • retry 옵션을 사용하여 일시적인 오류에 대한 복원력을 제공합니다.
    • onError 콜백을 사용하여 에러 로깅 및 알림을 구현합니다.
  5. 성능 최적화:

    • keepPreviousData를 사용하여 페이지네이션 UX를 개선합니다.
    • useInfiniteQuery를 사용하여 무한 스크롤을 구현합니다.
    • refetchOnWindowFocus, refetchOnMount, refetchOnReconnect 옵션을 적절히 설정합니다.
    • DevTools를 활용하여 쿼리 동작을 모니터링하고 디버깅합니다.
  6. 타입스크립트 활용:

    • 제네릭을 활용하여 타입 안정성을 확보합니다.
    • QueryKey 팩토리에 as const를 사용하여 타입 추론을 개선합니다.
    • QueryFunctionContext를 활용하여 queryFn에서 타입 안정성을 확보합니다.
  7. 코드 구조화:

    • 커스텀 훅으로 쿼리 로직을 캡슐화합니다.
    • 관련 쿼리를 모듈로 그룹화합니다.
    • QueryKey 팩토리를 별도 파일로 분리하여 관리합니다.

이러한 모범 사례를 따르면 TanStack Query를 사용하여 효율적이고 유지보수하기 쉬운 데이터 페칭 로직을 구현할 수 있습니다.

결론

TanStack Query v5는 서버 상태 관리를 위한 강력한 도구입니다. 이 가이드에서는 기본 사용법부터 고급 패턴까지 다양한 주제를 다루었습니다. QueryKey 관리, 캐싱 전략, 페이지네이션, Mutation, 성능 최적화, 에러 처리 등의 개념을 이해하고 적용하면 더 효율적이고 사용자 친화적인 애플리케이션을 개발할 수 있습니다.

TanStack Query의 공식 문서와 커뮤니티 리소스를 활용하여 더 깊이 있는 지식을 습득하고, 실제 프로젝트에 적용해보세요. 서버 상태 관리의 복잡성을 줄이고 개발 경험을 향상시키는 TanStack Query의 강력한 기능을 최대한 활용하시기 바랍니다.

1. QueryKey 관련 질문

  1. QueryKey 팩토리 패턴을 실제로 사용하나?

    • AI가 ['products', { minPrice, maxPrice, category }] 이런 식으로 쓰는거랑 합쳐서 설명해서 헷갈림.
  2. JSON.stringify는 TanStack Query가 알아서 해주는건지?

2. 사이드이펙트 발생 시 캐시에 반영하기

  1. 변경된 데이터를 캐시에 반영하는거 궁금증.

만약에 유저 목록(users)을 조회하는 API가 여러개라서

  • 'users', {name, keyword},
  • 'users', {type, subType},
  • 'users', {gender},
  • 'user', {id},
  • 'user', {name},
  • 등등

이런 식이면, prefix가 'users'인걸 지우고, idname은 캐시 지우거나 setQueryData 해주는거임?

저런 식으로 관리하는게 좀 많게 되었을 때, 연관 된 API 호출 하나하나 작업해줘야하는지 궁금함. 뭔가 비효율적인거 같아서?

  1. 객체를 사용했을 떄 변환

'users', {type, subType} 에서 {type, subType}는 객체(object)니까 실제론 {type: 'ABC', subType: 'ZZZZ'} 이런 식으로 캐싱되는거 맞음?

  1. 여러 객체가 묶여서 반환되는 API의 캐싱
  • 배경: 한 유저가 여러 상품을 등록 가능한 서비스. 'products'를 반환하는 API가 있는데, 거기서 users 값도 일부를 함께 반환함.
    • 예시: {[상품이름: 카메라, 상품 가격: 3,299 사용자 정보:{사용자ID: 123, 사용자 이름: 홍길동}, ...]}
  • 어떤 유저 정보가 업데이트 된다면, 'products' API도 캐싱을 만료시켜줘야 하는가?
  • 데이터의 정합성이 중요한 경우 만료하는게 맞다고 생각하는데, 이러면 유저같이 의존하는게 많은 경우면 성능 문제가 많이 생길거 같음.

실제 코드 보일러플레이트

실제 프로젝트에선 어떤 식으로 쓰는가? 관련 내용

  1. Response Model이나 DTO 매핑
  • 실제론 Json Data를 그대로 사용하는게 아니라 DTO 형태로 관리할거 같은데, 이런거 매핑하는건 별도 라이브러리를 사용하는지?
  1. 호출 함수 분리
  • 실제 코드 상으로는 API 호출하는 부분을 함수로 빼서 관리할 거 같은데, 어떤 패턴으로 하는지?
  • 더모먼트나 MSG나 깃헙 코드 중에 참고할만한거 있는지? 구조 설명해주는게 좋긴 한데, 말하기 귀찮을거 같아서 참고할만한거라도...?
  • 나였으면 대충 이런 역할로 나눌듯?
    1. 호출부 - 컴포넌트 정의나 페이지 구성하는 쪽, 캡슐화된 함수를 가져다 씀
    2. API 호출/DTO 매핑
    • GET: 캐싱 역할
    • 나머지 요청: 응답에 따라 데이터 캐싱 값을 변경하거나 만료시킴

Q&A 형식으로 정리


1. QueryKey 관련

Q1. QueryKey 팩토리 패턴을 실제로 사용하나?

A.

  • 복잡한 키를 일일이 조합하기 귀찮은 경우에 많이 사용합니다.

  • 예) [lukemorales/query-key-factory](https://github.com/lukemorales/query-key-factory) 같은 라이브러리를 쓰면,

    const usersKeys = createKeyFactory('users');
    // usersKeys.list({ page: 1, size: 20 }) → ['users', { page: 1, size: 20 }]

    처럼 간단하게 타입 안전한 키를 생성할 수 있어요.

Q2. JSON.stringify는 TanStack Query가 알아서 해주는 건지?

A.

  • 객체를 QueryKey로 넣을 때 내부적으로 JSON.stringify로 직렬화해서 고유 키로 사용합니다.
  • 단, 응답 데이터 자체는 JSON.stringify 하지 않아요.

2. 사이드 이펙트 발생 시 캐시에 반영하기

Q3. 여러 형태의 users 쿼리가 있을 때, 변경된 데이터를 캐시에 반영하려면?

A.

  • 범위(invalidateQueries)로 무효화

    // prefix 'users'가 포함된 모든 쿼리 무효화
    queryClient.invalidateQueries(['users']);
  • 특정 키만 업데이트

    // id가 123인 사용자만
    queryClient.setQueryData(['user', { id: 123 }], newUserData);
  • 쿼리 키 설계를 잘 해두면, prefix 무효화로 일괄 처리하고, 개별 키 업데이트는 드물게 사용해 효율적으로 관리할 수 있어요.

Q4. 객체를 사용했을 때 어떻게 변환되나?

A.

  • 'users', { type, subType } 는 내부적으로

    ['users', JSON.stringify({ type: 'ABC', subType: 'ZZZZ' })]

    와 같이 캐싱됩니다.

  • {type: 'ABC', subType: 'ZZZZ'}{type: 'ABC', subType: 'CCCC'} 는 서로 다른 키로 인식돼요.

Q5. 연관된 API(예: products → user 정보 포함)도 모두 만료해야 하나?

A.

  • 데이터 정합성이 최우선이라면, user 업데이트 후 products 쿼리도 invalidateQueries('products') 로 무효화해야 합니다.

  • 하지만 규모가 커지면 성능 이슈가 생기므로,

    1. 정규화(normalized cache)
    2. 별도 쿼리로 분리(products 내부의 user는 useUser로 읽어오기) 등의 전략을 쓰면 “한 엔티티 변경 시 모든 dependent 쿼리 무효화”를 피할 수 있어요.
  • 내가 걱정하는 예시는 RESTful하지 않은 예시임. RESTful을 잘 지켰으면 이런 문제 없음.
    • RESTful하게 헸으면 확장성이 좋은 구조가 됨.
  • 정확히 따지면 저런 예시가 있고 정합성이 중요하면 만료하는게 맞음.
  • 근데 BFF 같이 저런 여러 엔티티의 묶음이 많은 경우라면 어떻게 되는지? 이게 좀 궁금하긴 함.

3. 실제 코드 보일러플레이트

Q6. Response Model이나 DTO 매핑은 어떻게 하나?

A.

  • [Zod](https://github.com/colinhacks/zod) 같은 스키마 기반 라이브러리로 유효성 검증·매핑을 많이 사용합니다.

    const UserSchema = z.object({
      id: z.number(),
      name: z.string(),
    });
    type User = z.infer<typeof UserSchema>;

Q7. 호출 함수 분리 패턴은?

A.

  1. API 레이어: api/users.ts 에서 axios 호출 + DTO 변환

    export const fetchUsers = async (params: UserParams) => {
      const res = await axios.get('/users', { params });
      return UserListSchema.parse(res.data);
    };
  2. Custom Hook: hooks/useUsers.ts 에서 useQuery 래핑

    export const useUsers = (params: UserParams) =>
      useQuery(usersKeys.list(params), () => fetchUsers(params));
  3. 컴포넌트: 필요한 훅을 불러와서 사용

    const UserList = () => {
      const [page, setPage] = useState(1);
      const { data } = useUsers({ page, size: 20 });
      // …
    };
  • query-key-factory를 쓰면 키 갱신도 알아서 해주고 해서 되게 편하다곤 함.
  • QueryKey의 한계: 캐싱 단위가 API의 응답이 되므로써 애매한 영역이 생김.
    • FE와 BE의 영역이 애매해지는 것도 있음. 캐싱의 단위가 API가 된다면, 그걸 제공하는 BE의 역할이여야 한다고 보는 의견이 있음.
      • 근데 FE에서 제어하니까 문제, 차라리 FE가 직접 값의 일부를 캐싱하는걸 선택하면 몰라.
    • 외부 요인(앱/웹 등 여러 디바이스에서 접근)에 의해 상태가 바뀌었을 때, 캐싱된 값이 반환되어 이전 상태가 보일 수 있음.
      • 이건 캐시가 있는 한 제어 불가능한 문제임.
      • 사실 이런 실시간성이 필요한 경우에는 웹소켓이나 SSE를 사용하는게 올바르겠지만
        • 팀의 규모나 기술적 한계 등으로 인해서 어려운 경우도 많을거임.
        • 해결하려고 하면, 뭐 마지막 수정 시간을 가져오는 API를 받아서, 수정이 발생했다는 걸 알면 기존 캐시를 만료시키는 이런 로직도 가능하긴 하겠지만. 이게 효율적인가? 너무 QueryKey의 동작 방식에 의존적인 해결방법 아닌가? 싶은 고민도 있을거 같고.
@YangSiJun528
Copy link
Author

@chanwoo00106
I need your comment plz

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment