Intersection Observer를 활용한 무한 스크롤 구현

Ryomi·2025년 1월 10일
0
post-thumbnail

실무에서 페이지네이션이 없는 테이블을 사용했을 때, 테이블의 데이터가 많아질 수록 로딩이 느려지는 현상이 있었습니다.
이를 해결하기 위해 테이블에 Intersection Observe를 활용했고, 무한스크롤을 적용하며 알게된 지식을 작성했습니다.

이번 글에서는 Intersection Observer API를 활용해 React에서 무한 스크롤을 구현하는 방법을 다룹니다.


무한 스크롤이란?

사용자가 페이지 하단에 도달하면 자동으로 콘텐츠를 로드하는 기술입니다.


Intersection Observer란?

Intersection Observer는 브라우저 API로, 특정 요소가 뷰포트 또는 다른 요소(ex. div)와 교차하는 상태를 비동기로 감지합니다.
이를 통해 스크롤 이벤트를 직접 감지하지 않고도, 요소가 화면에 보이는지 여부를 알 수 있습니다.

-> 그럼 스크롤 계산 로직은 필수가 아니게 됩니다.

주요 개념

  • Observer: 감시를 수행하는 객체.
  • Target Element: 관찰 대상 요소.
  • isIntersecting: 요소가 뷰포트와 교차하고 있는지 나타내는 속성.

-> 데이터를 불러오는 교차지점을 감시한다는 개념만 기억해두시면 이해하기 쉬울 것 같습니다.


사용법: 옵션과 설정

Intersection Observer는 두 가지 매개변수를 설정해 사용합니다:

1. Callback 함수

관찰 중인 요소의 교차 상태가 변할 때 실행되는 함수입니다.
entriesobserver 두 개의 인자를 받습니다.

const callback: IntersectionObserverCallback = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('요소가 화면에 보입니다.');
    }
  });
};

2. Option 객체

관찰 동작을 제어하는 설정값으로 구성됩니다

  • root: 관찰 기준이 되는 요소. 기본값은 null로 뷰포트를 기준으로 설정됩니다.
  • rootMargin: 관찰 기준의 여백. CSS 단위로 작성하며, "0px 0px -50px 0px"처럼 사용합니다.
  • threshold: 요소가 어느 정도 보일 때 callback이 실행될지 결정합니다. (0~1 사이 값)
const options = {
  root: null,
  rootMargin: '0px',
  threshold: 1.0,
};

구현해보기

1. 데이터 패칭하기

fetchPins 함수를 작성해 데이터를 가져옵니다. 무한 스크롤에서는 데이터를 계속 쌓아야 하므로 이전 데이터를 유지하면서 새로운 데이터를 추가해야 합니다. 이를 위해 pins라는 state를 사용합니다.

코드

const Main = () => {
  const [pins, setPins] = useState([]); // 데이터를 담는 state
  const [page, setPage] = useState(1); // 페이지 번호 state
  const [loading, setLoading] = useState(false); // 로딩 상태 관리

  // 데이터를 가져오는 함수
  const fetchPins = async (page: number) => {
    const API_KEY = "YOUR_API_KEY_HERE"; // Unsplash API 키
    const res = await fetch(
      `https://api.unsplash.com/photos/?client_id=${API_KEY}&page=${page}&per_page=10`
    );
    const data = await res.json();
    setPins(prev => [...prev, ...data]); // 기존 데이터에 새로운 데이터 추가
    setLoading(true); // 로딩 완료 상태로 변경
  };

  // 페이지가 변경될 때마다 fetchPins 실행
  useEffect(() => {
    fetchPins(page);
  }, [page]);

  return (
    <div>
      {/* pins 데이터를 활용한 렌더링 */}
    </div>
  );
};

2. 페이지 넘버를 증가시키는 함수 만들기

새로운 데이터를 가져오기 위해 페이지를 1씩 증가시키는 loadMore 함수를 작성합니다.

코드

const Main = () => {
  const [page, setPage] = useState(1);

  // 페이지를 1씩 증가시키는 함수
  const loadMore = () => {
    setPage(prev => prev + 1);
  };

  return (
    <div>
      {/* 콘텐츠와 로딩 컴포넌트 */}
    </div>
  );
};

3. useRef로 감시할 타겟 설정하기

무한 스크롤에서는 특정 요소가 뷰포트 내에 들어왔을 때 데이터를 가져옵니다. 이때 마지막 요소를 ref로 지정해 관찰 타겟으로 설정합니다.

코드

import { useRef } from "react";

const Main = () => {
  const pageEnd = useRef<HTMLDivElement>(null); // 마지막 요소 ref 생성

  return (
    <div>
      {/* 콘텐츠 */}
      <div ref={pageEnd} />
    </div>
  );
};

4. Intersection Observer로 타겟 관찰하기

Intersection Observer를 사용해 마지막 요소가 뷰포트 안에 들어왔을 때 데이터를 추가로 로드합니다.

구현 흐름
IntersectionObserver를 생성하고, 타겟 요소를 감지합니다.
타겟 요소가 화면에 보일 때 loadMore 함수를 호출합니다.

코드

const Main = () => {
  const [loading, setLoading] = useState(false);
  const pageEnd = useRef<HTMLDivElement>(null);

  const loadMore = () => {
    setPage(prev => prev + 1); // 페이지 증가
  };

  useEffect(() => {
    if (loading && pageEnd.current) {
      // 로딩 중일 때만 옵저버 실행
      const observer = new IntersectionObserver(
        entries => {
          if (entries[0].isIntersecting) {
            loadMore(); // 페이지 증가
          }
        },
        { threshold: 1 } // 100% 보여야 실행
      );

      // 타겟 요소 관찰
      observer.observe(pageEnd.current);

      // 컴포넌트 언마운트 시 옵저버 해제
      return () => observer.disconnect();
    }
  }, [loading]);

  return (
    <div>
      {/* 콘텐츠 */}
      <div ref={pageEnd} />
    </div>
  );
};

전체 코드 구조

코드

import React, { useState, useEffect, useRef } from "react";

const Main = () => {
  const [pins, setPins] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const pageEnd = useRef<HTMLDivElement>(null);

  const fetchPins = async (page: number) => {
    const API_KEY = "YOUR_API_KEY_HERE";
    const res = await fetch(
      `https://api.unsplash.com/photos/?client_id=${API_KEY}&page=${page}&per_page=10`
    );
    const data = await res.json();
    setPins(prev => [...prev, ...data]);
    setLoading(true);
  };

  const loadMore = () => {
    setPage(prev => prev + 1);
  };

  useEffect(() => {
    fetchPins(page);
  }, [page]);

  useEffect(() => {
    if (loading && pageEnd.current) {
      const observer = new IntersectionObserver(
        entries => {
          if (entries[0].isIntersecting) {
            loadMore();
          }
        },
        { threshold: 1 }
      );

      observer.observe(pageEnd.current);

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

  return (
    <div>
      {pins.map((pin, index) => (
        <div key={index}>{pin.description || "No Description"}</div>
      ))}
      <div ref={pageEnd}>Loading...</div>
    </div>
  );
};

export default Main;

요약

  1. 데이터를 패칭하는 fetchPins 함수를 작성.
  2. 페이지를 관리하는 loadMore 함수로 데이터를 추가.
  3. useRef로 마지막 요소를 타겟으로 설정.
  4. Intersection Observer를 통해 타겟 요소를 관찰하고, 데이터를 추가로 불러옴.

피드백은 환영입니다!

profile
making a list, checking it twice 🐥

0개의 댓글