GitHub

React 개발자라면 TanStack Query를 써야 하는 이유 feat 서버 컴포넌트

hojun lee · 09/18/2025
커버이미지

useEffect 안에 fetch 넣기 그만

요즘은 Nextjs가 대세다. 암튼 대세다! 근데 백오피스나 물류시스템 등을 개발하려면 사실 SEO와 여러 기능들이 있는 nextjs의 기능들이 too much 하다고 느끼는 경우가 많다. 나는 nextjs를 사용하지만 최소한의 필요 기능만 사용하고 react로 재고 관리 현황 백오피스를 구현해보고자 한다. 그리고 tanstack query가 좋다는데, 아직 안써봐서 이번에 써보며 좋은 점을 함께 나눠보자.

일단 엄청나게 코드 효율이 좋아진 걸 느끼면서 시작!

useEffect 과부하

방대해진 로직에 useEffect가 복잡해진다.

  • 필터링, 페이지네이션 등 기능이 추가될수록 useEffect의 의존성 배열과 내부 로직이 복잡해집니다.
  • 로딩과 에러 상태를 매번 직접 관리해야 하는 번거로움이 있습니다.
  • 컴포넌트가 언마운트된 후 setState를 호출하여 발생하는 메모리 누수(memory leak)를 방지하기 위한 추가 코드가 필요합니다.
  • 가져온 데이터를 캐싱하여 불필요한 API 요청을 줄이는 로직을 직접 구현해야 합니다.

이러한 문제들을 해결하기 위해 우리는 수많은 useStateuseEffect를 작성하며 비즈니스 로직보다 서버 데이터 상태를 관리하는 데 더 많은 시간을 쏟게 된다.

서버컴포넌트 + CSR

똑똑한 초기로딩

리액트를 사용할 때 초기 데이터를 불러오는 건 useEffect 안에 fetch를 넣어서 페칭되는 시간을 기다렸다. 이 때 isLoading이나 적절한 error 처리를 해주지 않으면 데이터보다 렌더링이 먼저되는 현상이 발생하여, 많은 주의를 요하였다. 이를 쉽게 해결해주는 여러 라이브러리들이 존재했으니, Tanstack query도 일부다. 근데 Nextjs를 사용하고자 하였으니, 초기 데이터는 서버컴포넌트에서 불러와서 전달해준다.

// src/app/inventory/page.tsx
export default async function InventoryPage() {
  const initialData = await fetchInventory(1);
  return <InventoryClient initialData={initialData} />;
}
  1. 사용자 경험 향상 : 사용자는 페이지에 접속하자마자 로딩 스피너 없이 데이터가 채워진 완성된 화면을 보게 된다. 서버에서 미리 데이터를 가져와 HTML에 포함시켜 보내주기 때문이다.
  2. 성능 최적화 : 클라이언트에서 Javascript 가 로드 된 후 또다시 API를 호출하는 요청을 방지한다. 서버에서 한방에 처리
  3. 검색 엔진 최적화 : 백오피스에선 필요 없지만 SEO 에 강하다.

이렇게 서버에서 가져온 initData를 Tanstack query가 물려받아 캐시를 초기화 한뒤, 그 이후 모든 상호작용 (필터링, 페이지 이동 등)은 클라이언트 사이드에서 관리 된다.

그래서 tanstack query가 무엇인가

한 마디로 서버 상태 관리 라이브러리이다. React App의 서버 상태를 클라이언트 상태와 분리하여 관리하고 동기화해준다. 뭔가 데이터가 섞이지 않을 것 같아서 좋다.

위에 언급한 isLoading 등 보일러플레이트 코드를 내장하고 있어서 사용하기 쉽다.

무엇이 어떻게 좋아졌을까

Before: useStateuseEffect를 사용한 전통적인 방식

function InventoryList() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setIsLoading(true);
    fetch('/api/inventory', { signal: controller.signal })
      .then(res => res.json())
      .then(data => setData(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      })
      .finally(() => setIsLoading(false));

    return () => controller.abort(); // Cleanup
  }, []);

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

  // ... 데이터 렌더링
}

After: TanStack Query의 useQuery를 사용한 방식

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

function InventoryList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['inventory'],
    queryFn: () => fetch('/api/inventory').then(res => res.json()),
  });

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

  // ... 데이터 렌더링
}

압도적으로 간결해졌다. 로딩, 에러, 데이터 상태를 위한 react 상태관리 로직이 useQuery 훅으로 대체 된다.

  • 자동화된 상태 관리
  • 강력한 캐싱
  • 손쉬운 업데이트

데이터 흐름으로 살펴보자

데이터 조회 (useQuery) 흐름

export default function InventoryClient({ initialData }: { initialData: InventoryResponse }) {
  const [page, setPage] = useState(1);
  const [filters, setFilters] = useState<InventoryFilters>({});
  const { data = initialData, isLoading, isFetching, error } = useInventory(page, filters);

재고관리 페이지의 초기 코드이다. 어라 tanstack query가 없는대요. 할수 있지만 이는 hook으로 래핑해놨다.


export function useInventory(page: number, filters: InventoryFilters) {
  return useQuery({
    queryKey: ['inventory', page, filters],
    queryFn: () => fetchInventory(page, filters),
    staleTime: 2 * 60 * 1000, // 2분 (재고는 자주 변경됨)
  });
}

useInventory를 호출하면 page와 filters의 항목을 받는다. 이는 변화를 감지하는 useEffect와 비슷한 효과를 가진다. useQuery의 queryKey로 변화를 계속 감지한다. 감지 될때마다 queryFn이 실행되는 재실행되는 것이다. 다시 client 페이지로 돌아와서

const { data = initialData, isLoading, isFetching, error } = useInventory(page, filters);

사용자가 필터를 바꾸거나 다른 페이지로 이동할 때, useInventory 훅 내부의 useQuery는 다음과 같이 동작한다.

  1. queryKey 변경 감지: queryKey['inventory', page, filters]로 설정되어 있습니다. 사용자가 페이지를 2로 바꾸면 queryKey['inventory', 2, filters]가 된다. TanStack Query는 이 queryKey가 이전과 달라졌음을 즉시 감지한다.
  2. 자동 데이터 요청: TanStack Query는 새로운 queryKey에 해당하는 데이터가 캐시에 없다고 판단하고, queryFn으로 등록된 fetchInventory(2, filters) 함수를 자동으로 실행하여 서버에 2페이지 데이터를 요청한다.
  3. 로딩 상태 전파: 요청이 시작되면 useQuery가 반환하는 isFetching 상태가 true가 되고, UI는 이를 이용해 사용자에게 "새로고침 중..."과 같은 피드백을 보여준다.
  4. 상태 파악 : isLoading, isFetching, error 을 제공하여 기존 useState로 관리 된 값을 쉽게 제공하여 DX와 UX을 향상시킨다.

데이터 수정 (useMutation) 흐름


export function useUpdateInventory() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, quantity }: { id: string; quantity: number }) =>
      updateInventory({ id, quantity }),
    onSuccess: () => {
      // 재고 업데이트 성공 시 관련 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['inventory'] });
    },
    onError: (error: Error) => {
      alert(`업데이트 실패: ${error.message}`);
    },
  });
}

재고 수량을 수정하고 '저장' 버튼을 눌렀을 때의 흐름은 TanStack Query의 진정한 파워를 보여준다.

  1. mutate 함수 호출: '저장' 버튼 클릭 시 useUpdateInventory 훅에서 반환된 mutation.mutate() 함수가 호출된다.
  2. 데이터 수정 요청 (PATCH): useMutation에 등록된 updateInventory 함수가 실행되어 서버에 PATCH 요청을 보내 데이터베이스의 실제 값을 수정한다.
  3. 캐시 무효화 (The Magic!): 서버로부터 "수정 성공" 응답을 받으면, onSuccess 콜백에 있던 queryClient.invalidateQueries({ queryKey: ['inventory'] })가 실행된다. 이 코드는 TanStack Query에게 "이제 'inventory'와 관련된 데이터는 최신이 아니니, 전부 무효화시켜!" 라고 선언하는 것과 같다.
  4. 자동 GET 요청: useInventory 훅은 자신이 보여주던 데이터가 무효화되었다는 신호를 받는다. 그리고 화면에 보여지고 있으므로, 데이터를 최신 상태로 유지하기 위해 자동으로 fetchInventory 함수를 다시 실행하여 서버로부터 새로운 GET 요청을 보낸다.
  5. 화면 자동 업데이트: 새로 가져온 최신 데이터가 화면에 반영되며, 사용자는 별도의 새로고침 없이 수정된 결과를 즉시 확인할 수 있다.

isLoading vs isFetching, 정확히 알고 쓰기

TanStack Query를 사용하다 보면 isLoadingisFetching이라는 두 가지 로딩 상태를 만나게 된다. 둘의 차이를 아는 것은 더 나은 UX를 만드는 데 중요하다.

  • isLoading: 오직 첫 로딩 시에만 true가 된다. 해당 queryKey에 대한 데이터가 캐시에 전혀 없을 때의 첫 요청을 의미한다. 보통 화면 전체를 덮는 스켈레톤 UI나 로딩 스피너를 보여줄 때 사용한다.
  • isFetching: 모든 요청 시에 true가 된다. 첫 로딩, 캐시된 데이터의 백그라운드 업데이트, 수동 새로고침 등 API 요청이 발생하기만 하면 항상 true가 된다. 보통 화면의 일부에 작은 로딩 인디케이터를 보여줄 때 사용한다.
시나리오 isLoading isFetching 설명
1. 재고 페이지 첫 방문 true true 첫 로딩. 전체 화면 로더에 적합.
2. 페이지 2로 이동 false true 데이터 리페칭. 기존 데이터는 보여주면서 로딩 인디케이터 표시.
3. 필터 변경 false true 데이터 리페칭.
4. '새로고침' 버튼 클릭 false true 데이터 리페칭.
5. 다른 탭에 갔다 돌아옴 false true 백그라운드 자동 리페칭.

결론

Tanstack query는 단순히 코드를 줄여주는 효과만 있는게 아니다. 데이터 관리 중간관리자가 생긴 느낌이다. "어떻게" 데이터를 가져오고 동기화 할지 고민하는 대신, '무엇을' 보여줄지만 집중할 수 있게 된다.

한 번 사용해보니, 사용하지 않을 이유가 없다. 복잡한 'useEffect'와 수많은 'useEffect'의 늪에서 벗어나 캐싱, 동기화, 상태관리 자동화의 세계로 빠져보자.