무한스크롤
thumbnail-maker.web.app

 

 

이전 Toast Editor와 마찬가지로, 리액트 환경에서의 무한 스크롤(Infinite Scroll)도 개인적인 포트폴리오에서 구현한 내용을 바탕으로 포스팅을 해보려 합니다.

 

 

무한 스크롤(Infinite Scroll) 이란?


무한 스크롤은 개인적으로 UI/UX측면에서 가장 좋아하는 페이징 기법입니다. 필자의 경우 고등학생 시절 페이스북에서 처음 경험하게 되었고 개인적인 생각으로 지금보다 미래에 데스크톱 환경에서는 무한 스크롤이 필수나 더욱 대중화되지 않을까 생각됩니다. 왜냐하면 젊은 세대일수록 컴퓨터보다 스마트폰의 경험이 익숙하기 때문인데요.

 

유튜브, 트위터, 인스타, 페이스북 등 방대한 컨텐츠를 효과적으로 노출시키기 위해 주로 채택하고 있습니다. 하지만 무한 스크롤 선택은 무조건 적인 정답이 아니라는 점!

 

Infinite scroll

 

무한 스크롤은 페이지 하단 영역까지 스크롤될 경우 다른 컨텐츠를 새롭게 로딩해 페이지에 추가되는 방식입니다. 스크롤 액션 하나로 많은 양의 컨텐츠를 보여줄 수 있어 사용자 이탈을 막을 수 있는 장점이 있습니다.

 

리액트 환경의 무한 스크롤


리액트 환경에서의 무한 스크롤은 가장 좋은 조합이라 생각하는데요. 가상 DOM을 사용해 빠르고 부드러운 로드와 퍼포먼스를 보여주기 때문입니다. 다른 가상 DOM을 사용하는 뷰와 앵귤러에서도 마찬가지입니다. 

 

무한 스크롤 구현에 사용할 방법은 스크롤 이벤트가 아닌 기본 Web API로 제공되는 Intersection Observer를 사용할 것 입니다. 이전 이미지 Lazy Load에 사용했던 API로 타겟을 지정해 Viewport 감지를 비동기적으로 관찰하는 API입니다.

 

 

이미지 Lazy Load를 활용한 성능 최적화

요즘 성능 최적화에 관심이 많아져 쓰로틀링, 디바운스 다음으로 이미지 Lazy Load를 공부해봤습니다. 제가 만든 현재 스킨에도 적용을 해서 나름 만족하고 있습니다. Lazy Load 구현 순서는 대략 이

juni-official.tistory.com

 

 

전체 로직


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


const Home = ({list, total}) => {

    const obsRef = useRef(null); 	//observer Element
    const [List, setList] = useState(()=> list);	//Post List

    const [page, setPage] = useState(1); //현재 페이지
    const [load, setLoad] = useState(false); //로딩 스피너
    const preventRef = useRef(true); //옵저버 중복 실행 방지
    const endRef = useRef(false); //모든 글 로드 확인


    useEffect(()=> { //옵저버 생성
        const observer = new IntersectionObserver(obsHandler, { threshold : 0.5 });
        if(obsRef.current) observer.observe(obsRef.current);
        return () => { observer.disconnect(); }
    }, [])


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


    const obsHandler = ((entries) => { //옵저버 콜백함수
        const target = entries[0];
        if(!endRef.current && target.isIntersecting && preventRef.current){ //옵저버 중복 실행 방지
          preventRef.current = false; //옵저버 중복 실행 방지
          setPage(prev => prev+1 ); //페이지 값 증가
        }
    })

    const getPost = useCallback(async() => { //글 불러오기  
      setLoad(true); //로딩 시작
      try {
        const res = await axios({method : 'GET', url : `/api/db/post/read/list/?page=${page}`});      
        if(res.data.end){ //마지막 페이지일 경우
        endRef.current = true;
        noPostShow();
        setList(prev => [...prev, ...res.data.list]); //리스트 추가
        prevent_duple.current = true;        
      } catch (e) {
      	console.error(e)
      } finally {
          setLoad(false); //로딩 종료      
      }
    }, [page]);

	return(
        <ul className="postList">
          {
            List &&
            {
              List.map((post, idx) =>
                <li key={post.id + idx}>post.title<li/>
              )
            }        
          }
          {
            load &&
            <li className="spinner">
				로딩 스피너
            </li>
          }
          <li className='' ref={obsRef}>
          	옵저버
          </li>
        </ul>
    )
    
}

export default Home

 

전체 코드를 보면 눈이 아프다는 것..

 

아래 하나하나 순차적으로 설명을 하겠습니다. 먼저 무한 스크롤이 동작하는 순서는 아래와 같습니다.

 

  1. 첫 렌더링이 완료된 후 옵저버 생성
  2. 옵저버 Element가 화면에 감지될 경우 obsHandler() 실행
  3. obsHandler() 함수가 page값 변경
  4. useEffect 훅에 의해 getPost() 실행
  5. 데이터 조회 API 호출
  6. 데이터 렌더링 완료

 

이렇게 설명하면 간단하지만 모든 게시글 로딩이 완료됐을 때를 확인하는 것과 필자가 겪은 옵저버 중복 실행 이슈를 막기 위해 코드가 조금 더 늘어났습니다.

 

1. Observer 및  콜백함수 생성


const [page, setPage] = useState(1);
const preventRef = useRef(true); //중복 실행 방지
const obsRef = useRef(null); //observer Element
const endRef = useRef(false); //모든 글 로드 확인

useEffect(()=> {
    const observer = new IntersectionObserver(obsHandler, { threshold : 0.5 });
    if(obsRef.current) observer.observe(obsRef.current);
    return () => { observer.disconnect(); }
}, []);

const obsHandler = ((entries) => { 
    const target = entries[0];
    if(!endRef.current && target.isIntersecting && preventRef.current){ //옵저버 중복 실행 방지
      preventRef.current = false; //옵저버 중복 실행 방지
      setPage(prev => prev+1 ); //페이지 값 증가
    }
})

// ...

return(
	<>
    	//...
        
        <li className='' ref={obsRef}>
          	옵저버
        </li>
        //...
	<>
)

 

useEffect() 훅을 사용해 컴포넌트가 마운트 될 때  옵저버를 생성하고 언마운트될 경우 옵저버를 해제해줍니다. 그 후 콜백함수로 obsHanlder() 함수를 작성하는데요. observer.observe(obsRef.current) 안에 넣은 Element가 화면에 감지됐을 때와 사라졌을 때 obsHanlder() 함수가 실행되고 매개변수로 entries 값을 전달받습니다. 

 

entries value

 

여기서 해당 Element가 화면에 감지된 경우를 체크하기 위해서는 isIntersecting 값을 확인합니다. 위 obsHandler()isIntersecting 이외에 endRef와 preventRef 값을 한 번 더 체크합니다.

 

모든 포스팅 로드 여부를 알려주는 endRef와 중복 실행을 방지해주는 preventRef를 useRef 훅으로 만들었습니다. (불러오는 데이터가 무한정일 경우 endRef는 필요없습니다

 

 

(불필요한 렌더링을 막기 위해 useState() 보다 useRef() Hook을 사용합니다)

 

 

preventRef는 특정 환경에서 옵저버 핸들러가 2~3번까지 중복으로 실행되는 경우가 있었습니다. 다른 페이지로 넘어가고 다시 뒤로가기로 돌아오면 발생하더라고요.

 

이때 한 번만 실행시키기 위해 화면에 감지되면 prevent.current 값을 false로 변경해 중복 실행을 막습니다. 그리고 데이터를 정상적으로 받아 왔을 때, true로 변경해줍니다.

 

제일 중요한 페이지 값을 증가시킵니다.

 

setPage(prev=> prev+1)

 

2. getData()


...
const [page, setPage] = useState(1);
const [load, setLoad] = useState(false);

useEffect(()=> {
  if(page !== 1) getPost();
}, [page]);


const getPost = useCallback(async() => { //글 불러오기  
  setLoad(true); //로딩 시작
  try {
    const res = await axios({method : 'GET', url : `/api/db/post/read/list/?page=${page}`});      
    if(res.data.end){ //마지막 페이지일 경우
    endRef.current = true;
    noPostShow();
    setList(prev => [...prev, ...res.data.list]); //리스트 추가
    prevent_duple.current = true;        
  } catch (e) {
    console.error(e)
  } finally {
      setLoad(false); //로딩 종료      
  }
}, [page]);
...

 

page State가 변경되면, getPost() 함수가 실행될 수 있도록 useEffect() 의존성 배열에 잘 넣어줍니다. getPost() 함수가 실행되면, 로딩 스피너를 보여줘야하기 때문에 load State를 변경하고 데이터를 받아옵니다.

 

page 값을 넘겨주면 해당 페이지에 10개의 데이터를 전송해주는데, 개인적으로 마지막 호출을 구분하기 위해 전송할 데이터가 10개 이하일 경우 백엔드에서는 { end :  true } 값을 전달해 구분합니다.

 

클라이언트에서는 end 값을 체크해 더 이상 요청을 하지 않도록 endRef 값을  true로 변경합니다. 받아온 데이터를 렌더링해주면 무한스크롤이 완성됩니다.

 

 

infinite scroll

 

더미 데이터를 30개 정도 넣어 테스트해본 결과 목록에 잘 보여집니다. 그럼 이제 고양이 사진을 랜덤으로 뿌려주는 API를 활용해 무한스크롤을 적용해보도록 하겠습니다.

 

랜덤 냥냥이 무한스크롤 예제


import { useState, useEffect, useRef, useCallback } from "react";
import axios from "axios";

const RandomCat = () => {

    const [list, setList] = useState([]);
    const [page, setPage] = useState(1);
    const [load, setLoad] = useState(1);
    const preventRef = useRef(true);
    const obsRef = useRef(null);

    useEffect(()=> {
        getDog();
        const observer = new IntersectionObserver(obsHandler, { threshold : 0.5 });
        if(obsRef.current) observer.observe(obsRef.current);
        return () => { observer.disconnect(); }
    }, [])
    
    useEffect(()=> {
        getDog();
    }, [page])

    const obsHandler = ((entries) => {
        const target = entries[0];
        if(target.isIntersecting && preventRef.current){ 
            preventRef.current = false;
            setPage(prev => prev+1 );
        }
    })

    const getDog = useCallback(async() => { //글 불러오기  
        console.log('고양이 사진 불러오기');
        setLoad(true); //로딩 시작
        const res = await axios({method : 'GET', url : `https://api.thecatapi.com/v1/images/search`});
        if(res.data){
            setList(prev=> [...prev, {...res.data[0]} ]); //리스트 추가
            preventRef.current = true;
        }else{
          console.log(res); //에러
        }
        setLoad(false); //로딩 종료
    }, [page]);

    return(
    <>
        <div className="wrap min-h-[100vh]">
            {
                list &&
                <>
                    {
                        list.map((li)=> 
                            <img key={li.id} className="opacity-100 mx-auto mb-6" src={li.url} alt={li.dke} width={'500px'} height={'300px'} />
                        )
                    }
                </>
                
            }
            {
                load &&
                <div className="py-3 bg-blue-500 text-center">로딩 중</div>
            }
            <div ref={obsRef} className="py-3 bg-red-500 text-white text-center">옵저버 Element</div>
        </div>
    </>
    )
}

export default RandomCat

 

고양이 이미지를 무한으로 불러오는 코드입니다. 하단으로 스크롤시 옵저버 Element가 보이면서 고양이 이미지를 받아와 리스트에 추가해줍니다.

 

고양이 이미지 무한 스크롤
냥냥이 이미지 무한 스크롤

 

이미지가 불러오면서 로딩이 끊겨 보이는데요. Lazy Load 까지 적용해주면 좀더 부드럽게 로드되는 효과를 줄 수 있습니다.

 

참고


 

이미지 Lazy Load를 활용한 성능 최적화

요즘 성능 최적화에 관심이 많아져 쓰로틀링, 디바운스 다음으로 이미지 Lazy Load를 공부해봤습니다. 제가 만든 현재 스킨에도 적용을 해서 나름 만족하고 있습니다. Lazy Load 구현 순서는 대략 이

juni-official.tistory.com

 

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.

developer.mozilla.org