junyeokk
Blog
React·2026. 01. 27

무한 스크롤 (Infinite Scroll)

개요

무한 스크롤은 사용자가 페이지 하단에 도달하면 자동으로 다음 데이터를 로드하는 UI 패턴이다. 페이지네이션의 대안으로, 소셜 미디어 피드나 검색 결과 목록에서 흔히 사용된다.


구현 방식 비교

1. scroll 이벤트

javascript
// 전통적인 방식
window.addEventListener('scroll', () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
    loadMore();
  }
});

문제점:

  • 스크롤할 때마다 이벤트가 발생 (초당 수십~수백 회)
  • throttle/debounce를 적용해도 메인 스레드 부하
  • offsetHeight, scrollY 계산이 리플로우를 유발할 수 있음

2. Intersection Observer

javascript
// 현대적인 방식
const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    loadMore();
  }
});
observer.observe(sentinelElement);

장점:

  • 브라우저가 최적화된 방식으로 교차 감지
  • 메인 스레드 블로킹 없음
  • 콜백은 교차 상태가 바뀔 때만 호출

Intersection Observer API

기본 개념

Intersection Observer는 타겟 요소루트 요소(또는 뷰포트)와 교차하는지 감시한다.

javascript
const observer = new IntersectionObserver(callback, options);
observer.observe(targetElement);

옵션

javascript
const options = {
  root: null,        // 기준이 되는 요소. null이면 뷰포트
  rootMargin: '0px', // 루트의 마진. '100px'이면 100px 전에 감지
  threshold: 0,      // 얼마나 보여야 교차로 판단할지 (0~1)
};

콜백

javascript
const callback = (entries, observer) => {
  entries.forEach(entry => {
    console.log(entry.isIntersecting); // 교차 여부
    console.log(entry.intersectionRatio); // 교차 비율 (0~1)
    console.log(entry.target); // 감시 대상 요소
  });
};

React에서 구현

기본 구조

jsx
function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef(null);

  // 데이터 로드
  const loadMore = async () => {
    const newItems = await fetchItems(page);
    if (newItems.length === 0) {
      setHasMore(false);
      return;
    }
    setItems(prev => [...prev, ...newItems]);
    setPage(prev => prev + 1);
  };

  // Intersection Observer 설정
  useEffect(() => {
    if (!hasMore) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { rootMargin: '100px' } // 100px 전에 미리 로드
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [page, hasMore]);

  return (
    <div>
      {items.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      
      {/* 센티널: 이 요소가 보이면 다음 페이지 로드 */}
      {hasMore && <div ref={sentinelRef}>로딩 중...</div>}
    </div>
  );
}

핵심 포인트

  1. 센티널 요소: 리스트 맨 아래에 빈 요소를 두고, 이 요소가 뷰포트에 들어오면 다음 데이터를 로드
  2. rootMargin: 양수 값을 주면 실제로 보이기 전에 미리 로드 시작
  3. cleanup: 컴포넌트 언마운트 시 observer.disconnect()로 정리
  4. hasMore: 더 로드할 데이터가 없으면 observer 해제

커스텀 훅으로 분리

재사용을 위해 커스텀 훅으로 분리할 수 있다.

jsx
function useInfiniteScroll(onIntersect, options = {}) {
  const sentinelRef = useRef(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsIntersecting(entry.isIntersecting);
        if (entry.isIntersecting) {
          onIntersect();
        }
      },
      {
        rootMargin: '100px',
        ...options,
      }
    );

    const sentinel = sentinelRef.current;
    if (sentinel) {
      observer.observe(sentinel);
    }

    return () => {
      if (sentinel) {
        observer.unobserve(sentinel);
      }
    };
  }, [onIntersect, options]);

  return { sentinelRef, isIntersecting };
}

사용 예시

jsx
function PostList() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [isLoading, setIsLoading] = useState(false);

  const loadMore = useCallback(async () => {
    if (isLoading || !hasMore) return;
    
    setIsLoading(true);
    const newPosts = await fetchPosts(page);
    
    if (newPosts.length === 0) {
      setHasMore(false);
    } else {
      setPosts(prev => [...prev, ...newPosts]);
      setPage(prev => prev + 1);
    }
    setIsLoading(false);
  }, [page, isLoading, hasMore]);

  const { sentinelRef } = useInfiniteScroll(loadMore);

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
      
      {hasMore && (
        <div ref={sentinelRef}>
          {isLoading ? <Spinner /> : <Skeleton />}
        </div>
      )}
    </div>
  );
}

TanStack Query와 함께 사용

TanStack Query(React Query)의 useInfiniteQuery와 조합하면 캐싱, 에러 처리, 로딩 상태 관리가 편해진다.

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

function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage, pages) => {
      return lastPage.length > 0 ? pages.length + 1 : undefined;
    },
  });

  const { sentinelRef } = useInfiniteScroll(() => {
    if (hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  });

  const posts = data?.pages.flat() ?? [];

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
      
      {hasNextPage && (
        <div ref={sentinelRef}>
          {isFetchingNextPage ? <Spinner /> : <Skeleton />}
        </div>
      )}
    </div>
  );
}

Skeleton UI와 함께 사용

무한 스크롤 로딩 중에 Skeleton UI를 보여주면 사용자 경험이 좋아진다.

jsx
function PostList() {
  // ... useInfiniteQuery 설정

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
      
      {/* 로딩 중일 때 Skeleton 표시 */}
      {isFetchingNextPage && (
        <>
          <PostCardSkeleton />
          <PostCardSkeleton />
          <PostCardSkeleton />
        </>
      )}
      
      {/* 센티널 */}
      {hasNextPage && !isFetchingNextPage && (
        <div ref={sentinelRef} style={{ height: 1 }} />
      )}
    </div>
  );
}

주의사항

중복 요청 방지

jsx
const loadMore = useCallback(async () => {
  if (isLoading) return; // 이미 로딩 중이면 무시
  // ...
}, [isLoading]);

메모리 누수 방지

jsx
useEffect(() => {
  const observer = new IntersectionObserver(callback);
  observer.observe(sentinel);
  
  return () => observer.disconnect(); // 반드시 정리
}, []);

초기 데이터가 뷰포트를 채우지 못할 때

첫 로드 데이터가 적으면 센티널이 바로 보여서 연속 로드될 수 있다.

jsx
// 첫 페이지 로드 후 약간의 지연
useEffect(() => {
  const timer = setTimeout(() => {
    setCanLoadMore(true);
  }, 500);
  return () => clearTimeout(timer);
}, []);

요약

개념설명
Intersection Observer요소가 뷰포트에 들어오는지 감시하는 API
센티널리스트 끝에 두는 감시 대상 요소
rootMargin실제 교차 전에 미리 감지하기 위한 마진
useInfiniteQueryTanStack Query의 무한 스크롤 지원 훅

scroll 이벤트 대신 Intersection Observer를 사용하면 성능과 코드 모두 개선된다.