티스토리 뷰

✅ 전에 React 무한 스크롤을 구현해 본 경험이 있는데, 최근에 해당 기능을 또 구현해 볼 기회가 생겨서 이참에 공부한 내용을 정리하고자 글을 작성합니다. (잘못된 정보가 있다면 말씀해주시면 감사하겠습니다.)

 

저는 테스트 데이터를 msw를 사용하여 데이터를 Mocking 하여 사용했습니다. (더보기 란에 간단한 설정법을 적어뒀습니다.)

더보기
  • msw 설치
    npm install msw / yarn add msw

 

  1. src 폴더 하위에 mocks 폴더 생성 후 browser.js, handlers.js 파일 만들기
  2. index.js 에 mock 설정 코드 추가하기
/*  browser.js */
import { setupWorker } from 'msw'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
/* handlers.js */

import {rest} from 'msw'

const pageData = Array.from({length: 50}).map((dr, idx) => {
    return {name: `${idx}`}
})

export const handlers = [
    rest.get("/api/getData", async (req, res, ctx) => {

        const p = Number(req.url.searchParams.get("page"));
        let returnData = {};

        returnData.data = pageData.slice(10 * (p - 1), 10 * p);
        returnData.page = p;
        returnData.hasNext = p < 5;

        await sleep(300);

        return res(ctx.status(200), ctx.json(returnData));
    }),
]

async function sleep(timeout) {
    return new Promise((resolve) => {
        setTimeout(resolve, timeout);
    });
}
// index.js에 추가할 코드
if (process.env.NODE_ENV === 'development') {
    const {worker} = require('./mocks/browser')
    worker.start()
}

 

Mocking 동작 확인

📌 Infinite Scroll ?

  • 무한 스크롤은 페이지 하단 영역까지 이동할 경우 새로운 데이터를 추가로 페칭하여 화면에 나타내어, 스크롤을 무한히 이동하며 새로운 데이터를 불러오는 방식입니다.
  • 사용자 참여 및 콘텐츠 탐색이 쉽고, 특히 모바일 환경에서 유용하게 적용됩니다.
  • 페이지 성능이 느려질 수 있으며, 특정 항목 검색 및 원하는 위치로 이동이 힘들다는 단점이 있습니다.
  • 리액트에서 무한 스크롤을 구현하는 방법은 주로 scroll 이벤트를 사용하거나 기본 Web API로 제공되는 Intersection Observer를 사용하는 2가지 방법이 있습니다. (혹은 "더보기" 버튼과 같은 버튼 이벤트를 사용하는 경우도 있습니다.)

 

💻 Scroll 이벤트로 구현하기

  • scrollTop은 요소의 처음부터 현재 화면에 보이는 부분까지의 높이입니다. 요소의 처음부터 얼마나 내려왔는지 알 수 있습니다.
  • scrollHeight는 요소에 들어있는 컨텐츠의 전체 높이입니다. (패딩과 보더 포함, 마진은 제외)
  • offsetHeight는 요소의 높이입니다. (패딩, 보더, 스크롤바 포함, 마진은 제외)
  • clientHeight는 요소 내부의 높이, 즉 사용자에게 보여지는 요소의 높이 입니다. (패딩 포함, 스크롤바, 보더, 마진은 제외)
구현 포인트 !

1. scroll이 영역의 끝에 닿았다고 판단하는 기준

· scrollTop + clientHeight >= clientHeight 
=> 위 이미지를 보면 scrollTop(현재까지 내려온 위치), clientHeight(사용자에게 보여지는 높이)를 더한 값이 scrollHeight(요소 전체 높이)와 같거나 크면 스크롤이 영역의 끝에 닿았다고 판단할 수 있습니다.

2. 다음 데이터를 불러오는 로직

· 추가 데이터를 렌더링 할 로직 필요

 

(소스 코드는 더보기란 참고해주세요)

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

const MainContainer1 = () => {

    const ViewportRef = useRef(null); // 스크롤 위치를 탐색 및 이벤트 처리를 위해
    const throttle = useRef(null); // 쓰로틀링 처리를 위해

    const [nextInfo, setNextInfo] = useState({
        hasNext: false,
        nextPage: 0,
    }); // 다음 데이터 존재 여부
    const [totalData, setTotalData] = useState([]); // 렌더링되는 전체 데이터
    const [isLoading, setIsLoading] = useState(false); // 새로운 데이터를 추가로 불러올 때 로딩처리를 위해

    useEffect(() => { // 스크롤 이벤트 정의
        ViewportRef.current.addEventListener("scroll", handleScroll);

        /*
            cleanup function ( 정리 함수 or 뒷 정리 함수 ) : 상태의 종료를 반환함수에 정의
                함수에 사용되었던 메모리 공간을 반환
                등록한 이벤트 리스너가 메모리 반환이 되지않아, 메모리 누수로 이어지는 현상을 항상 방지
         */
        return () => {
            ViewportRef.current.removeEventListener("scroll", handleScroll);
        };
    });

    useEffect(() => {
        callData(1); // 초기값 불러오기
    }, []);

    const callData = async (pageNum) => { // 데이터 추가로 불러오는 로직

        setIsLoading(true); //로딩 true

        const selectData = await fetch(`/api/getData?page=${pageNum}`, {
            headers: {
                "Content-type": "application/json; charset=UTR-8;"
            }
        }).then((res) => {
            if (res.status === 200) {
                return res.json();
            }
        }); // 데이터 불러오기

        setTotalData((prev) => [...prev, ...selectData.data]); // 불러온 데이터 추가

        setNextInfo((prev) => ({
            ...prev,
            hasNext: selectData.hasNext,
            nextPage: selectData.page + 1,
        })); // 다음 데이터 존재 여부 최신화

        setIsLoading(false);  //로딩 false

    }

    const handleScroll = () => { // 스크롤 이벤트

        if (!throttle.current) { // 쓰로틀링 처리

            throttle.current = setTimeout(async () => {

                const scrollHeight = ViewportRef.current?.scrollHeight;
                const scrollTop = ViewportRef.current?.scrollTop;
                const clientHeight = ViewportRef.current?.clientHeight;

                if (scrollTop + clientHeight >= scrollHeight && nextInfo.hasNext) { // 사용자 스크롤이 영역 하단에 위치할때 && 다음 데이터가 존재할때
                    await callData(nextInfo.nextPage); // 추가 데이터 불러오기 로직 실행
                }

                throttle.current = null;

            }, 300);

        }

    }

    return (
        <div style={{
            position: "absolute",
            width: '50%',
            height: '500px',
            overflow: 'auto',
        }} ref={ViewportRef}>

            {
                totalData.map((dr, idx) => {
                    return <div key={idx} style={{
                        width: '200px',
                        height: '160px',
                        margin: "20px",
                        border: "1px solid black"
                    }}> {dr.name} </div>
                })
            }

            {
                isLoading && <div> Loading </div>
            }

        </div>
    )

}

export default MainContainer1;

 

  • scroll 이벤트로 구현 시 단점

    • 스크롤 시 수많은 이벤트가 동기적으로 실행될 수 있습니다. 페이지 내 요소가 각각의 목적(광고, 레이지 로딩, 무한 스크롤 등 )의 이유로 scroll 이벤트를 리스닝하기 때문에 이에 상응하는 콜백이 무수히 많이 실행 될 수 있으며 이는 메인 스레드에 큰 부하를 줄 수 있습니다.
    • getBoundingClientRect (뷰포트에 상대적인 위치 정보를 제공하는 객체) 메서드는 호출 시 값(top, right 등)을 정확히 읽기 위하여 큐를 flush 하고 스타일을 적용함으로써 다 수의 reflow를 발생 시킬 수 있습니다.

* reflow : DOM이 화면에 표시되는 구조가 바뀔 때 또는 CSS클래스가 바뀔 때 일어나며, DOM 트리가 배치되는 위치를 전체적으로 다시 계산하여 화면에 출력하는 것을 의미합니다. 전체적인 위치를 다시 계산해야하므로 repaint 보다 자원 소모가 큽니다.

 

💻 Intersection Observer API로 구현하기

  • scroll 이벤트의 단점을 해결하기 위해 주로 사용하는 것이 Intersection Observer API 입니다.
  • Intersection Observer API는 브라우저 Viewport와 Target으로 설정한 요소의 교차점을 관찰하여 그 Target이 Viewport에 포함되는지 구별하는 기능을 제공합니다.
  • new IntersectionObserver(callback, options) 를 통해 생성한 인스턴스로 관찰차를 초기화하고 관찰할 대상을 지정합니다.
상세 정보

1. callback 인수

· 관찰할 대상이 등록되거나 가시성(보이는지 안보이는지, threshold와 만날 때)에 변화가 생기면 콜백을 실행합니다.
· 콜백은 2개의 인수를 가집니다. ( entries, observer )

[ observer ] 콜백이 실행되는 해당 인스터스를 참조합니다.

[ entries ]IntersectionObserverEntry(루트요소와 타겟요소 교차의 상황) 인스턴스를 담은 배열입니다. 포함된 프로퍼티들은 모두 읽기 전용 입니다. 

 boundingClientRect
타겟 요소의 사각형(DOMRectReadOnly) 정보, reflow를 발생시키지 않음.

 intersectionRect
타겟 요소의 가시성(DOMRectReadOnly)이 감지된 부분의 정보

 intersectionRatio
타겟 요소가 루트 요소와 얼마나 교차하는지는지에 대한 정보

 isIntersecting
타겟 요소와 루트요소가 교차하는 지 여부를 Boolean 값으로 반환

 rootBounds
루트 요소의 사각형 정보(DOMRectReadOnly)를 반환, rootMargin 옵션 설정에 영향을 받음.

 target
타겟 요소 반환

 time
문서가 만들어진 표준 신간을 기준으로 타겟 요소와 루트 요소의 교차가 발생한 시간을 반환

2. option 인수

· root, rootMargin, threshold 속성값을 지정할 수 있습니다.
 root
타겟의 가시성을 검사하기 위해 뷰포트 대신 사용할 요소 객체를 지정
타겟의 조상 요소로 지정하며, 지정하지 않거나 null(기본값) 인 경우 브라우저의 뷰포트가 기본 사용

 rootMargin
여백을 이용해 루트 범위를 확장하거나 축소가 가능
기본값은 "0px 0px 0px 0px" 이며 단위 필수 입력 해야함

 intersectionRatio
옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시
기본값은 Array 타입의 [0] 이지만, Number 타입의 단일 값으로도 작성 가능

Ex ) 
=> 0 : 타겟의 가장자리 픽셀이 루트 범위를 교차하는 순간 옵저버 실행
=> 0.3 : 타겟의 가시성이 30% 일 때 옵저버 실행
=> [0, 0.3, 1] : 타겟의 가시성이 0%, 30%, 100% 일때 모두 옵저버 실행

3. Methods

· IntersectionObserver.observe(targetElement) : 타겟 요소 관찰 시작
· IntersectionObserver.unobserve(targetElement) : 타겟 요소 관찰 중지
· IntersectionObserver.disconnect( ) : 인스턴스의 타겟 요소들에 대한 모든 관찰 중지
· IntersectionObserver.takerecords(targetElement) : IntersectionObserverEntry 인스턴스들의 배열을 리턴

 

(소스 코드는 더보기란 참고해주세요)

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

const MainContainer1 = () => {

    const [totalData, setTotalData] = useState([]); // 렌더링되는 전체 데이터
    const [isLoading, setIsLoading] = useState(false); // 새로운 데이터를 추가로 불러올 때 로딩처리를 위해
    const [nextInfo, setNextInfo] = useState({
        hasNext: false,
        nextPage: 0,
    }); // 다음 데이터 존재 여부

    useEffect(() => {
        callData(1); // 초기값 불러오기
    }, []);

    const observerRef = useRef(null);

    const observer = (node) => {

        if (isLoading) return;

        observerRef.current && observerRef.current.disconnect();

        observerRef.current = new IntersectionObserver(async ([entry]) => {
            if (entry.isIntersecting && nextInfo.hasNext) {
                await callData(nextInfo.nextPage);
            }
        });

        node && observerRef.current.observe(node);

    };

    const callData = async (pageNum) => { // 데이터 추가로 불러오는 로직

        setIsLoading(true); //로딩 true

        const selectData = await fetch(`/api/getData?page=${pageNum}`, {
            headers: {
                "Content-type": "application/json; charset=UTR-8;"
            }
        }).then((res) => {
            if (res.status === 200) {
                return res.json();
            }
        }); // 데이터 불러오기

        setTotalData((prev) => [...prev, ...selectData.data]); // 불러온 데이터 추가

        setNextInfo((prev) => ({
            ...prev,
            hasNext: selectData.hasNext,
            nextPage: selectData.page + 1,
        })); // 다음 데이터 존재 여부 최신화

        setIsLoading(false);  //로딩 false

    }

    return (

        <div style={{
            position: "absolute",
            width: '50%',
            height: '500px',
            overflow: 'auto',
        }}>

            {
                totalData.map((dr, idx) => {
                    return <div key={idx} style={{
                        width: '200px',
                        height: '160px',
                        margin: "20px",
                        border: "1px solid black"
                    }}> {dr.name} </div>
                })
            }

            {
               nextInfo.hasNext && <div ref={observer}/>
            }

            {
                isLoading && <div> Loading </div>
            }

        </div>

    )

}

export default MainContainer1;

 

 

🔗 참고한 글

 

실전 Infinite Scroll with React

시작하며 안녕하세요. 카카오엔터프라이즈 워크코어개발셀에서 프론트엔드 개발을 담당하고 있는 Denis(배형진) 입니다. 약 1년 전, 저는 프레임워크의 선택, React vs Angular 이라는 포스팅을 통해

tech.kakaoenterprise.com

 

 

실무에서 느낀 점을 곁들인 Intersection Observer API 정리

실무에서 Intersection Observer API를 사용해보고 느낀 생각정리

velog.io

댓글