TanStack Query를 사용한 고급 데이터 가져오기 - 낙관적 업데이트, 페이지네이션 및 WebSocket 통합
Ethan Miller
Product Engineer · Leapcell

소개
현대의 웹 개발 환경에서 성능이 뛰어나고 반응성이 좋으며 데이터가 풍부한 사용자 인터페이스를 구축하는 것은 매우 중요합니다. 사용자들은 원활한 상호 작용과 최신 정보를 기대합니다. 전통적인 데이터 가져오기 메커니즘은 간단한 시나리오에는 종종 충분하지만, 복잡한 애플리케이션의 요구 사항은 더 정교한 솔루션을 필요로 합니다. TanStack Query(이전 React Query)와 같은 라이브러리가 빛을 발하는 곳입니다. 서버 상태를 관리하기 위한 강력한 기본 요소를 제공하여 데이터 가져오기, 캐싱, 동기화 및 오류 처리를 크게 단순화합니다. 기초적인 기능 너머에, TanStack Query는 사용자 경험과 개발자 생산성을 진정으로 향상시킬 수 있는 고급 기능 제품군을 제공합니다. 이 문서는 이러한 중요한 세 가지 기능, 즉 즉각적인 피드백을 위한 낙관적 업데이트, 대규모 데이터 세트를 처리하기 위한 효율적인 페이지네이션 전략, 실시간 데이터 동기화를 위한 WebSocket과의 원활한 통합에 대해 자세히 알아봅니다. 이러한 고급 패턴을 이해하고 활용하면 단순히 기능적인 애플리케이션을 즐겁게 반응하고 동적인 애플리케이션으로 전환할 수 있습니다.
핵심 개념
고급 기능에 대해 자세히 알아보기 전에, 이 토론에서 언급될 TanStack Query의 몇 가지 핵심 개념을 간략하게 정의해 보겠습니다.
- 쿼리(Query): 백엔드에서 데이터를 가져오기 위한 요청을 나타냅니다. 쿼리는 고유한
queryKey
로 식별되며 TanStack Query에 의해 자동으로 캐싱되고 다시 가져와집니다. - 뮤테이션(Mutation): 백엔드에서 데이터를 수정하는 작업(예: 생성, 업데이트, 삭제)을 나타냅니다. 뮤테이션은 종종 부작용이 있으며 쿼리 무효화를 트리거할 수 있습니다.
- 쿼리 클라이언트(Query Client): 모든 쿼리와 뮤테이션을 관리하는 중앙 인스턴스입니다. 캐시를 보유하고 있으며 이를 상호 작용하는 메서드를 제공합니다.
- 쿼리 캐시(Query Cache): TanStack Query가 쿼리 결과와 해당 메타데이터를 저장하는 곳입니다. 이 영구 캐시를 통해 이전에 가져온 데이터를 즉시 렌더링할 수 있습니다.
- 무효화(Invalidation): 캐시된 쿼리를 "오래된(stale)" 것으로 표시하는 프로세스로, 다음에 액세스할 때 TanStack Query가 백그라운드에서 다시 가져오도록 합니다. 이를 통해 데이터 신선도를 보장합니다.
이러한 기초적인 개념은 TanStack Query가 애플리케이션의 서버 상태를 지능적으로 관리할 수 있도록 하여 더 고급 동작을 구현하기 위한 강력한 플랫폼을 제공합니다.
낙관적 업데이트
낙관적 업데이트는 애플리케이션의 지각된 성능과 반응성을 향상시키는 강력한 기법입니다. UI를 업데이트하기 전에 서버 응답을 기다리는 대신, 낙관적 업데이트는 예상되는 변경 사항을 UI에 즉시 적용합니다. 서버 작업이 성공하면 UI는 업데이트된 상태를 유지합니다. 실패하면 UI는 이전 상태로 롤백됩니다. 이를 통해 사용자에게 즉각적인 피드백을 제공하여 애플리케이션을 훨씬 더 빠르게 느끼게 합니다.
작동 방식
낙관적 업데이트의 핵심 아이디어는 클라이언트 측에서 "성공을 가정"하는 것입니다. 뮤테이션이 시작되면 다음을 수행합니다.
- 낙관적 업데이트와 간섭할 수 있는 모든 나가는 쿼리를 취소합니다.
- 뮤테이션의 영향을 받을 현재 쿼리 데이터를 스냅샷합니다. 이는 뮤테이션이 실패할 경우 원활한 롤백을 허용합니다.
- 뮤테이션의 예상 결과를 반영하도록 캐시된 쿼리 데이터를 직접 수정하여 UI를 낙관적으로 업데이트합니다.
- 서버에서 실제 뮤테이션을 실행합니다.
- 성공 시: 영향을 받는 쿼리를 무효화하여 서버에서 최신 데이터를 다시 가져와 일관성을 보장합니다.
- 오류 시: 2단계에서 찍은 스냅샷으로 UI를 롤백합니다.
구현 예시
사용자가 할 일 항목의 "완료" 상태를 토글하는 시나리오를 고려해 보겠습니다.
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateTodoApi } from './api'; // 이것이 API 호출이라고 가정 function TodoItem({ todo }) { const queryClient = useQueryClient(); const { mutate } = useMutation({ mutationFn: updateTodoApi, onMutate: async (newTodo) => { // 1단계: todos 쿼리에 대한 모든 나가는 재가져오기를 취소합니다. await queryClient.cancelQueries({ queryKey: ['todos'] }); // 2단계: 이전 값을 스냅샷합니다. const previousTodos = queryClient.getQueryData(['todos']); // 3단계: 캐시를 낙관적으로 업데이트합니다. queryClient.setQueryData(['todos'], (old) => old ? old.map((t) => (t.id === newTodo.id ? newTodo : t)) : [] ); // 스냅샷과 함께 컨텍스트 객체를 반환합니다. return { previousTodos }; }, onError: (err, newTodo, context) => { // 6단계: 오류 시 롤백합니다. queryClient.setQueryData(['todos'], context.previousTodos); console.error('할 일 업데이트 실패:', err); }, onSettled: () => { // 5단계: 뮤테이션 후 최신 데이터를 보장하기 위해 쿼리를 무효화합니다. queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); const toggleComplete = () => { mutate({ ...todo, completed: !todo.completed }); }; return ( <div> <input type="checkbox" checked={todo.completed} onChange={toggleComplete} /> <span>{todo.title}</span> </div> ); }
이 예시에서 사용자가 체크박스를 클릭하면 요청이 성공한 것처럼 UI가 즉시 업데이트됩니다. updateTodoApi
호출에 실패하면 UI가 이전 상태로 부드럽게 되돌아갑니다. 이는 서버 왕복을 기다리는 것에 비해 훨씬 더 부드러운 사용자 경험을 제공합니다.
애플리케이션 시나리오
낙관적 업데이트는 즉각적인 시각적 피드백이 중요하고 실패 가능성이 상대적으로 낮은 작업에 이상적입니다.
- 체크박스 토글(예: 할 일 완료, 읽음으로 표시).
- 게시물 좋아요/싫어요.
- 장바구니에 항목 추가/제거(재고 문제에 대한 원활한 오류 처리 포함).
- 즉각적인 확인이 도움이 되는 간단한 양식 제출.
페이지네이션 쿼리
대규모 데이터 세트를 효과적으로 처리하는 것은 웹 개발에서 일반적인 과제입니다. 한 번에 수천 개의 레코드를 표시하는 것은 비효율적이며 성능에 해롭습니다. 페이지네이션은 사용자에게 관리 가능한 청크를 통해 데이터를 탐색할 수 있도록 하는 널리 채택된 솔루션입니다. TanStack Query는 다양한 페이지네이션 전략을 구현하기 위한 강력한 기능을 제공하여 효율적인 데이터 가져오기와 원활한 사용자 경험을 보장합니다.
페이지네이션 유형
기본적으로 두 가지 유형의 페이지네이션이 있습니다.
- 오프셋 기반 페이지네이션(Offset-based pagination): 이 가장 일반적인 형식은
page
번호와limit
(또는per_page
)를 기반으로 데이터를 요청합니다. 서버는 전체 목록에서 특정 "오프셋"의 항목을 반환합니다. - 커서 기반 페이지네이션(Cursor-based pagination, Infinite Scrolling): 이 방법은 이전에 가져온 세트의 "커서"(일반적으로 ID 또는 타임스탬프)를 기반으로 데이터를 요청합니다. 이는 사용자가 아래로 스크롤함에 따라 새 데이터가 추가되는 무한 스크롤 경험에 자주 사용됩니다.
useQuery
를 사용한 오프셋 기반 페이지네이션
표준 페이지별 탐색의 경우 useQuery
가 완벽하게 적합합니다.
import { useQuery } from '@tanstack/react-query'; import { fetchPostsApi } from './api'; // API가 페이지별로 게시물을 가져온다고 가정 function PostsList() { const [page, setPage] = useState(0); const { data, isPreviousData, isLoading, isError, error } = useQuery({ queryKey: ['posts', page], // queryKey는 페이지 번호에 따라 변경됩니다. queryFn: () => fetchPostsApi(page), keepPreviousData: true, // 새 데이터 로딩 중에 이전에 가져온 데이터를 유지합니다. }); if (isLoading) return <div>게시물 로딩 중...</div>; if (isError) return <div>오류: {error.message}</div>; return ( <div> {data.posts.map((post) => ( <div key={post.id}>{post.title}</div> ))} <button onClick={() => setPage((old) => Math.max(old - 1, 0))} disabled={page === 0} > 이전 </button> <button onClick={() => { if (!isPreviousData && data.hasMore) { // API에 hasMore가 반환되는지 확인합니다. setPage((old) => old + 1); } }} disabled={isPreviousData || !data.hasMore} > 다음 </button> <span>현재 페이지: {page + 1}</span> </div> ); }
page
를 queryKey
에 추가함으로써 TanStack Query는 각 페이지의 데이터를 캐시의 별도 항목으로 처리합니다. keepPreviousData: true
옵션은 여기서 필수적입니다. 새 페이지의 데이터가 로딩되는 동안 이전에 가져온 데이터를 계속 표시하여 빈 상태를 방지하고 사용자 경험을 개선합니다.
useInfiniteQuery
를 사용한 커서 기반 페이지네이션
무한 스크롤 또는 "더 로드" 패턴의 경우 useInfiniteQuery
가 적합한 솔루션입니다. 시간이 지남에 따라 증가하는 목록을 가져오고 관리하도록 특별히 설계되었습니다.
import { useInfiniteQuery } from '@tanstack/react-query'; import { fetchCommentsApi } from './api'; // API가 'cursor'로 댓글을 가져온다고 가정 function CommentsFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error, } = useInfiniteQuery({ queryKey: ['comments'], queryFn: ({ pageParam }) => fetchCommentsApi(pageParam), // pageParam은 커서입니다. initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, // API는 nextCursor를 반환해야 합니다. }); if (isLoading) return <div>댓글 로딩 중...</div>; if (isError) return <div>오류: {error.message}</div>; return ( <div> {data.pages.map((page, i) => ( <React.Fragment key={i}> {page.comments.map((comment) => ( <div key={comment.id}>{comment.text}</div> ))} </React.Fragment> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? '더 로딩 중...' : hasNextPage ? '더 로드' : '더 로드할 내용 없음'} </button> </div> ); }
useInfiniteQuery
는 데이터를 페이지별로 그룹화된 평면화된 배열에 저장합니다. getNextPageParam
은 TanStack Query에 다음 가져오기 요청에 대한 pageParam
을 얻는 방법을 알려주는 중요한 함수이며, 일반적으로 서버 응답(lastPage.nextCursor
)에서 제공하는 커서입니다. 이 패턴은 필요한 만큼만 새 데이터를 가져오기 때문에 초기 로드 시간을 줄이고 서버 부하를 줄이는 데 매우 효율적입니다.
애플리케이션 시나리오
- 오프셋 기반 페이지네이션: 관리자 대시보드, 검색 결과, 고정 페이지 번호가 있는 전자 상거래 제품 목록.
- 커서 기반 페이지네이션: 소셜 미디어 피드, 활동 로그, 채팅 기록, "무한 스크롤" 경험.
WebSocket 통합
TanStack Query는 RESTful API에서 서버 상태를 관리하는 데 뛰어나지만, 현대 애플리케이션은 종종 WebSocket을 통해 실시간 업데이트를 필요로 합니다. TanStack Query와 WebSocket을 통합하면 실시간 변경 사항을 Query Cache에 직접 푸시하여 UI가 항상 최신 상태를 반영하도록 할 수 있습니다. 이는 지속적인 폴링이나 수동 다시 가져오기 없이 가능합니다.
문제점
적절한 통합 없이는 WebSocket의 실시간 업데이트가 TanStack Query 관리 데이터에 자동으로 반영되지 않을 수 있습니다. 일반적으로 수동으로 구성 요소 상태를 업데이트하거나 다시 가져오기를 트리거해야 하므로 불일치와 상용구 코드가 발생합니다.
queryClient.setQueryData
및 queryClient.invalidateQueries
를 사용한 솔루션
WebSocket을 통합하는 핵심은 queryClient.setQueryData
를 사용하여 캐시를 직접 업데이트하고 queryClient.invalidateQueries
를 사용하여 적절한 시점에 다시 가져오기를 트리거하는 것입니다.
import React, { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchStockPricesApi } from './api'; // 초기 주가를 가져오는 API // WebSocket 연결 유틸리티라고 가정 const connectWebSocket = (onMessage) => { const ws = new WebSocket('ws://localhost:8080/stock-prices'); ws.onmessage = (event) => onMessage(JSON.parse(event.data)); return ws; }; function StockPricesDisplay() { const queryClient = useQueryClient(); // 초기 주가 가져오기 const { data: stockPrices, isLoading, isError, error } = useQuery({ queryKey: ['stockPrices'], queryFn: fetchStockPricesApi, }); useEffect(() => { const ws = connectWebSocket((newPrice) => { // 캐시에서 특정 주가 업데이트 queryClient.setQueryData(['stockPrices'], (oldPrices) => { if (!oldPrices) return [newPrice]; // 캐시가 비어 있는 경우 초기 상태 처리 return oldPrices.map((price) => price.symbol === newPrice.symbol ? newPrice : price ); }); // 다른 데이터에 대한 전체 다시 가져오기가 필요한 경우 쿼리를 무효화할 수 있습니다. // 예를 들어, 개별 주가에서 계산된 "포트폴리오 총계" // queryClient.invalidateQueries({ queryKey: ['portfolioTotal'] }); }); return () => ws.close(); // 마운트 해제 시 WebSocket 연결 정리 }, [queryClient]); if (isLoading) return <div>주가 로딩 중...</div>; if (isError) return <div>오류: {error.message}</div>; return ( <div> <h2>실시간 주가</h2> {stockPrices.map((stock) => ( <div key={stock.symbol}> {stock.symbol}: ${stock.price.toFixed(2)} <span style={{ color: stock.change > 0 ? 'green' : 'red' }}> ({stock.change > 0 ? '+' : ''} {stock.change.toFixed(2)}%) </span> </div> ))} </div> ); }
이 예시에서:
- 구성 요소가 마운트될 때 WebSocket 연결을 설정합니다.
- WebSocket(예: 업데이트된 주가)에서 새 메시지를 받으면 구문 분석합니다.
- 그런 다음
queryClient.setQueryData(['stockPrices'], ...)
을 사용하여 캐시의stockPrices
쿼리를 직접 업데이트합니다. 이 수정은 즉시 해당 쿼리를 사용하는 모든 구성 요소의 다시 렌더링을 트리거하여 실시간 업데이트를 반영합니다. - 선택적으로, 들어오는 WebSocket 이벤트가 파생 데이터(예: 항목 목록에서 계산된 총계)에 영향을 미치는 경우
queryClient.invalidateQueries
를 사용하여 해당 종속 쿼리의 다시 가져오기를 트리거할 수 있습니다.
이 접근 방식은 WebSocket의 실시간 기능과 TanStack Query의 강력한 상태 관리를 결합하는 강력한 방법을 제공하여 수동 상태 처리를 최소화하면서 뛰어난 사용자 경험을 제공합니다.
애플리케이션 시나리오
- 실시간 대시보드: 주가 티커, 암호화폐 가격, 라이브 분석.
- 채팅 애플리케이션: 즉각적인 메시지 전달.
- 알림: 사용자에게 실시간 알림 푸시.
- 협업 편집: 여러 사용자 간의 변경 사항 동기화.
결론
TanStack Query는 기본 데이터 가져오기를 훨씬 뛰어넘어 복잡한 서버 상태를 관리하기 위한 강력한 도구 생태계를 제공합니다. 낙관적 업데이트, 정교한 페이지네이션 및 WebSocket 통합과 같은 고급 기능을 마스터함으로써 개발자는 성능이 뛰어나고 강력할 뿐만 아니라 탁월하게 반응성이 좋고 동적인 애플리케이션을 만들 수 있습니다. 이러한 기법은 지각된 성능과 즐거운 사용자 경험에 크게 기여하여 애플리케이션이 오늘날의 까다로운 디지털 환경에서 돋보이도록 합니다. TanStack Query를 사용하면 자신감과 용이성으로 매우 매력적이고 실시간인 사용자 인터페이스를 구축할 수 있습니다.