junyeokk
Blog
React·2024. 11. 21

useInfiniteQuery 커서 기반 페이지네이션

페이지네이션은 대량의 데이터를 나눠서 가져오는 기본적인 전략이다. 전통적으로 오프셋 기반 페이지네이션이 널리 쓰였지만, 실시간으로 데이터가 추가/삭제되는 피드형 서비스에서는 심각한 문제가 발생한다. 커서 기반 페이지네이션은 이 문제를 해결하기 위해 등장한 방식이고, TanStack Query의 useInfiniteQuery는 이를 React에서 선언적으로 구현할 수 있게 해준다.

오프셋 기반 페이지네이션의 문제

오프셋 방식은 "몇 번째부터 몇 개"를 요청한다.

GET /posts?offset=20&limit=10

SQL로 보면 이렇다.

sql
GET /posts?offset=20&limit=10

단순하고 직관적이지만, 두 가지 근본적인 문제가 있다.

데이터 중복·누락

사용자가 1페이지(offset=0)를 보고 있는 사이에 새 글이 3개 올라왔다고 하자. 2페이지(offset=10)를 요청하면, 기존 1페이지에 있던 마지막 3개 항목이 밀려 내려와서 2페이지에 다시 나타난다. 반대로 글이 삭제되면 일부 항목을 아예 건너뛰게 된다.

[초기 상태] 1페이지: A B C D E F G H I J (offset=0, limit=10) 2페이지: K L M N O ... (offset=10, limit=10) [새 글 3개 추가 후] 1페이지: X Y Z A B C D E F G (offset=0) 2페이지: H I J K L ... (offset=10) → H, I, J가 1페이지와 2페이지에 중복 노출

피드형 서비스에서 이런 중복은 사용자 경험을 크게 해친다. 같은 글이 반복해서 보이면 버그처럼 느껴진다.

성능 저하

OFFSET 10000이면 DB는 10,000개의 행을 읽고 버린 뒤 그다음 10개를 반환한다. 데이터가 많아질수록 뒤쪽 페이지 조회가 점점 느려진다. 인덱스를 타더라도 OFFSET 자체가 스캔을 유발하기 때문에 근본적인 해결이 안 된다.

sql
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 20;

커서 기반 페이지네이션

커서 방식은 "이 지점 이후로 몇 개"를 요청한다. 여기서 "이 지점"이 커서(cursor)다.

GET /posts?lastId=42&limit=10

SQL로 보면 이렇다.

sql
[초기 상태]
1페이지: A B C D E F G H I J  (offset=0, limit=10)
2페이지: K L M N O ...         (offset=10, limit=10)

[새 글 3개 추가 후]
1페이지: X Y Z A B C D E F G  (offset=0)
2페이지: H I J K L ...         (offset=10)
→ H, I, J가 1페이지와 2페이지에 중복 노출

차이점이 보이는가? OFFSET 대신 WHERE id < 42 조건을 사용한다. id에 인덱스가 걸려 있으면 B-Tree에서 42보다 작은 첫 번째 위치를 바로 찾아서 거기서부터 10개만 읽으면 된다. 100만 번째 데이터든 1번째 데이터든 동일한 성능이다.

데이터 일관성

커서 방식은 특정 레코드를 기준점으로 삼기 때문에 새 데이터가 추가되거나 삭제되어도 영향을 받지 않는다.

[초기 상태] 1페이지: A(50) B(49) C(48) ... J(41) → lastId=41 [새 글 추가 후] 2페이지 요청: WHERE id < 41 ORDER BY id DESC LIMIT 10 → K(40) L(39) M(38) ... → 새 글이 추가되었든 아니든, 41보다 작은 id를 기준으로 정확히 가져옴

커서로 뭘 쓸까?

가장 흔한 선택은 PK(id)다. 유니크하고 정렬 가능하며 인덱스가 있기 때문이다. 하지만 반드시 id일 필요는 없다. 커서의 조건은 세 가지다.

  1. 유니크해야 한다 — 같은 커서 값을 가진 행이 여러 개면 건너뛰기가 발생한다
  2. 순서가 있어야 한다 — 정렬 기준과 일치해야 한다
  3. 인덱스가 있어야 한다 — 성능을 위해 필수

만약 created_at으로 정렬하고 싶은데 같은 시간에 여러 행이 생성될 수 있다면, 복합 커서를 사용한다.

sql
-- 뒤쪽 페이지일수록 느려짐
SELECT * FROM posts ORDER BY id DESC LIMIT 10 OFFSET 100000;
-- → 100,000개 행을 건너뛰기 위해 모두 스캔

또는 정렬용 별도 컬럼을 만들어서 유니크하게 유지하는 방법도 있다. 예를 들어 타임스탬프 기반 정수형 order_id를 사용하면 단일 컬럼으로 커서 역할을 할 수 있다.

서버 측 구현

서버에서 커서 기반 페이지네이션 API를 만들 때는 클라이언트에게 "다음 페이지가 있는지"를 함께 알려줘야 한다. 일반적인 패턴은 요청한 것보다 1개 더 가져와서 확인하는 것이다.

typescript
GET /posts?lastId=42&limit=10

여기서 limit + 1개를 가져오는 이유는, 만약 11개가 반환되면 "아직 더 있다"는 뜻이고, 10개 이하면 "마지막 페이지"라는 뜻이다. 추가 COUNT 쿼리 없이 hasMore를 판단할 수 있어서 효율적이다.

서브쿼리를 사용하는 이유도 중요하다. 클라이언트가 보내는 lastId는 실제 PK이지만, 정렬 기준이 order_id 같은 다른 컬럼일 수 있다. 서브쿼리로 해당 PK의 order_id 값을 먼저 구한 뒤, 그 값보다 작은 행들을 가져오는 것이다.

오프셋 vs 커서 비교

기준오프셋커서
구현 난이도쉬움보통
특정 페이지 점프가능불가능
데이터 변경 시 일관성중복/누락 발생일관성 유지
대용량 데이터 성능OFFSET 커질수록 저하항상 일정
적합한 UI"1, 2, 3..." 페이지 번호무한 스크롤, "더 보기"
전체 개수 필요보통 필요 (total count)불필요

커서 방식의 단점은 "5페이지로 바로 가기" 같은 랜덤 접근이 안 된다는 것이다. 항상 이전 페이지의 마지막 항목을 알아야 다음 페이지를 요청할 수 있다. 그래서 무한 스크롤이나 "더 보기" 버튼 같은 순차적 탐색 UI에 적합하다.

useInfiniteQuery

TanStack Query(구 React Query)의 useInfiniteQuery는 커서 기반 페이지네이션을 위해 설계된 훅이다. 일반 useQuery와 다르게, 여러 "페이지"의 데이터를 누적해서 관리한다.

기본 구조

typescript
SELECT * FROM posts WHERE id < 42 ORDER BY id DESC LIMIT 10;

핵심 옵션을 하나씩 보자.

queryFn과 pageParam

queryFn은 데이터를 가져오는 함수인데, pageParam이라는 인자를 추가로 받는다. 이 pageParam이 바로 커서 값이다.

typescript
[초기 상태]
1페이지: A(50) B(49) C(48) ... J(41)  → lastId=41

[새 글 추가 후]
2페이지 요청: WHERE id < 41 ORDER BY id DESC LIMIT 10
→ K(40) L(39) M(38) ...
→ 새 글이 추가되었든 아니든, 41보다 작은 id를 기준으로 정확히 가져옴

첫 페이지에서는 initialPageParam(여기선 0)이 전달되고, 이후 페이지에서는 getNextPageParam이 반환한 값이 전달된다.

getNextPageParam

이 함수가 커서 기반 페이지네이션의 핵심 로직이다. 현재 페이지 응답을 받아서 다음 페이지의 커서를 결정한다.

typescript
-- 복합 커서: (created_at, id)
SELECT * FROM posts 
WHERE (created_at, id) < ('2024-01-15 10:30:00', 42)
ORDER BY created_at DESC, id DESC 
LIMIT 10;

undefined를 반환하면 "더 이상 페이지가 없다"는 신호다. 이 값은 hasNextPage 상태에 반영된다.

initialPageParam

v5부터 필수가 된 옵션이다. 첫 번째 페이지 요청에 사용할 초기 커서 값을 지정한다.

typescript
async function findPaginated(lastId: number, limit: number) {
  const queryBuilder = repository
    .createQueryBuilder('post')
    .orderBy('post.id', 'DESC')
    .take(limit + 1); // 1개 더 가져옴

  if (lastId) {
    // 서브쿼리로 lastId의 정렬 기준값을 구한 뒤 비교
    const subQuery = queryBuilder
      .subQuery()
      .select('p.order_id')
      .from('post', 'p')
      .where('p.id = :lastId', { lastId })
      .getQuery();

    queryBuilder.where(`post.order_id < (${subQuery})`);
  }

  const results = await queryBuilder.getMany();
  const hasMore = results.length > limit;

  if (hasMore) {
    results.pop(); // 초과분 제거
  }

  return {
    result: results,
    hasMore,
  };
}

서버에서 lastId가 0이면 조건 없이 최신 데이터부터 가져오도록 처리하는 게 일반적이다.

반환값 활용

useInfiniteQuery가 반환하는 값들은 useQuery와 비슷하지만, 페이지 관련 상태가 추가된다.

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

interface Post {
  id: number;
  title: string;
}

interface PageResponse<T> {
  result: T[];
  hasMore: boolean;
}

function usePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) =>
      fetchPosts({ limit: 10, lastId: pageParam }),
    getNextPageParam: (lastPage) => {
      if (!lastPage.hasMore) return undefined;
      const lastItem = lastPage.result[lastPage.result.length - 1];
      return lastItem.id;
    },
    initialPageParam: 0,
  });
}

data.pages는 각 페이지 응답의 배열이다. 모든 아이템을 하나의 배열로 합치려면 flatMap을 사용한다.

typescript
queryFn: ({ pageParam = 0 }) =>
  fetchPosts({ limit: 10, lastId: pageParam }),

이 패턴은 거의 항상 사용되기 때문에 커스텀 훅 안에서 미리 처리해두는 게 편하다.

커스텀 훅으로 추상화

실제 프로젝트에서는 여러 목록에서 무한 스크롤을 사용하기 때문에, 공통 로직을 제네릭 훅으로 추출하면 반복을 줄일 수 있다.

typescript
getNextPageParam: (lastPage) => {
  // 더 이상 데이터가 없으면 undefined 반환 → 다음 페이지 없음
  if (!lastPage.hasMore) return undefined;

  // 마지막 아이템의 id를 다음 커서로 사용
  const lastItem = lastPage.result[lastPage.result.length - 1];
  return lastItem.id;
},

이 훅의 설계 포인트를 보자.

  1. 제네릭 T extends Identifiableid 속성만 있으면 어떤 엔티티든 사용 가능
  2. queryKeytags 포함 — 필터 조건이 바뀌면 자동으로 캐시가 분리되고 리페치
  3. fetchFn 주입 — API 엔드포인트를 외부에서 주입받아서 재사용성 확보
  4. flatMap 결과 내부 처리 — 사용하는 쪽에서 매번 합칠 필요 없음

사용하는 쪽에서는 이렇게 간단해진다.

typescript
initialPageParam: 0,  // 첫 요청: lastId=0 → 가장 최신 데이터부터

IntersectionObserver와 결합

무한 스크롤의 완성형은 useInfiniteQueryIntersectionObserver의 조합이다. 사용자가 리스트 끝에 도달하면 자동으로 다음 페이지를 로드한다.

tsx
const {
  data,              // 모든 페이지 데이터
  fetchNextPage,     // 다음 페이지 로드 함수
  hasNextPage,       // 다음 페이지 존재 여부
  isFetchingNextPage,// 다음 페이지 로딩 중인지
  isLoading,         // 첫 페이지 로딩 중인지
  isError,
  error,
} = useInfiniteQuery({ ... });

rootMargin: '200px'으로 설정하면 리스트 끝에 도달하기 200px 전에 미리 다음 페이지를 로드한다. 사용자가 스크롤하는 동안 데이터가 이미 준비되어 있어서 끊김 없는 경험을 제공한다.

hasNextPage && !isFetchingNextPage 조건은 중복 요청을 방지한다. 이미 로딩 중이거나 마지막 페이지에 도달했으면 추가 요청을 하지 않는다.

getPreviousPageParam: 양방향 페이지네이션

useInfiniteQuerygetNextPageParam 외에 getPreviousPageParam도 지원한다. 채팅 앱에서 위로 스크롤하면 이전 메시지를 로드하는 것처럼, 양방향으로 데이터를 로드해야 할 때 유용하다.

typescript
const allItems = data?.pages.flatMap((page) => page.result) ?? [];

fetchPreviousPage()를 호출하면 getPreviousPageParam이 반환한 커서로 이전 데이터를 가져온다. data.pages 배열의 앞쪽에 추가된다.

캐시 무효화와 리페치

커서 기반 페이지네이션에서 캐시 무효화는 주의가 필요하다. useInfiniteQuery는 모든 페이지를 하나의 캐시 키로 관리하기 때문에, invalidateQueries를 호출하면 로드된 모든 페이지가 순차적으로 리페치된다.

typescript
interface Identifiable {
  id: number;
}

interface InfiniteScrollResponse<T> {
  result: T[];
  hasMore: boolean;
}

interface UseInfiniteScrollOptions<T extends Identifiable> {
  queryKey: string;
  fetchFn: (params: {
    limit: number;
    lastId: number;
    tags: string[];
  }) => Promise<InfiniteScrollResponse<T>>;
  tags: string[];
}

function useInfiniteScrollQuery<T extends Identifiable>({
  queryKey,
  fetchFn,
  tags,
}: UseInfiniteScrollOptions<T>) {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error,
  } = useInfiniteQuery({
    queryKey: [queryKey, tags],
    queryFn: ({ pageParam = 0 }) =>
      fetchFn({ limit: 12, lastId: pageParam, tags: tags || [] }),
    getNextPageParam: (lastPage) => {
      if (!lastPage.hasMore) return undefined;
      const lastItem = lastPage.result[lastPage.result.length - 1];
      return lastItem.id;
    },
    initialPageParam: 0,
  });

  const items = data?.pages.flatMap((page) => page.result) ?? [];

  return {
    items,
    isLoading,
    isFetchingNextPage,
    hasNextPage,
    fetchNextPage,
    isError,
    error,
  };
}

이건 의도된 동작이다. 커서가 이전 페이지의 마지막 아이템에 의존하기 때문에 순차적으로 리페치해야 한다. 하지만 페이지가 많이 쌓이면 리페치 비용이 커질 수 있다.

v5에서는 maxPages 옵션으로 메모리에 유지할 최대 페이지 수를 제한할 수 있다.

typescript
function PostList() {
  const [selectedTags, setSelectedTags] = useState<string[]>([]);

  const { items, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteScrollQuery({
      queryKey: 'posts',
      fetchFn: fetchPostList,
      tags: selectedTags,
    });

  // items를 렌더링하고, 스크롤 끝에 도달하면 fetchNextPage() 호출
}

오래된 페이지는 자동으로 제거되고, 사용자가 다시 스크롤하면 리페치된다.

낙관적 업데이트

피드에서 좋아요를 누르거나 글을 삭제할 때, 서버 응답을 기다리지 않고 UI를 먼저 업데이트하고 싶다면 setQueryData를 사용한다.

typescript
function InfiniteList() {
  const { items, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteScrollQuery({
      queryKey: 'posts',
      fetchFn: fetchPostList,
      tags: [],
    });

  const observerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!observerRef.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { rootMargin: '200px' }
    );

    observer.observe(observerRef.current);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div>
      {items.map((item) => (
        <PostCard key={item.id} post={item} />
      ))}

      {/* 이 요소가 뷰포트에 들어오면 다음 페이지 로드 */}
      <div ref={observerRef} />

      {isFetchingNextPage && <Spinner />}
    </div>
  );
}

InfiniteData 타입의 구조가 { pages: PageResponse[], pageParams: number[] }이기 때문에, pages 배열을 순회하면서 각 페이지 안의 아이템을 수정해야 한다. 일반 useQuery보다 한 단계 더 깊은 중첩이 있어서 실수하기 쉬우니 주의하자.

정리

커서 기반 페이지네이션은 오프셋 방식의 데이터 일관성 문제와 대용량 성능 저하를 근본적으로 해결한다. useInfiniteQuery는 이 패턴을 React에서 선언적으로 구현할 수 있게 해주고, getNextPageParam으로 커서 로직을 캡슐화하며, flatMap으로 페이지 경계를 추상화한다.

다만 모든 상황에 커서 방식이 맞는 건 아니다. 페이지 번호로 직접 이동해야 하는 관리자 대시보드나, 전체 개수를 보여줘야 하는 검색 결과 페이지에는 오프셋이 더 적합할 수 있다. 서비스의 데이터 특성과 UI 패턴에 맞춰 선택하면 된다.

관련 문서