Advanced (업데이트: 2026. 6. 2.)

Claude Code로 이미지 최적화 파이프라인 만들기

Claude Code로 WebP/AVIF 변환, 반응형 이미지, CI 이미지 예산 검사를 자동화합니다.

Claude Code로 이미지 최적화 파이프라인 만들기

이미지 최적화는 배포 직전에 한 번 압축하는 작업으로 끝나지 않습니다. 히어로 이미지, 글 안의 스크린샷, 상품 썸네일, 다이어그램, OGP 이미지가 늘어나면 수작업은 금방 흔들립니다. 특히 큰 이미지가 Largest Contentful Paint, 즉 화면에서 가장 큰 주요 요소가 되는 순간 페이지 체감 속도에 직접 영향을 줍니다.

이 글에서는 Claude Code를 구현 파트너로 사용해 반복 가능한 이미지 최적화 파이프라인을 만듭니다. sharp로 AVIF, WebP, JPEG 파생 이미지를 만들고, 반응형 picture 컴포넌트로 제공하며, CI에서 이미지 크기 예산을 검사합니다. 목적은 무조건 가장 작은 파일을 만드는 것이 아니라, 읽을 수 있는 품질과 브라우저 호환성, 예측 가능한 파일명, 리뷰 가능한 절차를 함께 지키는 것입니다.

Masa가 작은 기술 블로그에서 처음 시도했을 때는 “AVIF만 만들면 충분하다”고 생각했습니다. 실제로 전송량은 줄었지만 일부 크롤러는 JPEG가 필요했고, 코드 스크린샷은 낮은 품질에서 글자가 흐려졌고, 히어로 이미지는 실수로 lazy load가 되어 LCP가 나빠졌습니다. 변환, 표시, 검증을 나누고 나서야 유지보수 가능한 흐름이 되었습니다.

Claude Code가 처음이라면 Claude Code 시작 가이드를 먼저 확인하세요. 이미지 외의 속도 개선까지 같이 보고 싶다면 Claude Code 성능 최적화도 함께 읽기 좋습니다.

파이프라인 구조

Claude Code에 “이미지를 빠르게 해줘”라고만 요청하면 범위가 흐립니다. 대신 입력, 변환, 표시, 검증을 분리해서 전달하는 편이 안전합니다.

flowchart LR
  A["original images"] --> B["sharp conversion"]
  B --> C["AVIF / WebP / JPEG variants"]
  C --> D["OptimizedImage component"]
  D --> E["browser chooses best source"]
  C --> F["manifest.json"]
  F --> G["CI size budget check"]

변환 스크립트는 원본에서 파생 파일을 만들고, 컴포넌트는 브라우저에 후보를 알려 주며, 검사 스크립트는 너무 큰 이미지를 배포 전에 막습니다. 이렇게 나누면 Claude Code가 만든 diff가 작아지고, 코드 리뷰에서도 어떤 단계의 문제인지 바로 볼 수 있습니다.

코드 작성 전에 품질 기준 정하기

가장 흔한 실수는 모든 이미지에 같은 품질 값을 적용하는 것입니다. 사진, UI 스크린샷, 선명한 다이어그램, 소셜 공유 이미지는 서로 다른 기준이 필요합니다. 저는 보통 아래 표를 Claude Code에 먼저 전달합니다.

용도목표리뷰 포인트
히어로 이미지1280px 이상, AVIF/WebP 우선, JPEG fallbackLCP 후보이므로 priority 처리
글 안의 스크린샷640px/960px 중심작은 UI 텍스트가 읽혀야 함
갤러리/목록320px/640px 적극 사용화면 아래 이미지는 lazy load
OGP/SNS 이미지JPEG 또는 PNG 유지일부 크롤러의 현대 포맷 미지원 대비

2026년 6월 기준으로 포맷 지원은 sharp 공식 문서를 확인하는 것이 좋습니다. HTML 쪽은 MDN 반응형 이미지 가이드의 원칙을 따르세요. srcset은 실제 표시 폭을 설명하는 sizes가 있어야 효과가 납니다.

구현1: sharp로 파생 이미지 만들기

아래 스크립트는 public/images/originaljpg, jpeg, png 파일을 읽고 public/images/optimized에 결과를 씁니다. 동시에 CI 검사에 사용할 manifest.json도 만듭니다.

npm install -D sharp glob tsx
// scripts/optimize-images.ts
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import { glob } from "glob";
import sharp from "sharp";

const inputDir = process.argv[2] ?? "public/images/original";
const outputDir = process.argv[3] ?? "public/images/optimized";
const widths = [320, 640, 960, 1280, 1920] as const;
const formats = ["avif", "webp", "jpeg"] as const;
const quality = { avif: 52, webp: 76, jpeg: 82 } as const;

type ImageFormat = (typeof formats)[number];
type ManifestEntry = {
  src: string;
  width: number;
  format: string;
  bytes: number;
};

const manifest: Record<string, ManifestEntry[]> = {};

function slugFromPath(filePath: string) {
  const relative = path.relative(inputDir, filePath);
  return relative
    .replace(path.extname(relative), "")
    .split(path.sep)
    .join("-")
    .replace(/[^a-zA-Z0-9_-]/g, "-")
    .toLowerCase();
}

function extension(format: ImageFormat) {
  return format === "jpeg" ? "jpg" : format;
}

async function buildVariant(filePath: string, slug: string, width: number, format: ImageFormat) {
  let image = sharp(filePath).rotate().resize({ width, withoutEnlargement: true });

  if (format === "avif") image = image.avif({ quality: quality.avif, effort: 4 });
  if (format === "webp") image = image.webp({ quality: quality.webp, effort: 4 });
  if (format === "jpeg") image = image.jpeg({ quality: quality.jpeg, mozjpeg: true });

  const fileName = `${slug}-${width}w.${extension(format)}`;
  const target = path.join(outputDir, fileName);
  const info = await image.toFile(target);

  return {
    src: `/images/optimized/${fileName}`,
    width: info.width,
    format: extension(format),
    bytes: info.size,
  };
}

async function optimizeOne(filePath: string) {
  const metadata = await sharp(filePath).metadata();
  const sourceWidth = metadata.width ?? widths[widths.length - 1];
  const targetWidths: number[] = widths.filter((width) => width <= sourceWidth);

  if (!targetWidths.includes(sourceWidth)) targetWidths.push(sourceWidth);
  targetWidths.sort((a, b) => a - b);

  const slug = slugFromPath(filePath);
  manifest[slug] = [];

  for (const width of targetWidths) {
    for (const format of formats) {
      manifest[slug].push(await buildVariant(filePath, slug, width, format));
    }
  }

  console.log(`optimized ${slug}: ${manifest[slug].length} files`);
}

async function main() {
  await mkdir(outputDir, { recursive: true });

  const pattern = `${inputDir.replace(/\\/g, "/")}/**/*.{jpg,jpeg,png}`;
  const files = await glob(pattern, { nodir: true });

  for (const filePath of files) {
    await optimizeOne(filePath);
  }

  await writeFile(
    path.join(outputDir, "manifest.json"),
    JSON.stringify(manifest, null, 2),
  );

  console.log(`done: ${files.length} source images`);
}

void main().catch((error) => {
  console.error(error);
  process.exit(1);
});

핵심은 원본보다 큰 이미지를 만들지 않는 것입니다. 900px 스크린샷이 1280w라는 이름으로 저장되면 나중에 품질 문제를 추적하기 어렵습니다. manifest에 실제 너비와 바이트 수를 남기면 리뷰어가 결과를 바로 확인할 수 있습니다.

구현2: 반응형 이미지 컴포넌트

다음은 생성된 파일을 브라우저에 전달하는 React 컴포넌트입니다. Astro에서도 같은 picture, source, img 구조를 컴포넌트로 옮기면 됩니다.

// src/components/OptimizedImage.tsx
import type { ImgHTMLAttributes } from "react";

type OptimizedImageProps = Omit<
  ImgHTMLAttributes<HTMLImageElement>,
  "src" | "srcSet" | "sizes" | "width" | "height" | "loading"
> & {
  slug: string;
  alt: string;
  width: number;
  height: number;
  widths?: number[];
  sizes?: string;
  priority?: boolean;
};

function srcSet(slug: string, widths: number[], extension: "avif" | "webp" | "jpg") {
  return widths
    .map((width) => `/images/optimized/${slug}-${width}w.${extension} ${width}w`)
    .join(", ");
}

export function OptimizedImage({
  slug,
  alt,
  width,
  height,
  widths = [320, 640, 960, 1280],
  sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 960px",
  priority = false,
  className,
  ...imgProps
}: OptimizedImageProps) {
  const fallbackWidth = widths.includes(960) ? 960 : widths[Math.floor(widths.length / 2)];
  const priorityProps = priority
    ? ({ fetchPriority: "high" } as ImgHTMLAttributes<HTMLImageElement>)
    : {};

  return (
    <picture className={className}>
      <source type="image/avif" srcSet={srcSet(slug, widths, "avif")} sizes={sizes} />
      <source type="image/webp" srcSet={srcSet(slug, widths, "webp")} sizes={sizes} />
      <img
        src={`/images/optimized/${slug}-${fallbackWidth}w.jpg`}
        srcSet={srcSet(slug, widths, "jpg")}
        sizes={sizes}
        width={width}
        height={height}
        alt={alt}
        loading={priority ? "eager" : "lazy"}
        decoding={priority ? "sync" : "async"}
        {...priorityProps}
        {...imgProps}
      />
    </picture>
  );
}

priority는 히어로 이미지처럼 첫 화면에서 바로 보이는 큰 이미지에만 사용하세요. 모든 이미지를 eager로 만들면 CSS, JS, 폰트, 실제 LCP 이미지가 네트워크를 두고 경쟁합니다. 우선순위를 정할 때는 web.dev의 LCP 가이드를 참고하면 판단 기준을 세우기 쉽습니다.

구현3: CI에서 이미지 예산 검사

리뷰어가 모든 파생 이미지의 크기를 직접 확인하기는 어렵습니다. 그래서 manifest를 읽고 큰 후보 이미지가 예산을 넘으면 실패시키는 스크립트를 추가합니다.

// scripts/check-image-budget.mjs
import { readFile } from "node:fs/promises";

const manifestUrl = new URL("../public/images/optimized/manifest.json", import.meta.url);
const manifest = JSON.parse(await readFile(manifestUrl, "utf8"));
const maxBytes = Number(process.env.IMAGE_BUDGET_BYTES ?? 240_000);
const failures = [];

for (const [slug, entries] of Object.entries(manifest)) {
  for (const entry of entries) {
    const isLargeCandidate = entry.width >= 1280 && ["avif", "webp", "jpg"].includes(entry.format);
    if (isLargeCandidate && entry.bytes > maxBytes) {
      failures.push(`${slug} ${entry.width}w.${entry.format}: ${entry.bytes} bytes`);
    }
  }
}

if (failures.length > 0) {
  console.error(`Image budget exceeded. Limit: ${maxBytes} bytes`);
  for (const failure of failures) console.error(`- ${failure}`);
  process.exit(1);
}

console.log("Image budget check passed.");
{
  "scripts": {
    "images:build": "tsx scripts/optimize-images.ts",
    "images:check": "node scripts/check-image-budget.mjs"
  }
}

처음에는 240KB 같은 단일 기준으로 시작해도 충분합니다. 실제 출력 데이터를 모은 뒤 히어로, 스크린샷, 썸네일마다 다른 예산을 두면 더 정교해집니다.

세 가지 실전 사용 사례

첫째, 기술 블로그입니다. 고해상도 PNG 스크린샷을 그대로 올리면 글 하나가 지나치게 무거워집니다. 본문 최대 폭과 모바일 폭, 텍스트 가독성을 Claude Code에 알려 주면 sizes와 품질 값을 더 현실적으로 제안합니다.

둘째, SaaS 랜딩 페이지입니다. 제품 스크린샷이나 히어로 이미지는 LCP가 될 가능성이 높습니다. widthheight를 지정하고, 이 한 장만 priority로 처리하며, 나머지 이미지는 lazy load로 두는 편이 안정적입니다.

셋째, 쇼핑몰이나 포트폴리오 갤러리입니다. 같은 원본이 카드, 상세 페이지, 추천 영역, OGP에 반복해서 사용됩니다. manifest가 있으면 각 원본에 어떤 변형이 있는지 테스트와 관리 화면에서 확인할 수 있습니다.

피해야 할 함정

AVIF 품질을 너무 낮추지 마세요. 사진은 괜찮아 보여도 UI 스크린샷의 작은 글자가 읽기 어려워질 수 있습니다.

sizes를 생략하지 마세요. 브라우저가 이미지를 전체 뷰포트 폭으로 가정하면 작은 카드에서도 큰 파일을 받을 수 있습니다.

첫 화면 히어로 이미지를 lazy load로 두지 마세요. 화면 아래 이미지는 늦게 받아도 되지만, 이미 보이는 큰 이미지를 늦추면 LCP가 나빠집니다.

Claude Code에 CDN 업로드, 관리자 화면, 프레임워크 이전, 이미지 변환을 한 번에 맡기지 마세요. 변환 스크립트, 컴포넌트, CI 검사 순서로 나누면 리뷰가 훨씬 쉽습니다.

Claude Code 프롬프트 예시

Create an image optimization script for jpg/png files in public/images/original.
Output files to public/images/optimized.
Generate 320, 640, 960, 1280, and 1920px widths in avif, webp, and jpg.
Do not generate a width larger than the original image.
Write manifest.json with src, width, format, and bytes.
Add package scripts named images:build and images:check.
Keep the diff minimal and do not touch unrelated files.

이 프롬프트는 입력 위치, 출력 위치, 형식, 검증 방식, 건드리지 말아야 할 범위를 모두 지정합니다. 경계가 명확할수록 Claude Code의 결과물도 리뷰하기 쉬워집니다.

실제 검증 결과

Masa의 테스트에서는 원본 1920px PNG 스크린샷을 이 파이프라인으로 바꾼 뒤 글 이미지 전송량이 절반 이하로 줄었습니다. 다만 코드 스크린샷에서 AVIF 품질을 45까지 낮추자 글자가 흐려졌습니다. 최종적으로는 사진은 AVIF 50대, UI 스크린샷은 WebP/JPEG도 눈으로 확인하고, priority는 히어로 이미지에만 적용하는 방식이 가장 안정적이었습니다.

다음 단계는 이미지 한 카테고리만 골라 npm run images:buildnpm run images:check를 실행하는 것입니다. 결과가 안정되면 Claude Code 워크플로 자동화와 연결해 Pull Request에서 이미지 회귀를 잡도록 만드세요.

#Claude Code #이미지 최적화 #WebP #AVIF #성능
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.