junyeokk
Blog
Node.js·2025. 12. 14

sharp

Node.js에서 이미지를 다뤄야 할 때, 가장 먼저 떠오르는 선택지는 보통 ImageMagick이나 GraphicsMagick 같은 외부 바이너리를 child_process로 호출하는 방식이다. 또는 jimp처럼 순수 JavaScript로 작성된 라이브러리를 쓸 수도 있다.

javascript
// jimp 방식 - 순수 JS
const Jimp = require("jimp");
const image = await Jimp.read("input.jpg");
image.resize(300, 200).quality(80).write("output.jpg");

문제는 성능이다. 순수 JS 구현체인 jimp는 4000×3000 이미지 하나를 리사이즈하는 데 수 초가 걸릴 수 있다. ImageMagick을 호출하는 방식은 프로세스 생성 오버헤드가 있고, 에러 핸들링이 까다롭다. 서버에서 수백 장의 이미지를 처리해야 하는 상황이라면 이런 방식은 현실적이지 않다.

sharp는 이 문제를 libvips라는 C 라이브러리를 Node.js 네이티브 바인딩으로 감싸서 해결한다. libvips는 이미지 프로세싱에 특화된 라이브러리로, 메모리 효율과 처리 속도 모두에서 ImageMagick을 크게 앞선다. sharp의 벤치마크에 따르면 jimp 대비 약 10배, ImageMagick 대비 약 4~5배 빠르다.


설치

bash
npm install sharp

sharp는 설치 시 플랫폼에 맞는 prebuilt 바이너리를 자동으로 다운로드한다. libvips를 별도로 설치할 필요가 없다. macOS, Linux, Windows 모두 지원하며, ARM64(Apple Silicon, Raspberry Pi)도 지원한다.

Docker 환경이라면 node:18-alpine 같은 Alpine 이미지에서도 잘 동작한다. 다만 Alpine에서는 libc6-compat이 필요할 수 있다.

dockerfile
FROM node:18-alpine
RUN apk add --no-cache libc6-compat

기본 사용법

sharp의 API는 파이프라인 패턴으로 설계되어 있다. 입력을 받고, 변환 작업을 체이닝하고, 출력을 지정하는 흐름이다.

javascript
import sharp from "sharp";

// 파일 경로로 입력
await sharp("input.jpg")
  .resize(300, 200)
  .toFile("output.jpg");

// Buffer로 입력
const outputBuffer = await sharp(inputBuffer)
  .resize(300, 200)
  .toBuffer();

// Buffer + 메타데이터
const { data, info } = await sharp(inputBuffer)
  .resize(300, 200)
  .toBuffer({ resolveWithObject: true });

console.log(info);
// { format: 'jpeg', width: 300, height: 200, channels: 3, size: 12345 }

입력은 파일 경로, Buffer, ReadableStream 모두 가능하다. 출력도 마찬가지로 toFile(), toBuffer(), pipe()를 사용할 수 있다.


메타데이터 추출

이미지의 크기, 포맷, 색상 정보 등을 변환 없이 바로 확인할 수 있다.

javascript
const metadata = await sharp("input.jpg").metadata();

console.log(metadata);
// {
//   format: 'jpeg',
//   width: 4000,
//   height: 3000,
//   space: 'srgb',
//   channels: 3,
//   depth: 'uchar',
//   density: 72,
//   hasAlpha: false,
//   orientation: 6,
//   exif: <Buffer ...>,
//   icc: <Buffer ...>
// }

이 기능은 이미지를 전체 디코딩하지 않고 헤더만 읽기 때문에 매우 빠르다. 서버에서 업로드된 이미지의 크기를 검증하거나, DB에 width/height를 저장할 때 유용하다.

typescript
// 실제 사용 예시 - 업로드된 이미지의 크기 추출
const metadata = await sharp(file.buffer).metadata();
if (metadata.width) asset.width = metadata.width;
if (metadata.height) asset.height = metadata.height;

리사이즈

sharp의 가장 핵심적인 기능이다. 단순히 크기를 바꾸는 것 이상으로 다양한 옵션을 제공한다.

javascript
// 기본 리사이즈
await sharp("input.jpg").resize(300, 200).toFile("output.jpg");

// 너비만 지정 (비율 유지)
await sharp("input.jpg").resize(300).toFile("output.jpg");

// 높이만 지정 (비율 유지)
await sharp("input.jpg").resize(null, 200).toFile("output.jpg");

fit 옵션

리사이즈할 때 원본 비율과 목표 비율이 다르면 어떻게 처리할지를 fit 옵션으로 결정한다.

javascript
// cover (기본값) - 영역을 꽉 채우고 넘치는 부분은 잘라냄
await sharp("input.jpg")
  .resize(300, 200, { fit: "cover" })
  .toFile("output.jpg");

// contain - 영역 안에 전체가 보이도록 (여백 발생 가능)
await sharp("input.jpg")
  .resize(300, 200, { fit: "contain", background: { r: 255, g: 255, b: 255 } })
  .toFile("output.jpg");

// fill - 비율 무시하고 강제 맞춤
await sharp("input.jpg")
  .resize(300, 200, { fit: "fill" })
  .toFile("output.jpg");

// inside - 원본이 목표보다 클 때만 축소
await sharp("input.jpg")
  .resize(300, 200, { fit: "inside" })
  .toFile("output.jpg");

// outside - 원본이 목표보다 작을 때만 확대
await sharp("input.jpg")
  .resize(300, 200, { fit: "outside" })
  .toFile("output.jpg");
fit동작비율 유지용도
cover채우고 자름썸네일, 프로필 이미지
contain전체 표시 + 여백상품 이미지, 갤러리
fill강제 맞춤특수 경우
inside큰 경우만 축소업로드 제한
outside작은 경우만 확대최소 크기 보장

position 옵션

cover 모드에서 어느 부분을 기준으로 자를지 결정한다.

javascript
await sharp("input.jpg")
  .resize(300, 200, {
    fit: "cover",
    position: "top"  // 상단 기준으로 자름
  })
  .toFile("output.jpg");

// 사용 가능한 값: top, right top, right, right bottom,
// bottom, left bottom, left, left top, centre (기본값)
// 또는 sharp.gravity: north, northeast, east, southeast, ...
// 또는 sharp.strategy: entropy (정보량 기반), attention (주목도 기반)

sharp.strategy.entropy는 이미지에서 정보량이 가장 높은 영역을 자동으로 찾아서 그 부분을 유지한다. attention은 피부색 감지 등 시각적 주목도를 기반으로 한다. 사람 얼굴이 포함된 이미지를 자동 크롭할 때 유용하다.

withoutEnlargement

원본보다 큰 크기로 리사이즈하는 것을 방지한다.

javascript
await sharp("small.jpg")  // 원본이 100x100
  .resize(300, 200, { withoutEnlargement: true })
  .toFile("output.jpg");  // 100x100 그대로 출력

업로드된 이미지를 처리할 때, 작은 이미지를 억지로 키워서 품질이 떨어지는 것을 방지할 수 있다.


포맷 변환

sharp는 JPEG, PNG, WebP, AVIF, TIFF, GIF, SVG 등 다양한 포맷을 지원한다.

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

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

// toFormat()으로 동적 변환
await sharp("input.jpg")
  .toFormat("webp", { quality: 80 })
  .toFile("output.webp");

포맷별 옵션

javascript
// JPEG
await sharp("input.png")
  .jpeg({
    quality: 80,          // 1-100, 기본 80
    progressive: true,    // 프로그레시브 JPEG
    mozjpeg: true,        // mozjpeg 인코더 사용 (더 작은 파일)
  })
  .toFile("output.jpg");

// PNG
await sharp("input.jpg")
  .png({
    compressionLevel: 6,  // 0-9, 기본 6
    palette: true,         // 팔레트 기반 PNG (색상 수 제한)
    colours: 128,          // 팔레트 색상 수
  })
  .toFile("output.png");

// WebP
await sharp("input.jpg")
  .webp({
    quality: 80,          // 1-100
    lossless: false,      // 무손실 여부
    nearLossless: true,   // 거의 무손실 (파일 크기 ↓, 품질 유지)
    effort: 4,            // 0-6, 인코딩 노력 (높을수록 느리지만 작은 파일)
  })
  .toFile("output.webp");

// AVIF
await sharp("input.jpg")
  .avif({
    quality: 50,          // 1-100
    lossless: false,
    effort: 4,            // 0-9
  })
  .toFile("output.avif");

WebP는 JPEG 대비 25-35% 더 작은 파일을 만들면서도 시각적 품질은 거의 동일하다. AVIF는 WebP보다도 20% 정도 더 작지만, 인코딩 속도가 느리다. 서버에서 실시간 변환이 필요하면 WebP, 미리 변환해둘 수 있으면 AVIF가 좋은 선택이다.


이미지 조작

리사이즈와 포맷 변환 외에도 다양한 이미지 조작이 가능하다.

회전과 반전

javascript
// EXIF 정보 기반 자동 회전 (기본으로 적용됨)
await sharp("photo.jpg")
  .rotate()  // EXIF orientation에 따라 자동 회전
  .toFile("output.jpg");

// 명시적 각도 회전
await sharp("input.jpg")
  .rotate(90)   // 시계방향 90도
  .toFile("output.jpg");

// 반전
await sharp("input.jpg")
  .flip()    // 상하 반전
  .flop()    // 좌우 반전
  .toFile("output.jpg");

rotate()를 인자 없이 호출하면 EXIF orientation 태그를 읽어서 자동으로 올바른 방향으로 회전한다. 스마트폰으로 찍은 사진은 실제 픽셀 배열과 표시 방향이 다른 경우가 많아서, 이 자동 회전이 없으면 사진이 옆으로 누워서 표시되는 문제가 생긴다. sharp는 기본적으로 이 자동 회전을 적용한다.

자르기 (Extract)

javascript
// 특정 영역 추출
await sharp("input.jpg")
  .extract({
    left: 100,
    top: 50,
    width: 400,
    height: 300,
  })
  .toFile("cropped.jpg");

색상 조정

javascript
await sharp("input.jpg")
  .greyscale()          // 흑백 변환
  .negate()             // 색상 반전
  .normalize()          // 히스토그램 정규화 (대비 자동 조정)
  .modulate({
    brightness: 1.2,    // 밝기 (1이 원본)
    saturation: 0.8,    // 채도
    hue: 30,            // 색조 회전 (도)
  })
  .toFile("adjusted.jpg");

블러와 샤프닝

javascript
// 가우시안 블러
await sharp("input.jpg")
  .blur(5)  // sigma 값 (0.3-1000)
  .toFile("blurred.jpg");

// 샤프닝
await sharp("input.jpg")
  .sharpen({
    sigma: 1,      // 가우시안 마스크의 sigma
    m1: 1,         // flat 영역 샤프닝 정도
    m2: 2,         // jagged 영역 샤프닝 정도
  })
  .toFile("sharpened.jpg");

이미지 합성 (Composite)

여러 이미지를 레이어처럼 겹쳐서 합성할 수 있다. 워터마크, 로고 삽입, 프레임 합성 등에 사용한다.

javascript
await sharp("background.jpg")
  .composite([
    {
      input: "watermark.png",
      gravity: "southeast",  // 우하단에 배치
    },
  ])
  .toFile("watermarked.jpg");

여러 레이어를 동시에 합성할 수도 있다.

javascript
await sharp("background.jpg")
  .composite([
    {
      input: "logo.png",
      top: 10,
      left: 10,
    },
    {
      input: "watermark.png",
      gravity: "southeast",
      blend: "multiply",  // 블렌드 모드
    },
    {
      input: Buffer.from(
        `<svg width="200" height="50">
          <text x="0" y="40" font-size="30" fill="white">Hello</text>
        </svg>`
      ),
      top: 100,
      left: 100,
    },
  ])
  .toFile("composed.jpg");

input에는 파일 경로, Buffer, SVG 문자열 등을 넣을 수 있다. SVG를 넘기면 텍스트 오버레이도 가능하다. blend 옵션으로 Photoshop과 유사한 블렌드 모드(over, multiply, screen, overlay 등)를 적용할 수 있다.


스트림 처리

sharp는 Node.js 스트림과 완벽하게 호환된다. 대용량 이미지를 메모리에 전부 올리지 않고 스트리밍으로 처리할 수 있다.

javascript
import { createReadStream, createWriteStream } from "fs";

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

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

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

Express/NestJS에서 업로드된 파일을 바로 변환해서 응답할 때 유용하다.

javascript
// Express 예시
app.get("/thumbnail/:id", async (req, res) => {
  const imageBuffer = await fetchImageFromS3(req.params.id);

  const thumbnail = await sharp(imageBuffer)
    .resize(200, 200, { fit: "cover" })
    .webp({ quality: 75 })
    .toBuffer();

  res.type("image/webp").send(thumbnail);
});

성능 최적화

동시 처리 제어

sharp는 내부적으로 libuv 스레드 풀을 사용한다. 기본 스레드 풀 크기(4)를 조정하면 동시 처리량을 높일 수 있다.

javascript
// 스레드 수 확인/설정
console.log(sharp.concurrency());  // 현재 동시 처리 수
sharp.concurrency(2);              // 동시 처리 수 제한

서버에서 이미지 처리가 CPU를 독점하면 다른 요청이 느려질 수 있다. sharp.concurrency(1)로 제한하면 처리 속도는 느려지지만 서버 전체 응답성은 좋아진다.

캐시 설정

javascript
// 캐시 설정 확인
console.log(sharp.cache());
// { memory: { current: 0, high: 0, max: 50 },
//   files: 20, items: 100 }

// 캐시 비활성화 (메모리 절약)
sharp.cache(false);

// 캐시 크기 제한
sharp.cache({ memory: 200, files: 20, items: 100 });

파이프라인 최적화

javascript
// ❌ 비효율적 - 중간 결과물을 매번 디코딩/인코딩
const buffer1 = await sharp("input.jpg").resize(800).toBuffer();
const buffer2 = await sharp(buffer1).greyscale().toBuffer();
const buffer3 = await sharp(buffer2).webp().toBuffer();

// ✅ 효율적 - 한 파이프라인에서 모든 작업 수행
const result = await sharp("input.jpg")
  .resize(800)
  .greyscale()
  .webp({ quality: 80 })
  .toBuffer();

체이닝은 단순히 코드가 깔끔한 것이 아니다. sharp는 파이프라인 전체를 분석해서 최적의 순서로 실행하기 때문에, 중간 버퍼를 만들지 않고 한 번에 처리한다. 메모리 사용량과 처리 시간 모두 줄어든다.

clone()으로 다중 출력

하나의 입력에서 여러 크기의 이미지를 생성해야 할 때, clone()을 사용하면 입력을 한 번만 디코딩한다.

javascript
const pipeline = sharp("input.jpg");

// 원본을 한 번만 읽고 여러 출력 생성
await Promise.all([
  pipeline.clone().resize(800, 600).webp().toFile("large.webp"),
  pipeline.clone().resize(400, 300).webp().toFile("medium.webp"),
  pipeline.clone().resize(200, 150).webp().toFile("small.webp"),
]);

libvips가 빠른 이유

sharp의 성능은 결국 libvips의 성능이다. libvips가 ImageMagick보다 빠른 핵심 이유가 몇 가지 있다.

수요 기반 처리 (Demand-driven processing): ImageMagick은 이미지 전체를 메모리에 올린 뒤 작업하는 반면, libvips는 출력에 필요한 부분만 계산한다. 4000×3000 이미지에서 300×200 썸네일을 만들 때, ImageMagick은 전체 이미지를 디코딩하지만 libvips는 필요한 영역만 디코딩한다.

타일 기반 캐싱: 이미지를 작은 타일로 나눠서 처리한다. 한 타일이 처리되면 바로 다음 단계로 넘기고, 이전 타일의 메모리는 해제한다. 이 방식 덕분에 100MB짜리 이미지를 처리해도 메모리 사용량이 수십 MB 수준에 머문다.

SIMD 최적화: CPU의 SIMD(Single Instruction, Multiple Data) 명령어를 활용해서 픽셀 연산을 병렬로 수행한다. 하나의 명령어로 여러 픽셀을 동시에 처리하기 때문에 순수 루프보다 몇 배 빠르다.


주의사항

SVG 입력

SVG를 래스터라이즈할 때는 density 옵션으로 해상도를 지정해야 선명한 결과물을 얻을 수 있다.

javascript
await sharp("icon.svg", { density: 300 })
  .resize(200, 200)
  .png()
  .toFile("icon.png");

기본 density는 72dpi인데, 이러면 작은 SVG가 흐릿하게 렌더링된다.

애니메이션 GIF/WebP

javascript
// 애니메이션 유지
await sharp("animation.gif", { animated: true })
  .resize(300)
  .webp()
  .toFile("animation.webp");

// 특정 페이지만 추출
await sharp("animation.gif", { pages: 1 })  // 첫 프레임만
  .toFile("first-frame.png");

animated: true를 넣지 않으면 첫 프레임만 처리된다.

메모리 관리

대량의 이미지를 반복 처리할 때는 메모리 누수에 주의해야 한다.

javascript
// ❌ 잠재적 메모리 누수
for (const file of files) {
  sharp(file).resize(300).toFile(`thumb-${file}`);
  // await 없이 실행하면 동시에 모든 파일을 처리하려 함
}

// ✅ 순차 처리
for (const file of files) {
  await sharp(file).resize(300).toFile(`thumb-${file}`);
}

// ✅ 또는 동시성 제한
import pLimit from "p-limit";
const limit = pLimit(5);

await Promise.all(
  files.map((file) =>
    limit(() => sharp(file).resize(300).toFile(`thumb-${file}`))
  )
);

관련 문서