본문 바로가기

React

React에서 Infinite Scroll 구현하기

Infinite Scroll은 목록의 끝에 도달할 때 자동으로 다음 데이터를 불러오는 패턴입니다. 적절히 구현하면 페이지 전환 없이 매끄러운 탐색을 제공하지만, 성능·접근성·에러 처리까지 신경 써야 합니다. 실무에서 바로 쓸 수 있는 IntersectionObserver 기반 구현, React Query 연동, 최적화 포인트를 정리합니다.

1. 전략 선택: Scroll 이벤트 vs IntersectionObserver

- IntersectionObserver: 브라우저가 뷰포트 교차 여부를 계산해 주어 정확하고 효율적입니다. 대부분 케이스에서 권장합니다.
- Scroll 이벤트: 구형 브라우저나 특수 환경에서 fallback으로 사용합니다. 반드시 throttle/passive 처리로 성능을 챙깁니다.
- 가상화(react-window/virtualized): 데이터가 많을 때 렌더링 비용을 줄입니다. Infinite Scroll과 병행하면 가장 효율적입니다.

2. 기본 구현: IntersectionObserver + Sentinel

목록 끝에 "감시용(div)" 엘리먼트를 두고 뷰포트에 들어올 때 다음 페이지를 요청합니다. 중복 요청 방지, 요청 취소, 마지막 페이지 처리까지 포함합니다.

import React, { useEffect, useRef, useState, useCallback } from 'react';

const PAGE_SIZE = 20;

async function fetchItems({ cursor, signal }) {
  const url = new URL('/api/items', window.location.origin);
  if (cursor) url.searchParams.set('cursor', cursor);
  url.searchParams.set('limit', PAGE_SIZE);
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error('데이터 로드 실패');
  // 서버 응답 예시: { items: [...], nextCursor: '...', hasMore: true }
  return res.json();
}

export default function InfiniteList() {
  const [items, setItems] = useState([]);
  const [cursor, setCursor] = useState(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const sentinelRef = useRef(null);
  const abortRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return; // 중복/불필요 요청 방지
    setLoading(true);
    setError(null);
    // 이전 요청 취소
    if (abortRef.current) abortRef.current.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const data = await fetchItems({ cursor, signal: controller.signal });
      setItems((prev) => [...prev, ...data.items]);
      setCursor(data.nextCursor ?? null);
      setHasMore(Boolean(data.hasMore));
    } catch (e) {
      if (e.name !== 'AbortError') setError(e);
    } finally {
      setLoading(false);
    }
  }, [cursor, hasMore, loading]);

  // 초기 로드
  useEffect(() => {
    loadMore();
  }, []);

  // 감시자 등록
  useEffect(() => {
    const node = sentinelRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry.isIntersecting) {
          loadMore();
        }
      },
      {
        root: null, // window 스크롤 기준
        rootMargin: '200px', // 미리 당겨서 로드
        threshold: 0,
      }
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [loadMore]);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      {error && (
        <p role="alert">에러가 발생했습니다. <button onClick={loadMore}>다시 시도</button></p>
      )}

      {/* 뷰포트에 들어오면 다음 페이지 로드 */}
      <div ref={sentinelRef} style={{ height: 1 }} />

      {loading && <p>로딩 중입니다...</p>}
      {!hasMore && <p>마지막 데이터입니다.</p>}

      {/* 접근성/네트워크 문제 대비 수동 버튼 */}
      <button
        onClick={loadMore}
        disabled={loading || !hasMore}
        aria-label="더 불러오기"
      >
        더 불러오기
      </button>
    </div>
  );
}

3. 커스텀 훅으로 감시 로직 분리

감시자 로직을 훅으로 빼면 재사용과 테스트가 쉬워집니다.

import { useEffect, useRef } from 'react';

export function useInfiniteObserver(callback, { enabled = true, root = null, rootMargin = '200px', threshold = 0 } = {}) {
  const ref = useRef(null);

  useEffect(() => {
    if (!enabled) return;
    const node = ref.current;
    if (!node) return;
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) callback();
    }, { root, rootMargin, threshold });
    observer.observe(node);
    return () => observer.disconnect();
  }, [callback, enabled, root, rootMargin, threshold]);

  return ref; // 이 ref를 sentinel에 연결합니다.
}

// 사용 예시
// const sentinelRef = useInfiniteObserver(loadMore, { enabled: hasMore });

4. 서버 페이징: 페이지 vs 커서

- 페이지 번호(page): 구현이 단순하지만 특정 항목 삭제/삽입 시 오프셋 불일치 위험이 있습니다.
- 커서(cursor): 마지막 항목의 키를 기준으로 다음 묶음을 요청합니다. 실무에서는 커서 기반을 권장합니다.
- 응답 예시: { items: [], nextCursor: 'abc', hasMore: true } 형태로 맞추면 프론트 처리가 명확합니다.

5. React Query로 더 간단하게

useInfiniteQuery를 쓰면 캐싱·중복요청 방지·에러 자동 처리까지 해결됩니다.

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

const fetchPage = async ({ pageParam = null, signal }) => {
  const url = new URL('/api/items', window.location.origin);
  if (pageParam) url.searchParams.set('cursor', pageParam);
  url.searchParams.set('limit', 20);
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error('로드 실패');
  return res.json(); // { items, nextCursor, hasMore }
};

export default function InfiniteWithRQ() {
  const {
    data,
    error,
    isLoading,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: fetchPage,
    getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
    staleTime: 60_000,
  });

  const sentinelRef = useRef(null);
  useEffect(() => {
    const node = sentinelRef.current;
    if (!node) return;
    const io = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    }, { rootMargin: '200px' });
    io.observe(node);
    return () => io.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

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

  return (
    <div>
      {isLoading && <p>초기 로딩 중입니다...</p>}
      {error && <p role="alert">에러: {String(error.message)}</p>}
      <ul>
        {items.map((it) => (
          <li key={it.id}>{it.title}</li>
        ))}
      </ul>
      <div ref={sentinelRef} style={{ height: 1 }} />
      {isFetchingNextPage && <p>더 불러오는 중...</p>}
      {!hasNextPage && <p>끝까지 탐색했습니다.</p>}
    </div>
  );
}

6. 성능 최적화 포인트

- 가상화: react-window를 사용해 DOM 노드 수를 제한합니다.
- 이미지 Lazy-Load: img에 loading="lazy"를 사용하거나 IntersectionObserver로 지연 로딩합니다.
- rootMargin: 150~300px로 미리 당겨 로딩하면 체감이 좋아집니다.
- 중복 요청 방지: loading 플래그 또는 React Query 상태로 제어합니다.
- 스켈레톤: 다음 페이지가 올 때 리스트 하단에 스켈레톤을 표시해 지연 체감을 줄입니다.

import { FixedSizeList as VList } from 'react-window';

function VirtualizedList({ items }) {
  return (
    <VList height={600} itemCount={items.length} itemSize={80} width="100%">
      {({ index, style }) => (
        <div style={style}>{items[index].title}</div>
      )}
    </VList>
  );
}

7. Scroll 이벤트 Fallback

컨테이너 스크롤이나 특수 환경에서는 throttled scroll을 사용합니다. passive 옵션으로 메인 스레드 점유를 줄입니다.

import { useEffect } from 'react';

export function useBottomScroll(loadMore, { offset = 200 } = {}) {
  useEffect(() => {
    let ticking = false;
    const onScroll = () => {
      if (!ticking) {
        window.requestAnimationFrame(() => {
          const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
          const distance = scrollHeight - scrollTop - clientHeight;
          if (distance <= offset) loadMore();
          ticking = false;
        });
        ticking = true;
      }
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, [loadMore, offset]);
}

8. 에러 처리와 접근성

- 에러 메시지와 재시도 버튼을 제공합니다.
- 마지막 페이지 도달 시 명확한 안내 문구를 표시합니다.
- 자동 로드가 실패할 수 있어 "더 불러오기" 수동 버튼을 항상 제공하는 것이 안전합니다.
- 키보드·스크린리더 사용자를 고려해 버튼에 명확한 레이블을 추가합니다.

9. 테스트/디버깅 팁

- IntersectionObserver 목킹으로 트리거를 검증합니다.
- 네트워크 지연을 시뮬레이션해 중복 호출, 취소 로직을 확인합니다.

// Jest 예시: IntersectionObserver 목킹
beforeEach(() => {
  global.IntersectionObserver = class {
    constructor(cb) { this.cb = cb; }
    observe = () => { this.cb([{ isIntersecting: true }]); };
    disconnect = () => {};
    unobserve = () => {};
  };
});

10. 체크리스트

- 커서 기반 페이지네이션 API 준비되어 있나요?
- hasMore/중복 요청/취소 처리를 했나요?
- rootMargin 조정으로 체감 속도를 개선했나요?
- 가상화·이미지 지연 로딩을 적용했나요?
- 에러·접근성·수동 로드 버튼을 제공했나요?
- 특수 스크롤 컨테이너(root)에서 동작 검증했나요?

위 구성으로 구현하면 Infinite Scroll을 안정적으로 운용할 수 있습니다. 먼저 IntersectionObserver로 간단히 시작하고, 데이터 규모가 커지면 가상화를 병행해 성능을 확보합니다.