[Uket] NextJS의 이미지 최적화 방식을 살펴보고, sharp로 직접 구현하기

coggiee
28 min readDec 3, 2024

--

이미지 최적화 횟수 제한

NextJS는 자체적으로 이미지를 최적화해주는 <Image> 컴포넌트를 제공한다.

하지만 vercel에 배포할 경우 무료 플랜은 월 1000개의 이미지 최적화 횟수에 제한이 있다. (한계를 넘어가면 일정 비용이 부과된다.)

최준생은 자본이 빵빵하지 않기 때문에 비용이 소중하다. 실제로 최근에 이미지 최적화 횟수가 제한에 거의 도달했다는 메일을 받았다.

그래서 비용을 신경쓰지 않기 위해, 내장 이미지 컴포넌트를 흉내 내보고자 한다.

<img> vs <Image> : 이미지 로딩 속도 차이

웹 로딩 속도에는 이미지가 정말 큰 영향을 미치기 때문에 최적화는 필수이다. 일반 시멘틱 태그 <img> 와 내장 컴포넌트 <Image> 의 속도 차이를 느껴보자.

제약 조건: 캐시 사용 X / 동일한 이미지 파일(png) / 다양한 네트워크 환경

테스트 결과

동일한 이미지 파일이지만, <Image> 컴포넌트는 자체적으로 이미지를 webp로 변환하고 <img> 는 전달받은 png를 그대로 사용한다.

  1. 3g 환경
    - <img> + png : 1.3 m
    - <Image> + webp : 7.37 s
  2. 느린 4g 환경
    - <img> + png : 21.33 s
    - <Image> + webp : 1.89 s
  3. 빠른 4g 환경
    - <img> + png : 3.87 s
    - <Image> + webp : 271 ms

결과를 살펴보면 확실히 이미지 최적화가 중요하다.

이미지 최적화 플로우

내장 컴포넌트를 흉내내기 위해 러프하게 아래 플로우를 생각했다.

이미지 최적화 라이브러리 sharp

NextJS의 내장 이미지 컴포넌트는 sharp 라이브러리를 사용해서 이미지를 최적화하고, 이를 사용하기를 권장하고 있다.

그리고 sharp는 Node.js 환경에서 작동하기에, 하단에 작성한 코드는 route handler에서 이미지 최적화 로직을 수행한다.

최적화 시도 #1) Sharp의 toFile()

핵심 로직

const optimizeImage = async () => {
await sharp("이미지 주소")
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toFile('최적화된 이미지 파일을 저장할 경로');
}
  1. 전달받은 이미지 주소로 sharp 생성자를 호출
  2. 전달받은 width, height로 사이즈 변경(resize)
  3. 전달받은 quality로 이미지 포맷을 webp로 변경
  4. 지정한 경로에 최적화된 이미지 저장

세부 로직 #1) 이미지 주소와 파일 저장 경로 생성

NextJS는 최적화한 이미지를 .next/cache/images 경로에 해싱된 값으로 폴더와 파일을 생성하여 저장하고 있다.

그래서 이와 비슷하게 가져가 보고자 했다. (.next/cache/[해싱 폴더]/[해싱 이미지 파일].webp 형태)

  1. 폴더 명은 전달받은 이미지 주소를 해싱한 값으로 사용한다.
  2. 파일 명은 전달받은 이미지 주소, width, height, quality를 조합하여 해싱한 값으로 사용한다.
  3. 해싱된 폴더 명, 파일 명을 생성하고 path.join 으로 실제 경로를 할당한다.
  4. .next/cache/images 경로에 해싱된 폴더를 생성한다.
  5. 해당 경로에 이미 캐싱된 이미지 파일이 있는지 확인한다.
    - 캐싱 되었다면 그대로 이미지를 사용한다.
    - 캐싱 되지 않았다면, 기존 경로에 있는 이미지 파일을 삭제하고 새로 생성한다.
// 캐시 디렉토리 기본 경로
const CACHE_DIR = path.join(process.cwd(), ".next", "cache", "images");

// 폴더 해시 생성 (이미지 URL 기반)
const generateImageFolderHash = (imageUrl: string) => {
return crypto.createHash("md5").update(imageUrl).digest("hex");
};

// 파일 이름 해시 생성 (요청 파라미터 포함)
const generateImageFileHash = (
imageUrl: string,
width: number,
height: number,
quality: number
) => {
const hashInput = `${imageUrl}-${width}-${height}-${quality}`;
return crypto.createHash("md5").update(hashInput).digest("hex");
};

// 캐시 디렉토리 존재 확인 및 생성
const ensureCacheDir = async (dirPath: string) => {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error) {
console.error("Failed to create directory:", error);
}
};

// 기존 파일 삭제 함수
const deleteExistingFiles = async (folderPath: string) => {
try {
const files = await fs.readdir(folderPath);
const deletePromises = files.map((file) =>
fs.unlink(path.join(folderPath, file))
);
await Promise.all(deletePromises);
} catch (error) {
console.error("Failed to delete existing files:", error);
}
};

세부 로직 #2) 외부 이미지와 로컬 이미지의 다른 프로세스

외부 이미지 주소(https://~~ )는 로컬 이미지와 다르게 직접 접근이 불가능하기 때문에, 네트워크 요청을 한 번 거쳐야 한다.

// 외부 이미지 다운로드 및 처리 함수
const downloadAndProcessImage = async (
imageUrl: string,
cachedImagePath: string,
width: number,
height: number,
quality: number
) => {
try {
const response = await fetch(imageUrl);

if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);

const buffer = await response.arrayBuffer(); // arrayBuffer() 사용
const imageBuffer = Buffer.from(buffer); // Buffer로 변환

// 이미지 리사이징 및 최적화
await sharp(imageBuffer)
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toFile(cachedImagePath);
} catch (error) {
console.error("Image processing error:", error);
throw error;
}
};

전체 로직

전체 로직은 아래와 같다.

// app/api/image/route.ts

import path from "path";
import fs from "fs/promises";
import crypto from "crypto";

import sharp from "sharp";
import { NextRequest, NextResponse } from "next/server";

// 캐시 디렉토리 기본 경로
const CACHE_DIR = path.join(process.cwd(), ".next", "cache", "images");

// 폴더 해시 생성 (이미지 URL 기반)
const generateImageFolderHash = (imageUrl: string) => {
return crypto.createHash("md5").update(imageUrl).digest("hex");
};

// 파일 이름 해시 생성 (요청 파라미터 포함)
const generateImageFileHash = (
imageUrl: string,
width: number,
height: number,
quality: number
) => {
const hashInput = `${imageUrl}-${width}-${height}-${quality}`;
return crypto.createHash("md5").update(hashInput).digest("hex");
};

// 캐시 디렉토리 존재 확인 및 생성
const ensureCacheDir = async (dirPath: string) => {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error) {
console.error("Failed to create directory:", error);
}
};

// 기존 파일 삭제 함수
const deleteExistingFiles = async (folderPath: string) => {
try {
const files = await fs.readdir(folderPath);
const deletePromises = files.map((file) =>
fs.unlink(path.join(folderPath, file))
);
await Promise.all(deletePromises);
} catch (error) {
console.error("Failed to delete existing files:", error);
}
};

// 이미지 다운로드 및 처리 함수
const downloadAndProcessImage = async (
imageUrl: string,
cachedImagePath: string,
width: number,
height: number,
quality: number
) => {
try {
const response = await fetch(imageUrl);
if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);

const buffer = await response.arrayBuffer(); // arrayBuffer() 사용
const imageBuffer = Buffer.from(buffer); // Buffer로 변환

await sharp(imageBuffer)
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toFile(cachedImagePath);
} catch (error) {
console.error("Image processing error:", error);
throw error;
}
};

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);

const rawImagePath = searchParams.get("image") || "./public/placeholder.png";
const width = parseInt(searchParams.get("width") || "200", 10);
const height = parseInt(searchParams.get("height") || "200", 10);
const quality = parseInt(searchParams.get("quality") || "80", 10);

// 폴더 해시 및 파일 해시 생성
const folderHash = generateImageFolderHash(rawImagePath);
const fileHash = generateImageFileHash(rawImagePath, width, height, quality);

const imageFolderPath = path.join(CACHE_DIR, folderHash);
const cachedImagePath = path.join(imageFolderPath, `${fileHash}.webp`);

// 캐시 디렉토리 생성
await ensureCacheDir(imageFolderPath);

try {
let isCached = true;

try {
// 파일이 이미 존재하는지 확인
await fs.access(cachedImagePath);
} catch {
isCached = false;
}

if (!isCached) {
// 기존 폴더의 모든 파일 삭제
await deleteExistingFiles(imageFolderPath);

if (rawImagePath.startsWith("http://") || rawImagePath.startsWith("https://")) {
// 외부 이미지 처리
await downloadAndProcessImage(
rawImagePath,
cachedImagePath,
width,
height,
quality
);
} else {
// 로컬 파일 처리
const localImagePath = path.join(process.cwd(), rawImagePath.replace(/^\.\//, ""));
await sharp(localImagePath)
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toFile(cachedImagePath);
}
}

// 캐시된 이미지 반환
const imageBuffer = await fs.readFile(cachedImagePath);
return new NextResponse(imageBuffer, {
status: 200,
headers: {
"Content-Type": "image/webp",
},
});
} catch (error) {
console.error("Image processing error:", error);
return NextResponse.json(
{ error: "Failed to process image", details: error.message },
{ status: 500 }
);
}
}

에러 발생

"/var/task/.next/cache/images/[해싱 폴더]/[해싱 이미지 파일].webp: unable to open for write\nunix error: No such file or directory"

로컬환경에서는 잘 동작했지만, 배포 환경에서는 에러가 발생했다.

원인은 vercel은 서버리스 환경에서 어플리케이션을 실행하기 때문에 사용자가 .next/cache 폴더에 직접적으로 접근할 수가 없기 때문에 발생되는 것으로 추측됐다.

최적화 시도 #2) Sharp의 toBuffer()

파일과 폴더를 생성하는 대신, 버퍼 형태로 받고 blob 객체로 변환 후 이미지 주소를 가져와 사용하는 방식이다.

핵심 로직

const optimizeImage = async () => {
await sharp("이미지 주소")
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toBuffer('최적화된 이미지 파일을 저장할 경로');
}
  1. 위의 1,2,3 과정과 동일
  2. toBuffer() 를 사용해서 최적화된 이미지를 파일이 아닌, 버퍼 형태로 생성
  3. 해당 버퍼를 클라이언트 측에 반환

클라이언트 측에서는…

  1. 클라이언트 측에서, 반환받은 이미지 버퍼를 blob 객체로 변환
  2. URL.createObjectURL() 을 사용해서 blob 객체에서 이미지 주소를 생성
  3. 해당 이미지 주소를 src로 사용

전체 로직 (클라이언트 측 코드 제외)

// app/api/image/route.ts

import path from "path";
import crypto from "crypto";

import sharp from "sharp";
import { NextRequest, NextResponse } from "next/server";

// 이미지 다운로드 및 처리 함수
const downloadAndProcessImage = async (
imageUrl: string,
width: number,
height: number,
quality: number,
) => {
const response = await fetch(imageUrl);

if (!response.ok)
throw new Error(`Failed to fetch image: ${response.statusText}`);

const buffer = await response.arrayBuffer(); // arrayBuffer() 사용
const imageBuffer = Buffer.from(buffer); // Buffer로 변환

return await sharp(imageBuffer)
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toBuffer(); // 파일 시스템에 저장하지 않고, 메모리에서 Buffer로 반환
};

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);

const rawImagePath =
searchParams.get("image") || "./public/landing-object.webp";
const width = parseInt(searchParams.get("width") || "200", 10);
const height = parseInt(searchParams.get("height") || "200", 10);
const quality = parseInt(searchParams.get("quality") || "80", 10);

try {
let imageBuffer;

if (
rawImagePath.startsWith("http://") ||
rawImagePath.startsWith("https://")
) {
// 외부 이미지 처리
imageBuffer = await downloadAndProcessImage(
rawImagePath,
width,
height,
quality,
);
} else {
// 로컬 파일 처리
const localImagePath = path.join(process.cwd(), "public", rawImagePath); // public 폴더 경로
imageBuffer = await sharp(localImagePath)
.resize({ width, height, fit: "contain" })
.webp({ quality })
.toBuffer();
}

return new NextResponse(imageBuffer, {
status: 200,
headers: {
"Content-Type": "image/webp",
},
});
} catch (error) {
console.error("Image processing error:", error);
return NextResponse.json(
{ error: "Image processing failed", details: (error as any).message },
{ status: 500 },
);
}
}

역효과! 외부 이미지 최적화가 필요한가?

외부 이미지를 최적화하는 과정에는 네트워크 요청이 한 번 더 필요하기 때문에, 이미지를 압축 하려다가 오히려 이미지 로딩 속도가 느려졌다.

최적화 테스트

  1. 최적화를 하지 않을 경우
외부 이미지 최적화 X

2. 최적화를 한 경우

외부 이미지 최적화 O

외부 이미지 최적화를 옵션으로 설정

커스텀 이미지 컴포넌트(=클라이언트 측)에서 외부 이미지 최적화는 추후 개선하는 것으로 남기기 위해서 ‘최적화 여부’를 옵션으로 받게 설정한다.

// components/custom-image.tsx
export const CustomImage: React.FC<CustomImageProps> = ({
src,
alt,
width = 200,
height = 200,
quality = 80,
className = "",
loading = "lazy",
priority = false,
placeholder = "empty",
blurDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mO0OgMAAUYBCAZFUJ4AAAAASUVORK5CYII=",
unOptimizeExternalImage = false,
}) => {
useEffect(() => {
// ... other code

// 메인 로직
const loadImage = async () => {
// 최적화 여부에 따른 분기
if (unOptimizeExternalImage) { // 최적화를 하지 않는다.
handleImagePreloading(src);
} else { // 최적화를 한다.
const optimizedImageSrc = await fetchOptimizedImage();
if (optimizedImageSrc) {
handleImagePreloading(optimizedImageSrc);
}
}
};

// ... other code

}, [src, width, height, quality, unOptimizeExternalImage]);

return (
<img ... />
);
};

주요 로직은 다음과 같다.

// components/custom-image.tsx

useEffect(() => {
// 이미지 사전 로딩 로직을 분리한 함수
const handleImagePreloading = (imageSrc: string) => {
setIsLoading(true);
setError(false);

const preloadImage = new Image();
preloadImage.src = imageSrc;

preloadImage.onload = () => {
setImageSrc(imageSrc); // <img>에 전달할 이미지 주소 설정
setIsLoading(false);
};

preloadImage.onerror = () => {
setError(true);
setIsLoading(false);
};
};

// 이미지 최적화 API 요청 함수
const fetchOptimizedImage = async () => {
try {
const normalizedSrc = src.startsWith("./")
? src.replace("./", "/")
: src;

const response = await fetch(
`/api/image?image=${encodeURIComponent(normalizedSrc)}&width=${width}&height=${height}&quality=${quality}`,
);

if (!response.ok) {
setError(true);
setIsLoading(false);
return null;
}

const imageBlob = await response.blob();
return URL.createObjectURL(imageBlob);
} catch (err) {
console.error("Error while fetching image:", err);
setError(true);
setIsLoading(false);
return null;
}
};

// 메인 로직
const loadImage = async () => {
// 최적화 여부에 따른 분기
if (unOptimizeExternalImage) { // 최적화를 하지 않는다.
handleImagePreloading(src);
} else { // 최적화를 한다.
const optimizedImageSrc = await fetchOptimizedImage();
if (optimizedImageSrc) {
handleImagePreloading(optimizedImageSrc);
}
}
};

loadImage();

return () => {
if (
imageSrc &&
((!unOptimizeExternalImage &&
!imageSrc.startsWith("http") &&
!imageSrc.startsWith("https")) ||
(unOptimizeExternalImage &&
!imageSrc.startsWith("http") &&
!imageSrc.startsWith("https")))
) {
URL.revokeObjectURL(imageSrc);
}
};
}, [src, width, height, quality, unOptimizeExternalImage]);

// ... 컴포넌트 반환문
return (...)

Service Worker로 이미지 요청 캐싱

현재 이미지 최적화를 위해 네트워크 요청을 사용하기 때문에, 이를 캐싱하면 좋을 것 같아서 Service Worker를 사용했다.

Service Worker 등록

처음에는 Provider로 생성하여 등록하려 했으나, 제대로 동작하지 않아서 최상단 레이아웃에 script 태그로 등록했다.

그리고 JS 실행은 DOM 생성이 완료된 후에 진행되도록 defer 를 추가한다.

// public/service-worker.js
function registerServiceWorker() {
if (typeof window !== "undefined") {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
console.log("Service Worker Registered");
}
}
}

registerServiceWorker();


// app/layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${pretendard.variable} bg-mainbackground font-pretendard antialiased`}
>
<main className="container mx-auto h-dvh w-full px-6">{children}</main>
<script defer src="/service-worker.js" /> // service worker 등록
</body>
</html>
);
}

Service Worker 내부 로직

모든 네트워크 요청이 아닌 이미지 요청만 캐싱하기 위해, event.request.url.startsWith(요청 URL) 로 관리한다.

self.addEventListener("fetch", event => {
const baseUrl = self.location.origin; // 현재 호스트의 기본 URL 가져오기
const apiUrl = `${baseUrl}/api/image`; // API URL 생성

if (event.request.url.startsWith(apiUrl)) {
event.respondWith(
caches.open("image-cache").then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('cache hit');
return cachedResponse;
}

console.log('cache miss');
return fetch(event.request)
.then(networkResponse => {
if (!networkResponse.ok) {
throw new Error("Network response was not ok");
}
const clonedResponse = networkResponse.clone();
cache.put(event.request, clonedResponse);
return networkResponse;
})
.catch(error => {
console.error("Fetch failed:", error);
return new Response("Error fetching the image", { status: 500 });
});
});
}),
);
}
});

테스트 결과

캐싱 테스트

처음 요청은 miss, 그 다음 요청 부터는 hit이 되는 걸 볼 수 있다.

테스트의 의문점

서비스 워커 적용 O

  • 캐싱이 적용되지 않은 처음 요청은, 최적화를 하지 않은 이미지가 더 빠르게 로딩되었다.
  • 캐싱이 적용된 후에는 최적화를 한 이미지가 더 빠르게 로딩되었다.

서비스 워커 적용 X

  • 최적화를 하지 않은 이미지가 거의 항상 더 빠르게 로딩되었다.

위 결과를 보고 서비스 워커를 적용하는 것이 맞는지에 대한 의문이 들었다.

조금 더 살펴봐야 겠다.

최종 이미지 로딩 속도 테스트

내장 <Image> vs 커스텀 <Image>

내장 <Image> vs 커스텀 <Image>

결과를 비교해보면, 커스텀 컴포넌트의 결과가 나쁘지 않게 나왔다.

그러나, NextJS는 브라우저 사이즈 별로 이미지 최적화를 다르게 해주고, 기타 다른 옵션들도 있기 때문에 “커스텀 컴포넌트의 결과가 더 낫다!” 는 절대 아니다.

그저 “유의미하게 최적화가 됐다.”

커스텀 <Image> vs 시멘틱 <img>

커스텀 <Image> vs 시멘틱 <img>

시멘틱 태그와 비교했을 때는 최적화가 아주 잘 적용된 것을 볼 수 있다.

마무리

이번에 만든 커스텀 이미지 컴포넌트는 겉모습을 흉내낸 컴포넌트에 불과해서 개선해야 할 점이 많이 보인다. 예를 들어 NextJS 내장 이미지 컴포넌트는 브라우저의 사이즈 별로 이미지의 사이즈도 다르게 적용한다.

그리고 외부 이미지 주소를 전달할 경우에도 아주 준수한 로딩 속도를 보여준다.

이번에는 속도 이슈로 인해 최종적으로 외부 이미지 최적화는 따로 적용하지 않는 것으로 결정했지만, 찾아보니 redis를 사용하는 예시도 있는 것 같아 시간이 되면 적용해보자.

구현 과정 중에 알아보니 이미지 최적화 제한이 계정 단위가 아닌 월 단위로 1000개라고 합니다.

Unlisted

--

--

coggiee
coggiee

Written by coggiee

0 Followers

Just doing

No responses yet