Sharp로 자바스크립트에서 이미지 처리하기

Sharp로 자바스크립트에서 이미지 처리하기

웹 서비스를 운영하다 보면 이미지를 다뤄야 하는 일이 참 많은데요. 사용자가 올린 프로필 사진을 썸네일로 줄이거나, 상품 이미지를 WebP로 변환하거나, 여러 이미지를 하나로 합쳐야 할 때가 있죠. 이런 작업을 브라우저가 아닌 서버 쪽에서 처리하려면 어떻게 해야 할까요?

Sharp는 Node.js에서 가장 널리 쓰이는 이미지 처리 라이브러리입니다. 내부적으로 C 기반의 libvips 엔진을 사용하기 때문에 ImageMagick보다 4~5배 빠르고, 메모리 사용량도 적습니다. JPEG, PNG, WebP, AVIF, GIF, TIFF, SVG 등 다양한 포맷을 지원하고, 리사이징, 크롭, 회전, 합성, 블러, 샤프닝 같은 기본적인 이미지 조작은 물론이고 메타데이터 추출까지 할 수 있어요. npm에서 주간 다운로드 수가 1,200만 건이 넘을 정도로 Node.js 생태계에서 사실상의 표준이라 할 수 있습니다.

이 글에서는 Sharp의 설치부터 자주 쓰이는 기능까지 하나씩 살펴보겠습니다.

설치

Sharp는 npm 패키지로 제공됩니다.

bun add sharp
# 또는
npm install sharp

대부분의 macOS, Windows, Linux 환경에서는 별도 설정 없이 바로 설치됩니다. Sharp가 필요한 네이티브 바이너리를 플랫폼에 맞게 자동으로 내려받기 때문이에요. Node.js 18.17.0 이상이 필요하고, Bun과 Deno에서도 사용할 수 있습니다.

설치가 잘 됐는지 간단히 확인해 볼까요?

check.js
import sharp from "sharp";

console.log(`Sharp 버전: ${sharp.versions.sharp}`);
console.log(`libvips 버전: ${sharp.versions.vips}`);
node check.js

버전 정보가 출력되면 설치가 정상적으로 된 것입니다.

기본 사용법

Sharp의 API는 메서드 체이닝 방식으로 설계되어 있습니다. sharp() 함수로 입력 이미지를 지정하고, 원하는 처리를 체이닝으로 연결한 뒤, .toFile()이나 .toBuffer()로 결과를 출력하는 흐름이에요.

import sharp from "sharp";

await sharp("input.jpg")
  .resize(800, 600)
  .webp({ quality: 80 })
  .toFile("output.webp");

이 코드는 input.jpg 파일을 800x600 크기로 리사이징하고, WebP 포맷으로 변환해서 output.webp로 저장합니다. sharp() 함수에는 파일 경로 외에도 Buffer나 Uint8Array를 넣을 수 있습니다.

import fs from "fs/promises";

// Buffer에서 읽기
const buffer = await fs.readFile("input.png");
const result = await sharp(buffer).resize(400).toBuffer();

// 결과를 파일로 저장
await fs.writeFile("output.png", result);

toBuffer()는 처리된 이미지를 Buffer로 반환합니다. 파일 시스템을 거치지 않고 메모리 안에서 바로 다음 작업으로 넘길 수 있어서, HTTP 응답으로 바로 보내거나 다른 서비스에 업로드할 때 유용해요.

이미지 리사이징

리사이징은 아마 Sharp에서 가장 많이 사용하는 기능일 텐데요. resize() 메서드 하나로 다양한 리사이징 전략을 적용할 수 있습니다.

너비와 높이를 모두 지정하면 해당 크기 안에 맞춰 줄어듭니다. 기본 동작은 가로세로 비율을 유지하면서 지정한 영역 안에 들어가는 것이에요.

// 너비만 지정하면 비율에 맞게 높이가 자동 계산
await sharp("photo.jpg").resize(800).toFile("resized.jpg");

// 너비와 높이를 모두 지정
await sharp("photo.jpg").resize(800, 600).toFile("resized.jpg");

// 옵션 객체로 지정할 수도 있음
await sharp("photo.jpg").resize({ width: 800 }).toFile("resized.jpg");

fit 옵션으로 리사이징 전략을 바꿀 수 있습니다.

// cover: 영역을 꽉 채우면서 넘치는 부분은 잘라냄 (기본값은 아님)
await sharp("photo.jpg").resize(800, 600, { fit: "cover" }).toFile("cover.jpg");

// contain: 영역 안에 다 들어가게, 남는 공간은 배경색으로 채움
await sharp("photo.jpg")
  .resize(800, 600, { fit: "contain", background: "#ffffff" })
  .toFile("contain.jpg");

// fill: 비율 무시하고 강제로 늘려서 채움
await sharp("photo.jpg").resize(800, 600, { fit: "fill" }).toFile("fill.jpg");

// inside: 비율 유지하면서 지정 영역 안에 들어가게 (기본값)
await sharp("photo.jpg")
  .resize(800, 600, { fit: "inside" })
  .toFile("inside.jpg");

// outside: 비율 유지하면서 지정 영역을 완전히 덮게
await sharp("photo.jpg")
  .resize(800, 600, { fit: "outside" })
  .toFile("outside.jpg");

이 옵션들은 CSS의 object-fit 속성과 비슷한 개념인데요, 웹 프론트엔드에서 이미지 비율을 조절할 때 CSS의 object-fit 속성을 사용하는 것과 같은 맥락입니다.

원본보다 큰 크기로 확대하고 싶지 않다면 withoutEnlargement 옵션을 사용합니다.

await sharp("small-photo.jpg")
  .resize(1200, 800, { withoutEnlargement: true })
  .toFile("no-upscale.jpg");

이 경우 원본이 1200x800보다 작으면 원본 크기 그대로 유지됩니다.

포맷 변환

Sharp가 지원하는 출력 포맷은 JPEG, PNG, WebP, AVIF, GIF, TIFF 등이 있습니다. 각 포맷에 맞는 메서드를 체이닝하면 됩니다.

// JPEG으로 변환 (quality 1-100)
await sharp("input.png").jpeg({ quality: 85 }).toFile("output.jpg");

// WebP로 변환
await sharp("input.jpg").webp({ quality: 80 }).toFile("output.webp");

// AVIF로 변환
await sharp("input.jpg").avif({ quality: 50 }).toFile("output.avif");

// PNG로 변환
await sharp("input.jpg").png().toFile("output.png");

포맷별로 고유한 옵션들이 있는데요. JPEG에서는 mozjpeg 옵션을 켜면 Mozilla의 최적화 인코더를 사용해서 동일 화질에서 파일 크기를 더 줄일 수 있습니다. PNG에서는 compressionLevel로 압축 수준을, palette로 팔레트 기반 PNG 생성 여부를 지정할 수 있어요.

// mozjpeg 인코더로 더 작은 JPEG 생성
await sharp("input.png")
  .jpeg({ quality: 80, mozjpeg: true })
  .toFile("optimized.jpg");

// PNG 압축 수준 조절 (0-9, 기본값 6)
await sharp("input.jpg").png({ compressionLevel: 9 }).toFile("compressed.png");

// WebP 무손실 압축
await sharp("input.png").webp({ lossless: true }).toFile("lossless.webp");

toFormat() 메서드를 사용하면 포맷을 문자열로 지정할 수도 있습니다. 사용자 입력에 따라 출력 포맷을 동적으로 결정해야 할 때 편리해요.

const format = "webp"; // 사용자 입력이나 설정에서 가져온 값
await sharp("input.jpg")
  .toFormat(format, { quality: 80 })
  .toFile(`output.${format}`);

이미지 자르기와 추출

이미지의 특정 영역만 잘라내려면 extract() 메서드를 사용합니다.

// 왼쪽 위에서 (100, 50) 위치부터 300x200 영역 추출
await sharp("photo.jpg")
  .extract({ left: 100, top: 50, width: 300, height: 200 })
  .toFile("cropped.jpg");

resize()extract()를 조합하면 리사이징 후 특정 영역만 잘라내는 것도 가능합니다. 이때 extract()의 위치는 메서드 호출 순서에 따라 달라지는데요. resize() 전에 extract()를 호출하면 원본 기준으로 잘라낸 뒤 리사이징하고, resize() 후에 extract()를 호출하면 리사이징된 이미지에서 잘라냅니다.

// 원본에서 먼저 자르고, 그 결과를 리사이징
await sharp("photo.jpg")
  .extract({ left: 100, top: 50, width: 600, height: 400 })
  .resize(300, 200)
  .toFile("crop-then-resize.jpg");

// 리사이징 후 특정 영역만 잘라내기
await sharp("photo.jpg")
  .resize(800, 600)
  .extract({ left: 0, top: 0, width: 400, height: 300 })
  .toFile("resize-then-crop.jpg");

resize()에서 fit: "cover"를 사용하면 잘라내기와 리사이징을 한 번에 처리할 수도 있습니다. position 옵션으로 어느 부분을 중심으로 자를지 지정할 수 있어요.

// 중앙 기준으로 자르면서 리사이징
await sharp("photo.jpg")
  .resize(400, 400, { fit: "cover", position: "center" })
  .toFile("square-center.jpg");

// 상단 기준으로 자르면서 리사이징 (인물 사진에 유용)
await sharp("photo.jpg")
  .resize(400, 400, { fit: "cover", position: "top" })
  .toFile("square-top.jpg");

회전과 뒤집기

EXIF 메타데이터에 회전 정보가 들어 있는 사진을 다룰 때는 rotate() 메서드가 필요합니다. 인자 없이 호출하면 EXIF 방향 정보에 따라 자동으로 회전시켜 줍니다.

// EXIF 방향 정보에 따라 자동 회전
await sharp("photo-from-camera.jpg").rotate().toFile("auto-rotated.jpg");

// 특정 각도로 회전 (시계 방향)
await sharp("photo.jpg").rotate(90).toFile("rotated-90.jpg");

// 임의의 각도로 회전 (빈 영역은 배경색으로 채움)
await sharp("photo.jpg")
  .rotate(45, { background: "#000000" })
  .toFile("rotated-45.jpg");

뒤집기는 flip()flop()으로 할 수 있는데요. flip()은 상하 반전(세로축 기준), flop()은 좌우 반전(가로축 기준)입니다.

// 좌우 반전
await sharp("photo.jpg").flop().toFile("mirrored.jpg");

// 상하 반전
await sharp("photo.jpg").flip().toFile("flipped.jpg");

이미지 합성

Sharp의 composite() 메서드를 사용하면 여러 이미지를 하나로 합칠 수 있습니다. 워터마크를 찍거나, 로고를 올리거나, 여러 사진을 콜라주로 만들 때 유용하죠.

// 워터마크 추가
await sharp("photo.jpg")
  .composite([
    {
      input: "watermark.png",
      gravity: "southeast", // 우측 하단에 배치
    },
  ])
  .toFile("watermarked.jpg");

gravity 옵션으로 합성할 이미지의 위치를 지정합니다. "north", "south", "east", "west", "center", "northeast", "southeast", "southwest", "northwest" 중에서 고를 수 있어요. 정확한 픽셀 좌표가 필요하면 topleft를 직접 지정합니다.

// 정확한 위치에 로고 배치
await sharp("background.jpg")
  .composite([
    {
      input: "logo.png",
      top: 20,
      left: 20,
    },
  ])
  .toFile("with-logo.jpg");

여러 이미지를 동시에 합성하는 것도 됩니다. 배열 안에 여러 레이어를 넣으면 순서대로 겹쳐집니다.

await sharp("background.jpg")
  .composite([
    { input: "overlay1.png", gravity: "northwest" },
    { input: "overlay2.png", gravity: "southeast" },
    { input: "text-banner.png", gravity: "south" },
  ])
  .toFile("composed.jpg");

텍스트나 도형을 직접 만들어서 합성할 수도 있습니다. SVG를 Buffer로 만들어서 input에 넣는 방식이에요.

const svgText = `
<svg width="400" height="60">
  <rect width="400" height="60" fill="rgba(0,0,0,0.5)" rx="8"/>
  <text x="200" y="38" font-size="24" fill="white"
    text-anchor="middle" font-family="sans-serif">
    © 2026 My Photo
  </text>
</svg>`;

await sharp("photo.jpg")
  .composite([
    {
      input: Buffer.from(svgText),
      gravity: "south",
    },
  ])
  .toFile("with-text.jpg");

이미지 보정

Sharp는 여러 가지 이미지 보정 기능도 제공합니다.

블러 효과를 주려면 blur() 메서드를 사용합니다. 인자로 시그마 값(0.3~1000)을 넘기는데, 값이 클수록 더 흐려집니다.

// 가우시안 블러 적용
await sharp("photo.jpg").blur(5).toFile("blurred.jpg");

// 살짝만 블러 (배경 흐림 효과 등)
await sharp("photo.jpg").blur(1.5).toFile("soft-blur.jpg");

반대로 이미지를 선명하게 만들고 싶다면 sharpen() 메서드를 사용합니다.

// 기본 샤프닝
await sharp("photo.jpg").sharpen().toFile("sharpened.jpg");

// 세밀한 조절 (sigma, flat, jagged)
await sharp("photo.jpg")
  .sharpen({ sigma: 2, m1: 0, m2: 3 })
  .toFile("custom-sharpened.jpg");

밝기, 채도, 색조를 조절하려면 modulate() 메서드를 사용합니다.

// 밝기 1.2배, 채도 0.8배로 조절
await sharp("photo.jpg")
  .modulate({ brightness: 1.2, saturation: 0.8 })
  .toFile("adjusted.jpg");

// 색조 회전 (0-360도)
await sharp("photo.jpg").modulate({ hue: 90 }).toFile("hue-shifted.jpg");

그 외에도 negate()로 색 반전, normalize()로 대비 자동 조정, gamma()로 감마 보정을 할 수 있습니다.

// 색 반전
await sharp("photo.jpg").negate().toFile("negative.jpg");

// 대비 자동 조정
await sharp("photo.jpg").normalize().toFile("normalized.jpg");

// 감마 보정 (1.0보다 크면 밝아짐, 작으면 어두워짐)
await sharp("photo.jpg").gamma(2.2).toFile("gamma-corrected.jpg");

// 흑백으로 변환
await sharp("photo.jpg").greyscale().toFile("grayscale.jpg");

메타데이터 추출

이미지를 처리하기 전에 원본의 크기, 포맷, 색 공간 같은 정보가 필요할 때가 있는데요. metadata() 메서드로 이런 정보를 가져올 수 있습니다.

const metadata = await sharp("photo.jpg").metadata();

console.log(metadata);
// {
//   format: 'jpeg',
//   width: 4032,
//   height: 3024,
//   space: 'srgb',
//   channels: 3,
//   depth: 'uchar',
//   density: 72,
//   chromaSubsampling: '4:2:0',
//   isProgressive: false,
//   hasProfile: true,
//   hasAlpha: false,
//   orientation: 1,
//   ...
// }

stats() 메서드를 사용하면 각 채널별 통계(최소값, 최대값, 평균, 표준편차 등)도 확인할 수 있습니다. 이미지 분석이나 자동 보정을 구현할 때 유용하죠.

const stats = await sharp("photo.jpg").stats();

// 각 채널별 통계
for (const channel of stats.channels) {
  console.log({
    min: channel.min,
    max: channel.max,
    mean: channel.mean.toFixed(2),
    std: channel.std.toFixed(2),
  });
}

메타데이터를 활용하면 이미지 크기에 따라 처리 방식을 달리하는 것도 가능합니다.

async function smartResize(inputPath, outputPath, maxWidth = 1200) {
  const image = sharp(inputPath);
  const { width } = await image.metadata();

  if (width > maxWidth) {
    await image.resize(maxWidth).toFile(outputPath);
    console.log(`${width}px → ${maxWidth}px로 리사이징됨`);
  } else {
    await image.toFile(outputPath);
    console.log(`${width}px: 원본 크기 유지`);
  }
}

스트림 처리

Sharp는 Node.js의 스트림과도 잘 어울립니다. sharp() 인스턴스 자체가 Duplex 스트림이기 때문에, 파이프라인으로 연결해서 사용할 수 있어요.

import { createReadStream, createWriteStream } from "fs";

const readStream = createReadStream("input.jpg");
const writeStream = createWriteStream("output.webp");

const transform = sharp().resize(800).webp({ quality: 80 });

readStream.pipe(transform).pipe(writeStream);

HTTP 서버에서 이미지를 실시간으로 변환해서 응답하는 것도 이 패턴으로 구현할 수 있습니다.

import http from "http";
import sharp from "sharp";
import { createReadStream } from "fs";

const server = http.createServer((req, res) => {
  const width = parseInt(req.url.split("/")[1]) || 400;

  res.setHeader("Content-Type", "image/webp");

  createReadStream("original.jpg").pipe(sharp().resize(width).webp()).pipe(res);
});

server.listen(3000);

이렇게 하면 http://localhost:3000/800 같은 URL로 요청하면 800px 너비의 WebP 이미지를 동적으로 생성해서 응답합니다.

일괄 처리

여러 이미지를 한꺼번에 처리해야 할 때는 Promise.all()이나 반복문을 활용하면 됩니다. 다만 한 번에 너무 많은 이미지를 동시에 처리하면 메모리가 부족해질 수 있으니, 적절한 동시성 제어가 필요합니다.

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

async function batchConvert(inputDir, outputDir, format = "webp") {
  const files = await fs.readdir(inputDir);
  const imageFiles = files.filter((f) => /\.(jpg|jpeg|png|tiff)$/i.test(f));

  // 동시에 5개씩 처리
  const concurrency = 5;
  for (let i = 0; i < imageFiles.length; i += concurrency) {
    const batch = imageFiles.slice(i, i + concurrency);
    await Promise.all(
      batch.map(async (file) => {
        const inputPath = path.join(inputDir, file);
        const outputName = file.replace(/\.[^.]+$/, `.${format}`);
        const outputPath = path.join(outputDir, outputName);

        await sharp(inputPath)
          .resize(1200, null, { withoutEnlargement: true })
          .toFormat(format, { quality: 80 })
          .toFile(outputPath);

        console.log(`변환 완료: ${file}${outputName}`);
      }),
    );
  }
}

이 함수는 지정한 디렉토리의 이미지 파일을 모두 찾아서 WebP로 변환합니다. 한 번에 5개씩 처리하는 방식으로 메모리 사용량을 조절하고 있어요. Node.js의 fs 모듈로 파일 목록을 읽고, Sharp로 변환하는 조합이 꽤 자주 쓰이는 패턴입니다.

새 이미지 만들기

기존 이미지를 수정하는 것뿐만 아니라 아예 새로운 이미지를 만들 수도 있습니다. sharp() 함수에 create 옵션을 넘기면 빈 캔버스에서 시작할 수 있어요.

// 400x300 크기의 빨간색 이미지 생성
await sharp({
  create: {
    width: 400,
    height: 300,
    channels: 4,
    background: { r: 255, g: 0, b: 0, alpha: 1 },
  },
})
  .png()
  .toFile("red-rectangle.png");

이 기능은 SVG 합성과 결합하면 더 쓸모가 많아집니다. 동적으로 OG 이미지를 생성하거나 플레이스홀더 이미지를 만들 때 활용할 수 있거든요.

async function createOgImage(title, outputPath) {
  const width = 1200;
  const height = 630;

  const svgOverlay = `
  <svg width="${width}" height="${height}">
    <rect width="${width}" height="${height}" fill="#1a1a2e"/>
    <text x="100" y="340" font-size="48" fill="white"
      font-family="sans-serif" font-weight="bold">
      ${title}
    </text>
  </svg>`;

  await sharp({
    create: {
      width,
      height,
      channels: 4,
      background: { r: 26, g: 26, b: 46, alpha: 1 },
    },
  })
    .composite([{ input: Buffer.from(svgOverlay) }])
    .png()
    .toFile(outputPath);
}

성능 관련 팁

Sharp는 이미 빠르지만, 몇 가지를 신경 쓰면 더 효율적으로 사용할 수 있습니다.

우선 처리가 끝난 sharp 인스턴스를 재사용하지 마세요. sharp() 인스턴스는 한 번만 사용하도록 설계되어 있기 때문에, 같은 입력으로 여러 출력을 만들어야 한다면 clone()을 사용합니다.

const image = sharp("input.jpg");

// 여러 크기의 썸네일 생성
const sizes = [200, 400, 800];
await Promise.all(
  sizes.map((size) =>
    image.clone().resize(size).webp().toFile(`thumb-${size}.webp`),
  ),
);

파이프라인에서 불필요한 단계는 빼는 게 좋습니다. 예를 들어 JPEG를 JPEG로 저장하면서 리사이징만 한다면 jpeg()을 명시적으로 호출하지 않아도 됩니다. Sharp가 입력 포맷을 유지해 줍니다.

sharp.cache()sharp.concurrency()로 캐시 크기와 동시 처리 수를 조절할 수도 있습니다. 메모리가 넉넉하다면 캐시를 키우고, CPU 코어가 많다면 동시성을 높이는 식이에요.

// 캐시 크기 설정 (메모리 MB 단위)
sharp.cache({ memory: 200 });

// 동시 처리 스레드 수 (기본값은 CPU 코어 수)
sharp.concurrency(2);

// 캐시 끄기 (메모리가 부족한 환경)
sharp.cache(false);

실전 예제: 썸네일 서비스

지금까지 배운 내용을 종합해서 간단한 썸네일 생성 서비스를 만들어 보겠습니다.

thumbnail.js
import sharp from "sharp";
import path from "path";

async function generateThumbnails(inputPath, outputDir) {
  const image = sharp(inputPath);
  const metadata = await image.metadata();

  console.log(
    `원본: ${metadata.width}x${metadata.height} (${metadata.format})`,
  );

  const variants = [
    { name: "sm", width: 320 },
    { name: "md", width: 640 },
    { name: "lg", width: 1280 },
  ];

  const baseName = path.basename(inputPath, path.extname(inputPath));

  const results = await Promise.all(
    variants.map(async ({ name, width }) => {
      // 원본보다 큰 썸네일은 건너뛰기
      if (width >= metadata.width) {
        console.log(`  ${name}: 건너뜀 (원본이 ${metadata.width}px)`);
        return null;
      }

      const outputPath = path.join(outputDir, `${baseName}-${name}.webp`);
      const info = await image
        .clone()
        .resize(width)
        .webp({ quality: 80 })
        .toFile(outputPath);

      console.log(
        `  ${name}: ${info.width}x${info.height} (${info.size} bytes)`,
      );
      return { name, ...info };
    }),
  );

  return results.filter(Boolean);
}

이 함수는 하나의 원본 이미지에서 세 가지 크기의 WebP 썸네일을 만들어 냅니다. 원본이 320px보다 작으면 sm 썸네일은 건너뛰는 식으로 불필요한 업스케일링도 방지합니다. clone()을 써서 입력 이미지를 한 번만 읽고 여러 출력을 동시에 생성하니 효율적이에요.

마치며

Sharp는 Node.js에서 이미지를 다룰 때 가장 먼저 떠올릴 수 있는 라이브러리입니다. 리사이징, 포맷 변환, 합성, 보정, 메타데이터 추출 같은 기본적인 기능은 물론이고, 스트림 처리와 일괄 변환까지 깔끔한 API로 제공하고 있어요. 무엇보다 libvips 기반이라 성능이 뛰어나서 프로덕션 환경에서도 안심하고 쓸 수 있다는 점이 가장 큰 장점이죠.

Astro의 이미지 최적화도 내부적으로 Sharp를 사용하고 있을 정도로, 이미지 처리가 필요한 Node.js 프로젝트라면 Sharp를 한번 검토해 보시는 걸 추천합니다. 파일 입출력과 함께 사용할 일이 많으니 Node.js의 fs 모듈도 같이 익혀두시면 좋고, 대량의 이미지를 스트리밍으로 처리해야 한다면 자바스크립트의 스트림에 대한 이해도 도움이 됩니다.

이미지 처리를 서버에서 직접 하지 않고 클라우드 서비스에 맡기고 싶다면 Cloudinary 사용법도 참고해 보세요. URL 파라미터만으로 리사이징, 포맷 변환, CDN 배포까지 처리할 수 있습니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord