치춘짱베리굿나이스

IntersectionObserver + 무한스크롤 본문

ClientSide/React

IntersectionObserver + 무한스크롤

치춘 2022. 5. 12. 03:17

IntersectionObserver

무한스크롤을 구현해야 해서 이전에 합류한 프로젝트 코드를 (모바일 뷰 구현할 때 무한스크롤 방식으로 게시글을 불러왔기 때문) 찾아보고 있었는데, 보편적인 방식은 Intersection Observer를 거는 방법 같다

이름만 들어보면 벌써 공포스러운데 차근차근 정리해보도록 하겠다

Intersection Observer가 뭐지?

한국어로 교차 관찰자 / 탐지자 라고 한다 한국말로 풀어도 어렵다 ㅋㅋ

‘타겟 요소’와 ‘타겟의 상위 요소 또는 최상위 요소의 뷰포트’가 교차되는 부분을 비동기적으로 관찰하는 API라고 한다

뷰포트라 함은 우리가 웹 페이지를 보고 있는 바로 그 화면을 의미하며, css 작업을 할 때 vw, vh 단위로 비교적 익숙할 것이다

쉽게 말하자면 어떤 공간에 CCTV를 설치해 놓고 (observer) CCTV가 지켜보고 있는 범위 (root) 내에 누군가 (target) 의 전신이 일정 비율 (threshold) 이상 보일 경우 (상반신, 어깨 위 등...) 그 사람을 잡아가거나 혼을 내는 등의 행동을 정의 (IntersectionObserverCallback)하는 것이다

예전에는?

각 요소마다 스스로의 교차 탐지 루틴이 존재하고, 이 모든 것들이 메인 스레드 위에서 동작했다

여러 섹션에 존재하는 요소들이라 하더라도 모든 코드가 메인 스레드에서 실행되기 때문에, 온갖 이벤트 핸들러와 루프를 걸어 구현하다 보니 하나가 꼬이면 모든 성능이 저하되었다

지금은?

교차 탐지 API를 이용하면 특정 두 요소의 교차부분이 변경될 때만 실행되는 콜백 함수를 등록하고, 이 함수는 비동기적으로 감시하여 실행되므로 메인 스레드에 모든 코드를 동작하게 할 필요 없이 최적화된 형태로 실행시킬 수 있다

어디다 사용할까?

  • 스크롤을 할 때마다 추가적인 데이터를 fetching 하여 하단에 더 많은 컨텐츠를 로딩 + 렌더링하는 무한 스크롤의 구현
  • 페이지의 스크롤 되는 도중에 발생하는 이미지 등 컨텐츠의 지연 로딩 (lazy loading) ⇒ 자원 절약
  • 광고 수익 계산을 위한 광고의 가시성 보고 (사용자에게 광고가 비춰지고 있는지 아닌지)
  • 사용자가 결과를 볼 수 있는지 없는지에 따른 작업 / 애니메이션 수행 여부 결정

Intersection Observer가 제공하는 것

  • 대상 요소가 뷰포트 / 특정 요소 (API 내에서 root라 불림) 와 교차할 때 콜백 생성
  • Observer가 타겟을 관측하도록 최초로 요청받을 때마다 콜백 생성

Intersection Observer에 제공해야 하는 것

  • 대상 요소
  • 교차를 감시하고자 하는 상위 요소
  • 겹치는 정도 (threshold)
  • 사용자 정의 콜백 함수

위의 값들을 사전에 지정해 주고, Intersection Observer는 대상 요소와 상위 요소가 threshold 이상으로 겹쳐졌을 때 사용자가 등록한 콜백 함수를 실행한다

사용 방법

1. Intersection Observer 생성하기

let options = {
    root: // 상위 요소
    rootMargin: // 상위 요소의 여백, 상 우 하 좌
    threshold: // 겹치는 정도, 0 ~ 1 사이의 값으로 표현됨
}

let observer = new IntersectionObserver([콜백 함수], options);

// options를 따로 선언하지 않고 중괄호 그대로 넣어도 상관 없다
  • root: 대상 요소의 상위 요소를 지정한다. 따로 지정하지 않을 경우 기본값으로 null이 들어가며, 현재 브라우저의 뷰포트로 자동 지정된다
  • rootMargin: 상위 요소의 margin을 설정하며, 이 여백은 교차성을 계산하기 전에 영역 크기를 잴 때 적용된다
  • threshold: 대상 요소의 가시성 퍼센티지를 나타내며, 0 ~ 1 사이 값을 갖는다. 배열이 들어갈 수도 있으며, 배열 안에 있는 값으로 가시성이 변경될 때마다 콜백이 실행된다. 예를 들면 [0, 0.25, 0.5, 0.75, 1] 이렇게 들어왔다면 대상 요소가 교차하는 (보여지는) 영역 비율이 0, 0.25, 0.5, 0.75, 1 일 때 콜백 함수가 실행된다는 뜻이다

대개 observeruseEffect 내에서 선언하고 대상 요소와 연결하며, 그 말인즉슨 컴포넌트가 렌더링되었을 때 연결된다는 뜻이다

2. 대상 요소 지정하기

const target = useRef(null);

useRef을 통해 옵저버를 달고 싶은 대상요소를 선택하자 (감시대상을 선택하자)

일단 선언부에서는 null로 초기화해 준다 (나중에 jsx 태그에 연결해줄 것이기 때문)

주의할 점이 null로 초기화하기 때문에 뒤에서 예외처리를 제대로 안 할 경우 타입스크립트가 울부짖는다

return (
    ...
    <div ref={target}>안녕하쇼</div>
    ...
);

요소를 useRefref prop으로 가져온다

현재 요소와 요소에서 사용가능한 모든 props의 값이 표시되는 것으로 잘 가져왔군 알 수 있다

if (target && target.current)    
    target.current && observer.observe(target.current);

그리고 대상 요소를 옵저버에 연결시켜 준다

조건문으로 target.current에 값이 있을 때만 실행되도록 한 이유는 타입스크립트가 target.currentnull일 때를 매우 싫어하기 때문이다

target이 아니라 target.current인 이유는 target의 타입은 observer.observe가 요구하는 Element 타입이 아니라 React.MutableRefObject<null> 타입이며, target이 갖고 있는 target.currentElement이기 때문

쉽게 말하자면 useRef으로 가져온 값을 바로 사용하는 것이 아니라 그 안에 들어있는 current라는 요소가 html 태그의 정보를 담고 있으므로 이것을 사용해야 한다는 뜻이다

observer.observe(target?.current)

이렇게만 하면 잘 작동할 것 같지만, target 자체의 값이 존재하지 않을 경우 (null이나 undefined) null을 뱉으므로 observer.observe 에 null이 들어가 버린다

observer.observe에는 Element 형식의 값이 들어갈 수 있도록 보장되어야 한다... 그래야 타입스크립트가 편안해진다 타입스크립트가 소리지른다 막

3. intersect 되었을 경우 실행되는 함수 작성

const onIntersect: IntersectionObserverCallback = useCallback(
    async ([entry], observer) => {
        if (entry.isIntersecting) {
            observer.unobserve(entry.target);
            await onIntersectFunc(); // Data Fetching 함수
            observer.observe(entry.target);
        }
    }, [onIntersectFunc]
}

useCallback에 안 담을 거면 useEffect 안에서 선언하라고 무진장 오류 띄우는데 useEffect를 복잡하게 만들고 싶지 않아서 그냥 따로 분리하고 useCallback을 사용하였다

useCallback에 대한 건 나중에 적되 간단하게 적어보자면 의존성 배열에 들어있는 값 (예시에서는 onIntersectFunc)이 변할 때만 함수를 새로 만드는 거라 생각하면 된다

따라서 평소에는 함수를 재정의하지 않고 캐싱하여 사용하기 때문에 메모리 낭비가 덜하다

onIntersectFunc은 밖에 따로 선언한 함수로, 교차가 감지될 때마다 (isIntersecting이 참일 때) 실행되므로 조건문 안에 배치되어 있다

entry.isIntersecting은 주기적으로 (빠르게) 대상 요소가 루트 요소와 겹쳐진 상태에서 요소가 보이기 시작하는지 여부를 검사하여 반환한다

threshold를 작게 주었다면 기준치가 낮으므로 대상 요소가 조금이라도 빼꼼 고개내밀 때마다 true가 되고, threshold를 많이 주었다면 기준치가 높으므로 대상 요소가 많이 드러날 때 true가 된다

observer.unobserve()는 인자로 받은 대상의 관찰을 중단하고, observer.observe()는 인자로 받은 대상의 관찰을 재개한다

관찰 중단 + 재개를 굳이 넣는 이유는 데이터를 받는 도중에 관찰이 진행되어 특정한 값이 바뀌기라도 하는 경우 예상치 못한 동작을 할 수도 있기 때문이지

4. 작성한 함수와 옵저버 연결

useEffect(() => {
    let observer: IntersectionObserver;
    if (target && target.current) {
        observer = new IntersectionObserver(onIntersect, { threshold: 0.4 });
        observer.observe(target.current);
    }
    return () => observer && observer.disconnect();
}, [onIntersect]);

useEffect 내에 observer를 선언하고 연결하게 되면, useEffectonIntersect 함수에 의존성을 가진다

이때 onIntersect가 콜백 함수가 아닐 경우 자칫 잘못해서 특정 값을 건드렸을 때 오류의 위험이 있다

useEffect의 반환문에 함수를 선언하면 컴포넌트가 언마운트 될 때마다 해당 함수가 실행되며, 컴포넌트가 언마운트되면 옵저버가 감시하던 모든 요소들의 감시를 해제하라는 의미이다

컴포넌트가 언마운트되었는데 옵저버가 존재하지 않는 요소의 감시를 계속할 필요는 없으니...

async ([entry], observer) => {

entry는 현재 옵저버가 감시하고 있는 대상 요소들의 정보가 들어간다

‘대상 요소들’ 이라는 점에서 알 수 있듯 배열이며, 위 예제에선 감시하는 요소가 1개밖에 없으므로 1개만 가져온다

 

entry에 포함되는 정보는 다음과 같다

  • bouncingClientRect: 관찰 대상 요소의 사각형 영역 정보, 대상 요소의 원래 크기를 뜻한다
  • intersectionRect: 관찰 대상 요소의 교차한 영역 정보, 대상 요소가 상위 요소에 가려져서 보이는 일부분의 크기를 뜻한다
  • intersectionRatio: 관찰 대상의 교차 영역 백분율 (intersectionRect / bouncingClientRect)
  • isIntersecting: 관찰 대상의 교차 상태, intersectionRatiothreshold를 넘어가면 true 반환
  • rootBounds: 상위 요소의 사각형 영역 정보
  • target: 대상 요소
  • time: 교차 영역의 변경이 발생한 시간정보

observer에는 위에서 선언한 observer를 인자로 넣어주며, 감시하고 있는 주체가 누구인지 정의한다

위의 모든 요소들을 사용해서 지금 observer가 감시중인 요소가 교차되었는지, threshold는 넘었는지 알 수 있으며, 경계값을 넘었다면 (isIntersecting: true) 무슨 행동을 취할 것인지 정의할 수 있다

얘네를 사용해서 지금 요소간 교차가 되었는지, 교차되었다면 무슨 행동을 할 것인지 정의한다

5. intersect hook 만들기 (useIntersect)

지금까지 작업한것만 봐도 분량이 꽤 되는데 이걸 컴포넌트 내부에 그대로 때려넣자니 useEffect가 지나치게 복잡해진다

useEffect는 쓰고 싶은데 코드가 길어질까봐 두렵다면 커스텀 훅을 만들면 된다

export const useIntersect = (intersectFunc, threshold) => {

    const target = useRef(null);

    const onIntersect: IntersectionObserverCallback = useCallback(
    async ([entry], observer) => {
        ...
    }, [onIntersectFunc]);

    useEffect(() => {
        let observer: IntersectionObserver;
        ...
    }, [target, threshold, onIntersect]);
return target;
}

우선 target을 훅 외부에서 연결할 수 있도록 useRef로 하나 만들어주고, 이를 반환하여 훅의 외부에서 사용할 수 있도록 한다

그리고 위에서 만든 onIntersect 함수와 useEffect를 붙이고, 의존성을 추가한다

나는 intersect 될 때마다 원하는 함수를 사용하기 위해 intersectFunc를 인자로 받아왔고, threshold도 그냥 입력이나 테스트가 쉽도록 밖에서 인자로 받아왔다

6. 훅 연결 및 동작

const intersectTarget = useIntersect(foo, 0.4);

...
<div ref={intersectTarget}>내가 타겟</div>

컴포넌트로 돌아와서 훅을 사용하여 ref를 받아오고, 이 ref를 html 태그 내에 심으면 해당 태그의 요소가 옵저버에게 감시당하게 된다

연결한 대상 요소 (리스트 맨 하단의 로딩 요소) 가 일정 비율 보여질 때마다 데이터를 더 가져오는 것을 볼 수 있다

결론

무한스크롤이 되기는 하지만 스크롤이 너무 빠르게 움직여 뭔가 안 이쁘게 움직여서 이래저래 찾아보는 중이다...

처음에는 훅으로 굳이 뺄 필요 있나? 했는데 온갖 오류를 잡으면서 누더기골렘이 되는 코드 때문에 훅으로 분리하고 광명을 찾았다

이번 과제에서는 교차될 때마다 실행되는 함수 intersectFunc를 다음 페이지의 데이터 받아오는 용도로 사용했는데, 알아두면 다른 쓸만한 구석도 있지 않을까?

예전에 합류한 팀의 레포를 참고자료로 활용했는데, 자바스크립트에선 오류나지 않고 잘 돌던게 타입스크립트에서 그대로 쓰자니 시뻘겋고 샛노래서 너무 무서웠다

타입스크립트... 어려운녀석

참고자료

Intersection Observer API - Web API | MDN

 

Intersection Observer API - Web API | MDN

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

developer.mozilla.org

React - Intersection Observer API를 사용하여 인피니트 스크롤 구현하기

 

React - Intersection Observer API를 사용하여 인피니트 스크롤 구현하기

스크롤이 특정 포지션을 지나갔을 때 아이템을 추가로 로드하는 인피니트 스크롤을 최근에는 Intersection Observer 를 이용해 구현했다. 이전에 scrollTop 같은 속성을 이용하는 것보다 훨씬 편하다. 물

godsenal.com

 

'ClientSide > React' 카테고리의 다른 글

react-portal 사용해보기  (0) 2022.05.15
데이터 불러오기, Suspense  (0) 2022.05.13
Custom Hook  (0) 2022.05.09
Too many re-renders 오류  (0) 2022.05.07
CSS Module  (0) 2022.05.04
Comments