레이지 로딩 구현하기 (Intersection Observer)

Inhwa Lee·2022년 9월 8일
0

Performance

목록 보기
1/2

서론

해커뉴스 리액트 프로젝트 진행 도중 기사의 댓글을 보여주는 페이지에서 댓글과 대댓글이 너무 많아 로딩이 오래 걸리고 심지어 버벅거리는 현상이 발생했다. 그래서 1차적으로 댓글만 보여주고 대댓글이 있을 경우 더보기 버튼을 생성해서 사용자가 클릭하면 대댓글을 불러올 수 있도록 수정했다. 댓글과 대댓글이 많은 경우 큰 흐름을 보기에도 괜찮은 방법이었다. 하지만 댓글 자체가 많은 경우에는 여전히 로딩 속도가 느린 문제가 있었다.

레이지 로딩 구현 방법들

그래서 초기 로딩시 모든 댓글을 불러오는게 아니라 뷰포트에 들어온 댓글만 불러오도록 수정하기로 했다. 검색 결과 레이지 로딩을 하는 방법으로는 3가지 정도가 있었는데 다음과 같은 이유로 Intersection Observer를 사용하였다.

loading='lazy'

imgiframe의 레이지 로딩만 지원하고 FirefoxSafari에서는 아직 실험적인 단계이므로 패스

웹브라우저 수준에서 지원하는 레이지 로딩이다. 때문에 브라우저에서 자바스크립트가 비활성화된 경우에도 작동된다. 나중에 렌딩되는 이미지의 공간 확보를 위해 width값과 height값을 미리 지정해줘야 이미지 로드 후 레이아웃 이동이 발생하지 않는다. 사용법이 간편하고 뷰포트에 진입하기 전에 미리 로딩해서 완전한 이미지만을 볼 수 있도록 한다는 장점이 있으나 <img><iframe> 태그에만 사용 가능하고 모든 브라우저에서 안정적으로 지원하는 기능은 아니다.

<img src="image.png" loading="lazy" alt="이미지" style="height:200px; width:200px;">

scroll, resize, orientationChange

스크롤 이벤트로 인한 성능상의 이슈가 있으므로 패스

이벤트 핸들러로 레이지 로딩을 구현하는 것은 실무에서도 많이 쓰이는 방법이라고 한다. 이미지 로드를 즉시 트리거하는 scr 대신 data-src 속성에 이미지 경로를 넣고 스크롤, 리사이즈, 장치 방향 변경 이벤트가 발생했을 때 src 속성에 data-src의 값을 넣어주는 식으로 사용해볼 수 있다. 하지만 한번 스크롤을 할 때마다 여러번 이벤트가 트리거 되기 때문에 성능상의 이슈가 있고 스로틀이나 디바운스 등으로 이를 개선해주는 작업이 필요하다.

react-lazyload 라이브러리

성능 개선 작업 중에 외부 라이브러리를 사용하면서 번들 크기를 늘리고 싶지 않아서 패스

scroll, resize 이벤트 기반 리액트 레이지 로드 라이브러리이다. 다운로드 수도 꽤 많고 사용 방법도 쉬워 현재 프로젝트에 적용하기 괜찮아 보인다. 하지만 업데이트 된지 약 2년 정도 되었고 데모페이지에서 이미지 엑박이 떠서 제대로 작동하는지는 직접 설치해서 확인해봐야 하는 번거로움이 있다. 성능을 개선하는 작업 중에 용량이 작다고 하더라도 굳이 외부 라이브러리를 사용하면서 번들 크기를 늘리고 싶지 않았고 공부 차원에서도 딱히 좋은 선택은 아닌걸로 보였다.

intersection observer

최신 브라우저에서 대부분 지원하고 scroll 보다 성능적으로 나으며 공부 차원에서 직접 구현해보기 위해 선택

최신 브라우저 대부분에서 지원하고, scroll 이벤트와 getBoundingClientRect를 통해 요소의 가시성을 체크할 때와 달리 비동기적으로 실행되며 reflow를 발생시키지 않아 좋은 성능을 보여준다. 또한 루트 요소와 타겟 요소의 교차로 가시성을 판단하므로 웹페이지의 특정 요소가 뷰포트 영역에 들어왔는지를 디테일하게 감시할 수 있다는 장점이 있다. 반면 스크롤 속도나 멈춤 상태 등에 따라 등록된 콜백 함수 호출 여부를 결정하는 것은 scroll 이벤트에 비해 어렵다. 또한 브라우저마다 동작이 조금씩 다를 수 있고 Opera MiniIE에서는 지원하지 않는다.

intersection observer 사용법

intersection observer를 사용하기 위해서는 관찰자와 관찰 대상, 교차지점을 설정하는 옵션, 교차지점에 실행할 콜백을 등록해야 한다. 등록한 콜백은 메인 스레드에서 실행된다.

const options = {
  root: document.querySelector('#scrollArea'), // 관찰 대상의 가시성 기준이 되는 화면 (기본값: 뷰포트)
  rootMargin: '0px', // root가 가진 여백
  threshold: 1.0 // 관찰 대상이 root 안에 얼만큼 들어와야 콜백을 실행할지 (1 = 100%)
}

const callback = (entries, observer) => { // entries: 관찰 대상 배열, observer: 관찰자
  entries.forEach(entry => {
    if(entry.isIntersecting){ // 화면 안에 관찰 대상이 들어왔는지 체크
      observer.unobserve(entry.target); // 관찰 대상을 더이상 관찰하지 않음
    }
    // 기타 메서드는 아래 링크를 확인하세요.
    // https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry
  });
};

const observer = new IntersectionObserver(callback, options);
const target = document.querySelector('#listItem');
observer.observe(target);

리액트에서 사용한 예시

왜 안되지?

재사용성을 고려하여 LazyComponet를 만들고, 실제 레이지 로드할 CommentItem 컴포넌트를 children props로 넘겨줘서 사용하는 컨셉을 잡고 작업한 뒤 야심차게 확인해보니 예상과 달리 2가지 문제가 있었다.

문제 1. 스크롤 하지 않았음에도 이미 댓글의 데이터가 전부 불러와져 있었다. (여전히 불필요한 통신 발생)

문제 2. 교차 지점 컴포넌트가 들어오면 콘솔이 찍히도록 하고 확인해보니, 3개가 찍힐거라는 예상과 달리 13개가 찍혀있었고 13개가 찍힌 기준을 알기 어려웠다.

// LazyComponent.jsx

import { useRef, useEffect } from "react";

const callback = (entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {          // 교차 지점에 들어오면
      console.log(entry.target);         // 해당 컴포넌트를 콘솔로 찍어봄
      observer.unobserve(entry.target);  // 관찰을 끊는다.
    }
  });
};

const observer = new IntersectionObserver(callback, { threshold: 1 });

const LazyComponent = ({ children }) => {
  const targetRef = useRef(null);

  useEffect(() => {
    observer.observe(targetRef.current);
  }, []);

  return <div ref={targetRef}>{children}</div>;
};

export default LazyComponent;
// CommentList.jsx
// 실제 레이지 로드할 CommentItem을 LazyComponent의 children props로 전달

{data.kids &&
  data.kids.map((id, index) => (
    <LazyComponent key={index} children={<CommentItem id={id} />} />
))}

아하, 해결!

차분히 살펴보니 react-query를 사용해서 댓글 데이터를 비동기적으로 불러오고 있어서 생긴 문제였다. 데이터를 불러오는 시점에 대한 별도의 조건 처리를 하지 않았기 때문에 컴포넌트를 생성하는 시점에서 이미 데이터 통신이 시작되었고, 데이터가 완전히 불러와 지기 전에는 스켈레톤이 보여지게 처리해놨었기 때문에 빨리 지나가서 눈으로 확인하지는 못했지만 스켈레톤이 약 13개 화면에 배치되었던 것이다.

문제 1. 이미 댓글의 데이터가 전부 불러와져 있었다. (여전히 불필요한 통신 발생)

원인 : 컴포넌트가 교차 지점에 들어오는 시점이 아닌 컴포넌트를 생성하는 단계에서 데이터 통신이 일어났기 때문이었음

해결 : 데이터를 원하는 시점에 불러오기 위해 레이지 컴포넌트에 화면에 진입했는지에 대한 여부를 관리하는 상태를 두고, 실제 레이지 로딩을 할 컴포넌트에 props로 상태를 전달해 useQuery의 enabled 옵션 값으로 넣어주는 방식으로 해결 (useQuery는 enabled 값이 true일 때만 데이터를 불러오는 콜백 함수를 실행한다.)

문제 2. 교차 지점 컴포넌트가 들어오면 콘솔이 찍히도록 하고 보니 2개가 찍힐거라는 예상과 달리 13개가 찍혀있었고 13개가 찍힌 기준을 알기 어려웠다.

원인 :데이터가 불러와지는 동안 스켈레톤이 교차 지점에 13개 정도 들어와 보여지고 있었음

해결 : 초기에는 최소한의 데이터 통신만 발생하게 하기 위해 스켈레톤 컴포넌트의 높이를 80vh로 수정하고, 교차 지점에 진입하자마자 통신이 일어나도록 스타일 및 설정을 수정함

// LazyComponent.jsx
// LazyComponent가 교차 지점에 있는지 여부를 체크하는 isInView 상태를 추가

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

const LazyComponent = ({ children }) => {
  const [isInView, setIsInView] = useState(false);
  const targetRef = useRef(null);
  
  const callback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {          // 교차 지점에 들어오면
        setIsInView(true);                 // 상태를 true로 변경하고
        observer.unobserve(entry.target);  // 관찰을 끊는다.
      }
    });
  };
  
  const observer = new IntersectionObserver(callback, { threshold: 0 });

  useEffect(() => {
    observer.observe(targetRef.current);
  }, []);

  return (
    <div ref={targetRef}>
      {React.cloneElement(children, { isInView })} // 실제 레이지 로드 할 컴포넌트에 isInView 상태 전달
    </div>
  );
};

export default LazyComponent;
// CommentList.jsx
// 실제 레이지 로드할 CommentItem을 LazyComponent로 감싸서 사용하는 컨셉

{data.kids &&
  data.kids.map((id, index) => (
    <LazyComponent key={index}>
      <CommentItem id={id} />
    </LazyComponent>
  )
)}
// CommentItem.jsx
const CommentItem = ({ id, type = "parent", isInView }) => {
  const { isLoading, isError, data } = useQuery([id], () => getStory(id), {
    ...queryOptions,
    enabled: isInView, // 이제 isInView가 true일 때만 데이터를 불러온다.
  });

마무리

이번 작업에서 느낀 것은 Intersection Observer에 대한 이해도 중요하지만 그것보다 우선시 되어야 하는 것은 코드가 동작되는 과정을 잘 이해하고 있어야 한다는 것이다. 그래야 문제를 보다 빠르게 파악하고 해결할 수 있음을 느꼈다.

또한 레이지 로딩을 구현하는 위의 방법들 중에 어느게 더 우월하거나 한 것은 없다. 처음에는 scroll 이벤트를 통한 레이지 로딩은 Intersection Observer로 대체되는 기술이라고 생각했다. 하지만 어느 분의 블로그를 통해 상황에 따라 scroll 이벤트로 구현해야 할 때도 있다는 것을 알게 되었다. (광고가 노출된 횟수를 감지해야하는 상황에서 사용자가 빠르게 스크롤 해서 광고를 지나친 경우에는 카운트 할 필요가 없으므로 교차 지점에 들어오면 콜백이 실행되는Intersection Observer보다는 scroll과 디바운스를 이용하는 것이 더 효율적이었다고 한다.) 간혹 배울게 많아 급한 마음에 눈에 보이는 것만 집고 넘어갈 때가 있는데, 깊이 있는 지식으로 상황에 따라 적절한 기술을 선택할 줄 아는 개발자가 되고 싶다.

참고

profile
Frontend Developer

0개의 댓글