React Query 활용방법과 예제: 리스트 조회부터 mutation까지

Frontend

지난 글에서는 React Query를 왜 쓰는지와 server state, queryKey, cache, invalidation 같은 기본 개념을 정리했습니다. 이번에는 한 단계 더 내려가서 실제로 화면을 만들 때 어떤 패턴으로 쓰게 되는지를 코드 중심으로 보겠습니다.

이 글에서 다룰 흐름은 아래와 같습니다.

  1. 기본 세팅
  2. 리스트 조회
  3. 상세 조회
  4. pagination
  5. infinite query
  6. mutation과 invalidation
  7. optimistic update
  8. prefetch

먼저 기본 세팅

앱 루트에 QueryClientProvider를 둡니다.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
const queryClient = new QueryClient();
 
export function AppProviders({ children }: { children: React.ReactNode }) {
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

그리고 API 함수는 보통 컴포넌트 바깥으로 분리합니다.

type Post = {
  id: number;
  title: string;
  body: string;
};
 
async function getPosts(page = 1): Promise<{ items: Post[]; nextPage: number | null }> {
  const response = await fetch(`/api/posts?page=${page}`);
 
  if (!response.ok) {
    throw new Error('게시글 목록을 불러오지 못했습니다.');
  }
 
  return response.json();
}
 
async function getPost(postId: number): Promise<Post> {
  const response = await fetch(`/api/posts/${postId}`);
 
  if (!response.ok) {
    throw new Error('게시글 상세를 불러오지 못했습니다.');
  }
 
  return response.json();
}

실무에서는 이 fetch 함수를 한 곳에 모아두는 것만으로도 화면 코드가 꽤 가벼워집니다.

1. 가장 기본적인 리스트 조회

가장 기본적인 예제는 useQuery로 리스트를 가져오는 것입니다.

import { useQuery } from '@tanstack/react-query';
 
export function PostList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['posts', { page: 1 }],
    queryFn: () => getPosts(1),
  });
 
  if (isLoading) {
    return <div>목록을 불러오는 중...</div>;
  }
 
  if (isError) {
    return <div>{(error as Error).message}</div>;
  }
 
  return (
    <ul>
      {data.items.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

여기서 중요한 것은 단순 fetch보다 queryKey 구성입니다.

['posts', { page: 1 }];

이 키를 쓰면:

  • 페이지별 캐시를 구분할 수 있고
  • 이후 invalidation도 비슷한 기준으로 맞출 수 있습니다

2. 상세 조회는 어떻게 연결할까?

상세 화면은 보통 리스트와 별도 query key를 가집니다.

import { useQuery } from '@tanstack/react-query';
 
export function PostDetail({ postId }: { postId: number }) {
  const { data, isLoading } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => getPost(postId),
    enabled: !!postId,
  });
 
  if (isLoading) {
    return <div>상세를 불러오는 중...</div>;
  }
 
  if (!data) {
    return null;
  }
 
  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

여기서 enabled가 중요합니다.

  • postId가 아직 없는 상태에서는 요청하지 않고
  • 필요한 조건이 준비됐을 때만 query를 실행할 수 있습니다

즉, React Query는 "항상 실행"보다 언제 실행할지 제어하는 패턴도 같이 제공합니다.

3. pagination은 어떻게 다룰까?

페이지네이션은 생각보다 React Query와 잘 맞습니다.

import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
 
export function PaginatedPostList() {
  const [page, setPage] = useState(1);
 
  const { data, isFetching, isPending } = useQuery({
    queryKey: ['posts', { page }],
    queryFn: () => getPosts(page),
    placeholderData: (previousData) => previousData,
  });
 
  if (isPending && !data) {
    return <div>불러오는 중...</div>;
  }
 
  return (
    <div>
      <ul>
        {data?.items.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
 
      <div className="flex gap-2">
        <button disabled={page === 1} onClick={() => setPage((prev) => prev - 1)}>
          이전
        </button>
        <button disabled={!data?.nextPage} onClick={() => setPage((prev) => prev + 1)}>
          다음
        </button>
      </div>
 
      {isFetching && <p>새 페이지를 불러오는 중...</p>}
    </div>
  );
}

여기서 placeholderData를 주면 페이지 전환 시 이전 데이터를 잠깐 유지하면서 새 데이터를 가져올 수 있습니다. 이 차이는 UX에서 꽤 큽니다.

  • 전환할 때마다 목록이 완전히 비지 않고
  • 사용자는 기존 화면을 보면서
  • 새 페이지 데이터를 기다릴 수 있습니다

4. 무한 스크롤은 useInfiniteQuery

무한 스크롤이나 "더 보기" 패턴에서는 useInfiniteQuery가 자연스럽습니다.

import { useInfiniteQuery } from '@tanstack/react-query';
 
export function InfinitePostList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam = 1 }) => getPosts(pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });
 
  if (isPending) {
    return <div>불러오는 중...</div>;
  }
 
  return (
    <div>
      {data?.pages.flatMap((page) =>
        page.items.map((post) => <div key={post.id}>{post.title}</div>)
      )}
 
      <button disabled={!hasNextPage || isFetchingNextPage} onClick={() => fetchNextPage()}>
        {isFetchingNextPage ? '불러오는 중...' : '더 보기'}
      </button>
    </div>
  );
}

핵심은 일반 query와 달리:

  • pages 배열로 누적되고
  • getNextPageParam으로 다음 페이지 기준을 넘기며
  • 사용자는 현재까지 읽은 결과를 유지한 채 추가 데이터를 볼 수 있다는 점입니다

5. mutation은 어떻게 연결할까?

읽기만 있으면 실서비스가 아닙니다. 생성, 수정, 삭제가 붙기 시작하면 useMutation과 invalidation이 중요해집니다.

type CreatePostInput = {
  title: string;
  body: string;
};
 
async function createPost(input: CreatePostInput) {
  const response = await fetch('/api/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(input),
  });
 
  if (!response.ok) {
    throw new Error('게시글 생성에 실패했습니다.');
  }
 
  return response.json();
}
import { useMutation, useQueryClient } from '@tanstack/react-query';
 
export function CreatePostForm() {
  const queryClient = useQueryClient();
 
  const createPostMutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
 
  const handleSubmit = () => {
    createPostMutation.mutate({
      title: 'React Query 글',
      body: '본문',
    });
  };
 
  return (
    <button disabled={createPostMutation.isPending} onClick={handleSubmit}>
      {createPostMutation.isPending ? '생성 중...' : '게시글 생성'}
    </button>
  );
}

여기서 핵심은 성공 후 ['posts']를 invalidate하는 부분입니다. 즉, 생성이 끝난 뒤 리스트를 다시 최신 상태로 맞추는 것입니다.

6. invalidation을 어떻게 생각하면 좋을까?

실무에서는 API 호출 함수보다 이 규칙이 더 중요할 때가 많습니다.

예를 들어 게시글 생성이 성공했다면:

  • 게시글 목록은 오래되었을 가능성이 높고
  • 최근 글 위젯도 오래되었을 수 있고
  • 유저 대시보드 카운트도 바뀌었을 수 있습니다

즉 mutation을 만들 때는 항상 같이 묻게 됩니다.

  • 어떤 query가 영향을 받는가
  • 어떤 query를 invalidate할 것인가
  • 즉시 캐시를 수정할 것인가

이 판단이 쌓여서 데이터 일관성이 나옵니다.

7. optimistic update는 어떻게 쓸까?

좋은 UX를 위해 서버 응답을 기다리기 전에 화면을 먼저 바꾸고 싶을 때가 있습니다. 이때 optimistic update를 씁니다.

예를 들어 todo 완료 여부를 토글한다고 해보겠습니다.

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};
 
async function toggleTodo(todoId: number) {
  const response = await fetch(`/api/todos/${todoId}/toggle`, {
    method: 'PATCH',
  });
 
  if (!response.ok) {
    throw new Error('토글에 실패했습니다.');
  }
 
  return response.json();
}
const queryClient = useQueryClient();
 
const toggleTodoMutation = useMutation({
  mutationFn: toggleTodo,
  onMutate: async (todoId: number) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
 
    const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
 
    queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
      old.map((todo) => (todo.id === todoId ? { ...todo, completed: !todo.completed } : todo))
    );
 
    return { previousTodos };
  },
  onError: (_error, _todoId, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

이 흐름은 조금 길어 보이지만 의도는 명확합니다.

  1. 먼저 관련 query를 잠시 멈추고
  2. 이전 캐시를 백업한 뒤
  3. 화면을 먼저 바꾸고
  4. 실패하면 롤백하고
  5. 마지막엔 다시 서버 기준으로 맞춥니다

즉 optimistic update는 단순 트릭이 아니라, 빠른 UX와 일관성 보장을 같이 설계하는 패턴입니다.

8. prefetch는 어디에 쓰면 좋을까?

사용자가 곧 이동할 화면을 미리 예상할 수 있다면 prefetch가 꽤 유용합니다.

예를 들어 게시글 링크에 hover했을 때 상세 데이터를 미리 가져올 수 있습니다.

import { useQueryClient } from '@tanstack/react-query';
 
export function PostLink({ postId, title }: { postId: number; title: string }) {
  const queryClient = useQueryClient();
 
  const handlePrefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['post', postId],
      queryFn: () => getPost(postId),
      staleTime: 1000 * 30,
    });
  };
 
  return (
    <a href={`/posts/${postId}`} onMouseEnter={handlePrefetch}>
      {title}
    </a>
  );
}

이렇게 하면 상세 페이지 진입 시 이미 캐시가 준비되어 있어서 체감 속도가 좋아질 수 있습니다.

prefetch는 특히 아래 상황에서 잘 맞습니다.

  • 리스트에서 상세로 자주 이동할 때
  • 탭 전환이 예측 가능할 때
  • 드롭다운을 열면 하위 데이터가 거의 확실히 필요할 때

9. 파생 query와 조합은 어떻게 볼까?

실무에서는 하나의 화면이 하나의 query로 끝나지 않습니다.

  • 유저 정보
  • 유저 게시글 목록
  • 알림 개수
  • 권한 정보

처럼 여러 query가 같이 들어갑니다. 이때 중요한 건 "모든 걸 하나로 합칠까?"보다 query 경계를 도메인 기준으로 어디까지 쪼갤까입니다.

예를 들어 상세와 댓글을 굳이 하나의 query로 묶지 않아도 됩니다. 오히려 분리하면:

  • 필요한 부분만 개별 재요청 가능하고
  • 로딩과 에러 표시를 나눌 수 있고
  • 캐시 무효화도 더 세밀하게 할 수 있습니다

예제를 볼 때 같이 생각할 기준

React Query 예제를 따라 하다 보면 문법은 금방 익숙해집니다. 그런데 실무에서 더 중요한 질문은 아래입니다.

  • 이 데이터의 queryKey는 무엇이 자연스러운가
  • 얼마나 자주 바뀌는 데이터인가
  • mutation 이후 어디를 invalidate해야 하는가
  • 즉시 캐시를 바꿀지, refetch만 할지
  • 사용자가 기다리는 경험을 어떻게 줄일 것인가

즉, 예제를 많이 보는 것보다 데이터 수명 주기와 UX 흐름을 같이 생각하는 습관이 중요합니다.

정리하면

이번 글에서 본 React Query 활용 패턴을 다시 줄이면 이렇습니다.

  • 리스트/상세 조회는 useQuery
  • 조건부 실행은 enabled
  • 페이지네이션은 page를 query key에 포함
  • 무한 스크롤은 useInfiniteQuery
  • 쓰기 작업은 useMutation
  • 서버 상태 동기화는 invalidation
  • 빠른 UX는 optimistic update와 prefetch

중요한 것은 훅 이름을 외우는 것이 아니라, 조회와 변경 이후 데이터가 어떻게 다시 최신 상태로 맞춰지는가를 이해하는 것입니다.

다음 글에서는 이 흐름을 한 단계 더 실무 쪽으로 가져가서:

  • query key 규칙을 어떻게 잡을지
  • 공통 옵션을 어디까지 추상화할지
  • 에러 처리와 로딩 정책을 어떻게 통일할지
  • 실제 팀 코드베이스에서 어떤 실수를 줄여야 할지

를 중심으로 정리하겠습니다.

같이 보면 좋은 글