Next.js 이론 8강

TanStack Query로 서버 상태 관리

TanStack Query v5를 사용하여 서버 상태를 효율적으로 관리하고 캐싱, 동기화, 백그라운드 업데이트를 구현합니다.

1. 서버 상태 vs 클라이언트 상태

상태를 서버 상태와 클라이언트 상태로 구분하면 더 효율적인 상태 관리가 가능합니다. 이 구분이 TanStack Query를 사용하는 핵심 이유입니다.

상태 구분

🌐 서버 상태 (Server State)
  • • 서버에 저장된 데이터
  • • 여러 사용자가 공유
  • • 비동기로 가져옴
  • • 언제든 변경될 수 있음
  • • 예: 상품 목록, 주문 내역, 사용자 정보
💻 클라이언트 상태 (Client State)
  • • 브라우저에만 존재
  • • 현재 사용자만 사용
  • • 동기적으로 접근
  • • 새로고침 시 초기화
  • • 예: 모달 열림, 테마 설정, 폼 입력

왜 구분해야 하는가?

서버 상태의 특성 (복잡함)

  • 캐싱: 같은 데이터 반복 요청 방지
  • 동기화: 서버와 클라이언트 데이터 일치
  • 로딩/에러: 비동기 상태 처리
  • 중복 제거: 동시 요청 병합
  • 재시도: 실패 시 자동 재요청

클라이언트 상태의 특성 (단순함)

  • • 즉시 접근 가능
  • • 단순한 상태 변경
  • • 새로고침 시 초기화

이커머스 예시

데이터유형관리 도구
상품 목록서버 상태TanStack Query
장바구니서버 상태TanStack Query
필터 UI 열림클라이언트 상태useState
다크모드클라이언트 상태Zustand

흔한 실수

서버 데이터를 useState나 Redux에 저장하면 캐싱, 동기화, 중복 요청 처리를 직접 구현해야 합니다. TanStack Query가 이를 자동으로 해결합니다.

핵심 원칙

서버에서 온 데이터는 TanStack Query로, UI 상태는 Zustand나 useState로 관리하세요.

2. TanStack Query v5 기초

TanStack Query는 서버 상태 관리를 위한 강력한 라이브러리입니다. 캐싱, 동기화, 백그라운드 업데이트를 자동으로 처리합니다.

설치 및 설정

npm install @tanstack/react-query

// app/providers.tsx
'use client';

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

export function Providers({ children }: { children: React.ReactNode }) {
  // useState로 QueryClient 생성 (SSR 안전)
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,  // 1분간 fresh 상태 유지
        gcTime: 5 * 60 * 1000, // 5분간 캐시 유지 (v5에서 cacheTime → gcTime)
      },
    },
  }));

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

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

useQuery 기본

'use client';

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

async function fetchProducts() {
  const res = await fetch('/api/products');
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

export function ProductList() {
  const { 
    data,           // 성공 시 데이터
    isLoading,      // 첫 로딩 중
    isFetching,     // 백그라운드 페칭 중
    error,          // 에러 객체
    isError,        // 에러 여부
    refetch         // 수동 재요청 함수
  } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

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

  return (
    <ul>
      {data.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Query Key 설계

// Query Key는 배열로 구성
// 계층적으로 설계하면 무효화가 쉬움

// 전체 상품
queryKey: ['products']

// 카테고리별 상품
queryKey: ['products', { category: 'electronics' }]

// 특정 상품
queryKey: ['products', productId]

// 상품의 리뷰
queryKey: ['products', productId, 'reviews']

// 무효화 예시
queryClient.invalidateQueries({ queryKey: ['products'] });
// → 위의 모든 쿼리가 무효화됨

주요 옵션

옵션설명기본값
staleTimefresh 상태 유지 시간0
gcTime캐시 유지 시간5분
retry실패 시 재시도 횟수3
enabled쿼리 활성화 여부true

Query Key 팁

Query Key를 계층적으로 설계하면 invalidateQueries로 관련 쿼리를 한 번에 무효화할 수 있습니다.

3. 뮤테이션 처리

useMutation으로 데이터 생성, 수정, 삭제를 처리합니다.

기본 useMutation

'use client';

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

async function addToCart(productId: string) {
  const res = await fetch('/api/cart', {
    method: 'POST',
    body: JSON.stringify({ productId }),
  });
  return res.json();
}

export function AddToCartButton({ productId }: { productId: string }) {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: () => addToCart(productId),
    onSuccess: () => {
      // 장바구니 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate()}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '추가 중...' : '장바구니 담기'}
    </button>
  );
}

낙관적 업데이트

const mutation = useMutation({
  mutationFn: updateProduct,
  
  // 뮤테이션 시작 전
  onMutate: async (newData) => {
    // 진행 중인 쿼리 취소
    await queryClient.cancelQueries({ queryKey: ['products', id] });
    
    // 이전 데이터 저장
    const previousData = queryClient.getQueryData(['products', id]);
    
    // 낙관적으로 캐시 업데이트
    queryClient.setQueryData(['products', id], newData);
    
    return { previousData };
  },
  
  // 에러 시 롤백
  onError: (err, newData, context) => {
    queryClient.setQueryData(['products', id], context.previousData);
  },
  
  // 성공/실패 후 항상 실행
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['products', id] });
  },
});

뮤테이션 상태

상태설명활용
isPending뮤테이션 진행 중버튼 비활성화
isSuccess성공성공 메시지
isError실패에러 표시
error에러 객체에러 메시지

낙관적 업데이트 활용

장바구니 수량 변경처럼 즉각적인 피드백이 필요한 경우 낙관적 업데이트로 UX를 개선하세요.

4. 캐시 무효화 전략

데이터 변경 후 관련 캐시를 적절히 무효화하는 것이 중요합니다. Query Key를 계층적으로 설계하면 무효화가 쉬워집니다.

invalidateQueries

const queryClient = useQueryClient();

// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['products', '123'] });

// 프리픽스로 무효화 (계층적 키의 장점)
queryClient.invalidateQueries({ queryKey: ['products'] });
// → ['products'], ['products', '123'], ['products', { category }] 모두 무효화

// 정확히 일치하는 키만
queryClient.invalidateQueries({ 
  queryKey: ['products'],
  exact: true 
});

// 조건부 무효화
queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'products'
});

Query Key Factory 패턴

// lib/queries/products.ts
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
};

// 사용
useQuery({
  queryKey: productKeys.detail(productId),
  queryFn: () => fetchProduct(productId),
});

// 무효화
queryClient.invalidateQueries({ queryKey: productKeys.lists() });

setQueryData로 직접 업데이트

// 뮤테이션 성공 후 캐시 직접 업데이트
const mutation = useMutation({
  mutationFn: updateProduct,
  onSuccess: (updatedProduct) => {
    // 상세 페이지 캐시 업데이트
    queryClient.setQueryData(
      productKeys.detail(updatedProduct.id),
      updatedProduct
    );
    
    // 목록에서도 업데이트
    queryClient.setQueryData(productKeys.lists(), (old) => 
      old?.map(p => p.id === updatedProduct.id ? updatedProduct : p)
    );
  },
});

무효화 vs 직접 업데이트

  • • invalidate: 간단하지만 재요청 발생
  • • setQueryData: 즉시 반영, 요청 없음

5. Prefetching과 SSR 통합

Next.js의 Server Components와 TanStack Query를 함께 사용하는 방법입니다. 서버에서 데이터를 미리 가져와 클라이언트에 전달하면 초기 로딩이 빨라집니다.

서버에서 Prefetch

// app/products/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ProductList } from './ProductList';

async function getProducts() {
  const res = await fetch('https://api.myshop.com/products');
  return res.json();
}

export default async function ProductsPage() {
  const queryClient = new QueryClient();
  
  // 서버에서 데이터 미리 가져오기
  await queryClient.prefetchQuery({
    queryKey: ['products'],
    queryFn: getProducts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductList />
    </HydrationBoundary>
  );
}

클라이언트 컴포넌트

// app/products/ProductList.tsx
'use client';

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

export function ProductList() {
  // 서버에서 prefetch된 데이터가 즉시 사용됨
  const { data } = useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('/api/products');
      return res.json();
    },
  });

  // isLoading이 false로 시작 (이미 데이터 있음)
  return (
    <ul>
      {data?.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Hydration 흐름

서버: prefetchQuery → dehydrate → HTML에 포함
클라이언트: HydrationBoundary → 캐시 복원 → useQuery 즉시 사용

1. 서버

prefetchQuery로 데이터 가져옴

2. dehydrate

캐시를 직렬화하여 HTML에 포함

3. HydrationBoundary

클라이언트에서 캐시 복원

4. useQuery

복원된 데이터 즉시 사용

SSR + TanStack Query 장점

  • • 초기 로딩 없이 데이터 표시
  • • SEO 최적화 (서버 렌더링)
  • • 클라이언트에서 자동 동기화

6. 이커머스 예제: 무한 스크롤 상품 목록

useInfiniteQuery로 무한 스크롤 상품 목록을 구현합니다. 페이지네이션보다 모바일 친화적인 UX를 제공합니다.

useInfiniteQuery

'use client';

import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';

async function fetchProducts({ pageParam = 1 }) {
  const res = await fetch(`/api/products?page=${pageParam}&limit=12`);
  return res.json();
}

export function ProductGrid() {
  const { ref, inView } = useInView();
  
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['products', 'infinite'],
    queryFn: fetchProducts,
    initialPageParam: 1,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });

  // 스크롤이 하단에 도달하면 다음 페이지 로드
  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, fetchNextPage]);

  return (
    <div>
      <div className="grid grid-cols-4 gap-4">
        {data?.pages.flatMap(page => 
          page.products.map(product => (
            <ProductCard key={product.id} product={product} />
          ))
        )}
      </div>
      
      {/* 감지 요소 */}
      <div ref={ref} className="h-10">
        {isFetchingNextPage && <p>로딩 중...</p>}
      </div>
    </div>
  );
}

장바구니 동기화

// hooks/useCart.ts
export function useCart() {
  return useQuery({
    queryKey: ['cart'],
    queryFn: fetchCart,
    staleTime: 0,  // 항상 최신 데이터
  });
}

export function useAddToCart() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: addToCart,
    onMutate: async (newItem) => {
      await queryClient.cancelQueries({ queryKey: ['cart'] });
      const previous = queryClient.getQueryData(['cart']);
      
      // 낙관적 업데이트
      queryClient.setQueryData(['cart'], (old) => ({
        ...old,
        items: [...old.items, { ...newItem, id: 'temp' }],
      }));
      
      return { previous };
    },
    onError: (err, newItem, context) => {
      queryClient.setQueryData(['cart'], context.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });
}

구현 요약

useInfiniteQuery로 무한 스크롤
Intersection Observer로 자동 로드
낙관적 업데이트로 빠른 피드백
Query Key Factory로 체계적 관리

핵심 포인트

TanStack Query 활용 전략

  • • 서버 상태는 TanStack Query로 관리
  • • SSR prefetch로 초기 로딩 제거
  • • 낙관적 업데이트로 UX 개선
  • • Query Key Factory로 체계적 캐시 관리