Use Cases (업데이트: 2026. 6. 2.)

Claude Code 이미지 처리 실전 가이드: Sharp, Canvas, WebP/AVIF, 업로드 검증

Claude Code로 안전한 이미지 처리를 구현합니다. Sharp, Canvas, EXIF 제거, WebP/AVIF, 검증과 테스트를 다룹니다.

Claude Code 이미지 처리 실전 가이드: Sharp, Canvas, WebP/AVIF, 업로드 검증

이미지 처리는 제품 티켓에서는 작아 보이지만, 실제 배포에서는 꽤 넓은 경계가 됩니다. 사용자가 사진을 올리고, 앱이 썸네일을 만들고, 페이지가 빨라지는 것처럼 보입니다. 하지만 안쪽에는 업로드 검증, 안전한 파일명, EXIF 메타데이터 제거, 리사이즈, 압축, 포맷 fallback, 백그라운드 작업, 개인정보 보호, 테스트가 모두 들어갑니다.

Claude Code는 여러 파일을 한 번에 다루는 작업에 강합니다. 문제는 “이미지를 최적화해줘”처럼 모호하게 요청하면, file.type만 믿고, 원본 파일명을 공개 URL에 쓰고, 메타데이터를 남기고, 모든 이미지를 동기적으로 AVIF로 바꾸는 코드가 나올 수 있다는 점입니다. 그래서 먼저 구현 경계를 명확히 써야 합니다.

검토할 때는 Claude Code 공식 문서, Sharp resize API, Sharp output API, MDN File API, MDN Canvas toBlob, OWASP File Upload Cheat Sheet를 기준으로 삼습니다. 브라우저에서 무거운 작업을 분리해야 한다면 내부 글인 Claude Code Web Worker 가이드도 함께 보세요.

처리 위치부터 정하기

포맷을 먼저 고르지 마세요. 브라우저, 서버 요청, 백그라운드 작업이 각각 맡을 책임을 먼저 정해야 합니다.

위치적합한 작업피해야 할 작업
브라우저미리보기, 가벼운 축소, 전송량 감소신뢰 검증, 대량 AVIF 변환, 개인정보 판단
서버 요청MIME과 magic bytes 검증, 크기 확인, EXIF 제거, 작은 썸네일여러 variant 생성, 느린 AVIF 인코딩
백그라운드 작업상품 이미지 세트, CMS 재생성, 기존 이미지 마이그레이션즉시 응답해야 하는 업로드 처리

실무에서는 브라우저가 UX를 개선하고, 서버가 반드시 다시 검증하며, 비싼 변환은 작업 큐로 넘기는 방식이 안전합니다. accept="image/*"file.type은 사용자를 돕는 힌트일 뿐 보안 경계가 아닙니다.

flowchart LR
  Browser["Browser preview / optional resize"]
  Upload["Upload endpoint"]
  Validate["Magic bytes, size, dimensions"]
  Store["Private raw storage"]
  Job["Background variants"]
  Public["Public WebP/JPEG/AVIF"]
  Browser --> Upload
  Upload --> Validate
  Validate --> Store
  Store --> Job
  Job --> Public

Claude Code 프롬프트에는 이 규칙을 직접 적습니다. 서버에서 MIME과 magic bytes를 확인한다. 공개 URL에 원본 파일명을 쓰지 않는다. rotate()로 휴대폰 사진 방향을 반영한다. .withMetadata()를 호출하지 않는다. AVIF는 옵션으로 둔다. 이 정도만 써도 생성 코드의 위험한 기본값이 많이 줄어듭니다.

실제 제품 유스케이스

첫 번째는 이커머스나 마켓플레이스의 상품 이미지입니다. 판매자는 고해상도 휴대폰 사진을 그대로 올립니다. 제품에는 정사각형 썸네일, 카드 이미지, 상세 이미지, SNS 공유 이미지가 필요합니다. 너무 압축하면 구매자가 질감, 색상, 라벨, 흠집을 확인하기 어렵습니다. WebP를 먼저 쓰고, AVIF는 실제 이미지에서 용량과 인코딩 시간을 비교한 뒤 추가하는 편이 안전합니다.

두 번째는 프로필 이미지와 팀 사진입니다. 여기서는 정사각형 크롭, 안전한 URL, 개인정보 보호가 중요합니다. kim-client-contract-final.png 같은 이름이 공개 경로에 들어가면 안 됩니다. 브라우저에서 이미 축소했더라도 서버 출력에서 EXIF를 제거해야 합니다.

세 번째는 블로그, 도움말, 강의 스크린샷입니다. 튜토리얼 이미지는 글자가 읽혀야 가치가 있습니다. 버튼 이름이 흐려지면 몇십 KB를 줄여도 실패입니다. 이미지와 문서 생성이 함께 필요하다면 Claude Code PDF 생성 가이드도 참고할 수 있습니다.

네 번째는 SaaS 내부 첨부파일입니다. 청구서, 본인확인 이미지, 지원 티켓 스크린샷은 공개 이미지가 아닙니다. public/uploads가 아니라 비공개 저장소, 접근 제어, 삭제 정책, 감사 로그가 필요합니다.

설치

아래 예시는 Node.js 20 이상을 기준으로 합니다. Next.js, Express, Hono, Astro API Route, queue worker에 같은 모듈을 연결할 수 있습니다.

npm i sharp file-type p-limit
npm i -D tsx typescript @types/node
mkdir -p src public/uploads

이미지 처리를 CI에서 확인할 수 있도록 테스트 스크립트를 추가합니다.

{
  "scripts": {
    "test:images": "node --import tsx --test src/**/*.test.ts"
  }
}

업로드 검증과 안전한 파일명

이 모듈은 서버의 신뢰 경계입니다. 확장자를 믿지 않고 magic bytes를 확인하고, Sharp로 크기를 읽고, 너무 큰 파일과 애니메이션 또는 다중 페이지 이미지를 거부합니다.

// src/image-policy.ts
import { randomUUID } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";

const MAX_BYTES = 6 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;

const EXTENSION_BY_MIME = {
  "image/jpeg": ".jpg",
  "image/png": ".png",
  "image/webp": ".webp",
  "image/avif": ".avif",
} as const;

export type MimeType = keyof typeof EXTENSION_BY_MIME;

export type ImageUploadInfo = {
  mime: MimeType;
  extension: string;
  width: number;
  height: number;
  bytes: number;
  originalName: string;
};

function isAllowedMime(mime: string): mime is MimeType {
  return mime in EXTENSION_BY_MIME;
}

export async function assertImageUpload(
  buffer: Buffer,
  originalName = "upload",
): Promise<ImageUploadInfo> {
  if (buffer.byteLength === 0) {
    throw new Error("Empty file");
  }

  if (buffer.byteLength > MAX_BYTES) {
    throw new Error("Image must be 6 MB or smaller");
  }

  const detected = await fileTypeFromBuffer(buffer);

  if (!detected || !isAllowedMime(detected.mime)) {
    throw new Error("Unsupported image type");
  }

  const metadata = await sharp(buffer, { failOn: "error" }).metadata();

  if (!metadata.width || !metadata.height) {
    throw new Error("Image dimensions could not be read");
  }

  if (metadata.pages && metadata.pages > 1) {
    throw new Error("Animated images are not allowed here");
  }

  const pixels = metadata.width * metadata.height;

  if (pixels > MAX_PIXELS) {
    throw new Error("Image dimensions are too large");
  }

  return {
    mime: detected.mime,
    extension: EXTENSION_BY_MIME[detected.mime],
    width: metadata.width,
    height: metadata.height,
    bytes: buffer.byteLength,
    originalName,
  };
}

export function safeImageName(mime: MimeType): string {
  return `${randomUUID()}${EXTENSION_BY_MIME[mime]}`;
}

원본 파일명은 관리자 화면의 표시용 값으로만 저장할 수 있습니다. 공개 URL에는 랜덤 ID를 쓰면 개인정보 노출, 중복 덮어쓰기, 유니코드 파일명 문제를 줄일 수 있습니다.

Sharp로 리사이즈, 압축, EXIF 제거

Node.js 서버 처리에서는 Sharp가 현실적인 기본 선택입니다. 핵심은 rotate()로 EXIF 방향을 적용한 뒤 출력하고, 특별한 이유가 없으면 .withMetadata()를 호출하지 않는 것입니다. 공개 웹 이미지는 메타데이터를 남기지 않는 쪽이 보통 안전합니다.

// src/optimize-image.ts
import { mkdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";

type Variant = {
  kind: "thumb" | "card" | "hero";
  width: number;
  height?: number;
};

const VARIANTS: Variant[] = [
  { kind: "thumb", width: 320, height: 320 },
  { kind: "card", width: 640 },
  { kind: "hero", width: 1280 },
];

export type OptimizedImage = {
  src: string;
  width: number;
  height: number;
  bytes: number;
  format: "webp" | "avif";
};

export async function optimizeImage(
  buffer: Buffer,
  outputDir: string,
  baseName: string,
  makeAvif = false,
): Promise<OptimizedImage[]> {
  await mkdir(outputDir, { recursive: true });

  const results: OptimizedImage[] = [];

  for (const variant of VARIANTS) {
    const resized = sharp(buffer)
      .rotate()
      .resize({
        width: variant.width,
        height: variant.height,
        fit: variant.height ? "cover" : "inside",
        withoutEnlargement: true,
      });

    const webpName = `${baseName}-${variant.kind}.webp`;
    const webpInfo = await resized
      .clone()
      .webp({ quality: 78, effort: 4 })
      .toFile(path.join(outputDir, webpName));

    results.push({
      src: `/uploads/${webpName}`,
      width: webpInfo.width,
      height: webpInfo.height,
      bytes: webpInfo.size,
      format: "webp",
    });

    if (makeAvif) {
      const avifName = `${baseName}-${variant.kind}.avif`;
      const avifInfo = await resized
        .clone()
        .avif({ quality: 45, effort: 4 })
        .toFile(path.join(outputDir, avifName));

      results.push({
        src: `/uploads/${avifName}`,
        width: avifInfo.width,
        height: avifInfo.height,
        bytes: avifInfo.size,
        format: "avif",
      });
    }
  }

  return results;
}

AVIF는 좋은 출력 포맷이지만 만능은 아닙니다. 압축률은 좋을 수 있지만 인코딩이 느립니다. 운영 도구나 오래된 전달 경로에서는 WebP나 JPEG가 더 다루기 쉬울 수 있습니다. 실제 이미지로 비교한 뒤 도입하세요.

Next.js 업로드 API 예시

다음은 App Router의 최소 POST 핸들러입니다. 비공개 이미지라면 public/uploads 대신 객체 저장소와 인증된 전달 방식을 사용해야 합니다.

// app/api/images/route.ts
import path from "node:path";
import { NextResponse } from "next/server";
import { assertImageUpload, safeImageName } from "@/src/image-policy";
import { optimizeImage } from "@/src/optimize-image";

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("image");

  if (!(file instanceof File)) {
    return NextResponse.json(
      { error: "image field is required" },
      { status: 400 },
    );
  }

  const buffer = Buffer.from(await file.arrayBuffer());
  const upload = await assertImageUpload(buffer, file.name);
  const storedName = safeImageName(upload.mime);
  const baseName = storedName.replace(/\.[^.]+$/, "");

  const variants = await optimizeImage(
    buffer,
    path.join(process.cwd(), "public", "uploads"),
    baseName,
    false,
  );

  return NextResponse.json({
    original: {
      width: upload.width,
      height: upload.height,
      bytes: upload.bytes,
    },
    variants,
  });
}

이 예시는 공개 미디어용입니다. 계약서, 증빙 이미지, 고객 지원 파일을 같은 위치에 두면 안 됩니다.

브라우저에서 가볍게 줄이기

브라우저 리사이즈는 전송량을 줄이고 미리보기를 빠르게 만듭니다. 하지만 서버 검증의 대체물은 아닙니다.

// src/resize-in-browser.ts
export async function resizeInBrowser(
  file: File,
  maxSide = 1600,
): Promise<File> {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
  const width = Math.round(bitmap.width * scale);
  const height = Math.round(bitmap.height * scale);

  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;

  const context = canvas.getContext("2d");

  if (!context) {
    throw new Error("Canvas 2D context is not available");
  }

  context.drawImage(bitmap, 0, 0, width, height);
  bitmap.close();

  const blob = await new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (result) => {
        if (result) resolve(result);
        else reject(new Error("Canvas export failed"));
      },
      "image/webp",
      0.82,
    );
  });

  const outputName = file.name.replace(/\.[^.]+$/, ".webp");

  return new File([blob], outputName, {
    type: blob.type || "image/webp",
    lastModified: Date.now(),
  });
}

Canvas 재인코딩은 많은 메타데이터를 떨어뜨리지만, 개인정보 보호를 그것에만 맡기면 안 됩니다. AVIF 브라우저 출력도 지원 차이가 있으므로 주 경로로 두기에는 위험합니다.

백그라운드 작업과 성능 예산

동기 업로드 요청은 짧아야 합니다. 검증, 저장, 응답에 필요한 최소 썸네일까지만 처리하고, 상세 이미지, OGP, AVIF, 기존 파일 재생성은 작업 큐로 넘깁니다.

초기 예산은 아바타 320x320 80KB 이하, 카드 이미지 폭 640 기준 120KB 이하, 히어로 이미지 폭 1280 기준 250KB 이하로 시작할 수 있습니다. 숫자는 제품마다 바뀌지만, 예산이 있어야 Claude Code가 과한 품질 설정을 고르는 일을 막을 수 있습니다.

// src/batch-optimize.ts
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import pLimit from "p-limit";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";

export async function batchOptimize(inputDir: string, outputDir: string) {
  const files = await readdir(inputDir);
  const limit = pLimit(3);

  const jobs = files.map((file) =>
    limit(async () => {
      const sourcePath = path.join(inputDir, file);
      const buffer = await readFile(sourcePath);
      const upload = await assertImageUpload(buffer, file);
      const baseName = safeImageName(upload.mime).replace(/\.[^.]+$/, "");
      const variants = await optimizeImage(buffer, outputDir, baseName, true);

      return {
        file,
        variants: variants.length,
      };
    }),
  );

  return Promise.allSettled(jobs);
}

실제 큐를 쓴다면 job ID, 원본 이미지 ID, variant 종류, 실패 이유, 재시도 횟수, 생성 경로를 기록하세요. 그렇지 않으면 파일은 남아 있는데 DB에서 설명할 수 없는 상태가 됩니다.

자주 나오는 실패

첫째, file.type만 믿는 코드입니다. 둘째, 원본 파일명을 공개 URL에 쓰는 코드입니다. 셋째, 휴대폰 사진 방향을 무시해 이미지가 옆으로 누운 상태로 나오는 문제입니다. 넷째, 다른 예제에서 복사한 .withMetadata() 때문에 EXIF가 남는 문제입니다. 다섯째, 업로드 요청 안에서 모든 이미지를 AVIF로 만들어 타임아웃을 일으키는 문제입니다.

제품 관점의 실패도 있습니다. 이미지는 줄었지만 전환 경로가 나빠지는 경우입니다. 강의 스크린샷이 흐리면 유료 템플릿 CTA의 신뢰가 떨어집니다. 상품 이미지가 늦게 뜨면 구매 버튼은 보여도 사용자는 제품을 이해하지 못합니다. 이미지 로드와 CTA 클릭을 같이 봐야 한다면 Claude Code 분석 구현도 함께 보세요.

테스트하기

테스트는 샘플 파일을 사람이 올려보는 수준에서 끝내지 않습니다. Sharp로 테스트 이미지 자체를 생성하면 CI에서도 안정적으로 재현됩니다.

// src/image-policy.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import sharp from "sharp";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";

test("validates and optimizes a generated image", async () => {
  const input = await sharp({
    create: {
      width: 1200,
      height: 800,
      channels: 3,
      background: "#38bdf8",
    },
  })
    .jpeg()
    .toBuffer();

  const info = await assertImageUpload(input, "masa-profile.jpg");
  assert.equal(info.mime, "image/jpeg");
  assert.equal(info.width, 1200);

  const safeName = safeImageName(info.mime);
  assert.match(safeName, /^[a-f0-9-]+\.jpg$/);

  const outDir = await mkdtemp(path.join(tmpdir(), "images-"));
  const baseName = safeName.replace(/\.[^.]+$/, "");
  const variants = await optimizeImage(input, outDir, baseName, false);

  assert.equal(variants.length, 3);
  assert.ok(variants.every((item) => item.bytes > 0));

  const thumb = await sharp(
    path.join(outDir, `${baseName}-thumb.webp`),
  ).metadata();

  assert.equal(thumb.width, 320);
  assert.equal(thumb.height, 320);
  assert.equal(thumb.exif, undefined);
});

수동 확인에는 모바일 폭, 느린 네트워크, 깨진 파일, 큰 파일, 세로 휴대폰 사진, 투명 PNG, 작은 글자가 있는 스크린샷을 포함합니다. 마지막에는 Claude Code에게 검증, 파일명, 메타데이터, CPU, fallback, 테스트 누락만 보라고 리뷰를 맡기면 좋습니다.

수익화 CTA와 실측 메모

이미지 처리는 속도 개선만이 아닙니다. 상품 사진이 빠르게 보이고, 튜토리얼 스크린샷이 읽히고, 프로필 사진에서 불필요한 정보가 사라지고, SNS 이미지가 깨지지 않는 것은 모두 다음 클릭에 영향을 줍니다. 개인 학습은 무료 Claude Code 체크리스트에서 시작하고, 재사용 가능한 프롬프트와 템플릿은 ClaudeCodeLab 제품 페이지를 확인하세요. 팀에서 업로드 규칙과 리뷰 게이트를 실제 저장소에 넣고 싶다면 training / consultation이 맞습니다.

2026년 6월 2일, Masa는 작은 Next.js 검증 프로젝트에서 이 흐름을 시험했습니다. 가장 안정적이었던 요청은 “브라우저 축소는 보조, 서버 검증은 필수, AVIF는 옵션, 원본 파일명은 공개하지 않는다”를 먼저 적은 경우였습니다. 반대로 “이미지 업로드를 만들어줘”라고만 했을 때는 file.type 검증, 원본 파일명 공개, 방향 처리 누락, 동기 AVIF 생성이 섞였습니다. 성능 예산과 실패 예시를 먼저 주는 것이 품질을 가장 크게 올렸습니다.

#Claude Code #이미지 처리 #Sharp #WebP #업로드 검증
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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