sharp
Node.js에서 이미지를 다뤄야 할 때, 가장 먼저 떠오르는 선택지는 보통 ImageMagick이나 GraphicsMagick 같은 외부 바이너리를 child_process로 호출하는 방식이다. 또는 jimp처럼 순수 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배 빠르다.
설치
npm install sharp
sharp는 설치 시 플랫폼에 맞는 prebuilt 바이너리를 자동으로 다운로드한다. libvips를 별도로 설치할 필요가 없다. macOS, Linux, Windows 모두 지원하며, ARM64(Apple Silicon, Raspberry Pi)도 지원한다.
Docker 환경이라면 node:18-alpine 같은 Alpine 이미지에서도 잘 동작한다. 다만 Alpine에서는 libc6-compat이 필요할 수 있다.
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
기본 사용법
sharp의 API는 파이프라인 패턴으로 설계되어 있다. 입력을 받고, 변환 작업을 체이닝하고, 출력을 지정하는 흐름이다.
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()를 사용할 수 있다.
메타데이터 추출
이미지의 크기, 포맷, 색상 정보 등을 변환 없이 바로 확인할 수 있다.
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를 저장할 때 유용하다.
// 실제 사용 예시 - 업로드된 이미지의 크기 추출
const metadata = await sharp(file.buffer).metadata();
if (metadata.width) asset.width = metadata.width;
if (metadata.height) asset.height = metadata.height;
리사이즈
sharp의 가장 핵심적인 기능이다. 단순히 크기를 바꾸는 것 이상으로 다양한 옵션을 제공한다.
// 기본 리사이즈
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 옵션으로 결정한다.
// 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 모드에서 어느 부분을 기준으로 자를지 결정한다.
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
원본보다 큰 크기로 리사이즈하는 것을 방지한다.
await sharp("small.jpg") // 원본이 100x100
.resize(300, 200, { withoutEnlargement: true })
.toFile("output.jpg"); // 100x100 그대로 출력
업로드된 이미지를 처리할 때, 작은 이미지를 억지로 키워서 품질이 떨어지는 것을 방지할 수 있다.
포맷 변환
sharp는 JPEG, PNG, WebP, AVIF, TIFF, GIF, SVG 등 다양한 포맷을 지원한다.
// 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");
포맷별 옵션
// 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가 좋은 선택이다.
이미지 조작
리사이즈와 포맷 변환 외에도 다양한 이미지 조작이 가능하다.
회전과 반전
// 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)
// 특정 영역 추출
await sharp("input.jpg")
.extract({
left: 100,
top: 50,
width: 400,
height: 300,
})
.toFile("cropped.jpg");
색상 조정
await sharp("input.jpg")
.greyscale() // 흑백 변환
.negate() // 색상 반전
.normalize() // 히스토그램 정규화 (대비 자동 조정)
.modulate({
brightness: 1.2, // 밝기 (1이 원본)
saturation: 0.8, // 채도
hue: 30, // 색조 회전 (도)
})
.toFile("adjusted.jpg");
블러와 샤프닝
// 가우시안 블러
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)
여러 이미지를 레이어처럼 겹쳐서 합성할 수 있다. 워터마크, 로고 삽입, 프레임 합성 등에 사용한다.
await sharp("background.jpg")
.composite([
{
input: "watermark.png",
gravity: "southeast", // 우하단에 배치
},
])
.toFile("watermarked.jpg");
여러 레이어를 동시에 합성할 수도 있다.
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 스트림과 완벽하게 호환된다. 대용량 이미지를 메모리에 전부 올리지 않고 스트리밍으로 처리할 수 있다.
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에서 업로드된 파일을 바로 변환해서 응답할 때 유용하다.
// 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)를 조정하면 동시 처리량을 높일 수 있다.
// 스레드 수 확인/설정
console.log(sharp.concurrency()); // 현재 동시 처리 수
sharp.concurrency(2); // 동시 처리 수 제한
서버에서 이미지 처리가 CPU를 독점하면 다른 요청이 느려질 수 있다. sharp.concurrency(1)로 제한하면 처리 속도는 느려지지만 서버 전체 응답성은 좋아진다.
캐시 설정
// 캐시 설정 확인
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 });
파이프라인 최적화
// ❌ 비효율적 - 중간 결과물을 매번 디코딩/인코딩
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()을 사용하면 입력을 한 번만 디코딩한다.
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 옵션으로 해상도를 지정해야 선명한 결과물을 얻을 수 있다.
await sharp("icon.svg", { density: 300 })
.resize(200, 200)
.png()
.toFile("icon.png");
기본 density는 72dpi인데, 이러면 작은 SVG가 흐릿하게 렌더링된다.
애니메이션 GIF/WebP
// 애니메이션 유지
await sharp("animation.gif", { animated: true })
.resize(300)
.webp()
.toFile("animation.webp");
// 특정 페이지만 추출
await sharp("animation.gif", { pages: 1 }) // 첫 프레임만
.toFile("first-frame.png");
animated: true를 넣지 않으면 첫 프레임만 처리된다.
메모리 관리
대량의 이미지를 반복 처리할 때는 메모리 누수에 주의해야 한다.
// ❌ 잠재적 메모리 누수
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}`))
)
);
관련 문서
- S3 Presigned URL - 이미지 업로드 흐름과 함께 사용