개요

Discord 이미지 플레이스홀더
최근 Discord를 사용하다가 흥미로운 부분을 발견했다. 이미지 공간에 동적인 플레이스홀더를 넣어 사용자 경험을 높이고 있었고, 나도 이것을 블로그에 적용하고 싶었다.
Gatsby로 블로그를 만들었다면 이미지 플러그인의 도움을 받을 수 있었지만, Next.js에서는 직접 구현해 보고 싶었다. Node.js 빌드 스크립트가 이미지별 blurDataURL을 미리 생성하고, 정적 JSON으로 저장하도록 만든 과정을 정리했다.
이 글은 Next.js 14.1.0과 sharp 0.33.2를 사용하던 2024년 2월의 구현 기록이다. 현재 환경에 그대로 적용하기보다는 빌드 시점과 이미지 형식에 관한 선택을 참고하기 바란다.
설계
<Image
src="/imgs/example.jpg"
alt=""
width={800}
height={450}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>Next.js Image에서 blur 플레이스홀더를 보이려면 placeholder="blur"와 blurDataURL을 함께 전달해야 한다. 지원되는 로컬 이미지를 정적으로 import하면 Next.js가 이 값을 자동으로 만들어 주지만, public의 경로나 외부 URL처럼 빌드 단계의 자동 분석 대상이 아닌 이미지에는 직접 제공해야 한다.
내 블로그는 static export 방식으로 배포하고 있었다. 이미지를 요청할 때마다 처리하기보다 빌드 전에 한 번만 변환한 뒤, 생성된 값을 컴포넌트가 정적으로 참조하도록 설계했다. 이 작업은 서버 컴포넌트의 특성이 아니라, Node.js로 실행하는 별도의 빌드 스크립트에 기대고 있다.
그러려면 아래 역할을 할 수 있는 자바스크립트가 필요했다.
imgs폴더에 접근한다.- 해당 이미지의 base64 데이터를 생성한다.
- 생성된 데이터를 JSON 파일로 저장해 컴포넌트에서 정적으로 참조한다.
이를 위해 Node.js 환경에서 파일 시스템에 접근하는 fs와 이미지를 처리하는 sharp로 코드를 구현했다.
Next.js 문서는 동적 이미지의 base64 생성을 도와줄 수 있는 하나의 해결책으로 Plaiceholder를 소개한다. 특정 라이브러리 사용을 필수로 권장하는 뜻은 아니다. Next.js Image 문서
Plaiceholder도 내부적으로 sharp를 사용한다. 이번에는 변환 단계를 직접 설계해 보면서 어떤 문제가 생기는지 확인하고 싶었다.
구현
Dependencies
- next.js@14.1.0
- sharp@0.33.2
당시 내 Vercel 배포 환경에서는 Next.js 14.1.0과 sharp 0.33.2로 버전을 맞춘 뒤 오류가 사라졌다. 다만 이는 내가 겪은 조합이지, sharp 0.33.2의 보편적인 Next.js 최소 버전을 뜻하지는 않는다. Next.js 14.1 릴리스
동적 blurDataURL 생성
fs와sharp를 사용하므로 아래 코드는 브라우저에서 작동하지 않는다.prebuild에서 Node.js 스크립트로 실행한다.
// generate-base64-image.mjs
import { readdir, readFile } from "fs/promises";
import path from "path";
import sharp from "sharp";
const generateAllBase64 = async (dir) => {
// 1. 파일 접근
const imageDir = path.join(process.cwd(), "public", "imgs", dir);
const result = {};
// 2. 이미지 파일 필터
let files = await readdir(imageDir);
files = files.filter((file) => {
return (
file.endsWith(".jpg") ||
file.endsWith(".jpeg") ||
file.endsWith(".png") ||
file.endsWith(".gif") ||
file.endsWith(".webp")
);
});
// 3. 이미지 읽기 및 base64 변환
for (const filename of files) {
const imageBuffer = await readFile(path.join(imageDir, filename));
const buffer = await sharp(imageBuffer)
.resize({ width: 10, withoutEnlargement: true })
.jpeg({ quality: 40 })
.toBuffer();
const base64 = `data:image/jpeg;base64,${buffer.toString("base64")}`;
// 4. result 객체 구성
result[filename] = {
blurDataURL: base64,
img: { src: `/imgs/${dir}/${filename}` },
};
}
// 5. result 객체 반환
return result;
};public/imgs의 이미지를 읽어 너비 10px의 작은 JPEG으로 변환하고 base64 data URL을 만드는 코드다. sharp().toBuffer()는 별도의 출력 형식을 지정하지 않으면 입력 형식을 유지한다. 따라서 PNG나 WebP 버퍼를 data:image/jpeg로 표시하지 않도록 .jpeg()를 명시했다. 변환이 실패하면 예외를 숨기지 않고 빌드도 함께 실패하도록 두었다.
자동화
// generate-base64-image.mjs
const init = async () => {
// getDirNames와 writeJSON 구현은 설명을 위해 생략했다.
// 1. imgs 특정 폴더에 접근해서 폴더명 추출
const dirNames = getDirNames();
const result = {};
// 2. 폴더명에 맞게 base64 데이터 변환
for (const dir of dirNames) {
const base64Data = await generateAllBase64(dir);
result[dir] = base64Data;
}
// 3. base64 json 파일 저장
for (const dir in result) {
await writeJSON(result[dir], dir);
}
};
await init();imgs 폴더를 탐색하고 폴더명에 맞춰 JSON 파일을 저장하는 코드다. package.json의 prebuild에 스크립트를 등록하면 build 앞에서 자동으로 실행된다. 이 실행이 실패하면 배폄 중단되므로, 플레이스홀더가 누락된 결과물을 그대로 내보내지 않는다.
static export에서 Next.js 기본 이미지 최적화 API는 사용할 수 없다. 커스텀 로더를 두지 않는다면 아래처럼 최적화를 끄고 export해야 한다. blur 플레이스홀더와 본 이미지 최적화는 별개의 문제다.
// next.config.mjs
const nextConfig = {
output: "export",
images: {
unoptimized: true,
},
};
export default nextConfig;마치며

적용 화면
여러 시행착오를 겪은 작업이었다. 처음에는 JSON이 아닌 txt 파일로 저장해 참조하다가 배포 환경에서 오류를 일으켰고, Next.js와 sharp의 버전 조합에서 생긴 문제를 바로 찾지 못해 한참 헤매기도 했다.
내 블로그 환경만 고려해 제한이 많은 구현과 달리, Plaiceholder는 여러 추상화를 거쳐 많은 사람이 쓸 수 있도록 배포했다는 점도 대단하게 느껴졌다.
Plaiceholder의 getPlaiceholder가 언제 실행되는지는 호출 위치와 Next.js의 렌더링·캐시 전략에 따라 달라진다. 동적 렌더링 경로에서 캐시 없이 호출하면 요청마다 변환할 수 있지만, static export나 빌드 단계에서 실행하면 그렇지 않다. 이 차이를 정확히 구분하지 않고 라이브러리의 제한으로 받아들였던 것이 이번에 다시 보면 가장 큰 오해였다.
