☁️ goormTIL | TanStack Query #51

매루·2025년 11월 20일

goormTIL

목록 보기
49/67
post-thumbnail

📅 2025-11-20

➡️ TanStack Query 낙관적 업데이트, 무한 스크롤에 대해 새롭게 알게 된 것 또는 헷갈리는 부분 정리


🔎 학습 리마인드

📌 Query Cancellation (쿼리 취소)

🔗 https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation

  • 진행 중인 네트워크 요청을 중단(취소)하는 기능
  • 사용자가 빠르게 페이지를 이동하거나, 짧은 시간 내에 여러 번 API를 호출하게 되는 상황에서 불필요한 네트워크 요청을 줄이고 UI 안정성을 개선할 수 있음

사용 상황

  • 대용량 파일/데이터 다운로드처럼 응답 시간이 긴 요청일 때
  • 검색창에 타이핑할 때처럼, 사용자가 입력을 계속 바뀌는 상황
  • 탭 전환, 페이지 이동 등으로 해당 데이터가 더 이상 필요 없어진 상황
  • 이미 새로운 요청을 보냈는데, 이전 요청의 응답은 더 이상 의미가 없을 때

장점

  • 네트워크 트래픽 감소
  • 서버 부하 감소
  • 불필요한 응답으로 인한 UI 버그 방지

사용 방법

  1. QueryFunctionContext에서 signal 사용하기

    • queryFn은 기본적으로 QueryFunctionContext 객체를 인자로 받음
      • queryKey: 쿼리의 고유 키

      • pageParam: useInfiniteQuery에서 사용되는 페이지 매개변수

      • signal: 요청 취소를 위한 AbortSignal

      • meta: 부가적인 메모 정보

        export const getTodos = async (context) => {
          const { queryKey, pageParam, signal, meta } = context;
          
          const response = await axios.get("http://localhost:5000/todos", { signal });
          return response.data;
        };
        
        useQuery({
          queryKey: ["todos"],
          queryFn: getTodos,
        });
      • signal을 axios 또는 fetch 요청에 전달하면, 쿼리가 취소될 때 해당 요청도 함께 중단됨

  2. 컴포넌트 언마운트 시 자동 취소

    useQuery({
      queryKey: ["todos"],
      queryFn: ({ signal }) => axios.get("/todos", { signal }),
    });
    • 기본적으로 GET 요청은 컴포넌트가 언마운트 되더라도 네트워크 요청이 계속 진행됨
    • AbortSignal 전달 시, 컴포넌트 언마운트 시 요청이 자동으로 취소
  3. 수동으로 쿼리 취소하기

    const queryClient = useQueryClient();
    
    const query = useQuery({
      queryKey: ["todos"],
      queryFn: ({ signal }) => fetch("/todos", { signal }).then((res) => res.json()),
    });
    
    <button onClick={() => queryClient.cancelQueries({ queryKey: ["todos"] })}>
      Cancel
    </button>;

사용 시 주의 사항

  • 모든 GET 요청에 AbortSignal을 적용하는 것은 비효율적임
  • 추천 적용 사례
    • 대용량 데이터 다운로드
    • 자동완성 검색 등 짧은 시간 내 여러 요청 발생
    • Optimistic UI, Infinite Query 등의 복잡한 UI

📌 Optimistic Update (낙관적 업데이트)

🔗 https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

  • 낙관적 업데이트서버 요청이 성공할 것이라고 가정하고, 서버 응답을 기다리지 않고 UI를 먼저 업데이트 하는 기법
  • 만약 서버 요청이 실패하면, 이전 상태로 되돌리는 rollback 작업 수행
  • UX를 부드럽게 만들고 반응성을 높이는데 큰 도움이 됨
  • 좋아요 버튼, 체크박스 토글, 장바구니 담기 등에 자주 사용

특징

  • UI 반응 속도가 빠름

  • 실패 처리를 반드시 고려해야 함

  • 서버와 클라이언트 상태가 일시적으로 불일치할 수 있음

  • 예시

    const { mutate } = useMutation({
        mutationFn: addTodo,
        onMutate: async (newTodo) => {
            // todos 쿼리로 진행 중인 요청이 있다면 취소
            // 낙관적 업데이트 할 떄 중간에 들어오는 응답이 캐시를 덮어버리는 현상 방지
            await queryClient.cancelQueries({ queryKey: ['todos'] });
    
            // 현재 캐시에 들어있는 todo를 가지고 오고, 나중에 에러가 나게 되면 롤백을 하기 위함
            const preTodos = queryClient.getQueryData(['todos']);
    
            // 서버 응답을 기다리지 않고, 바로 캐시에 newTodo 추가 -> 즉시 화면에 업데이트 됨 => 낙관적 업데이트
            queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
            
            return { preTodos }; // context 객체 반환
        },
        onSuccess: () => {
            queryClient.invalidateQueries(['todos']); // refetching
            setTodo('');
        },
        // mutation이 실패하면 onMutate에서 반환된 context를 사용하여 롤백 진행
        onError: (error, newTodo, context) => {
            queryClient.setQueryData(['todos'], context.preTodos);
        },
        onSettled: () => {
            // 성공 또는 실패 이후 공통적으로 실행되는 곳
    
            // todos 쿼리를 무효화 -> refetch 진행해서 서버와 캐시의 동기화를 유도
            queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
    });

📌 Prefetching (미리 데이터 가져오기)

🔗 https://tanstack.com/query/latest/docs/framework/react/guides/prefetching

  • 특정 데이터가 필요해지기 전에 백그라운드에서 미리 가져오는 기술
  • 사용자가 페이지를 이동하면 즉시 캐시된 데이터를 기반으로 화면을 표시 가능 → 로딩 시간 단축
queryClient.prefetchQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});
  • 주로 hover, focus, 클릭 직전에 사용

📌 Pagination & Infinite Queries

💡 Paginated / Lagged Queries (페이지네이션 데이터 유지)

🔗 https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries

  • v4 방식 (keepPreviousData)

    useQuery({
      queryKey: ["todos", page],
      queryFn: () => fetchTodos(page),
      keepPreviousData: true, // 이전 데이터 유지
    });
    • 페이지 전환 시 이전 데이터를 유지해서 목록 깜빡임 방지
    • isPreviousData로 이전 페이지인지 확인 가능
  • v5 방식 (placeholderData, isPlaceholderData)

    🔗 https://github.com/ssi02014/react-query-tutorial/blob/main/document/v5.md#9-%EF%B8%8F-removed-keeppreviousdata-in-favor-of-placeholderdata-identity-function

    • keepPreviousDataisPreviousData 제거됨
      → 대신 placeholderData + isPlaceholderData 플래그 활용

    • 이전 페이지 데이터를 그대로 활용하면서 같은 UX 구현 가능

      import { useQuery } from "@tanstack/react-query";
      
      const { data } = useQuery({
        queryKey: ["super-heroes", page],
        queryFn: getAllSuperHero,
        placeholderData: (previousData, previousQuery) => previousData,
      });
    • placeholderData캐시가 없는 경우 보여줄 초기 데이터를 정의

    • 이전 페이지 데이터를 그대로 반환하면, v4의 keepPreviousData: true와 동일한 효과

    • 필요에 따라 isPlaceholderData를 확인해서 UI에 표시할지 여부 결정 가능


💡 Infinite Queries (무한 쿼리)

🔗 https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries

🔗 https://tanstack.com/query/v5/docs/framework/react/reference/useInfiniteQuery

  • Infinite Query는 페이지별로 데이터를 추가로 불러와 리스트에 연속적으로 붙이는 방식의 데이터를 처리할 때 사용 (무한 스크롤이나 load more(더 보기))
  • 무한 쿼리를 지원하기 위해 useQuery의 유용한 버전인 useInfiniteQuery을 지원
    useInfiniteQuery({
      queryKey: ["projects"],
      queryFn: fetchProjects,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    });
    • getNextPageParam을 통해 다음 페이지 여부를 결정
      • lastPage는 fetch 해온 가장 최근에 가져온 페이지 목록
    • 가져온 데이터는 pages 배열 형태로 관리됨
    • Intersection Observer와 함께 사용 시 → 스크롤 위치 기반 무한 스크롤 UI 구현 가능

💡 Intersection Observer

  • 브라우저에서 제공하는 "요소가 화면(Viewport)에 얼마나 보이는지"를 감지하는 기능
    • 화면에 보이면 → 이벤트 발생
    • 화면에서 벗어나면 → 이벤트 발생

왜 필요한가?

  • 스크롤 이벤트(scroll)를 수동으로 추적하면 계속 이벤트가 쏟아지기 때문에 성능 문제가 생기기 쉬움
  • 그러나 Intersection Observer
    • 브라우저 레벨에서 효율적으로 처리됨
    • 리스너를 수동으로 관리할 필요 없음
    • 특정 요소가 정확히 화면에 보일 때만 콜백 실행
  • 그래서 무한 스크롤, 이미지 지연 로딩(lazy loading), 광고 배너 노출 감지, 애니메이션 트리거 같은 곳에서 아주 자주 사용됨

useInView 훅을 사용한 자동 무한스크롤

  • Infinite Query는 보통 "더보기" 버튼을 누르는 방식으로도 사용할 수 있지만,
    Intersection Observer를 활용하면 스크롤이 특정 지점에 도달했을 때 자동으로 다음 페이지를 불러오는 무한 스크롤 UI를 쉽게 만들 수 있음
  • 이를 위해 useInView 훅을 제공하는 react-intersection-observer 라이브러리를 사용 🔗 https://www.npmjs.com/package/react-intersection-observer
    npm i react-intersection-observer
  • 실습
    import { fetchTmdbData } from '../api/tmdb';
    import { useInView } from 'react-intersection-observer';
    import { useInfiniteQuery } from '@tanstack/react-query';
    
    const MovieLoadMore = () => {
        const {
            data: movieData,
            hasNextPage, // 다음 페이지 호출 가능한지 여부
            fetchNextPage, // 실제 다음 페이지를 불러오는 함수
            isFetchingNextPage,
            // 지금 현재 fetchNextPage가 실행 중인지 알려주는 상태값
            // true -> 호출되고 있는 중, false -> 호출 끝
            // 여러 번 트리거가 되고 있으면 어떠한 버그가 일어날지 모르니까 방어적 코드를 작성하기 위함
        } = useInfiniteQuery({
            queryKey: ['movies'],
            queryFn: fetchTmdbData,
            getNextPageParam: (lastPage) => {
                console.log('lastPage: ', lastPage.total_pages);
                console.log('lastPage: ', lastPage.page);
    
                if (lastPage.page < lastPage.total_pages) {
                    return lastPage.page + 1;
                }
            },
            select: (data) => {
                return data.pages.flatMap((page) => page.results);
            },
        });
    
        const { ref } = useInView({
            threshold: 1,
            onChange: (inView) => {
                console.log(inView);
                if (!inView || !hasNextPage || isFetchingNextPage) return;
                fetchNextPage();
            },
        });
    
        return (
            <div style={{ padding: 0 }}>
                <h2 style={{ paddingTop: '4rem' }}>영화 더보기 무한스크롤</h2>
    
                <ul style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '200px' }}>
                    {movieData?.map((movie) => (
                        <li key={`${movie.id}`}>{movie.title}</li>
                    ))}
                </ul>
    
                <div ref={ref} style={{ background: 'red', color: 'white', width: '100%', height: '100px' }}>
                    트리거
                </div>
            </div>
        );
    };
    
    export default MovieLoadMore;

0개의 댓글