React Query로 Infinite Scroll을 실전에 적용하는 방법

Frontend

Infinite Scroll은 데모 수준에서는 쉬워 보여도, 실무로 들어가면 금방 복잡해집니다.

  • 다음 페이지를 언제 요청할 것인가
  • 중복 호출은 어떻게 막을 것인가
  • 필터가 바뀌면 기존 페이지는 어떻게 버릴 것인가
  • 상세 화면에 다녀온 뒤 스크롤 위치는 어떻게 복원할 것인가
  • 데이터가 추가되거나 삭제되면 이미 쌓인 리스트는 어떻게 다룰 것인가

그래서 Infinite Scroll은 단순히 스크롤 끝에 도달하면 fetchNextPage()를 부르는 문제가 아닙니다. 페이지 누적, 사용자 스크롤, API 페이지네이션 방식, 캐시 수명 주기를 같이 설계하는 문제에 가깝습니다.

이번 글은 React QueryuseInfiniteQuery를 실무에 적용하는 관점으로 정리합니다. 개념 소개보다 실제 구현 흐름과 자주 깨지는 지점에 더 집중하겠습니다.

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • Infinite Scroll은 보통 offset보다 cursor 기반 API가 더 안정적입니다.
  • 프론트에서는 useInfiniteQueryIntersectionObserver 조합이 기본 패턴이 됩니다.
  • 중요한 것은 다음 페이지를 불러오는 코드보다 중복 호출 방지, 필터 변경, 스크롤 복원, 캐시 초기화 기준입니다.
  • 실무에서는 무조건 자동 로딩보다 "더 보기" 버튼과 혼합하는 경우도 꽤 많습니다.

즉, 핵심은 무한 스크롤을 멋지게 보이게 하는 것이 아니라 예측 가능하고 유지보수 가능한 데이터 흐름으로 만드는 것입니다.

먼저 API부터: 왜 cursor 기반이 더 나을까?

무한 스크롤은 프론트만 잘 짠다고 끝나지 않습니다. API가 어떤 페이지네이션 방식을 가지는지가 훨씬 중요합니다.

가장 단순한 방식은 offset + limit입니다.

GET /api/posts?offset=0&limit=20
GET /api/posts?offset=20&limit=20
GET /api/posts?offset=40&limit=20

이 방식은 구현이 쉽지만 실무에서는 금방 흔들릴 수 있습니다.

  • 중간에 새 데이터가 끼어들면 중복이 생길 수 있고
  • 삭제가 일어나면 일부 항목이 건너뛰어질 수 있고
  • 정렬 기준이 바뀌면 이어붙인 리스트가 어색해질 수 있습니다

반면 cursor 기반은 보통 이렇게 갑니다.

GET /api/posts?cursor=initial&limit=20
GET /api/posts?cursor=eyJpZCI6MjB9&limit=20

응답도 대개 이런 구조를 가집니다.

type InfinitePostsResponse = {
  items: Post[];
  nextCursor: string | null;
};

실무에서는 이쪽이 더 안정적입니다.

  • 이어서 읽을 위치가 명확하고
  • 중간 삽입/삭제에 상대적으로 강하며
  • 정렬 기준과도 더 자연스럽게 맞출 수 있습니다

즉, 프론트에서 Infinite Scroll이 자꾸 흔들린다면 UI 코드보다 먼저 API 페이지네이션 방식을 의심해보는 편이 맞습니다.

기본 도메인 타입부터 잡아보자

실전 예제로 게시글 피드를 가정해보겠습니다.

type Post = {
  id: number;
  title: string;
  summary: string;
  createdAt: string;
  liked: boolean;
};
 
type FeedFilter = {
  category: string;
  sort: 'latest' | 'popular';
};
 
type InfinitePostsResponse = {
  items: Post[];
  nextCursor: string | null;
};

API 함수는 필터와 cursor를 함께 받습니다.

async function getInfinitePosts({
  pageParam,
  filter,
}: {
  pageParam: string | null;
  filter: FeedFilter;
}): Promise<InfinitePostsResponse> {
  const searchParams = new URLSearchParams();
 
  if (pageParam) {
    searchParams.set('cursor', pageParam);
  }
 
  searchParams.set('category', filter.category);
  searchParams.set('sort', filter.sort);
 
  const response = await fetch(`/api/posts?${searchParams.toString()}`);
 
  if (!response.ok) {
    throw new Error('피드 데이터를 불러오지 못했습니다.');
  }
 
  return response.json();
}

여기서 중요한 건 필터도 query key와 fetch 파라미터에 같이 들어가야 한다는 점입니다.

useInfiniteQuery 기본 패턴

실제 훅은 대개 이렇게 생깁니다.

import { useInfiniteQuery } from '@tanstack/react-query';
 
export function useInfinitePostFeed(filter: FeedFilter) {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite', filter],
    queryFn: ({ pageParam = null }) =>
      getInfinitePosts({
        pageParam,
        filter,
      }),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
}

핵심은 세 가지입니다.

  • queryKey에 필터가 들어간다
  • pageParam은 다음 cursor를 의미한다
  • getNextPageParam이 다음 페이지 존재 여부를 결정한다

즉, 무한 스크롤의 핵심은 "배열을 누적한다"보다 다음 요청 기준을 어디서 가져오는가입니다.

컴포넌트에서는 어떻게 펼칠까?

가장 기본적인 화면은 아래처럼 만들 수 있습니다.

'use client';
 
export function PostFeed() {
  const filter = {
    category: 'all',
    sort: 'latest' as const,
  };
 
  const { data, hasNextPage, fetchNextPage, isFetchingNextPage, isPending, isError } =
    useInfinitePostFeed(filter);
 
  const posts = data?.pages.flatMap((page) => page.items) ?? [];
 
  if (isPending) {
    return <div>피드를 불러오는 중...</div>;
  }
 
  if (isError) {
    return <div>피드를 불러오지 못했습니다.</div>;
  }
 
  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.summary}</p>
          </li>
        ))}
      </ul>
 
      {hasNextPage && (
        <button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>
          {isFetchingNextPage ? '불러오는 중...' : '더 보기'}
        </button>
      )}
    </div>
  );
}

실무에서도 처음에는 이 "더 보기" 버튼 버전으로 시작하는 것이 꽤 좋습니다. 이유는 간단합니다.

  • 자동 로딩보다 디버깅이 쉽고
  • 호출 타이밍이 명확하고
  • 접근성과 제어권이 좋기 때문입니다

무한 스크롤이 정말 필요한지 아직 확신이 없다면, 버튼형으로 먼저 안정화한 뒤 IntersectionObserver를 얹는 방식이 안전합니다.

자동 로딩은 IntersectionObserver로 붙이는 편이 좋다

스크롤 이벤트 직접 계산도 가능하지만, 실무에서는 IntersectionObserver가 더 안정적이고 단순한 편입니다.

import { useEffect, useRef } from 'react';
 
export function PostFeed() {
  const loadMoreRef = useRef<HTMLDivElement | null>(null);
 
  const { data, hasNextPage, fetchNextPage, isFetchingNextPage, isPending } = useInfinitePostFeed({
    category: 'all',
    sort: 'latest',
  });
 
  const posts = data?.pages.flatMap((page) => page.items) ?? [];
 
  useEffect(() => {
    const target = loadMoreRef.current;
 
    if (!target || !hasNextPage) {
      return;
    }
 
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
 
        if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      {
        rootMargin: '300px',
      }
    );
 
    observer.observe(target);
 
    return () => {
      observer.disconnect();
    };
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
 
  if (isPending) {
    return <div>피드를 불러오는 중...</div>;
  }
 
  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
 
      <div ref={loadMoreRef} />
 
      {isFetchingNextPage && <p>다음 글을 불러오는 중...</p>}
    </div>
  );
}

여기서 rootMargin: '300px' 같은 값이 실전적으로 중요합니다. 바닥에 딱 닿았을 때 로딩하는 것보다, 조금 미리 다음 페이지를 당겨오는 편이 체감이 더 좋기 때문입니다.

중복 호출은 왜 자주 생길까?

무한 스크롤에서 가장 흔한 버그 중 하나입니다.

  • observer가 여러 번 붙는다
  • sentinel이 짧은 시간 안에 반복 진입한다
  • 이미 요청 중인데 또 fetchNextPage()가 호출된다

그래서 아래 조건은 거의 필수에 가깝습니다.

if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
  fetchNextPage();
}

그리고 경우에 따라 isFetching까지 같이 보기도 합니다. 중요한 것은 "보이면 호출"이 아니라 지금 이 호출이 정말 유효한가를 항상 같이 검사하는 것입니다.

필터가 바뀌면 기존 페이지는 어떻게 될까?

실무에서 자주 빠지는 포인트입니다.

예를 들어:

  • 카테고리 변경
  • 정렬 변경
  • 검색어 변경

이런 값이 바뀌면 기존 무한 스크롤 페이지는 더 이상 같은 데이터가 아닙니다. 그래서 필터는 반드시 queryKey에 포함돼야 합니다.

queryKey: ['posts', 'infinite', filter];

이렇게 해두면 필터가 바뀔 때 React Query는 새 캐시 그룹으로 인식합니다.

즉:

  • 기존 리스트를 그대로 이어붙이지 않고
  • 새로운 조건 기준으로 첫 페이지부터 다시 가져옵니다

실무적으로는 이것만으로도 많은 꼬임이 줄어듭니다.

검색 input과 바로 붙일 때는 debounce를 같이 보자

검색어가 매 입력마다 바로 query key에 들어가면, 무한 스크롤과 결합될 때 요청이 너무 자주 발생할 수 있습니다.

그래서 보통은:

  • input state
  • debounced keyword

를 나눠서, query key에는 debounced 값만 넣는 편이 좋습니다.

const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
 
const query = useInfiniteQuery({
  queryKey: ['posts', 'infinite', { keyword: debouncedKeyword, sort }],
  ...
});

즉, 무한 스크롤 자체보다 필터 입력의 변경 빈도를 같이 제어해야 합니다.

스크롤 복원은 왜 어렵고 어떻게 볼까?

사용자가 리스트에서 상세로 들어갔다가 다시 나왔을 때:

  • 쌓아둔 페이지를 유지할 것인가
  • 스크롤 위치를 복원할 것인가

이 두 가지가 같이 중요해집니다.

React Query 캐시 덕분에 데이터 자체는 어느 정도 유지할 수 있습니다. 하지만 스크롤 위치는 별도 문제입니다.

실무에서는 보통:

  • 리스트 페이지를 unmount하지 않도록 라우팅 구조를 잡거나
  • 스크롤 위치를 별도 상태나 브라우저 복원 전략으로 관리하거나
  • 적어도 "뒤로 가기" 시 이전 데이터 캐시를 재사용하게 만들어 화면이 처음부터 비지 않게 하는 것

정도를 같이 고려합니다.

즉, 좋은 infinite scroll UX는 데이터 캐시만으로 완성되지 않고 스크롤 위치와 라우팅 흐름 설계까지 같이 들어갑니다.

리스트 아이템 변경은 어떻게 다룰까?

무한 스크롤 리스트는 mutation 이후 더 까다롭습니다.

예를 들어:

  • 좋아요 토글
  • 북마크
  • 항목 삭제
  • 댓글 수 증가

이런 변경이 생기면 모든 페이지를 무조건 invalidate할 수도 있고, 특정 아이템만 부분 갱신할 수도 있습니다.

예를 들어 좋아요 토글 정도는 캐시 직접 수정이 실무적으로 좋을 수 있습니다. 앞에서 Post 타입에 liked를 포함해둔 이유도 이 예제처럼 리스트 아이템의 즉각적인 UI 반응을 보여주기 위해서입니다.

import type { InfiniteData } from '@tanstack/react-query';
 
queryClient.setQueryData(
  ['posts', 'infinite', filter],
  (oldData: InfiniteData<InfinitePostsResponse> | undefined) => {
    if (!oldData) return oldData;
 
    return {
      ...oldData,
      pages: oldData.pages.map((page) => ({
        ...page,
        items: page.items.map((post) =>
          post.id === postId ? { ...post, liked: !post.liked } : post
        ),
      })),
    };
  }
);

이 패턴의 포인트는, 무한 스크롤 캐시는 단일 배열이 아니라 pages 배열 안에 다시 items가 중첩된 구조라는 점입니다. 그래서 캐시 수정을 할 때도 그 구조를 정확히 이해해야 합니다.

모든 데이터를 끝까지 캐시에 쌓아두는 것이 항상 좋을까?

꼭 그렇지는 않습니다.

피드가 아주 길어질 수 있는 서비스라면:

  • 메모리 사용량이 커지고
  • 뒤로 가기 시 복원은 좋지만
  • 너무 많은 페이지를 오래 들고 있는 것이 부담이 될 수 있습니다

그래서 실무에서는 아래를 같이 고민합니다.

  • gcTime을 얼마나 줄 것인가
  • 페이지를 전부 유지할 것인가
  • 특정 조건에서 리스트를 리셋할 것인가

즉, 무한 스크롤은 UX를 위해 캐시를 많이 쓰는 패턴이지만, 캐시를 무한히 유지하는 패턴은 아니다라는 점도 같이 봐야 합니다.

자동 로딩만 고집하지 말자

무한 스크롤은 종종 "자동 로딩"이 정답처럼 보이지만, 실무에서는 아래 혼합형도 꽤 유용합니다.

  • 첫 몇 페이지는 자동 로딩
  • 이후에는 "더 보기" 버튼 노출

이 방식이 좋은 이유는:

  • 지나친 자동 호출을 줄이고
  • 사용자가 현재 위치를 더 잘 인식할 수 있으며
  • footer 접근성이나 제어권 문제도 조금 완화할 수 있기 때문입니다

특히 콘텐츠 소비형 피드가 아니라, 검색 결과나 상품 목록처럼 사용자가 위치 감각을 유지해야 하는 화면에서는 이 혼합형이 더 나은 경우가 많습니다.

실무에서 자주 하는 실수

정리하면 아래 실수가 정말 자주 나옵니다.

  • offset 기반 API를 그대로 써서 중복/누락이 생긴다
  • queryKey에 필터를 넣지 않는다
  • isFetchingNextPage 체크 없이 fetchNextPage()를 계속 부른다
  • 검색 입력과 무한 스크롤을 debounce 없이 바로 연결한다
  • 상세 진입 후 돌아왔을 때 스크롤 복원을 전혀 고려하지 않는다
  • 무한 스크롤 캐시 구조를 모르고 mutation 후 캐시를 잘못 수정한다

이 문제들은 대부분 useInfiniteQuery 문법보다 리스트 수명 주기 설계 부족에서 나옵니다.

추천하는 실전 접근 순서

처음부터 완전 자동 무한 스크롤로 가지 말고, 보통은 아래 순서가 안전합니다.

  1. 먼저 cursor 기반 API를 확보한다
  2. useInfiniteQuery + 더 보기 버튼으로 안정화한다
  3. 그다음 IntersectionObserver를 붙인다
  4. 필터 변경, 상세 이동, 스크롤 복원까지 붙인다
  5. 이후 mutation과 캐시 직접 수정이 정말 필요한지 판단한다

이 순서가 좋은 이유는 문제를 한 번에 다 열지 않고, 호출 타이밍, 캐시 구조, UX 흐름을 단계적으로 검증할 수 있기 때문입니다.

정리하면

React QueryInfinite Scroll을 실전에 적용할 때 중요한 것은 단순히 useInfiniteQuery를 쓰는 것이 아닙니다.

핵심은 이렇습니다.

  • API는 가능하면 cursor 기반으로 설계하고
  • queryKey에는 필터를 반드시 포함하며
  • 자동 로딩은 IntersectionObserver로 붙이되 중복 호출을 막고
  • 필터 변경, 상세 이동, 스크롤 복원, mutation 이후 캐시 수정까지 같이 봐야 합니다

즉, 좋은 infinite scroll은 "계속 불러오는 리스트"가 아니라 페이지 누적과 사용자 흐름이 예측 가능하게 유지되는 리스트입니다.

React Query는 그 과정에서 페이지 누적과 캐시 재사용을 잘 도와주는 도구이지만, 진짜 실전성은 결국 API 설계, 로딩 트리거, 리스트 생명주기 설계에서 나옵니다.

같이 보면 좋은 글