React Query + IntersectionObserver로 무한 스크롤 구현하기

진밥·2026년 4월 19일
post-thumbnail

썸네일 이미지는 AI를 활용했지만, 글은 모두 직접 작성했습니다.


🪄 TL;DR

  • 한 번에 모든 데이터를 불러오는 대신, useSuspenseInfiniteQuery로 데이터를 페이지 단위로 나누어 가져왔다.
  • 리스트 맨 아래에 보이지 않는 sentinel div를 두고, IntersectionObserver로 그 div가 화면에 들어오는 순간을 감지해 다음 페이지를 불러오게 했다.
  • hasNextPagefalse가 되면 sentinel div가 DOM에서 사라지고, 데이터 조회가 자연스럽게 종료된다.
  • MSW 핸들러에서 _limit, _page로 배열을 직접 잘라 응답을 만들고, hasNextPage로 종료 시점을 제어했다.

무한스크롤을 사용하는 이유

100개, 1000개가 넘어가는 데이터 목록을 처음부터 전부 불러오면 메모리 낭비가 된다. 사용자가 첫 화면에서 보게 되는 데이터는 5~10개 남짓인데 수많은 데이터를 먼저 불러오면 서버에서 데이터를 받아오는 비용, 브라우저에서 받아서 메모리에 올려두는 비용이 모두 낭비가 된다.

이를 해결하는 방법 중 하나가 페이지네이션이고, 또 다른 방법이 무한스크롤이다. 무한스크롤은 처음에 지정한 만큼의 데이터만 보여준 후, 스크롤을 내려 페이지 하단에 걸리면 다음 데이터를 불러와서 쭉쭉 아래로 데이터를 로드하는 방식이다. 뉴스나 피드처럼 아래로 내리며 읽는 콘텐츠에는 무한스크롤이 UX적으로 편하고 자연스럽다.

이번 프로젝트에서 이슈 목록, 주식 목록, 상세 페이지의 뉴스 목록 세 곳에 무한스크롤을 붙여보았다.

무한스크롤 전체 흐름

처음 무한스크롤을 구현하려니 막막했지만, 그럴 때일수록 하나씩 뜯어서 차근차근 진행해보면 된다. 전체적인 구현 흐름은 크게 세 가지 축으로 나뉜다.

1. useSuspenseInfiniteQuery

  • 페이지 단위로 데이터를 fetch 하는 역할
  • data.pages.flatMap()으로 전체 목록을 하나로 합쳐준다.

2. IntersectionObserver

  • 리스트 맨 아래 sentinel div를 감시하는 역할
  • sentinel이 화면에 들어오면 fetchNextPage()을 호출한다.

3. hasNextPage === false

  • hasNextPage가 false가 되면 sentinel이 DOM에서 사라진다.
  • 옵저버가 감시할 대상이 없어지면서 자연스럽게 종료된다.

페이지 단위로 데이터를 불러오고, 끝에 닿으면 다음 걸 호출하고, 더 없으면 멈추는 구조이다.

useSuspenseInfiniteQuery

React Query에는 일반적인 useQuery 말고도 useInfiniteQuery라는 훅이 있다. 일반 useQuery가 한 번 요청해서 한 번 응답받는 구조라면, useInfiniteQuery는 "다음 페이지" 개념을 내장하고 있어서 여러 번의 요청 결과를 data.pages 배열에 순서대로 쌓아준다. 나는 이전에 useSuspenseQuery를 사용하고 있었기 때문에 useSuspenseInfiniteQuery를 사용해주었다.

실제 useNewsInfinityQuery 훅을 보면서 각 옵션을 살펴보도록 하겠다.

// useNewsInfinityQuery.ts
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';

import { getNewsInfinityByIds } from '../api/newsAPI';
import { NEWS_QUERY_KEYS } from './queryKeys';

export const useNewsInfinityQuery = (newsList: string[]) => {
	return useSuspenseInfiniteQuery({
		queryKey: [NEWS_QUERY_KEYS.newsListInfinity],
		queryFn: ({ pageParam }) => getNewsInfinityByIds({ newsList, pageParam }),
		initialPageParam: { size: 4, page: 1 },
		getNextPageParam: (lastPage, allPages) => {
			if (!lastPage.hasNextPage) return undefined;
			return { size: 4, page: allPages.length + 1 };
		},
	});
};

queryFn 에는 실제로 데이터를 요청할 함수를 넣는다. 이때 pageParam을 인자로 받는데, 이 값이 바로 아래에서 설명할 initialPageParam이나 getNextPageParam에서 넘겨준 값이다. 즉, 매 요청마다 "지금 몇 페이지를 가져와야 하는지"를 pageParam으로 받아서 API에 전달하는 구조다.

initialPageParam 은 첫 번째 요청에 쓸 기본값이다. { size: 4, page: 1 }이니까, 처음엔 4개씩, 1페이지부터 시작한다.

getNextPageParam 은 다음 페이지 값을 계산해서 반환하는 함수다. 두 가지 인자를 받는다.

  • lastPage: 가장 최근에 받은 응답. 여기에 hasNextPage가 들어있다
  • allPages: 지금까지 쌓인 전체 페이지 응답 배열

lastPage.hasNextPage가 없으면 undefined를 반환해서 더 이상 요청하지 않도록 한다. 있으면 allPages.length + 1로 다음 페이지 번호를 계산한다. 1페이지를 받은 시점에서 allPages.length는 1이니까 다음 요청은 2페이지가 되는 식이다.

마지막으로 훅이 newsList를 파라미터로 받는 이유는, 이슈나 종목마다 연결된 뉴스 id 목록이 다르기 때문이다. 해당 이슈에 연결된 뉴스만 필터해서 가져와야 하니까, 어떤 id들을 가져와야 하는지를 외부에서 주입받는 구조로 만들었다.

IntersectionObserver

IntersectionObserver는 특정 DOM 요소가 뷰포트(화면에 보이는 영역)에 들어왔는지 감시하는 브라우저 내장 API다.

스크롤 이벤트(scroll)로 같은 걸 구현할 수도 있지만, 스크롤 이벤트는 스크롤할 때마다 매번 실행되기 때문에 성능 부담이 크다. 반면 IntersectionObserver는 감시 대상 요소가 뷰포트에 걸쳤을 때만 콜백이 실행되서 훨씬 효율적이다.

실제 코드를 보자.

const { data, fetchNextPage, hasNextPage } = useNewsInfinityQuery(newsList);
const observerRef = useRef<HTMLDivElement>(null);

const list = data.pages.flatMap((page) => page.list);

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

  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && hasNextPage) {
        fetchNextPage();
      }
    });
  });

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

return (
  <ul>
    {list.map((news) => <NewsCard key={news.id} {...news} />)}
    {hasNextPage && <div ref={observerRef} />}
  </ul>
);
  • data.pages.flatMap((page) => page.list)
    data.pages는 페이지별 응답이 쌓인 2차원 구조다. [[뉴스1,뉴스2], [뉴스3,뉴스4], ...] 이런 모양인데, 이걸 flatMap으로 [뉴스1, 뉴스2, 뉴스3, 뉴스4, ...]처럼 1차원으로 펼쳐서 목록 렌더링에 쓴다.

  • if (!observerRef.current) return
    컴포넌트가 처음 렌더링될 때 ref가 아직 DOM에 연결되지 않았을 수 있다. 감시할 대상이 없는데 옵저버를 만들면 오류가 나기 때문에 얼리 리턴으로 막아준다.

  • new IntersectionObserver((entries) => { entries.forEach(...) })
    콜백이 배열(entries)을 받는 이유는, 하나의 옵저버로 여러 요소를 동시에 감시할 수 있기 때문이다. 감시 대상 여러 개의 상태 변화가 한꺼번에 배열로 들어온다. 이 코드에서는 sentinel 하나만 감시하지만, API 설계가 원래 그런 구조다.

  • entry.isIntersecting && hasNextPage
    isIntersecting은 해당 요소가 뷰포트에 들어왔는지를 나타내는 boolean이다. 두 조건을 같이 쓰는 이유는, 화면에 들어왔더라도 이미 마지막 페이지라면 다음 요청을 보내면 안 되기 때문이다.

  • observer.observe(observerRef.current)
    실제로 그 요소를 감시 시작하겠다는 선언이다.

  • return () => observer.disconnect()
    컴포넌트가 언마운트(화면에서 사라질)될 때 옵저버를 정리하는 클린업 함수다. 이걸 빠뜨리면 컴포넌트가 화면에서 사라진 뒤에도 옵저버가 살아남아 불필요한 동작이나 메모리 누수로 이어질 수 있다.

  • {hasNextPage && <div ref={observerRef} />}
    sentinel div는 hasNextPage가 true일 때만 렌더링된다. 더 불러올 데이터가 없으면 sentinel이 DOM에서 사라지고, 옵저버도 감시할 대상이 없어지면서 자연스럽게 비활성화된다.

MSW 핸들러

현재 프로젝트는 실제 서버 없이 MSW로 목 데이터를 쓰고 있다. 무한 스크롤은 페이지 단위 응답이 필요하기 때문에, 핸들러에서 배열을 직접 잘라서 응답을 만들어줘야 했다.

http.get(`${BASE_PATH}/news/infinity`, ({ request }) => {
  const url = new URL(request.url);
  const ids = (url.searchParams.get('ids') ?? '')
    .split(',')
    .map((v) => v.trim())
    .filter(Boolean);

  const size = Number(url.searchParams.get('_limit'));
  const page = Number(url.searchParams.get('_page'));
  const start = size * (page - 1);
  const end = start + size;

  const newsList = news.filter((item) => ids.includes(String(item.id)));

  return HttpResponse.json({
    list: newsList.slice(start, end),
    hasNextPage: end < newsList.length,
  });
});
  • ids
    쿼리 파라미터(URL에 붙어오는 파라미터)로 넘어온 뉴스 id 목록이다. 쉼표로 연결된 문자열을 split(',')으로 나누고, trim()으로 공백 제거, filter(Boolean)으로 빈 문자열을 걸러낸다.

  • start / end
    전체 배열에서 이번 페이지에 해당하는 구간을 계산한다. size: 4, page: 1이면 start: 0, end: 4라서 0~3번 인덱스를 가져오고, page: 2가 되면 start: 4, end: 8이 되어 그다음 4개를 가져온다.

  • newsList.slice(start, end)
    계산한 구간만큼 배열을 잘라서 list로 응답한다.

  • hasNextPage: end < newsList.length
    end가 전체 길이보다 작으면 아직 데이터가 남아있다는 뜻이니 true, 같거나 크면 마지막 페이지라는 뜻이니 false를 반환한다. React Query가 이 값을 보고 getNextPageParam에서 undefined를 반환할지 다음 페이지 번호를 반환할지 결정한다.

트러블슈팅

1. /issues vs /issues/infinity 경로 혼재

// API 호출
// before
issuesAPI.get<IssueInfinityProps>('/')
// after
-> issuesAPI.get<IssueInfinityProps>('/infinity')

// MSW 핸들러
// before
http.get('/issues', ...)
// after
-> http.get('/issues/infinity', ...)

초기에는 무한 스크롤용 API도 /issues 경로를 그대로 쓰고 있었다. 일반 목록 조회와 무한 스크롤 조회가 같은 엔드포인트를 공유하는 상황이었는데, 두 응답의 스키마가 달라서 충돌 가능성이 있었다. 역할이 다른 API는 경로도 분리해주어야 한다.

2. /issues/:id/issues/infinity를 가로채는 문제

// 구체적인 경로를 먼저 작성
http.get('/issues/infinity', ...),
http.get('/issues/:id', ...),

MSW는 핸들러를 등록된 순서대로 위에서부터 매칭한다. /issues/:id가 먼저 등록돼 있으면 /issues/infinity 요청이 들어왔을 때 infinity를 id 값으로 해석해버린다. 왜 안 되는지 원인을 찾다가 순서를 바꾸는 걸로 간단히 해결할 수 있었다.

3. useEffect 의존성 배열 누락

// before
}, [observerRef]);

// after
-> }, [fetchNextPage, hasNextPage]);

observerRef는 ref 객체라서 값이 바뀌어도 리렌더가 일어나지 않는다. 의존성 배열에 넣어봤자 effect가 다시 실행되지 않는다는 뜻이다. 반면 hasNextPage는 페이지를 불러올 때마다 바뀌는 값이고, fetchNextPage도 React Query 내부적으로 참조가 교체될 수 있다. 이 두 값이 의존성 배열에 없으면 effect 내부에서 항상 클로저에 캡처된 값(effect가 처음 실행될 때 기억해둔 값)을 바라보게 된다. 예를 들어 2페이지까지 불러와서 hasNextPagefalse가 됐는데도 effect 안에서는 여전히 true로 보여서 계속 fetchNextPage()를 호출하는 상황이 생길 수 있다.


마무리

처음에는 막막해 보였는데, 옵저버로 감지하고 다음 페이지를 불러오는 흐름을 익히고 나니 생각보다 어렵지 않았다. 오히려 구현 자체보다 엔드포인트 분리, 핸들러 순서, 의존성 배열 같은 주변 안정성 포인트에서 시간을 더 썼다. 이런 것들은 무한스크롤이 아니어도 계속 마주치는 문제라서, 제대로 배울 수 있는 기회라서 좋았다.

이번 프로젝트의 목표 중 하나였던 무한스크롤을 구현해서 뿌듯하다!

다음 글에서는 모달 구현까지 작성한 후 길고 긴 시리즈의 마무리를 해보려고 한다.

profile
꼬들밥 말고 진밥

0개의 댓글