무한스크롤(2), URL 과 IntersectionObserver API [TOY / Simple-SNS]

알락·2022년 11월 27일
0

Simple-SNS

목록 보기
9/9

URL 객체

현재 "Simple SNS" 프로젝트는 axios 라이브러리를 이용하여 클라이언트에서 서버로 데이터를 요청하여 받고 있다. 하지만 XMLHTTPrequestfetch를 사용할 때도 마찬가지로 서버에 요청을 하기 위해서는 우선 서버의 URL이 필요하다.

JavaScript에서는 웹브라우저에서 사용가능한 URL 객체가 있다. 단순히 이용가능한 URL 주소를 넣으면, URL 구성요소 에 맞게 파싱해준다. 이번 구현에서는 URL 객체를 요긴하게 사용하였다.

[URL 객체 사용]

let url = new URL("http://velog.io/@jejualrock")

url.protocol
// result : 'http'
url.host
// result : 'velog.io'
url.hostname
// result : 'velog.io'
url.origin
// result : 'http://velog.io'
url.pathname
// result : '/@jejualrock'

url의 필드 중에서는 searchParam 이라는 문자열을 반환하는 것이 아닌 또 하나의 객체가 있다. 이 객체는 URL을 파싱하여 저장해놓은 url 인스턴스에 쿼리를 "key=value" 형식으로 추가해주거나 존재하는 파라미터를 삭제하는 기능을 제공해준다.

[SearchParams 객체]

url.searchParams.append("limit", "5");
url.toString();
// result : 'http://velog.io/@jejualrock?limit=5'
url.searchParams.delete("limit")
url.toString();
// result : 'http://velog.io/@jejualrock"

IntersectionObserver API

무한스크롤을 구현하는 아이디어는 생각보다 간단하다. 스크롤을 내러서 끝에 도달했다는 것을 인지하게 하여 새로운 데이터를 요청하면 된다. 아이디어는 간단하지만, 구현은 힘이 좀 들어간다.
이 구현을 쉽게 만들어주는 API를 현대의 대부분의 브라우저에서 제공해주고 있다. 이 말은 모든 브라우저가 제공하지는 않는다는 것이다. 이 API는 IntersectionObserver이다.

IntersectionObserver는 HTML의 특정 엘리먼트를 쫓는다. 만약 해당 엘리먼트가 브라우저의 뷰포트에 들어온다면 미리 등록해놓은 콜백을 실행시켜준다.

[IntersectionObserver 사용법]

const observer = new IntersectionObserver(callback, options)

options 에는 root, rootmargin, threshold 의 값을 지정할 수 있다. 이 중 threshold는 대상의 몇 퍼센트가 뷰에 들어왔을 때 콜백함수를 실행할지를 지정할 수 있어 용이하다. root는 기본값으로 브라우저 뷰포트로 설정되어 있으며, 관찰할 대상보다 조상 요소의 엘리먼트를 설정하면 된다.

callback에 넘겨지는 인자는 entries와 observer가 있다. 사실 observer는 new로 만들어지는 객체 그 자체이다. entries는 앞으로 observer가 감시하고자 하는 요소를 배열의 형태로 가지고 있다.

생성된 인스턴스 observer는 observe(), unobserve(), disconnect() 메소드를 가지고 있다. 각각 감시 요소 추가, 감시 요소 삭제, 모든 감시 요소 제거의 기능을 제공해준다.


구현

⌞ 서버 데이터 요청

우선 스크롤이 바닥에 닿을 때마다 5개의 메세지를 요청한다고 설정했다. 그럼 데이터를 요청할때마다 limit은 변경할 일 없고, offset 값을 5씩 더해줘야 새로운 5개의 메세지를 요청할 수 있다. 해당 데이터 요청은 다음과 같이 구현을 했다. 현재 해당 리액트 컴포넌트의 코드가 길기 때문에 이해에 필요한 부분만 옮겨놓도록 하겠다.

[데이터 요청 코드]

const Plaza = (props)=>{
	//...생략
	const [data, setData] = useState([]);
	// State for displaying Loading
    const [scroll, setScroll] = useState({
        offset: 0,
        limit : 5,
    })
    
    // URL for fetch
    const getUrlQuery =  new URL("http://localhost:4000");
    
    // Fetch for request data to Server
    const getMessages = async (offset, limit)=>{
        setIsLoading(true);
        getUrlQuery.searchParams.append("skip",`${offset}`);
        getUrlQuery.searchParams.append("limit", `${limit}`);

        const result = await axios.get(getUrlQuery)
        .then(result=>{
            setData(data.concat(result.data));
            setScroll({
                ...scroll,
                offset : offset + 5
            });
            setIsLoading(false);

            return result.data;
        })
        .catch(err=>{
            return new Error(err);
        });

        if (result.length === 0 || !result) observer.disconnect();

        getUrlQuery.searchParams.delete("skip");
        getUrlQuery.searchParams.delete("limit");
    }
    
    //...생략
    
    useEffect(()=>{
        if (triggerLoad === true){
            setTriggerLoad(false);
            getMessages(scroll.offset, scroll.limit);
        }
    }, [triggerLoad])

현재 이 페이지에서 몇 번째 메세지를 보고 있는지에 대한 offset 값을 scroll이라는 상태 변수에서 관리하고 있다. 이 값은 데이터가 요청되고 난 이후에 설정해 준 limit 값과 동일하게 5를 더해주면서 갱신된다.

이렇게 관리되는 offset 값과 limit 값은 요청할 URL의 쿼리 파라미터로 추가된다. 이는 getUrlQuery라는 함수 내 변수에서 사용되고 있다. 메세지가 요청될 때마다 갱신된 offset 값과 limit 값을 serachParams 객체를 이용하여 파라미터로 추가해준다. 이후 해당 URL로 데이터를 요청하게 된다. 데이터 요청에 대한 응답을 받은 이후에는 URL을 초기화해준다.

⌞ 스크롤 이벤트 생성 및 처리

[Observer 감시 요소]

이 프로젝트에서 새로운 데이터를 요청하는 이벤트가 발생하는 조건은 위 그림과 같이 미리 설정한 빨강색 네모 박스가 브라우저 뷰포트에 모두 들어왔을 때다. 조건을 만족할 시에 위에서 잠깐 설명한 데이터 요청 함수를 실행하게 된다.

[Observer 코드]

const Plaza = (props)=>{
	// ... 생략
	// Trigger to reqeust more data
  	const [triggerLoad, setTriggerLoad] = useState(false);
  	const target = useRef(null);
	let observer;
  
	useEffect(()=>{
        observer = new IntersectionObserver((entries, observer)=>{
            if (entries[0].isIntersecting === true){
                setTriggerLoad(true);
            } else return;
        }, {threshold : 1});
      
        observer.observe(target.current);
      
        setTriggerLoad(true);
    },[])
  
  	return (
      	{/*생략*/}
    	<div className="red-box" ref={target}>
      	{isLoading ?
       		<img src="https://i.gifer.com/ZKZg.gif" width="50px" height="50px"/>
       	   : <></>}
       	</div>
         {/*생략*/}
    )
}

우선 위의 빨강 네모 박스에 해당하는 엘리먼트를 만들어주었다. 그리고 React의 useRef 훅을 이용하여 target이 해당 엘리먼트를 참조하게 만들었다. target.current를 통해 해당 요소에 접근할 수 있다.

Plaza 페이지를 처음 마운트 할 때 observer를 초기화해준다. 이는 빨강 네모 박스가 렌더링 되어야 감시 받는요소로 설정할 수 있기 때문이다. Observer 인스턴스를 생성할 때 넘겨주는 콜백함수를 확인해보자.
조건문에서 isIntersecting을 확인할 수 있다. 이는 감시받는 요소가 뷰포트(기본값)에 들어왔을 때 true를 반환한다. 옵션으로 threshold1을 지정해줬다. 이는 빨강 네모 박스에 해당하는 영역이 모두 뷰포트에 들어왔을 때 콜백함수를 실행시켜달라는 설정이다.

이 조건문이 없는 경우에는 스크롤이 바닥을 칠 때마다 연속 2번 데이터를 요청하는 문제가 있어서 추가해주었다.

[전체 코드]

// Tools
import {useState, useEffect, useRef} from 'react';
import axios from "axios";

// Components
import Send from "../component/Send";
import Message from "../component/Message";

const Plaza = (props)=>{
    const [data, setData] = useState([]);
    // Trigger to reqeust more data
    const [triggerLoad, setTriggerLoad] = useState(false);
    // State for displaying Loading
    const [isLoading, setIsLoading] = useState(false);
    const [scroll, setScroll] = useState({
        offset: 0,
        limit : 5,
    })
    // Ref for Trigger View
    const target = useRef(null);
    let observer;

    const getUrlQuery =  new URL("http://localhost:4000");

    // Fetch for request data to Server
    const getMessages = async (offset, limit)=>{
        setIsLoading(true);
        getUrlQuery.searchParams.append("skip",`${offset}`);
        getUrlQuery.searchParams.append("limit", `${limit}`);

        const result = await axios.get(getUrlQuery)
        .then(result=>{
            setData(data.concat(result.data));
            setScroll({
                ...scroll,
                offset : offset + 5
            });
            setIsLoading(false);

            return result.data;
        })
        .catch(err=>{
            return new Error(err);
        });

        if (result.length === 0 || !result) observer.disconnect();

        getUrlQuery.searchParams.delete("skip");
        getUrlQuery.searchParams.delete("limit");
    }

    const getMessageSended = (message)=>{
        setData([message].concat(data));
    }

    // init observer
    useEffect(()=>{
        observer = new IntersectionObserver((entries, observer)=>{
            if (entries[0].isIntersecting === true){
                setTriggerLoad(true);
            } else return;
        }, {threshold : 1});
        observer.observe(target.current);
        setTriggerLoad(true);
    },[])

    // trigger activate
    useEffect(()=>{
        if (triggerLoad === true){
            setTriggerLoad(false);
            getMessages(scroll.offset, scroll.limit);
        }
    }, [triggerLoad])

    return (
        <div className="wrapper" aria-label="plaza">
            <div className="content-width">
                <div className="box-colored" aria-label="title">
                    <div className="p-4 text-xl text-center">ZET</div>
                </div>
                <div className="mt-2 content-width border-b-2 p-4">
                    <Send getMessageSended={getMessageSended}></Send>
                </div>
            </div>
            <div className="content-width">
                {data.map(message=>{
                    return <Message key={message._id} message={message}></Message>
                })}
                <div className="flex flex-col items-center justify-center min-h-[200px]" ref={target}>
                {isLoading ?
                    <img src="https://i.gifer.com/ZKZg.gif" width="50px" height="50px"/>
                : <></>}
                </div>
            </div>
      </div>
    )
}

export default Plaza;

실수

1. offset과 limit이 갱신이 안된다.

구현하는 도중 offset과 limit이 갱신이 안되는 문제가 생겼다.
이는 getMessage 함수 내에서 offset과 limit이 담겨있는 scroll 상태변수를 직접 접근하였기 때문이다. 심지어 이를 IntersectionObserver의 생성자 콜백함수에 직접 넘겨주는 결례를 범했다. 이 Plaza 페이지가 렌더링되고 난 이후에 observer는 딱 한 번 처음 초기화를 한다. 이 때 넘겨준 getMessage 내에 직접 접근한 offset과 limit은 처음 그 값으로 고정되게 된다. offset과 limit은 상태 변수가 변경될 때마다 새로운 값으로 업데이트 될 수 있게, 인자로 넘겨주어야 한다.
리액트 컴포넌트의 함수와 변수들, 그리고 렌더링 과정을 다시 고민하게 되는 기회였다.

2. 스크롤 이벤트 때마다 데이터가 2번 요청된다.

이는 IntersectionObserver의 콜백함수에서 isIntersecting을 체크를 하면서 해결했다. 알고보니 이벤트 발생은 해당 감시받는 요소가 뷰포트에 들어올 때와 나갈 때 2 번 일어나는 것이었다.


개선할 점

이번 구현은 결국 또 다른 상태변수인 triggerLoad를 빌려 사용함으로써 급하게 일단락 되었다. 데이터 로딩이 필요할때마다 triggerLoad를 true로 바꿔주고, useEffect로 변화를 감지하여 데이터를 요청하게 만든 것이다. 하지만 IntersectionObserver에 넘겨주는 콜백함수를 적절하게 사용하면 다른 요인을 빌릴 필요없이 해결할 수 있을 것 같아 알아보려고 한다.

profile
블록체인 개발 공부 중입니다, 프로그래밍 공부합시다!

0개의 댓글