본문 바로가기

Frontend/성능 최적화

[홈페이지 이미지 사이즈 최적화] 웹 성능 최적화까지 해보자-11

이미지 사이즈 최적화

느린 이미지 로딩 분석

이전에 이미지 지연 로딩을 통해 배너의 동영상 콘텐츠를 먼저 다운로드할 수 있도록 했다.

그래서 지연 로딩이 적용된 이미지는 스크롤이 이미지 위치에 도달했을 때 로드되는 것을 확인할 수 있었다.

하지만 이미지 위치에 도달하면 로드되는 속도는 상당히 느리다.

Network 패널을 통해 이미지들을 살펴보면 파일 크기가 매우 큰 것을 볼 수 있다.

이렇게 이미지 사이즈가 크면 다운로드에 많은 시간이 걸리기 때문에 이미지 사이즈 최적화를 해볼 것이다.

 

이미지 포맷 종류

이미지 사이즈 최적화는 이미지의 가로, 세로 사이즈를 줄여서 이미지 용량을 줄이고 그만큼 더 빠르게 다운로드하는 기법이다.

이미지 사이즈를 줄이기 전에 이미지 포맷을 알아야 한다.

SVG와 같은 벡터 이미지가 아닌 비트맵 이미지 포맷 중 대표적인 세 가지 포맷을 살펴보자.

  • PNG
  • JPG(JPEG)
  • WebP

PNG는 무손실 압축 방식으로 원본을 훼손 없이 압축하고 알파 채널을 지원하는 이미지 포맷이다.

알파 채널은 투명도를 의미한다.

PNG 포맷으로 배경 색을 투명하게 해서 뒤에 있는 요소가 보이는 이미지를 만들 수 있다.

 

JPG는 PNG와는 다르게 압축 과정에서 정보 손실이 발생한다.

하지만 그만큼 이미지를 더 작은 사이즈로 줄일 수 있다.

고화질이어야 하거나 투명도 정보가 필요한 경우가 아니라면 JPG를 사용한다.

 

WebP는 무손실 압축과 손실 압축을 모두 제공하는 최신 이미지 포맷으로, 기존의 PNG나 JPG에 비해서 효율적으로 이미지를 압축할 수 있다.

하지만 WebP는 최신 이미지 파일 포맷이라서 지원하지 않는 브라우저도 있다.

 

정리해서 비교해보자면 다음과 같다.

  • 사이즈: PNG > JPG > WebP
  • 화질: PNG = WebP > JPG
  • 호환성: PNG = JPG > WebP

 

Squoosh를 사용하여 이미지 변환

현재 적용된 JPG 또는 PNG 포맷의 이미지WebP 포맷으로 변환해서 고화질, 저용량의 이미지로 최적화해보려고 한다.

그러기 위해서는 이미지를 변환해주는 컨버터가 필요한데, Squoosh를 사용해보려고 한다.

Width와 Height만 600으로 변경해서 Resize 설정은 마친다.

이는 화면에 보이는 이미지 사이즈가 300 x 300 px이기 때문이다.

화면이 가장 클 때 300 x 300 px이므로 600 x 600 px 사이즈로 이미지를 변환하는게 좋다.

이후, Compress 섹션의 압축 방식과 압축률을 WebP, 75로 설정한다.

이 값이 너무 작으면 화질이 많이 떨어지고 너무 크면 용량이 커져서 70 ~ 80 정도를 권장한다.

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.webp";
import main2 from "../assets/_main2.webp";
import main3 from "../assets/_main3.webp";
import main_items from "../assets/_main-items.webp";
import main_parts from "../assets/_main-parts.webp";
import main_styles from "../assets/_main-styles.webp";

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;

다시 확인해보면 빠른 속도로 로드되는 것을 확인할 수 있다.

이는 이미지의 사이즈가 크게 줄어 다운로드 시간이 짧아졌기 때문이다.

하지만 문제가 있는데, WebP는 효율은 좋지만 호환성 문제가 있다고 했다.

그래서 WebP로만 이미지를 렌더링할 경우 특정 브라우저에서는 제대로 렌더링되지 않을 수도 있다는 것이다.

 

이 문제를 해결하려면 단순 img 태그로만 이미지를 렌더링하면 안 되고, picture 태그를 사용해야 한다.

picture 태그는 다양한 타입의 이미지를 렌더링하는 컨테이너로 사용된다.

브라우저 사이즈에 따라 지정된 이미지를 렌더링하거나 지원되는 타입의 이미지를 찾아 렌더링이 가능하다.

# 뷰포트에 따라 구분
<picture>
	<source media="(min-width: 650px)" srcset="img_pink_flowers.jpg">
	<source media="(min-width: 465px)" srcset="img_white_flower.jpg">
	<img src="img_orange_flowers.jpg" alt="Flowers" style="width: auto;">
</picture>

# 이미지 포맷에 따라 구분
<picture>
	<source srcset="photo.avif" type="image/avif">
	<source srcset="photo.webp" type="image/webp">
  <img src="photo.jpg" alt="photo">
</picture>

이제 브라우저에서 변환한 WebP 이미지를 지원하지 않는 경우를 대비해서 picture 태그를 사용할 것이다.

그러면 브라우저가 WebP를 렌더링하지 못할 때 JPG 이미지로 렌더링하도록 코드를 수정해보자.

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";
import main1_webp from "../assets/_main1.webp";
import main2_webp from "../assets/_main2.webp";
import main3_webp from "../assets/_main3.webp";
import main_items_webp from "../assets/_main-items.webp";
import main_parts_webp from "../assets/_main-parts.webp";
import main_styles_webp from "../assets/_main-styles.webp";

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} webp={main1_webp}>롱보드는 아주 재밌습니다.</Card>,
            <Card image={main2} webp={main2_webp}>롱보드를 타면 아주 신납니다.</Card>,
            <Card image={main3} webp={main3_webp}>롱보드는 굉장히 재밌습니다.</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;
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) {
          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">
      <picture>
        <source data-srcset={props.webp} type="image/webp" />
        <img data-src={props.image} ref={imgRef} />
      </picture>
      <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;

Card 컴포넌트의 props에 webp라는 이름의 prop을 추가했다.

또, img 태그를 picture 태그로 수정하고, 안에 source 태그와 img 태그를 넣었다.

이렇게 하면 가장 상위에 있는 WebP를 우선으로 로드하고, 브라우저가 WebP를 지원하지 않으면 img 태그에 있는 JPG 이미지를 렌더링한다.

 

또한, 이전에 적용한 이미지 지연 로딩에 대해서도 신경을 써야 하는데, 이미지 지연 로딩을 위해 img 태그에 src 값을 바로 넣지 않고 data-src로 임시 저장한 뒤 Intersection Observer의 콜백이 실행됐을 때 data-src에 있는 값을 src로 옮겨 주었다.

source 태그에 있는 srcset도 이미지 주소가 바로 들어가면 이미지 지연 로딩이 되지 않기 때문에 이미지 주소를 data-srcset에 임시로 넣어 주고 콜백에서 srcset으로 옮겨준다.

 

이렇게 하면 이미지 지연 로딩을 유지한 채로 브라우저 호환성에 따라 이미지를 로드할 수 있다.

브라우저가 WebP를 지원하지 않더라도 원본 JPG 이미지가 아닌 최적화된 이미지로 렌더링될 수 있도록 최적화된 JPG 이미지를 생성 및 추가하자.

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";
import main1_webp from "../assets/_main1.webp";
import main2_webp from "../assets/_main2.webp";
import main3_webp from "../assets/_main3.webp";
import main_items_webp from "../assets/_main-items.webp";
import main_parts_webp from "../assets/_main-parts.webp";
import main_styles_webp from "../assets/_main-styles.webp";

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) {
          const sourceEl = entry.target.previousSibling;
          sourceEl.srcset = sourceEl.dataset.srcset;
          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} webp={main1_webp}>
              롱보드는 아주 재밌습니다.
            </Card>,
            <Card image={main2} webp={main2_webp}>
              롱보드를 타면 아주 신납니다.
            </Card>,
            <Card image={main3} webp={main3_webp}>
              롱보드는 굉장히 재밌습니다.
            </Card>,
          ]}
        />
        <TwoColumns
          bgColor={"#f4f4f4"}
          columns={[
            <picture>
              <source data-srcset={main_items_webp} type="image/webp" />
              <img data-src={main_items} ref={imgEl1} alt="" />
            </picture>,
            <Meta
              title={"Items"}
              content={
                "롱보드는 기본적으로 데크가 크기 때문에 입맛에 따라 정말 여러가지로 변형된 형태가 나올수 있습니다. 실제로 데크마다 가지는 모양, 재질, 무게는 천차만별인데, 본인의 라이딩 스타일에 맞춰 롱보드를 구매하시는게 좋습니다."
              }
              btnLink={"/items"}
            />,
          ]}
        />
        <TwoColumns
          bgColor={"#fafafa"}
          columns={[
            <Meta
              title={"Parts of Longboard"}
              content={
                "롱보드는 데크, 트럭, 휠, 킹핀, 베어링 등 여러 부품들로 구성됩니다. 롱보드를 타다보면 조금씩 고장나는 부품이 있기 마련인데, 이럴때를 위해 롱보들의 부품들에 대해서 알고 있으면 큰 도움이 됩니다."
              }
              btnLink={"/part"}
            />,
            <picture>
              <source data-srcset={main_parts_webp} type="image/webp" />
              <img data-src={main_parts} ref={imgEl2} alt="" />
            </picture>,
          ]}
          mobileReverse={true}
        />
        <TwoColumns
          bgColor={"#f4f4f4"}
          columns={[
            <picture>
              <source data-srcset={main_styles_webp} type="image/webp" />
              <img data-src={main_styles} ref={imgEl3} alt="" />
            </picture>,

            <Meta
              title={"Riding Styles"}
              content={
                "롱보드 라이딩 스타일에는 크게 프리스타일, 다운힐, 프리라이딩, 댄싱이 있습니다. 보통 롱보드는 라이딩 스타일에 따라 데크의 모양이 조금씩 달라집니다. 많은 롱보드 매니아들이 각 쓰임새에 맞는 보드들을 소유하고 있습니다."
              }
              btnLink={"/riding-styles"}
            />,
          ]}
        />
      </div>
    </div>
  );
}

export default MainPage;

최적화 후에는 이미지 다운로드 속도가 엄청나게 줄은 것을 확인할 수 있다.