ํ”„๋กœ์ ํŠธ

[react] ์ด๋ฏธ์ง€ ์ตœ์ ํ™”ํ•˜๊ธฐ

Yuuuki 2024. 7. 13. 21:20

์ง„ํ–‰์ค‘์ธ ํ”„๋กœ์ ํŠธ๋Š” ๋ฐ˜๋ ค๋™๋ฌผ์˜ ๊ธฐ๋ก๊ณผ ์‚ฌ์ง„ ๊ณต์œ ๊ฐ€ ์ฃผ๋œ ๊ธฐ๋Šฅ์ธ ๋งŒํผ, ์ด๋ฏธ์ง€ ์ตœ์ ํ™” ์ž‘์—…์ด ์ค‘์š”ํ•˜๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ๋‹ค.

๋ฒˆ๋“ค์‚ฌ์ด์ฆˆ๋ฅผ ์—ด์‹ฌํžˆ ์ค„์—ฌ๋„ KB๋‹จ์œ„์ง€๋งŒ.. ์ด๋ฏธ์ง€๋Š” ๋‹จ์œ„๋ถ€ํ„ฐ MB์ด๋ฏ€๋กœ, ์ด๋ฏธ์ง€ ์ตœ์ ํ™”๋Š” ํ•„์ˆ˜์ด๋‹ค.

 

Next.js์˜ ์ด๋ฏธ์ง€ ์ตœ์ ํ™”

Next.js๋Š” next/image๋ฅผ ํ†ตํ•ด ๊ธฐ๋ณธ์ ์œผ๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ด๋ฏธ์ง€ ์ตœ์ ํ™” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ด์ค€๋‹ค.
But, ๋ฆฌ์•กํŠธ๋Š” ์ด์™€ ๊ฐ™์€ ์ด๋ฏธ์ง€ ์ตœ์ ํ™”๋ฅผ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ด์ฃผ์ง€ ์•Š์œผ๋ฏ€๋กœ, ์ง์ ‘ ์ตœ์ ํ™” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ด์•ผํ•œ๋‹ค...!๐Ÿซฃ
 
 
  • ์ž๋™ resizing
  • Webp ๊ฐ™์€ ์ตœ์‹  ์ด๋ฏธ์ง€ ํฌ๋งท ์ง€์›
  • Lazy Loading๋กœ ํŽ˜์ด์ง€ ๋กœ๋”ฉ ์†๋„ ํ–ฅ์ƒ
  • CDN ์ง€์›
์ด๋ฏธ์ง€ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์—ฌ๋Ÿฌ๊ฐ€์ง€ ๋ฐฉ๋ฒ•๋“ค์„ ๊ณต๋ถ€ํ•ด๋ฉด์„œ, ์ ์šฉํ•ด๋ณด์•˜๋‹ค.

 

 

1. browser-image-compression ํ™œ์šฉํ•œ ์ด๋ฏธ์ง€ ํญ, ํƒ€์ž… ์ •์˜

ํŽ˜์ด์ง€์— ์‚ฌ์šฉํ•˜๋Š” ์ด๋ฏธ์ง€๋Š” ๋ฐ์Šคํฌํƒ‘ ๋ฒ„์ „์ด์—ฌ๋„, ๋ณดํ†ต 1000px์ด ๋„˜์ง€ ์•Š๋Š”๋‹ค. ํŠนํžˆ ๋ชจ๋ฐ”์ผ(14 Promax ๊ธฐ์ค€) ๋ฒ„์ „์—์„œ post์˜ ์ด๋ฏธ์ง€๊ฐ€ 400px๋กœ๋„ ์ถฉ๋ถ„ํ•˜๋‹ค.
ํ•˜์ง€๋งŒ, ํ•ธ๋“œํฐ์œผ๋กœ ์ฐ์€ ์‚ฌ์ง„์ด ๊ฐ€๋กœํญ์ด ๊ฑฐ์˜ 3000px์ธ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.
 
 
4032x3024 : 2.7MB ๐Ÿ‘‰๐Ÿป 800x600: 133KB
์ด๋ฏธ์ง€ ํฌ๊ธฐ๋งŒ ์ค„์—ฌ๋„, ์ด์™€ ๊ฐ™์ด 20๋ฐฐ ์ •๋„์˜ ์šฉ๋Ÿ‰์ด ๊ฐ์†Œํ•  ์ˆ˜ ์žˆ๋‹ค.
 

 

 

์ตœ์ ํ™”๋œ ์ด๋ฏธ์ง€ ํฌ๋งท

webp๋Š” ๊ตฌ๊ธ€์ด ๊ฐœ๋ฐœํ•œ ์ด๋ฏธ์ง€ ํฌ๋งท์œผ๋กœ, ์••์ถ• ํšจ์œจ์ด ๋†’์•„ lighthouse๋„ webp ํฌ๋งท์„ ์ถ”์ฒœํ•˜๊ณ  ์žˆ๋‹ค.
 
๐Ÿ‘‰๐Ÿป browser-image-compression ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ™œ์šฉํ•ด, ์ด๋ฏธ์ง€ ์••์ถ•, ํญ, ํŒŒ์ผ ํƒ€์ž…์„ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค.
 
const options = {
        maxSizeMB: 1,
        maxWidthOrHeight: 1920,
        useWebWorker: true,
        fileType: "image/webp",
      };

 

 if (refPath === "users") {
          const compressedImage = await compressImage(image, options);
          const imageRef = ref(storage, `${refPath}/${userId}`);
          return await uploadImage(imageRef, compressedImage);
 } else if (refPath === "posts") {
           const largeImageOptions = { ...options, maxWidthOrHeight: 1080 };
          const smallImageOptions = { ...options, maxWidthOrHeight: 480 };

 

user profile ์ด๋ฏธ์ง€์˜ ๊ฒฝ์šฐ, maxWidthOrHeight 300px๊ฐ’์œผ๋กœ ์ •์˜ํ•˜๊ณ , ๋ชจ๋ฐ”์ผ๊ณผ ๋ฐ์Šคํฌํƒ‘์—์„œ ์‚ฌ์šฉํ•  ์šฉ๋„๋กœ ๋‘๊ฐ€์ง€ ์‚ฌ์ด์ฆˆ ๋ฒ„์ „์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ 480, 1080 ์‚ฌ์ด์ฆˆ๋กœ ์ง€์ •ํ•ด์ฃผ์—ˆ๋‹ค.
 

 

 
1.8MB์˜ pngํŒŒ์ผ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ด๋ณด์•˜๋‹ค.
 
 
1.8MB ๐Ÿ‘‰๐Ÿป 255kB๋กœ 85% ๊ฐ์†Œ๋œ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.
 
 

2. width, height ๊ฐ’ ์ง€์ •์œผ๋กœ, Reflow๋ฅผ ๋ฐฉ์ง€

๊ณ ์ •๋œ ์‚ฌ์ด์ฆˆ์˜ ์ด๋ฏธ์ง€์˜ ๊ฒฝ์šฐ์—” width, height๋ฅผ ์ •์˜ํ•˜๊ณ , ์ƒ์œ„ ์š”์†Œ์— ๋”ฐ๋ผ ์œ ๋™์ ์œผ๋กœ ํฌ๊ธฐ๋ฅผ ๋ณ€ํ™”ํ•˜๋Š” ๋ฐ˜์‘ํ˜• ์ด๋ฏธ์ง€๋Š” aspect-ratio(๊ฐ€๋กœ,์„ธ๋กœ ๋น„์œจ)๋ฅผ ์ง€์ •ํ•˜์˜€๋‹ค.
์ด๋ ‡๊ฒŒ ์ง€์ •ํ•ด๋‘๋ฉด, ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ฒ˜์Œ์— ํฌ๊ธฐ๋ฅผ ๊ฒฐ์ •ํ•  ์ˆ˜ ์žˆ์–ด ์ด๋ฏธ์ง€ ๊ณต๊ฐ„ ํ• ๋‹น์„ ๋ฏธ๋ฆฌ ์˜ˆ์•ฝํ•˜๋Š”๊ฒƒ์ด ๊ฐ€๋Šฅํ•ด์ง€๊ธฐ ๋•Œ๋ฌธ์—, ์ด๋ฏธ์ง€ ๋กœ๋”ฉ์™„๋ฃŒ ํ›„, ํ…์ŠคํŠธ๊ฐ€ ๋ฐ€๋ฆฌ๋Š” Reflow๊ฐ™์€ ํ˜„์ƒ์„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค.
  {post.images && post.images.length > 0 && (
          <div className="flex items-center justify-center aspect-w-1 aspect-h-1">
            <div className="w-full h-full overflow-hidden">
              <ImageSwiper images={post.images} />
            </div>
          </div>
        )}
 
 

3. ์—ฌ๋Ÿฌ ๋ฒ„์ „(type, size)์˜ ์ด๋ฏธ์ง€ ์ œ๊ณต

1) File Type
 
<picture> ์š”์†Œ ์•ˆ์— ๋ฅผ ํ†ตํ•ด ์ด๋ฏธ์ง€์˜ ๋‹ค๋ฅธ ํ˜•์‹๊ณผ ์šฐ์„ ์ˆœ์œ„๋ฅผ ์ง€์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•
๐Ÿ‘‰๐Ÿป ์‚ฌ์šฉ์ž๊ฐ€ ์—…๋กœ๋“œํ•˜๋Š” profile, post ์ด๋ฏธ์ง€๋ฅผ ํด๋ผ์ด์–ธํŠธ ์ธก์—์„œ webp ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ด ์ €์žฅํ•˜๊ณ  Firebase Storage์— ์—…๋กœ๋“œํ›„, ์ œ๊ณตํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, picture๋Š” ์ ์šฉํ•˜์ง€ ์•Š์•˜๋‹ค.

 

2) File Size
srcset๊ณผ sizes ์†์„ฑ์„ ์‚ฌ์šฉํ•ด ๋ฐ˜์‘ํ˜• ์ด๋ฏธ์ง€๋ฅผ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋‹ค.
 
๋ธŒ๋ผ์šฐ์ €๊ฐ€ ํ™”๋ฉด ํฌ๊ธฐ์™€ ํ•ด์ƒ๋„์— ๋”ฐ๋ผ ์ ํ•ฉํ•œ ์ด๋ฏธ์ง€๋ฅผ ์„ ํƒํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด๋ฏธ์ง€ ๋กœ๋”ฉ ์„ฑ๋Šฅ๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„ ์ด ๊ฐ€๋Šฅํ•˜๋‹ค.
Large Size
 
Small Size
 
๐Ÿ‘‰๐Ÿป ๋ฐ์Šคํฌํƒ‘์ผ๋•Œ Large ๋ฒ„์ „, ๋ชจ๋ฐ”์ผ์ผ๋•Œ, Small ๋ฒ„์ „ ์ด๋ฏธ์ง€์˜ ํฌ๊ธฐ๊ฐ€ ์ ์šฉ๋˜๋Š”๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.
Large (161kB), small (78kB)๋กœ ์•ฝ 2๋ฐฐ์˜ ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ์ฐจ์ด๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค๋Š” ์ ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.
 
const largeImageRef = ref(
  storage,
  `${refPath}/${userId}/${currentTime}_large_${image.name}`
);
const smallImageRef = ref(
  storage,
  `${refPath}/${userId}/${currentTime}_small_${image.name}`
);
const originalImageRef = ref(
  storage,
  `${refPath}/${userId}/${currentTime}_${image.name}`
);

const [largeImageUrl, smallImageUrl, originalImageUrl] = await Promise.all([
  uploadImage(largeImageRef, compressedLargeImage),
  uploadImage(smallImageRef, compressedSmallImage),
  uploadImage(originalImageRef, image),
]);

return {
  original: originalImageUrl,
  small: smallImageUrl,
  large: largeImageUrl,
};
 
์ด์ฒ˜๋Ÿผ original, small, large 3๊ฐ€์ง€ ๋ฒ„์ „์˜ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜์—ฌ, post์˜ images ํ•„๋“œ์— ๋„ฃ์–ด์ฃผ์—ˆ๋‹ค.

 

<img
  src={image.original}
  srcSet={`${image.small} 480w, ${image.large} 1080w`}
  sizes="(max-width: 768px) 480px, 1080px"
  alt={`image_${index + 1}`}
  className="object-cover w-full h-full overflow-hidden rounded-md"
/>;
๐Ÿ‘‰๐Ÿป srcSet, sizes ์ ์šฉ
 
 

4. lazy loading ์ ์šฉ

ํŽ˜์ด์ง€๋ฅผ ๋กœ๋“œํ• ๋•Œ, ๋ชจ๋“  ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ์ค‘์š”ํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ๋‹น์žฅ ํ•„์š” ์—†๋Š” ์ž์›์˜๊ฒฝ์šฐ ์„œ๋ฒ„์— ์š”์ฒญ์„ ๋ฏธ๋ฃจ๊ณ  ์ด๋ฏธ์ง€๊ฐ€ ์‹ค์ œ๋กœ ๋ณด์ผ ๋•Œ ์š”์ฒญ๋ฐ›๋Š” ๋ฐฉ๋ฒ•
 
โ—๏ธ HTML๋‚ด ์ฒซ ๋ฒˆ์งธ ์ด๋ฏธ์ง€๋“  1000๋ฒˆ์งธ ์ด๋ฏธ์ง€๋“ , ๋˜๋Š” ๋ทฐํฌํŠธ ๋ฐ–์— ์žˆ๋“  ์ƒ๊ด€์—†์ด ๋งŒ์•ฝ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ src ์†์„ฑ์„ ๊ฐ–๋Š”๋‹ค๋ฉด ์ด๋ฏธ์ง€๋ฅผ ๋ฌด์กฐ๊ฑด ๋กœ๋“œํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ์ ์ ˆํ•œ ์ƒํ™ฉ์— ์ด๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ตœ์ ํ™”์— ๋„์›€์ด ๋œ๋‹ค.
 
import React, { useState } from "react";
import { useInView } from "react-intersection-observer";

interface PlaceholderImageProps {
  src: string;
  alt: string;
  loading?: "eager" | "lazy";
  className?: string;
  srcSet?: string;
  sizes?: string;
}

const PlaceholderImage: React.FC<PlaceholderImageProps> = ({
  src,
  alt,
  loading = "eager",
  className = "",
  srcSet,
  sizes,
}) => {
  const [loaded, setLoaded] = useState(false);

  const { ref, inView } = useInView({
    triggerOnce: true,
    threshold: 0.5,
  });

  const handleLoad = () => {
    setLoaded(true);
  };
  const LoadedImage = loading === "eager" || inView;

  return (
    <div ref={ref} className={`relative ${className} aspect-w-1 aspect-h-1`}>
      <div
        className={`absolute top-0 left-0 w-full h-full bg-gray-100 transition-opacity duration-300 rounded-md ${
          loaded ? "opacity-0" : "opacity-100"
        }`}
      ></div>
      {LoadedImage && (
        <img
          src={src}
          srcSet={srcSet}
          sizes={sizes}
          alt={alt}
          onLoad={handleLoad}
          className={`absolute top-0 left-0 w-full h-full object-cover transition-opacity duration-300 rounded-md ${
            loaded ? "opacity-100" : "opacity-0"
          }`}
          loading={loading}
        />
      )}
    </div>
  );
};

export default PlaceholderImage;
 
<PlaceholderImage
src={post.images[0].small}
srcSet={`${post.images[0].small} 400w, ${post.images[0].large} 1080w`}
sizes="(max-width: 600px) 480px, 1080px"
className="object-cover w-full h-full "
alt={`${post.title} img`}
loading="lazy"
/>;
 

 

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋กœ postCardList์— ํ•ด๋‹นํ•˜๋Š” ์ด๋ฏธ์ง€๋“ค์— ๋Œ€ํ•ด ์ง€์—ฐ๋กœ๋”ฉ์„ ์ ์šฉํ•ด, ๋ทฐํฌํŠธ์— ๋ณด์ด๋Š” ์ด๋ฏธ์ง€์— ๋Œ€ํ•ด์„œ๋งŒ ๋กœ๋”ฉ์„ ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋กœ๋“œ์‹œ๊ฐ„์„ ๋‹จ์ถ•ํ•˜์˜€๋‹ค.

 

 
๊ทธ ์™ธ์˜ ์ตœ์ ํ™” ๊ธฐ๋ฒ•

 
CSS Sprite ๊ธฐ๋ฒ• ์‚ฌ์šฉ
์—ฌ๋Ÿฌ๊ฐœ์˜ ์ž‘์€ ์ด๋ฏธ์ง€๋ฅผ ํ•˜๋‚˜์˜ ํฐ ์ด๋ฏธ์ง€ ํŒŒ์ผ๋กœ ๊ฒฐํ•ฉํ•ด, CSS๋ฅผ ์‚ฌ์šฉํ•ด ํ•ด๋‹น ์ด๋ฏธ์ง€์˜ ํŠน์ •ํ•œ ๋ถ€๋ถ„์„ ๋ณด์—ฌ์ฃผ๋Š” ๊ธฐ๋ฒ•์œผ๋กœ, HTTP ์š”์ฒญ๊ณผ ๋กœ๋”ฉ์†๋„๋ฅผ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.
์•„์ด์ฝ˜, ๋ฒ„ํŠผ๊ฐ™์ด ๊ฐ™์€ ์ด๋ฏธ์ง€๋“ค์„ ์“ธ ๋•Œ๋งˆ๋‹ค ์—ฌ๋Ÿฌ ์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๊ฒƒ์ด๋‹Œ, ํ•œ ์ด๋ฏธ์ง€๋กœ ํ†ตํ•ฉํ•ด ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€๋กœ ๋งŒ๋“ค์–ด ๋ถˆ๋Ÿฌ์˜ค๊ณ , background-position ๊ฐ’์„ ์กฐ์ ˆํ•ด ์›ํ•˜๋Š” ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๋ฐฉ์‹์ด๋‹ค.
๐Ÿ‘‰๐Ÿป lucide ์•„์ด์ฝ˜์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด ํฌ๊ฒŒ ํ•„์š”์„ฑ์„ ๋Š๋ผ์ง€ ๋ชปํ•ด, ์ ์šฉํ•˜์ง€ ์•Š์•˜๋‹ค.
 

 

Image CDNs ์‚ฌ์šฉ
๐Ÿ‘‰๐Ÿป Firebase Storage ๋Œ€์‹  Cloudinary ์‚ฌ์šฉ ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด ์ข‹์„๊ฒƒ ๊ฐ™๋‹ค.

 

 
 
 
 
 
BEFORE
 
 

 

 
 
AFTER
 
 
 
 
๐Ÿ“Ž CLS ์ ์ˆ˜ ๊ฐœ์„  ํฌ์ŠคํŒ…
 
 
 

๊ฒŒ์‹œ๋ฌผ ์ด๋ฏธ์ง€ CLS ๊ฐœ์„  | ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…

๊ฒŒ์‹œ๋ฌผ ์ด๋ฏธ์ง€ CLS ๊ฐœ์„  ์„œ๋น„์Šค ํŠน์„ฑ ์ƒ, ํ…์ŠคํŠธ๋ณด๋‹จ ์ด๋ฏธ์ง€์˜ ๊ด€์‹ฌ๋„๊ฐ€ ๋†’๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฏธ์ง€๋ฅผ ๋จผ์ € ๋ฐฐ์น˜ํ•˜์˜€๋‹ค. ๊ทธ๋Ÿฌ๋‹ค๋ณด๋‹ˆ, ์šฐ๋ คํ–ˆ๋˜๋Œ€๋กœ ์ด๋ฏธ์ง€๊ฐ€ ๋กœ๋”ฉ๋˜๊ณ ๋‚˜์„œ ํ…์ŠคํŠธ๊ฐ€ ๋ฐ€๋ฆฌ๊ฒŒ๋˜๋ฉด์„œ ๋Œ€๊ทœ

s-organization-335.gitbook.io