Lazy Loading

개발자 취준생 밍키·2022년 11월 9일

Lazy Loading

개요

사용자가 웹페이지를 열면 전체 페이지의 내용이 다운로드되어 단일 이동으로 렌더링 됨.
하지만 사용자는 다운로드한 모든 콘텐츠를 보는 게 아님.
원하는 부분만 잠깐 확인하고 페이지를 떠났을 때, 웹페이지에서는 메모리 및 대역폭 낭비 발생.
-> 페이지 엑세스 시 모든 콘텐츠를 한꺼번에 로드하는 대신, 사용자가 필요한 페이지 일부에 엑세스할 때만 콘텐츠를 로드하면 효율적

Lazy Loading 사용 시, 페이지가 placeholder 콘텐츠로 작성되며, 사용자가 필요할 때만 실제 콘텐츠로 대체됨.

생각했던 비슷한 해결책

  • 모든 리소스를 가져온 후, 20개 정도의 리스트만 보여준 뒤 '더보기' 버튼을 구현해볼까?
    -> 처음에 모든 리소스를 가져와야하기 때문에 속도에 차이가 없음
  • API 자체에 Pagination을 걸어서 특정 스크롤 위치에 도달하는 경우 지속적으로 API 요청해볼까?
    -> 프론트에서 해결하는 것이 목표이기 때문에 최후의 수단으로..!
    => 이미지를 처음에 필요한만큼만 로딩하고, 나머지는 필요한 타이밍에 로딩하는 것(Lazy Loading)이 최선이라고 생각

장점

  • 콘텐츠 전달 최적화, 최종 사용자 간의 경험을 간소화를 통해 균형 맞춤
  • 콘텐츠 로드 속도 빨라짐
  • 콘텐츠가 지속적으로 사용자에게 공급되므로 사용자가 웹사이트를 이탈할 확률을 낮춤
  • 리소스 비용(사용자의 배터리, 시간, 시스템 리소스)가 낮아짐

이미지 Lazy Loading

✅ 1) img 태그를 이용한 일반적 방법

이미지 로딩을 사전에 막기!

src 속성을 이용하면 이미지를 무조건 로드하므로, 대신 data-src 속성에 이미지 URL을 지정하면 src는 비워져 있고 브라우저는 해당 이미지를 로드하지 않음

-> 해당 이미지를 언제 로딩할 것인지 알려주어야 함
: 뷰포트에 들어오자마자 로딩해야함

=> 해당 이미지 로드가 완료되면 data-src에 있는 주소 값을 src값으로 세팅하고 data-src 속성은 삭제됨

어떻게?

✅ 2) JS 이벤트로 image load하기

scroll 이벤트 리스너 : 사용자가 페이지에서 스크롤하는 시점 확인 가능
resize 이벤트 리스너 : 윈도우 브라우저 크기가 바뀔때 발생
orientationChange 이벤트 리스너 : 디바이스 화면이 가로에서 세로 모드로 바뀔 때 발생
-> 모두 화면 내 보여지는 이미지 수가 바뀔 수 있으므로 이미지 로드하는 트리거가 필요할 수 있음

3개의 이벤트 중 하나가 발생할 때 뷰포트 안에서 로딩이 지연되었거나, 아직 로딩되지 않은 이미지들을 모두 찾음
-> 뷰포트 안에서 img 태그의 data-src 속성에 지정된 URL을 src 속성에 넣어서 이미지 로드

cf) scroll 이벤트는 스크롤 시 여러 이벤트가 급격히 발생하므로, 약간의 쓰로틀 기능을 이용해서 lazy loading 기능을 적용하는게 좋음. (캐러셀 공부할때도 나온 쓰로틀!)

document.addEventListener("DOMContentLoaded", function() {
  var lazyloadImages = document.querySelectorAll("img.lazy");    
  var lazyloadThrottleTimeout;
  
  function lazyload () {
    if(lazyloadThrottleTimeout) {
      clearTimeout(lazyloadThrottleTimeout);
    }    
    
    lazyloadThrottleTimeout = setTimeout(function() {
        var scrollTop = window.pageYOffset;
        lazyloadImages.forEach(function(img) {
            if(img.offsetTop < (window.innerHeight + scrollTop)) {
              img.src = img.dataset.src;
              img.classList.remove('lazy');
            }
        });
        if(lazyloadImages.length == 0) { 
          document.removeEventListener("scroll", lazyload);
          window.removeEventListener("resize", lazyload);
          window.removeEventListener("orientationChange", lazyload);
        }
    }, 20);
  }
  
  document.addEventListener("scroll", lazyload);
  window.addEventListener("resize", lazyload);
  window.addEventListener("orientationChange", lazyload);
});

: 처음 3개 이미지는 미리 로딩하게 구현, data-src 속성대신 src 사용하여 더 빠름, timeout 때문에 이미지 로드에 약간의 딜레이 발생

✅ 3) Intersection Observer API로 image load하기 🌟

엘리먼트 요소가 뷰포트에 들어가는 것을 감지하고 액션을 취하는 것을 간단하게 만들어줌.
JS 이벤트 방법은 엘리먼트가 뷰포트에 들어가는 것에 대해서 연산하는 것을 구현하고, 이벤트를 직접 바인드 시켰지만 Intersection Observer API는 그 부분을 쉽게 구현 가능!! 성능 면에서도 좋음

  • 모든 이미지에 observer 부착
  • 뷰포트에 들어간 것을 API가 감지했을 때 isIntersecting 속성을 통해 URL을 data-src 속성에서 src 속성으로 이동시켜 브라우저가 이미지를 로드하도록 트리거 일으킴
  • 전부 로드되면 lazy 클래스명을 이미지에서 삭제, 부착했던 옵저버 제거
document.addEventListener("DOMContentLoaded", function() {
  var lazyloadImages;    

  if ("IntersectionObserver" in window) {
    lazyloadImages = document.querySelectorAll(".lazy");
    var imageObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          var image = entry.target;
          image.src = image.dataset.src;
          image.classList.remove("lazy");
          imageObserver.unobserve(image);
        }
      });
    });

    lazyloadImages.forEach(function(image) {
      imageObserver.observe(image);
    });
  } else {  
    var lazyloadThrottleTimeout;
    lazyloadImages = document.querySelectorAll(".lazy");
    
    function lazyload () {
      if(lazyloadThrottleTimeout) {
        clearTimeout(lazyloadThrottleTimeout);
      }    

      lazyloadThrottleTimeout = setTimeout(function() {
        var scrollTop = window.pageYOffset;
        lazyloadImages.forEach(function(img) {
            if(img.offsetTop < (window.innerHeight + scrollTop)) {
              img.src = img.dataset.src;
              img.classList.remove('lazy');
            }
        });
        if(lazyloadImages.length == 0) { 
          document.removeEventListener("scroll", lazyload);
          window.removeEventListener("resize", lazyload);
          window.removeEventListener("orientationChange", lazyload);
        }
      }, 20);
    }

    document.addEventListener("scroll", lazyload);
    window.addEventListener("resize", lazyload);
    window.addEventListener("orientationChange", lazyload);
  }
})

: 앞보다 로딩시간 훨씬 빠름, 스크롤 시 이미지가 느리게 나타나지 않음
(적용 안되는 브라우저가 있을 수 있음)

✅ 4) Native Lazy Loading로 image load하기

임베딩 할 이미지에 'loading' 속성만 추가해주면 됨
구현하는데 개발자가 필요없을 정도...
(적용 안되는 브라우저가 있을 수 있음)

  • 속성
    lazy : 뷰포트에서 일정한 거리에 닿을 때까지 로딩을 지연
    eager : 현재 페이지 위치가 위, 아래 어디에 위치하던 상관없이, 페이지가 로딩되자마자 해당 요소를 로딩
    auto : 이 속성은 디폴트로 로딩을 지연하는 것을 트리거함. loading 속성을 쓰지 않는 것과 같음

로딩 지연된 이미지가 로드될 때 콘텐츠 밀림 방지하기

<img src="" loading="lazy" alt="" width="200" height="200">
<img src="" loading="lazy" alt="" style="height:200px; width:200px;">

올바른 image placeholder 이용 방법

  • 주요 색상을 placeholder로 사용하기
  • 저화질의 이미지로 placeholder로 사용하기

이미지 로딩을 위한 버퍼시간 추가 방법

사용자가 페이지에서 스크롤을 빠르게 할 시, 이미지가 로딩될 시간이 필요하다는 것이 placeholder로 보여지게 됨.
-> 이것은 이미지를 로딩하는 스크롤 이벤트를 성능 이슈로 인해 쓰로틀링을 사용해서 딜레이가 발생하게되는 것!

1)
이미지가 뷰포트에 완전히 들어올 때 로딩하는 대신, 뷰포트에 들어오는 부분에서 (예를 들면) 500px 정도 떨어진 곳에 들어오면 로딩을 하면 됨.
-> 로딩 트리거 시점을 더 빠르게 잡자는 의미

2)
Intersection Observer API에서는 'root'파라미터와 'rootMargin'파라미터(기본 CSS 마진 규칙에서 동작)를 함께 사용하여 'intersection'(이벤트가 발생되는 부분)을 찾는 위치를 증가시킬 수 있음

ex) 상단 3개의 이미지가 바로 보여질 때 5개의 이미지가 로딩, 4개의 이미지가 보여질 때 6개의 이미지가 로딩 -> 이미지가 완벽히 로딩되도록 충분히 시간을 가지게 함 -> 사용자가 placeholder를 볼일이 거의 없어짐

모든 이미지에 lazy loading 적용하지 않기

페이지 내 사용되는 모든 이미지에 lazy loadng 사용하는 것이 아님!

Lazy Loading에 어울리는 이미지 식별하는 기준

  • 뷰포트에 있는, 웹페이지에서 시작되는 이미지들에는 적용 금지
  • 뷰포트에서 살짝 떨어져있는 이미지들도 금지 (하단으로부터 500px 이내인 이미지)
  • 페이지가 길지 않은 경우
  • 미리 로딩할 리소스들을 결정하기 위해 디바이스 타입 고려

관련 JS 라이브러리

yall.js
lazysizes
JQuery Lazy
WeltPizel Lazy Loading Enhanced
Magento Lazy Image Loader
Shopify Lazy Image Plugin
Wordpress A3 Lazy Load 등

테스트 방법

간단하게 크롬 브라우저 개발자 툴 이용
: 브라우저 내 네트워크 tab > image 확인
: waterfall column에서 이미지 로딩 타이밍도 알 수 있음, 이미지 로딩 트리거 관련 이슈사항이 있을 때 각 이미지 로딩에 대해 식별하는 것에 도움 줌

컴포넌트 Lazy Loading

개인적으로 저희 프로젝트에는 Intersection Observer API 사용이 가장 좋아보입니다..!

대상 요소와 상위 요소 사이, 또는 최상위 document의 viewport 사이의 interaction 내 변화를 비동기적으로 관찰하는 방법
-> 화면에 내가 지정한 대상이 보이는지 관찰하는 API

저희 프로젝트에 적용한다면, 리스트를 뿌려주는 컴포넌트에 Interaction Observer을 적용시켜 현재 보이고있는(뷰 포트에 있는) 부분은 해당 리스트 컴포넌트를 로딩하고, 그 이외의 부분은 공용으로 사용되는 Loading 컴포넌트를 대신 보여주는게 어떨까요?

다른분이 API를 사용하여 구현한 코드를 참고하여 어떤 느낌으로 구현해야할지 작성한 코드입니다.

infinite scroll

: Lazy Loading의 대표적 솔루션
: 사용자가 페이지로 스크롤할 때 지속적으로 로드

기본적인 방법인 Scroll Event를 감지해서 유저가 화면 제일 끝에 도달했을 때 아이템을 불러오게도 할 수 있지만, Intersection Observer API를 사용하여 무한 스크롤을 구현해보겠습니다.

이유

  • Scroll Event의 debounce와 throttle을 사용하지 않아도 됨
  • Scroll Event의 offsetTop값을 구할때는 정확한 값을 구하기 위해 매번 layout을 새로 그리는 Reflow가 발생하는데, Reflow를 하지 않아도 됨
  • 사용하기 쉬워서

1) Intersection Observer 생성

let observer = new IntersectionObserver(callback, options);

2) 옵션 설정

  • root
    : 이 옵션에 정의된 element를 기준으로 Target Element가 노출되었는지, 노출되지 않았는지를 판단
    : 기본값은 Browser Viewport, root 값이 null이나 지정되지 않았을 때 기본값으로 설정됨
  • rootMargin
    : root에 정의된 element가 가진 margin값, threshold를 계산할때 rootMargin만큼 더 계산
  • threshold
    : Target element가 root에 정의된 element에 얼만큼 노출되었을 때 콜백함수를 실행시킬지 정의하는 옵션
    : number 또는 number[ ]로 정의
    : number로 정의할 경우엔 Target element의 노출 비율에 따라 콜백함수를 한번 호출할 수 있지만, number[ ]로 정의할 경우, 각각의 비율로 노출될 때마다 콜백함수를 호출

3) 구현

import { useEffect, useState } from "react";
import styled from "styled-components";
import Item from '../lazyLoading/Item'
import Loader from '../lazyLoading/Loader'


const AppWrap = styled.div`
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
  align-items: center;
`

const TargetElement = styled.div`
    width: 100vw;
    height: 140px;
    display: flex;
    justify-content: center;
    text-align: center;
    align-items: center;
`;

const InfiniteScroll = () => {

    const [target, setTarget] = useState(null);
    const [isLoaded, setIsLoaded] = useState(false);
    const [itemLists, setItemLists] = useState([1]);

    // useEffect(() => {
    //     console.log(itemLists);
    // }, [itemLists]);

    const getMoreItem = async () => {
        //Loader 컴포넌트 보이게
        setIsLoaded(true);
        
        //1.5초 기다린 후 아이템 로드하여 비동기통신처럼 보임
        await new Promise((resolve) => setTimeout(resolve, 1500));

        //Item을 state로 만들어 로드해올때마다 10개씩
        let Items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        //concat 함수를 이용해 ItemList에 이어붙임
        setItemLists((itemLists) => itemLists.concat(Items));

        //Loader 컴포넌트 안보이게
        setIsLoaded(false);
    };

    const onIntersect = async ([entry], observer) => {
        if (entry.isIntersecting && !isLoaded) {
        observer.unobserve(entry.target);
        await getMoreItem();
        observer.observe(entry.target);
        }
    };

    useEffect(() => {
        //IntersectionObserver 담을 observer 변수 선언
        let observer;

        if (target) {
            //IntersectionObserver 생성하여 observer에 담음
            observer = new IntersectionObserver(onIntersect, {
                threshold: 0.4,
            });

            //observer가 관찰할 대상(TargetElement) 지정
            observer.observe(target);
        }
        //disconnect로 관찰요소 없애고 새로 지정
        return () => observer && observer.disconnect();

        //의존성 배열 : target 요소가 바뀌면(새 아이템 받아오면) target state가 변경
    }, [target]);


    return (
        
            <AppWrap>
                {itemLists.map((v, i) => {
                    return <Item number={i+1} key={i}/>;
                })}
                <TargetElement ref={setTarget}>
                    {isLoaded && <Loader/>}
                </TargetElement>
            </AppWrap>

    );
};

export default InfiniteScroll;
profile
개발자가 되고싶어요

0개의 댓글