์งํ์ค์ธ ํ๋ก์ ํธ๋ ๋ฐ๋ ค๋๋ฌผ์ ๊ธฐ๋ก๊ณผ ์ฌ์ง ๊ณต์ ๊ฐ ์ฃผ๋ ๊ธฐ๋ฅ์ธ ๋งํผ, ์ด๋ฏธ์ง ์ต์ ํ ์์ ์ด ์ค์ํ๋ค๊ณ ์๊ฐํ๋ค.
๋ฒ๋ค์ฌ์ด์ฆ๋ฅผ ์ด์ฌํ ์ค์ฌ๋ 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 ๋ฒ์ , ๋ชจ๋ฐ์ผ์ผ๋, 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

'ํ๋ก์ ํธ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[React] SEO, ์น ์ ๊ทผ์ฑ ๊ฐ์ ํ๊ธฐ (0) | 2024.07.19 |
---|---|
๊ฒ์๋ฌผ ์ด๋ฏธ์ง CLS ๊ฐ์ (0) | 2024.07.11 |
Firebase ๋ถ๋ถ ๋ฌธ์ ๊ฒ์๊ธฐ๋ฅ ๊ตฌํ (0) | 2024.07.11 |
code Splitting, prefetch ๋ก๋ฉ์๋ ๊ฐ์ (0) | 2024.07.10 |
[react-query] ์ข์์ ๊ธฐ๋ฅ ์ต์ ํํ๊ธฐ (0) | 2024.07.02 |