React 무한스크롤 구현기

shy·2025년 10월 13일

내일이면 잊어버림

목록 보기
13/13

이번에 리액트 프로젝트를 하면서 가장 구현해보고 싶었던 기능 중 하나가 무한스크롤이기 때문에..! 이번에 도전해보았다.

Intersection Observer API

1. 개념

무한스크롤 구현에는 여러 방법이 있지만, 주로 Intersection Observer API
를 많이들 쓰는 것 같았다. 내가 파악한 바를 간단히 적자면 다음과 같다.

원래 무한스크롤을 구현하려면 스크롤 이벤트를 활용해 만들어야 한다. 하지만 스크롤 이벤트는 동기적으로 실행되는데다, 스크롤을 조금만 움직여도 위치 계산을 위해 수많은 이벤트가 발생하기 때문에 성능이 저하될 수 있다.
이 API는 브라우저에게 "감시자"의 역할을 주고, 감시 대상이 시야에 들어왔을 때만 비동기적으로 실행되도록 해준다.

2. 사용법

생성은 다음과 같이 한다.

let observer = new IntersectionObserver(callback, options);

요 녀석을 사용하기 위해서는 위에 보이는 것과 같이 callbackoption을 전달해주어야 한다. 하나씩 뜯어보겠다.

callback은 감시 대상이 화면에 나타날 때 실행할 함수이다. 이 함수는 감시 대상의 정보인 entries와 감시자 자신인 observer를 받는다.

let callback = (entries, observer) => {
  entries.forEach((entry) => {
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

entry에 여러 요소가 있는데, 그 중에서 우리가 눈여겨 보아야 할 것은 isIntersecting이다. 감시 대상이 시야에 들어왔는지 여부를 알려주는 역할을 한다!

option감시 대상의 기준을 정해주는 역할을 한다. 털끝이라도 보이면 보인거냐, 근처에서 냄새가 나는데 이것도 보인걸로 치냐 등을 정해주는 녀석이라고 보면 되겠다.

let options = {
  root: null,
  rootMargin: "0px",
  threshold: 1.0,
};

root는 기준이 되는 스크롤 영역이고, 지정하지 않으면 뷰포트가 기준이 된다.
rootMargin은 감시 영역에 여백을 주어 감시 대상이 감시자에게 닿기 전에 감지되도록 한다.
threshold는 감시 대상이 얼마나 보여졌을 때 실행할지를 알려준다.

이렇게 callback 함수와 option을 작성했다면 다음과 같이 작성해 사용한다.

let target = document.querySelector("#listItem");
observer.observe(target);

자바스크립트에서는 위와 같이 작성하면 그만이겠지만 리액트는 조금 다르다. 직접 요소에 접근하기 어렵기 때문에, 여기선 useRef 라는 것을 사용한다.

useRef

값이 바뀔 때마다 리렌더링 시키는 useState와 달리 useRef는 값이 바뀌어도 리렌더링 시키지 않는다. 우리가 지정한 감시 대상을 불필요한 렌더링 없이 참조만 하기엔 useRef가 제격인 것이다.

const ref = useRef();

...

<div ref={ref} style={{height: '50px'}}/>

이런 식으로 감시대상을 지정해주면 된다.

전체 코드

function List() {
    const {loading, list, hasMore, getMore} = usePokemonList();
    const ref = useRef();

    // 무한스크롤 설정
    useEffect(() => {
        if (!ref.current) return;

        // 감시자 옵션. 각각 무엇을 기준으로, 어느 위치에서, 얼마만큼 보일때 실행할건지 설정한다.
        const options = {root: null, rootMargin: "100px", threshold: 0};

        // 감시자가 감시 대상이 시야에 들어왔을 때 실행하는 함수.
        // 감시하는 요소들의 배열인 entries와 observer 객체를 받음
        // 여기서는 감시자가 하나이므로 아래와 같이 사용
        const callback = ([entry]) => {
            if (entry.isIntersecting && !loading && hasMore) {
                getMore();
            }
        };

        const observer = new IntersectionObserver(callback, options);

        const currentRef = ref.current; // disconnect 실행 시 ref.current 값이 바뀌어도 사용 가능하도록 변수 할당
        if (currentRef) observer.observe(currentRef); //감시 시작

        // 컴포넌트 언마운트 시 감시자 해제
        return () => observer.disconnect();

    }, [getMore, loading, hasMore, list]);

    return (
        <Layout loading={loading && list.length === 0}>
            <Container className={styles.list}>
                {list?.map(p =>
                    <ListCard
                        key={p.id}
                        id={p.id}
                        img={p.img}
                        name={p.name}
                        types={getTypeIds(p.types)}
                    />
                )}
            </Container>
            {/* 로딩 중일 때 스피너 표시 */}
            {loading && <div style={{textAlign: 'center', margin: "50px 0"}}>
                <Spinner animation="border" variant="danger"/>
            </div>}

            {/* 더 가져올 데이터가 있을 때만 관찰 대상을 렌더링 */}
            {hasMore && !loading && <div ref={ref} style={{height: '50px'}}/>}
        </Layout>
    )
}

export default List;

API 호출은 훅으로 따로 분리했기 때문에 위 코드에서는 무한스크롤과 리스트 페이지 출력 부분만 작성되어 있다. 무한스크롤을 리스트 외에 다른 곳에서도 사용하게 된다면 이것도 훅으로 분리할 예정이다.

아무래도 독학하는 것이기도 하고, 여전히 모르는 부분이 많아서 더듬더듬 찾아다 작성했다🥲 공부에 도움이 된 곳들을 하단에 첨부한다.

Intersection Observer의 한계와 해결책

Intersection Observer의 개념 설명

profile
과거의 끝에서 현대의 끝으로

0개의 댓글