이미지최적화

블로그 이미지 178MB → 8.5MB로 줄인 압축 파이프라인

2026.04.21

레포가 갑자기 178MB가 됐다

주말 동안 카페 후기 글 4개를 썼다. 글 하나에 사진 5~7장이 들어갔다. 푸시하고 나서 git status를 보다가 깜짝 놀랐다.

$ du -sh public/images
178M    public/images

178메가. 사진 28장이 평균 6MB씩 차지하고 있었다. iPhone에서 그대로 옮긴 사진들이 4032x3024 해상도, 4~10MB짜리였다.

블로그 페이지가 무겁고, 빌드 시간도 늘어나고, git clone 시간이 길어진다. 압축 파이프라인을 짤 시점이었다. 결과적으로 178MB → 8.5MB(95% 감소)까지 줄였다. 이 글은 그 과정이다.

문제 진단

먼저 이미지가 왜 큰지 확인.

  • 해상도: 4032x3024 (iPhone 기본). 실제 블로그에서는 800px 폭이면 충분
  • 포맷: HEIC 또는 JPEG. WebP는 같은 화질에서 30% 작다
  • 품질: 100%. 80% 정도로 떨어뜨려도 사람 눈에는 차이 없다

세 가지를 다 잡으면 90% 이상 줄어든다.

도구 선택

이미지 처리 라이브러리는 sharp가 사실상 표준이다. Node.js 환경에서 가장 빠르고 안정적이다.

npm install -D sharp

(-D로 devDependency. 빌드/스크립트 용도라 런타임 번들에는 안 들어간다.)

CLI 도구로는 cwebp(Google이 만든 WebP 인코더)도 있다. 둘 다 결과는 비슷한데, sharp가 Node 스크립트로 통합하기 편해서 그걸 골랐다.

압축 스크립트

scripts/compress-images.ts를 만든다.

import sharp from "sharp";
import fs from "node:fs/promises";
import path from "node:path";
 
const INPUT_DIR = "public/images-raw";   // 원본
const OUTPUT_DIR = "public/images";       // 압축 결과
const MAX_WIDTH = 1600;                   // 최대 폭
const QUALITY = 80;                       // WebP 품질
 
async function processImage(inputPath: string, outputPath: string) {
  const img = sharp(inputPath);
  const meta = await img.metadata();
 
  let pipeline = img;
 
  // 가로가 MAX_WIDTH보다 크면 리사이즈
  if (meta.width && meta.width > MAX_WIDTH) {
    pipeline = pipeline.resize({ width: MAX_WIDTH });
  }
 
  // WebP로 변환 + 품질 80
  await pipeline.webp({ quality: QUALITY }).toFile(outputPath);
}
 
async function main() {
  const dirs = await fs.readdir(INPUT_DIR);
  for (const dir of dirs) {
    const inDir = path.join(INPUT_DIR, dir);
    const outDir = path.join(OUTPUT_DIR, dir);
    await fs.mkdir(outDir, { recursive: true });
 
    const files = await fs.readdir(inDir);
    for (const file of files) {
      const ext = path.extname(file).toLowerCase();
      if (![".jpg", ".jpeg", ".png", ".heic"].includes(ext)) continue;
 
      const outName = file.replace(/\.\w+$/, ".webp");
      const inPath = path.join(inDir, file);
      const outPath = path.join(outDir, outName);
 
      await processImage(inPath, outPath);
      console.log(`✓ ${file} → ${outName}`);
    }
  }
}
 
main();

tsx scripts/compress-images.ts로 실행. 원본은 public/images-raw에 두고, 압축 결과는 public/images로 나간다.

.gitignore에 raw 폴더를 추가한다.

public/images-raw/

이러면 git에는 압축본만 들어간다.

결과

178MB → 8.5MB. 95% 감소.

항목
원본
압축 후
파일당 평균 크기
6.4MB
290KB
총 크기
178MB
8.5MB
해상도
4032x3024
1600x1200
포맷
JPEG
WebP

블로그 페이지 로드 속도가 체감으로 5배 정도 빨라졌다. PageSpeed Insights에서 모바일 점수가 60점대에서 90점대로 올랐다.

빌드 통합

매번 손으로 스크립트를 돌리면 잊는다. package.json에 빌드 전 자동 실행으로 박는다.

{
  "scripts": {
    "compress": "tsx scripts/compress-images.ts",
    "prebuild": "npm run compress",
    "build": "next build"
  }
}

prebuild는 build 직전에 자동 실행된다. npm run build만 해도 압축이 먼저 돈다.

캐시 추가

스크립트가 매번 모든 파일을 다시 처리하면 느리다. mtime 기반 캐시를 추가.

async function processImage(inputPath: string, outputPath: string) {
  // 출력 파일이 이미 있고 입력보다 새로우면 skip
  try {
    const inStat = await fs.stat(inputPath);
    const outStat = await fs.stat(outputPath);
    if (outStat.mtimeMs > inStat.mtimeMs) {
      console.log(`- ${path.basename(inputPath)} (cached)`);
      return;
    }
  } catch {}
 
  // 압축 로직 (위와 동일)
}

이제 변경된 파일만 다시 처리한다. 사진 100장 중 1장만 추가했으면 그 1장만 처리한다.

Next.js Image 컴포넌트와의 관계

Next.js의 <Image> 컴포넌트는 자체 최적화 기능이 있다. 빌드 시 또는 요청 시 이미지를 리사이즈하고 포맷 변환한다.

근데 Cloudflare Pages 환경에서는 images.unoptimized: true로 끄는 게 일반적이다. Cloudflare는 Next.js Image Optimization을 직접 지원 안 한다.

그래서 직접 압축이 답이다. 빌드 타임에 미리 압축해두면 런타임 최적화가 필요 없다.

추가 최적화: 반응형 이미지

스크롤하면서 보는 모바일에서는 800px 폭이면 충분하고, 데스크톱 retina에서는 1600px이 좋다. 두 사이즈를 모두 만들어두고 srcset으로 분기한다.

const SIZES = [800, 1600];
 
for (const width of SIZES) {
  await sharp(inputPath)
    .resize({ width })
    .webp({ quality: 80 })
    .toFile(outputPath.replace(".webp", `-${width}.webp`));
}

<img srcset="...-800.webp 800w, ...-1600.webp 1600w">로 쓰면 브라우저가 화면에 맞는 사이즈를 받는다. 모바일 사용자는 800KB 대신 200KB만 받는다.

다만 복잡도가 늘어나서 나는 일단 1600px 단일 버전만 쓰고 있다. 진짜 사용자가 늘어나면 그때 다단계 srcset을 도입할 생각이다.

흔한 함정

함정 1: HEIC 처리

iPhone에서 옮긴 HEIC 파일은 sharp가 기본 지원 안 한다. macOS에서는 sips로 미리 JPEG로 변환하거나, heic-convert 패키지를 추가로 깐다.

brew install libheif

또는 macOS Finder에서 HEIC 파일을 우클릭 → Quick Actions → Convert Image로 미리 변환한다.

함정 2: 메타데이터 보존

EXIF 데이터(위치, 카메라 정보)가 그대로 들어 있으면 개인 정보가 노출될 수 있다. sharp는 기본적으로 EXIF를 제거한다. withMetadata()를 명시적으로 부르지 않는 한 안전.

함정 3: 큰 PNG는 WebP보다 AVIF가 유리

스크린샷처럼 텍스트가 많은 이미지는 PNG가 무겁다. sharp의 .avif()로 AVIF 출력을 추가하면 더 작아진다. 다만 AVIF 지원 안 하는 옛 브라우저가 있으니 picture 태그로 fallback을 함께 둔다.

직접 겪은 일: 파일명을 안 바꿔서 옛 사진이 계속 나왔다

찜질방 페이지에 방 사진을 여러 장 깔아뒀는데, 습식사우나 사진이 처음에는 청록색이었다. 습기를 물로 읽어 파랗게 잡았더니, 28~35도짜리 더운 방이 화면에서는 제일 시원해 보였다. 그래서 따뜻한 색으로 다시 만들어 같은 경로에 덮어썼다.

배포하고 열어보니 여전히 청록색이었다. 새로고침해도 그대로였다.

주소가 같았기 때문이다. 정적 파일에 4시간짜리 캐시가 걸려 있어서, 브라우저와 CDN 양쪽이 이미 받아둔 옛 사진을 그대로 들고 있었다. 내용이 바뀐 걸 알아낼 방법이 없다 — 주소가 같으면 같은 파일이다.

wetSauna.webp를 wetSauna-warm.webp로 바꾸고 나서야 새 사진이 나왔다.

압축 설정을 바꿔 이미지를 다시 뽑을 때는 파일명도 같이 바꿔야 한다. 화질만 조정해 같은 이름으로 덮어쓰는 경우가 특히 위험하다. 눈에 띄게 달라졌는데 화면은 그대로라면, 압축 스크립트보다 캐시를 먼저 의심하는 편이 빠르다.

정리

블로그 이미지 압축은 한 번 셋업하면 평생 자동이다. sharp + WebP + mtime 캐시 조합이 표준이다.

내 셋업의 결론은 단순하다.

  1. 원본은 public/images-raw/에 둔다 (gitignore)
  2. tsx scripts/compress-images.ts로 자동 압축
  3. prebuild에 박아두면 빌드마다 자동 실행

178MB → 8.5MB로 줄어든 결과는 단순한 숫자가 아니다. 페이지 로드 속도, 모바일 사용자 경험, 빌드 시간, git 작업 속도가 모두 좋아진다. 한 번의 스크립트가 평생의 차이를 만든다.

글 4개를 추가하면서 178MB로 늘어났던 그 시점이 이 작업의 트리거였다. 작은 마찰이 누적되기 전에 자동화하는 게 답이다.

같이 읽으면 좋은 글