개발/Etc

웹 성능 개선 - 이미지 lazy loading(리액트)

달리초이 2023. 1. 10. 23:29

 

[TL;DR]
리액트 환경이라면 react-lazyload 라이브러리가 참 좋다.

 

이미지 lazy loading에 앞서 뷰포트라는 개념을 알고 있는가?

 

1. 뷰포트(viewport)

MDN 뷰포트 문서에 따르면 아래와 같다.

컴퓨터 그래픽스에서, 뷰포트(viewport)는 현재 화면에 보여지고 있는 다각형(보통 직사각형)의 영역입니다. 웹 브라우저에서는 현재 창에서 문서를 볼 수 있는 부분(전체화면이라면 화면 전체)을 말합니다. 뷰포트 바깥의 콘텐츠는 스크롤 하기 전엔 보이지 않습니다.
뷰포트 중에서도 지금 볼 수 있는 부분을 '시각적 뷰포트'라고 부릅니다. 스마트폰에서 사용자가 화면을 확대했을 때와 같은 특정 상황에서 '레이아웃 뷰포트'의 크기는 변하지 않지만 시각적 뷰포트는 더 작아집니다.

 

간단히 말하자면, 통상적으로 visual viewport를 말하며, 브라우저에서 보여지는 영역을 의미한다.(아래 '요기요' 영역)

html을 다루다 보면 head 태그 안에 넣는 메타 태그에서도 뷰포트를 본 적이 있을 것이다.

<meta name="viewport" content="width=device-width, initial-scale=1.0">

참고로 content="width=device-width, initial-scale=1.0" 옵션은 페이지의 너비를 기기의 스크린(뷰포트) 너비로 설정하고, 첫 페이지 로딩시 확대/축소가 되지 않은 원래 크기를 사용한다는 의미이다. scale 조절이나 user-scalable을 선택할 수 있는 등 더 많은 옵션이 있다.

 

 

2. Intersection Observer API

리액트에서 이미지 lazy loading 을 위해 여러가지 방법이 있는데 대표적으로 intersection observer api를 이용하는 방법과 라이브러리를 이용하는 방법이 있다. 먼저 api를 이용하는 방법을 살펴보자.

 

MDN Intersection Observer API 문서를 살펴보면,

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

여담이지만 기술 문서가 한국어 번역을 제공한다는 것 자체에 감사함을 느끼지만, 종종 기술 정의를 보면서 내 모국어 독해력(?)이 의심될 때가 많다. 종종 외계어를 만나는 기분. 그래서 코드가 훨씬 편하게 느껴진다. 기술 수준이 비루할수록(나같은) 더 그렇다. 사용되는 용어부터 낯설기 때문인데, 사실 단어 하나하나마다 공부하지 않으면 모르는 것들 투성이라 그렇다. 우린 앞서 이걸 대비해서 미리 뷰포트라는 낯선 단어를 학습했으니 계속 살펴보자.

Intersection Observer API 는 그들이 감시하고자 하는 요소가 다른 요소(viewport)에 들어가거나 나갈때 또는 요청한 부분만큼 두 요소의 교차부분이 변경될 때 마다 실행될 콜백 함수를 등록할 수 있게 합니다. 즉, 사이트는 요소의 교차를 지켜보기 위해 메인 스레드를 사용할 필요가 없어지고 브라우저는 원하는 대로 교차 영역 관리를 최적화 할 수 있습니다.

예를 들면 내가 지연 로딩을 하려고 하는 이미지가 브라우저 화면 안에 보이는지, 아닌지를 판단할 수 있다는 말이다. 또 문서를 보면 페이지가 스크롤 되는 도중에 발생하는 이미지나 다른 컨텐츠의 지연 로딩에도 이용할 수 있다는 내용 또한 확인이 된다. 내가 원하는 바로 그 기능이다. 그럼 이제 적용을 해보자.

 

코드샌드박스를 이용해 샘플 코드를 먼저 실습을 해봤다. 스크롤 해보면 알겠지만 뷰포트에 들어오면 배경색이 진해지고 벗어나면 연해지는 것을 볼 수 있을 것이다.(배경색 ratio값 변경)

https://codesandbox.io/s/intersection-observer-bzf2wb?file=/index.html 

observer = new IntersectionObserver(handleIntersect, options);
observer.observe(boxElement);

new IntersectionObserver()를 통해 생성한 인스턴스를 초기화하고 관찰할 대상(boxElement)을 지정한다.
생성자는 2개의 인수(callback, options)를 가진다.

 

아래는 불필요한 코드를 걷어내고 사용한 코드의 대략적인 모습이다. 적용할 경우 이미지가 화면에 들어오기 전까지 호출하지 않는 걸 확인할 수 있다. 참고로 옵션은 기본값만 사용했기 때문에 넣지 않아도 상관없다. webp를 지원하지 않는 브라우저를 위해 picture 태그를 이용해 jpg를 호출하는 대체 코드를 작성했다.

import React, { useEffect, useRef } from 'react'

function ImageItem(props) {
    const imgRef = useRef(null);
    
    useEffect(() => {
    	// 콜백
        const callback = (entries, observer) => {
          entries.forEach(entry => {
            if(entry.isIntersecting) {
              const target = entry.target;
              const previousSibling = target.previousSibling;
              target.src = target.dataset.src;
              previousSibling.srcset = previousSibling.dataset.srcset;
              observer.unobserve(entry.target);
            }
          });
        }
        // 옵션
        const options = {
            root: null,		// 브라우저 뷰포트(기본값 null)
            rootMargin: "0px",	// root 범위를 확장/축소 가능(기본값 0px 0px 0px 0px)
            threshold: 0	//옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시
        };
        const observer = new IntersectionObserver(callback, options);
        observer.observe(imgRef.current);
	}, [])
    
	return (
    	<div>
        	<picture>
                <source data-srcset={webp이미지주소} />	// webp 있으면 먼저
                <img
                  data-src={jpg이미지주소}
                  alt={이미지대체문구}
                  ref={imgRef}
                />	// webp 파일 없을 때 jpg 호출
            </picture>
            <div>{props.children}</div>
        </div>
    )
}

export default ImageItem

적용 결과 화면이다. 웹 화면은 뷰포트가 커서 모바일 버전으로 확인했다.

모바일 화면으로보니 한가지 문제가 발생했다. 빠르게 스크롤 하면 뷰포트 안으로 들어오는 이미지들이 응답을 받기도 전에 노출이 되면서 순간적으로 공백이 보여진다. 이미지 영역이 비어있는 순간이 포착되는 거다. Layout Shift 수정을 해 놨더니 더 어색해 보인다. 참고로 Layout Shift 수정은 다음 글에서 설명할 예정이다. 위 영상을 기준으로 간단히만 언급하자면, 페이지가 로드될 때 이미지 영역을 미리 설정해두는 것을 말한다.

 

이 문제는 뷰포트 밖에 있는 이미지를 일정 부분 먼저 호출해 놓을 수 있다면 어느정도 해결이 가능하다. threshold 옵션으로 가시성을 조절할 순 있지만 테스트 해보니 뷰포트 안에 들어오는 영역만 확인이 가능하다. 그렇다면 lazy loading을 하면서 뷰포트 영역 밖의 이미지도 함께 조절할 수 있는 방법은 없을까?

 

 

3. react-lazyload 라이브러리

결론부터 말하자면 이 react-lazyload 라이브러리가 문제를 해결 해 주었다. 앞서 api를 이용해서 하는 것도 괜찮은 방법이지만, 이 라이브러리는 뷰포트 바깥 영역을 preload 하고 싶은 경우까지 쉽게 처리 해 준다. 게다가 사용법도 훨씬 쉽고, 무겁지 않아서 좋다. Intersection Observer는 callback 함수를 이용하지만 해당 라이브러리는 스크롤 이벤트를 이용하는 것으로 보인다.

https://www.npmjs.com/package/react-lazyload

 

react-lazyload

Lazyload your components, images or anything where performance matters.. Latest version: 3.2.0, last published: 2 years ago. Start using react-lazyload in your project by running `npm i react-lazyload`. There are 426 other projects in the npm registry usin

www.npmjs.com

- 설치 방법

npm install --save react-lazyload

- 사용법

import React from 'react';
import ReactDOM from 'react-dom';
import LazyLoad from 'react-lazyload';
import MyComponent from './MyComponent';

const App = () => {
  return (
    <div className="list">
      <LazyLoad height={200}>
        <img src="tiger.jpg" /> /*
                                  Lazy loading images is supported out of box,
                                  no extra config needed, set `height` for better
                                  experience
                                 */
      </LazyLoad>
      <LazyLoad height={200} once >
                                /* Once this component is loaded, LazyLoad will
                                 not care about it anymore, set this to `true`
                                 if you're concerned about improving performance */
        <MyComponent />
      </LazyLoad>
      <LazyLoad height={200} offset={100}>
                              /* This component will be loaded when it's top
                                 edge is 100px from viewport. It's useful to
                                 make user ignorant about lazy load effect. */
        <MyComponent />
      </LazyLoad>
      <LazyLoad>
        <MyComponent />
      </LazyLoad>
    </div>
  );
};

ReactDOM.render(<App />, document.body);

라이브러리를 호출하고 <LazyLoad> 로 감싸주기만 하면 끝.

내가 필요했던 preload 부분이 바로 offset={100} 속성이다. 숫자를 입력하면 뷰포트 아래 영역에서 그 픽셀만큼 더 호출해 준다. 숫자를 바꾸면서 테스트를 돌려보니 이미지 2줄 정도 미리 호출할 정도로 두니 적당할 것 같다.(자신의 작업에 맞춰 조절하는 게 필요하다.)

 

수정된 결과 화면의 움직임과 개발자도구에서 네트워크 탭을 통해 이미지를 응답받는 것을 보니 다행히 의도한대로 잘 움직이는 것 같다.

 

 

4. img 태그의 loading 속성

img 태그에 'loading' 이라는 속성이 이미지 호출을 지연시킬 수 있는 lazy 옵션을 제공하고 있다. 비교적 최신 브라우저에 적용되었기 때문에 각자의 사용 환경을 확인해 보고 사용하자. 현재도 크롬, 엣지, 오페라에서만 공식 지원하고 있다.

- 브라우저 지원 현황 : https://caniuse.com/?search=lazy 

<img src="..." loading="lazy" />
옵션 설명
auto 기본값으로 loading 속성을 생략한 것과 같다. 브라우저가 결정한다.
lazy 화면에 보이는 부분만 먼저 호출하고 보이지 않는 부분은 호출하지 않는다.(지연 로딩)
eager 페이지가 로딩될 때 이미지도 함께 호출한다.(즉시 로딩)

한가지 주의할 점은 이 방법으로 지연시키게 되면 호출하는 페이지 안에 이미지가 여러 개일 경우 첫번째 lazy loading 이미지를 호출할 때 나머지 모든 이미지를 한 번에 호출하게 된다. 뷰포트에 들어오는 이미지마다 개별적으로 하려면 위의 자바스크립트를 이용하는 방법을 추천한다.

 

 

 

[참고] - 지식 공유 감사합니다.🙏

프론트엔드 성능 최적화 가이드 - 유동균

Intersection Observer - 요소의 가시성 관찰 : https://heropy.blog/2019/10/27/intersection-observer/

웹 성능 최적화를 위한 Image Lazy Loading 기법 : https://helloinyong.tistory.com/297

728x90
반응형