발행일

React Infinite Scroll 구현하기 — 세 가지 방법과, 실무에서 안 통했던 지점

React Infinite Scroll 구현하기

무한 스크롤은 많은 웹 애플리케이션에서 데이터 로딩 경험을 개선하기 위해 자주 쓰이는 패턴입니다. React에서 구현하는 방법은 여러 가지인데, 여기서는 @tanstack/react-queryuseInfiniteQueryuseQuery, 그리고 swruseSWR을 비교합니다.

아래 1~3은 처음 정리할 때 쓴 예제입니다. 그 뒤에 [TripTune](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발)에 실제로 넣으면서 이 예제들이 왜 그대로는 안 되는지 알게 됐고, 4절부터가 그 기록입니다. 예제를 고쳐 쓰지 않고 남겨둔 건, 뭘 몰랐는지가 4절의 재료라서예요.


1. useInfiniteQuery

import React, { useEffect, useRef } from 'react'
import { useInfiniteQuery, QueryFunctionContext } from '@tanstack/react-query'

interface Photo {
  id: number
  title: string
  url: string
  thumbnailUrl: string
}

const fetchPhotos = async ({ pageParam = 1 }: QueryFunctionContext): Promise<Photo[]> => {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/photos?_page=${pageParam}&_limit=10`
  )
  return res.json()
}

const InfiniteScroll: React.FC = () => {
  const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery<
    Photo[],
    Error
  >({
    queryKey: ['photos'],
    queryFn: fetchPhotos,
    getNextPageParam: (lastPage, allPages) => (lastPage.length ? allPages.length + 1 : undefined),
  })

  const observerRef = useRef<HTMLDivElement | null>(null)

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage()
      }
    })

    if (observerRef.current) {
      observer.observe(observerRef.current)
    }

    return () => {
      if (observerRef.current) {
        observer.unobserve(observerRef.current)
      }
    }
  }, [fetchNextPage, hasNextPage])

  if (error) return <div>Failed to load</div>

  return (
    <div>
      <h1>Infinite Scroll</h1>
      <div>
        {data?.pages.map((page, i) => (
          <React.Fragment key={i}>
            {page.map((photo: Photo) => (
              <div key={photo.id}>
                <img src={photo.thumbnailUrl} alt={photo.title} />
                <p>{photo.title}</p>
              </div>
            ))}
          </React.Fragment>
        ))}
      </div>
      {isFetchingNextPage && <p>Loading more...</p>}
      <div ref={observerRef} style={{ height: '1px' }}></div>
    </div>
  )
}

export default InfiniteScroll

장점

  • 페이지네이션 처리 내장: 다음 페이지 데이터를 손쉽게 받아옵니다.
  • 간단한 설정: getNextPageParam으로 다음 페이지 매개변수가 자동 설정됩니다.
  • 로딩 상태 관리: isFetchingNextPage 같은 상태로 로딩 표시를 쉽게 다룹니다.

단점

  • 설정 복잡성: react-query의 개념을 충분히 이해해야 제대로 설정할 수 있습니다.

2. useQuery + 페이지 상태

import React, { useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'

const fetchPhotos = async (page: number): Promise<Photo[]> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`)
  return res.json()
}

const InfiniteScroll: React.FC = () => {
  const [page, setPage] = useState<number>(1)
  const [photos, setPhotos] = useState<Photo[]>([])
  const observerRef = useRef<HTMLDivElement | null>(null)

  const { data, error, isLoading } = useQuery(['photos', page], () => fetchPhotos(page), {
    keepPreviousData: true,
  })

  useEffect(() => {
    if (data) {
      setPhotos((prevPhotos) => [...prevPhotos, ...data])
    }
  }, [data])

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        setPage((prevPage) => prevPage + 1)
      }
    })

    if (observerRef.current) {
      observer.observe(observerRef.current)
    }

    return () => {
      if (observerRef.current) {
        observer.unobserve(observerRef.current)
      }
    }
  }, [])

  if (error) return <div>Failed to load</div>

  return (
    <div>
      {photos.map((photo) => (
        <div key={photo.id}>
          <img src={photo.thumbnailUrl} alt={photo.title} />
          <p>{photo.title}</p>
        </div>
      ))}
      {isLoading && <p>Loading...</p>}
      <div ref={observerRef} style={{ height: '1px' }}></div>
    </div>
  )
}

장점

  • 간단한 로직: 페이지 번호를 직접 들고 있어 흐름이 눈에 보입니다.
  • 상태 관리: useState라 다른 상태와 엮기 쉽습니다.

단점

  • 수동 설정 필요: 무한 스크롤 로직을 손수 짜야 합니다.
  • 코드 작성 증가: useInfiniteQuery보다 코드량이 늘어납니다.

3. useSWR

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then((res) => res.json())

const InfiniteScroll: React.FC = () => {
  const [page, setPage] = useState<number>(1)
  const [photos, setPhotos] = useState<Photo[]>([])
  const observerRef = useRef<HTMLDivElement | null>(null)

  const { data, error } = useSWR<Photo[]>(
    `https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`,
    fetcher
  )

  useEffect(() => {
    if (data) {
      setPhotos((prevPhotos) => [...prevPhotos, ...data])
    }
  }, [data])

  // ... IntersectionObserver 로 setPage(p => p + 1)
}

장점

  • 간단한 설정: 데이터 패칭과 캐싱을 자동으로 처리합니다.

단점

  • 무한 스크롤 추가 로직 필요: 별도 로직을 직접 붙여야 합니다.

4. 실무에서 안 통했던 지점

여기서부터가 실제로 넣어보고 안 기록입니다. 세 예제 모두 브라우저에서 "돌아가긴" 합니다. 그래서 처음엔 문제를 못 느꼈어요.

① 3절의 단점은 사실이 아니었다

먼저 위 글의 오류를 정정합니다. 원래 이렇게 적어놨었어요.

"react-query처럼 다음 페이지 데이터를 명확하게 다루는 내장 옵션이 부족합니다."

틀렸습니다. SWR에는 useSWRInfinite가 있고, 그게 정확히 useInfiniteQuery에 대응하는 API입니다.

const getKey = (pageIndex: number, previousPageData: Photo[] | null) => {
  if (previousPageData && !previousPageData.length) return null   // 끝
  return `.../photos?_page=${pageIndex + 1}&_limit=10`
}
const { data, size, setSize } = useSWRInfinite<Photo[]>(getKey, fetcher)

getKeynull을 반환하면 끝이라는 신호입니다. useInfiniteQuerygetNextPageParamundefined를 반환하는 것과 같은 역할이에요.

"내가 안 찾아본 것"과 "라이브러리에 없는 것"을 구분하지 않고 단점 칸에 적었습니다. 비교 글에서 제일 하지 말아야 할 일이었어요.

② 2·3번 예제에는 종료 조건이 없다

const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    setPage((prevPage) => prevPage + 1)   // 끝인지 안 봄
  }
})

마지막 페이지에 도달해도 계속 페이지를 올립니다. 서버는 빈 배열을 계속 돌려주고, 관측 대상은 화면에 남아 있으니 요청이 멈추지 않아요.

1번 예제는 hasNextPage가 있어서 이 문제가 없습니다. useInfiniteQuery의 진짜 값어치가 "코드가 짧다"가 아니라 "끝났다는 상태를 라이브러리가 관리한다" 는 걸 여기서 알았습니다.

③ 2·3번 예제는 항목이 중복된다

useEffect(() => {
  if (data) {
    setPhotos((prev) => [...prev, ...data])
  }
}, [data])

data가 바뀔 때마다 이어붙이는데, data가 바뀌는 이유가 "새 페이지"만이 아닙니다.

  • StrictMode에서 이펙트가 두 번 실행되면 같은 페이지가 두 번 붙습니다.
  • keepPreviousData가 켜져 있으면 전환 과정에서 이전 값이 한 번 더 흘러들어옵니다.
  • 캐시된 페이지로 되돌아가도 data가 바뀌며 또 붙습니다.

증상은 같은 사진이 두 번 보이고 콘솔에 key 중복 경고가 뜨는 것이었습니다. "가끔 중복된다"라서 원인 찾는 데 시간이 걸렸어요.

"서버 데이터를 useState 배열에 누적"은 무한 스크롤에서 가장 흔한 함정입니다. 누적된 배열은 서버 상태의 사본인데, 사본은 원본이 바뀔 때마다 어긋납니다. useInfiniteQuerydata.pages는 라이브러리가 관리하는 원본 자체예요.

④ v5에서는 2번 예제가 아예 안 돈다

// v4 문법 — v5에서 제거됨
useQuery(['photos', page], () => fetchPhotos(page), { keepPreviousData: true })

// v5
useQuery({
  queryKey: ['photos', page],
  queryFn: () => fetchPhotos(page),
  placeholderData: keepPreviousData,   // keepPreviousData 옵션도 대체됨
})

1번 예제도 v5에서는 initialPageParam이 필수입니다. ({ pageParam = 1 })처럼 기본값으로 때우는 건 v4 방식이에요.

버전이 올라가면서 문법이 바뀐 건 예제의 잘못이 아니지만, "어느 버전 기준인지 안 적은 것"은 잘못입니다. 라이브러리 비교 글에 버전이 없으면 나중에 그대로 복사한 사람이 헤맵니다.

getNextPageParam을 길이로 판정하면 요청이 한 번 더 나간다

// 예제
getNextPageParam: (lastPage, allPages) => (lastPage.length ? allPages.length + 1 : undefined)

마지막 페이지가 정확히 _limit만큼 찼을 때 lastPage.length가 참이라 다음 페이지를 요청합니다. 그 요청이 빈 배열을 받고 나서야 끝난 걸 알아요. 끝을 확인하기 위한 빈 요청이 항상 한 번 나갑니다.

실제 프로젝트에서는 서버가 페이지 정보를 주기 때문에 이렇게 씁니다.

getNextPageParam: (lastPage) => {
  if (!lastPage?.data) return undefined;
  const { currentPage, totalPages } = lastPage.data;
  return currentPage < totalPages ? currentPage + 1 : undefined;
}

서버가 총 페이지 수를 알려준다면 그걸 쓰는 게 맞습니다. 길이로 추론하는 건 그 정보가 없을 때의 차선책이에요. API 설계 단계에서 totalPages를 요청해둘 값어치가 있습니다.

isFetchingNextPage 가드가 없으면 연타된다

// 예제
if (entries[0].isIntersecting && hasNextPage) {
  fetchNextPage()
}

관측 대상이 화면에 남아 있는 동안 fetchNextPage가 여러 번 불립니다. 특히 페이지 크기가 작아서 새 데이터를 붙여도 관측 대상이 여전히 보이면, 요청이 우르르 나가요.

// 실제 코드
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
  fetchNextPage();
}

⑦ cleanup에서 ref.current를 다시 읽으면 안 된다

// 예제 — cleanup 시점에 ref.current 가 이미 null 일 수 있다
return () => {
  if (observerRef.current) {
    observer.unobserve(observerRef.current)
  }
}
// 실제 코드 — 이펙트 실행 시점의 값을 캡처해둔다
const currentObserverRef = observerRef.current;
if (currentObserverRef) observer.observe(currentObserverRef);

return () => {
  if (currentObserverRef) observer.unobserve(currentObserverRef);
  observer.disconnect();
};

observer.disconnect()를 같이 부르는 게 확실합니다. unobserve는 대상 하나만 떼지만 disconnect는 관측을 통째로 끝내요.


5. 그리고 실제로 두 번 물렸다

여기까지는 예제를 보며 알게 된 것이고, 배포 뒤에 실제로 물린 게 둘 있습니다.

① 의존성 배열에 빠진 값이 옛 함수를 붙잡고 있었다

일정 목록 화면에는 탭이 둘(전체/공유)이고 검색 모드가 따로 있습니다. 그래서 fetchNextPage가 상황에 따라 갈립니다.

const fetchNextPage = isSearching
  ? fetchNextSearchPage
  : selectedTab === 'all'
    ? fetchNextAllPage
    : fetchNextSharedPage;

그런데 옵저버를 다는 useEffect의 의존성 배열이 이랬어요.

}, [fetchNextPage, hasNextPage, isFetchingNextPage]);

isSearchingselectedTab이 빠져 있습니다. 탭을 바꾸거나 검색으로 들어가도 이펙트가 다시 안 돌고, 옵저버 콜백은 처음 잡은 클로저를 그대로 씁니다. 그래서 전체 탭에서 스크롤하면 공유 탭의 다음 페이지를 부르거나, 아무것도 안 불렀어요.

}, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching, selectedTab]);

두 개를 추가한 게 수정 전부입니다.

삼항 연산자로 파생된 값은 의존성 배열에서 빠뜨리기 쉽습니다. fetchNextPage라는 이름 하나만 보이니까요. ESLint의 exhaustive-deps도 이 경우엔 fetchNextPage만 요구하고 그 안의 조건은 못 봅니다.

② 모달 안에서만 무한 스크롤이 안 됐다

목록은 잘 되는데 "내 일정에 담기" 모달 안의 목록만 다음 페이지를 안 불러왔습니다. 모달은 IntersectionObserver가 아니라 onScroll로 바닥을 판정하고 있었어요.

onScroll={(e) => {
  const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
  if (scrollHeight - scrollTop === clientHeight && hasNextPage && !isFetchingNextPage) {
    fetchNextPage();
  }
}}

=== 로 바닥을 판정하고 있었습니다.

scrollTop은 정수가 아닙니다. 브라우저 확대/축소, HiDPI 디스플레이, 관성 스크롤에서 소수점 값이 나와요. 그러면 scrollHeight - scrollTopclientHeight와 정확히 같아지는 일이 거의 없습니다. 바닥에 닿아도 조건이 거짓이에요.

const tolerance = 1;
if (scrollHeight - scrollTop <= clientHeight + tolerance && hasNextPage && !isFetchingNextPage) {
  fetchNextPage();
}

<= 로 바꾸고 1px 톨러런스를 뒀습니다.

화면 좌표를 등호로 비교하지 않습니다. 부동소수점이고, 브라우저·배율·OS마다 다릅니다. "닿았다"는 판정은 항상 범위로 합니다.

이건 왜 내 컴퓨터에서만 재현이 안 됐는지도 설명해줍니다. 배율 100%의 일반 디스플레이에서는 정수로 떨어질 때가 많거든요.


6. 그래서 뭘 쓸 것인가

실제 프로젝트에서는 세 방식을 화면에 따라 다르게 쓰고 있습니다. 하나로 통일하지 못한 게 아니라, 화면마다 필요한 게 달랐어요.

화면방식이유
일정 목록 (전체/공유/검색)useInfiniteQuery페이지 누적·종료 판정을 라이브러리에 맡김. 서버가 totalPages를 줌
여행지 탐색 목록useQuery + 페이지 상태페이지 번호가 URL·필터와 엮여야 함. 목록↔상세 왕복에서 페이지를 복원해야 하는데 useInfiniteQuery의 누적 구조와 안 맞았음
일정 편집의 여행 루트useInView + ZustandBFF에서 병합된 데이터라 서버 페이지와 클라이언트 순서가 섞임. react-query로 하다 병합 이슈로 되돌림

세 번째 코드에는 당시 판단이 주석으로 남아 있습니다.

// 무한 스크롤 준비
// 이전에는 react-query로 했었음
// 병합 이슈로 인하여 zustand만 살리기로!
const { ref, inView } = useInView();

되돌린 기록을 코드에 남긴 게 잘한 일이었어요. 안 남겼으면 "왜 여기만 react-query를 안 쓰지?" 하고 누군가(주로 나중의 나) 다시 react-query로 바꿨을 겁니다.

고르는 기준

  • 서버가 페이지의 끝을 알려주고, 받은 그대로 쌓으면 되는가useInfiniteQuery
  • 페이지 번호를 URL·필터·다른 상태와 엮어야 하는가useQuery + 페이지 상태
  • 서버 페이지와 클라이언트 가공 결과가 섞이는가 → 페이징을 클라이언트 스토어가 맡고, 관측만 훅으로

관측 자체는 직접 IntersectionObserver를 쓰든 react-intersection-observeruseInView를 쓰든 상관없었습니다. useInView가 나은 점은 4절 ⑦의 cleanup 실수를 애초에 못 하게 한다는 것 정도예요.


7. 남은 것

  • 예제 코드에 버전을 안 적었습니다. v4/v5에서 문법이 갈리는데 이 글에는 기준이 없습니다.
  • 모달 쪽만 onScroll을 씁니다. IntersectionObserver로 통일하면 5절 ②번 부류의 문제가 구조적으로 사라지는데 안 했어요.
  • useSWRInfinite는 결국 안 써봤습니다. 3절의 비교는 여전히 추정이에요.
  • 중복 항목 문제(4절 ③)를 정량으로 재본 적이 없습니다.

정리

  • useInfiniteQuery의 값어치는 코드가 짧은 게 아니라 "끝났다"를 관리해준다는 것입니다.
  • 서버 데이터를 useState 배열에 누적하지 않습니다. 사본은 원본이 바뀔 때마다 어긋납니다.
  • 끝 판정은 길이 추론보다 서버의 totalPages가 낫습니다. 길이로 하면 확인용 빈 요청이 한 번 더 나갑니다.
  • !isFetchingNextPage 가드 없이는 연타됩니다.
  • cleanup에서 ref.current를 다시 읽지 말고 캡처해둡니다.
  • 삼항으로 파생된 값은 의존성 배열에서 빠뜨리기 쉽습니다. 조건까지 배열에 넣어야 합니다.
  • 화면 좌표를 ===로 비교하지 않습니다. 소수점이 나옵니다.
  • 그리고 — "라이브러리에 없다"고 쓰기 전에 찾아봅니다. useSWRInfinite는 처음부터 있었습니다.

관련 글: 무한 스크롤 비교 · 무한 스크롤에서는 왜 이슈가 생긴 걸까 · 인증이 확정되기 전에 쏜 요청