본문 바로가기

Frontend/성능 최적화

[홈페이지 이미지 지연 로딩] 웹 성능 최적화까지 해보자-10

이미지 지연 로딩

최적화에 앞서 네트워크 분석부터 해보자.

처음에는 당장 중요한 리소스인 bundle 파일이 다운로드되고, 그 다음으로 main1, 2, 3 이미지와 폰트가 다운로드되는 것을 확인할 수 있다.

그 이후 한동안 banner-video 파일이 다운로드되는 것을 찾아볼 수 있다.

 

그런데, banner-video는 페이지에서 가장 처음으로 사용자에게 보이는 콘텐츠인데 가장 나중에 로드되면 사용자가 첫 화면에서 아무것도 보지 못한 채로 오랫동안 머물게 되기 때문에 사용자 경험에 좋지 않을 것이다.

그래서, 동영상의 다운로드를 방해하는, 당장 사용되지 않는 이미지를 나중에 다운로드되도록 해서 동영상이 먼저 다운로드되게 하면 된다.

 

여기서 지연 로드할 이미지는 배너 아래에 있는 총 6개의 이미지이다.

이 이미지들은 화면에 보이는 순간 또는 그 직전에 이미지를 로드하도록 이미지를 지연 로드할 것이다.

 

Intersection Observer

하지만, 이미지 지연 로딩 작업을 위해서 스크롤이 이동했을 때 해당 뷰포트에 이미지를 보이게 할지 판단해야 하는데, 스크롤 이벤트에 이 로직을 넣으면 스크롤할 때마다 해당 로직이 너무 많이 실행된다.

 

그런데 이 문제를 해결할 수 있는 방법이 있는데, Intersection Observer이다.

Intersection Observer는 브라우저에서 제공하는 API인데, 이를 통해서 웹 페이지의 특정 요소를 관찰하면 페이지 스크롤 시 해당 요소가 화면에 들어왔는지 아닌지를 알려준다.

즉, 스크롤 이벤트처럼 스크롤할 때마다 함수를 호출하는 것이 아니라, 요소가 화면에 들어왔을 때만 함수를 호출한다는 것이다.

const options = {
	root: null,
	rootMargin: '0px',
	threshold: 1.0
};

const callback = (entries, observer) => {
	console.log('entries', entries);
}

const observer = new IntersectionObserver(callback, options);

observer.observe(document.querySelector('#target-element1'));
observer.observe(document.querySelector('#target-element2'));

options는 Intersection Observer의 옵션이다.

여기서 root는 대상 객체의 가시성을 확인할 때 사용하는 뷰포트 요소이고, 기본값은 null인데, null로 설정 시 브라우저의 뷰포트로 설정된다.

rootMargin은 root 요소의 여백이다.

즉, root의 가시 범위를 가상으로 확장하거나 축소할 수 있다.

threshold는 가시성 퍼센트이다.

대상 요소가 어느 정도로 보일 때 콜백을 실행할지 결정한다.

1.0으로 설정하면 대상 요소가 모두 보일 때 실행되고, 0으로 설정하면 1px이라도 보이는 경우 콜백이 실행된다.

callback은 가시성이 변경될 때마다 실행되는 함수이다.

options와 callback을 정의한 후, IntersectionObserver 객체를 생성하면 인스턴스(observer)가 나오는데 이 인스턴스를 이용해서 원하는 요소를 관찰할 수 있다.

대상 요소의 가시성이 변할 때마다 콜백이 실행되고 콜백에서는 첫 번째 인자로 가시성이 변한 요소(entries)를 배열 형태로 전달 받는다.

이번에는 이미지가 보이는 순간에 이미지를 로드할 것이기 때문에 이미지를 대상 요소로 잡고 적용해보자.

 

Intersection Observer 적용하기

가장 먼저 지연 로딩을 적용할 이미지는 다음과 같다.

이 이미지는 Card 컴포넌트에서 img 요소로 렌더링되고 있다.

import React from 'react'

function Card(props) {
	return (
		<div className="Card text-center">
		    <img src={props.image}/>
		    <div className="p-5 font-semibold text-gray-700 text-xl md:text-lg lg:text-xl keep-all">
			    {props.children}
		    </div>
		</div>
	)
}

export default Card

해당 컴포넌트에 지연 로딩 로직을 추가해보자.

Intersection Observer를 생성하고 img 컴포넌트를 관찰하면 된다.

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

function Card(props) {
  const imgRef = useRef(null);

  useEffect(() => {
    const options = {};
    const callback = (entries, observer) => {
      console.log("entries", entries);
    };

    const observer = new IntersectionObserver(callback, options);

    observer.observe(imgRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="Card text-center">
      <img src={props.image} ref={imgRef} />
      <div className="p-5 font-semibold text-gray-700 text-xl md:text-lg lg:text-xl keep-all">
        {props.children}
      </div>
    </div>
  );
}

export default Card;

useEffect 안에서 Intersection Observer를 생성했는데, 이렇게 하지 않으면 렌더링할 때마다 인스턴스가 생성되고 대상 요소를 관찰하게 되면서 대상 요소에 여러 개의 콜백이 실행될 것이다.

또한 생성된 인스턴스는 정리 함수에서 observer.disconnect 함수를 호출함으로써 리소스가 낭비되지 않도록 한다.

 

스크롤해보면, 세 이미지가 보이는 순간에 아래와 같은 로그가 출력되는 것을 볼 수 있다.

가장 중요한 것은 isIntersecting이라는 값인데, 이 값은 해당 요소가 뷰포트 내에 들어왔는지를 나타내는 값이다.

해당 값을 통해 해당 요소가 화면에 보이는 것인지 화면에서 나가는 것인지 알 수 있다.

이제, 화면에 이미지가 보이는 순간, 즉 콜백이 실행되는 순간에 이미지를 로드하면 된다.

 

이미지 로딩 img 태그에 src가 할당되는 순간에 일어난다.

따라서 최초에는 img 태그에 src 값을 할당하지 않다가 콜백이 실행되는 순간 src를 할당하게 되면 이미지 지연 로딩을 적용할 수 있다.

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

function Card(props) {
  const imgRef = useRef(null);

  useEffect(() => {
    const options = {};
    const callback = (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          console.log("is intersecting", entry.target.dataset.src);
          entry.target.src = entry.target.dataset.src;
          observer.unobserve(entry.target);
        }
      });
    };

    const observer = new IntersectionObserver(callback, options);

    observer.observe(imgRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="Card text-center">
      <img data-src={props.image} ref={imgRef} />
      <div className="p-5 font-semibold text-gray-700 text-xl md:text-lg lg:text-xl keep-all">
        {props.children}
      </div>
    </div>
  );
}

자세히 살펴보면 달라진 점을 알 수 있는데, 콜백 함수 내부 코드와 img 태그의 속성이 달라졌다.

원래 src에 넣었던 이미지 주소(props.image)를 data-src에 넣었다.

이렇게 하면 src 값이 할당되지 않기 때문에 이미지를 로드하지 않는다.

나중에 이미지가 뷰포트에 들어왔을 때 data-src에 있는 값을 src로 옮겨 이미지를 로드하기 위해서이다.

 

콜백에서는 entries에 있는 entry를 forEach로 검사하고 있다.

isIntersecting이 true라면 entry.target.src에 dataset.src 값을 넣는다.

그렇게 하면 해당 이미지가 로드되면서 화면에 보이게 된다.

 

Network 패널을 통해 확인해보면, 최초 페이지 로딩 시에는 main 1, 2, 3 이미지가 로드되지 않고 있다가 스크롤이 이미지 영역에 도달하면 콜백에서 작성한 로그 코드에 의해 로그가 출력되면서 세 이미지가 로드된다.

그래서 최초 페이지 로딩 시에는 보이지 않는 이미지가 우선순위가 높은 콘텐츠인 동영상의 로딩을 방해하지 않고 나중에 필요할 때 로드되는 것이다.

 

다른 이미지들도 같은 방식으로 적용해보면 된다.

import React, { useEffect, useRef } from "react";
import BannerVideo from "../components/BannerVideo";
import ThreeColumns from "../components/ThreeColumns";
import TwoColumns from "../components/TwoColumns";
import Card from "../components/Card";
import Meta from "../components/Meta";
import main1 from "../assets/main1.jpg";
import main2 from "../assets/main2.jpg";
import main3 from "../assets/main3.jpg";
import main_items from "../assets/main-items.jpg";
import main_parts from "../assets/main-parts.jpg";
import main_styles from "../assets/main-styles.jpg";

function MainPage(props) {
  const imgEl1 = useRef(null);
  const imgEl2 = useRef(null);
  const imgEl3 = useRef(null);

  useEffect(() => {
    const options = {};

    const callback = (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.src = entry.target.dataset.src;
          observer.unobserve(entry.target);
        }
      });
    };

    let observer = new IntersectionObserver(callback, options);
    observer.observe(imgEl1.current);
    observer.observe(imgEl2.current);
    observer.observe(imgEl3.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="MainPage -mt-16">
      <BannerVideo />
      <div className="mx-auto">
        <ThreeColumns
          columns={[
            <Card image={main1}>롱보드는 아주 재밌습니다.</Card>,
            <Card image={main2}>롱보드를 타면 아주 신납니다.</Card>,
            <Card image={main3}>롱보드는 굉장히 재밌습니다.</Card>,
          ]}
        />
        <TwoColumns
          bgColor={"#f4f4f4"}
          columns={[
            <img data-src={main_items} ref={imgEl1} />,
            <Meta
              title={"Items"}
              content={
                "롱보드는 기본적으로 데크가 크기 때문에 입맛에 따라 정말 여러가지로 변형된 형태가 나올수 있습니다. 실제로 데크마다 가지는 모양, 재질, 무게는 천차만별인데, 본인의 라이딩 스타일에 맞춰 롱보드를 구매하시는게 좋습니다."
              }
              btnLink={"/items"}
            />,
          ]}
        />
        <TwoColumns
          bgColor={"#fafafa"}
          columns={[
            <Meta
              title={"Parts of Longboard"}
              content={
                "롱보드는 데크, 트럭, 휠, 킹핀, 베어링 등 여러 부품들로 구성됩니다. 롱보드를 타다보면 조금씩 고장나는 부품이 있기 마련인데, 이럴때를 위해 롱보들의 부품들에 대해서 알고 있으면 큰 도움이 됩니다."
              }
              btnLink={"/part"}
            />,
            <img data-src={main_parts} ref={imgEl2} />,
          ]}
          mobileReverse={true}
        />
        <TwoColumns
          bgColor={"#f4f4f4"}
          columns={[
            <img data-src={main_styles} ref={imgEl3} />,
            <Meta
              title={"Riding Styles"}
              content={
                "롱보드 라이딩 스타일에는 크게 프리스타일, 다운힐, 프리라이딩, 댄싱이 있습니다. 보통 롱보드는 라이딩 스타일에 따라 데크의 모양이 조금씩 달라집니다. 많은 롱보드 매니아들이 각 쓰임새에 맞는 보드들을 소유하고 있습니다."
              }
              btnLink={"/riding-styles"}
            />,
          ]}
        />
      </div>
    </div>
  );
}

export default MainPage;