npm 주간 1,200만 다운로드 — Sharp가 Node.js 이미지 처리의 사실상 표준인 이유
![]()
서버에서 이미지를 다뤄야 하는 일, 생각보다 자주 생기잖아요. 사용자가 올린 프로필 사진을 썸네일로 줄이거나, 상품 이미지를 WebP로 변환하거나, 여러 이미지를 하나로 합쳐야 하거나. 브라우저에서 할 수 있는 작업이 아니에요.
Sharp는 Node.js에서 이미지를 다룰 때 가장 먼저 떠올릴 수 있는 라이브러리예요. npm 주간 다운로드 수 1,200만 건. 사실상의 표준이라 불러도 무리 없어요. 내부적으로 C 기반의 libvips 엔진을 쓰기 때문에 ImageMagick보다 4~5배 빠르고, 메모리 사용량도 적어요. JPEG, PNG, WebP, AVIF, GIF, TIFF, SVG 등 다양한 포맷을 지원하고, 리사이징, 크롭, 회전, 합성, 블러, 샤프닝, 메타데이터 추출까지 가능하죠.
Astro의 이미지 최적화도 내부적으로 Sharp를 쓰고 있을 정도예요.
설치부터 기본 패턴 — 체이닝이 핵심
설치는 `npm install sharp`(또는 `bun add sharp`). 대부분의 macOS, Windows, Linux 환경에서 별도 설정 없이 바로 됩니다. 네이티브 바이너리를 플랫폼에 맞게 자동으로 내려받으니까요. Node.js 18.17.0 이상이 필요하고, Bun과 Deno에서도 쓸 수 있어요.
Sharp의 API는 메서드 체이닝 방식이에요. `sharp()` 함수로 입력 이미지를 지정하고, 원하는 처리를 체이닝으로 연결한 뒤, `.toFile()`이나 `.toBuffer()`로 결과를 출력하는 흐름이죠.
```javascript await sharp("input.jpg") .resize(800, 600) .webp({ quality: 80 }) .toFile("output.webp"); ```
이 코드 한 줄로 800x600 리사이징 + WebP 변환 + 저장이 끝나요. `sharp()` 함수에는 파일 경로 말고 Buffer나 Uint8Array도 넣을 수 있어서, HTTP 응답으로 바로 보내거나 다른 서비스에 업로드할 때 `toBuffer()`가 유용하고요.
리사이징 — CSS의 object-fit을 서버에서 쓰는 느낌
리사이징이 아마 Sharp에서 가장 많이 쓰이는 기능일 텐데요. `resize()` 메서드 하나로 다양한 전략을 적용할 수 있어요.
```javascript // 너비만 지정하면 비율에 맞게 높이 자동 계산 await sharp("photo.jpg").resize(800).toFile("resized.jpg");
// 너비와 높이 모두 지정 await sharp("photo.jpg").resize(800, 600).toFile("resized.jpg"); ```
`fit` 옵션으로 리사이징 전략을 바꿀 수 있는데, CSS의 `object-fit` 속성과 비슷한 개념이에요. `cover`는 영역을 꽉 채우면서 넘치는 부분을 잘라내고, `contain`은 영역 안에 다 들어가게 남는 공간을 배경색으로 채우고, `fill`은 비율 무시하고 강제로 늘려서 채우고, `inside`는 비율 유지하면서 영역 안에 들어가게, `outside`는 비율 유지하면서 영역을 완전히 덮게 해요.
원본보다 큰 크기로 확대하고 싶지 않으면 `withoutEnlargement: true` 옵션을 쓰면 돼요. 원본이 지정 크기보다 작으면 그대로 유지하죠.
포맷 변환과 자르기 — 메서드 호출 순서가 결과를 바꾼다
포맷 변환은 각 포맷에 맞는 메서드를 체이닝하면 끝이에요. `.jpeg({ quality: 85 })`, `.webp({ quality: 80 })`, `.avif({ quality: 50 })`, `.png()`. JPEG에서는 `mozjpeg` 옵션을 켜면 Mozilla의 최적화 인코더를 써서 동일 화질에서 파일 크기를 더 줄일 수 있고, WebP에서는 `lossless: true`로 무손실 압축도 가능해요. `toFormat()` 메서드를 쓰면 포맷을 문자열로 지정할 수도 있어서, 사용자 입력에 따라 출력 포맷을 동적으로 결정해야 할 때 편리하고요.
자르기는 `extract()` 메서드를 써요. 재밌는 건 `resize()`와 `extract()`의 호출 순서에 따라 동작이 달라진다는 점이에요.
```javascript // 원본에서 먼저 자르고, 그 결과를 리사이징 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` 옵션으로 중앙이든 상단이든 기준점을 잡을 수 있고요. 인물 사진이면 `position: "top"`이 유용하죠.
회전, 합성, 보정 — 생각보다 다 돼요
회전은 `rotate()` 메서드. 인자 없이 호출하면 EXIF 방향 정보에 따라 자동 회전시켜주고, 각도를 지정하면 시계 방향으로 돌려요. 45도처럼 임의의 각도로 돌리면 빈 영역은 배경색으로 채워지고요. `flip()`은 상하 반전, `flop()`은 좌우 반전.
합성은 `composite()` 메서드로 여러 이미지를 하나로 합쳐요. 워터마크 찍거나 로고 올리거나 콜라주 만들 때 쓰죠.
```javascript await sharp("photo.jpg") .composite([{ input: "watermark.png", gravity: "southeast", // 우측 하단 }]) .toFile("watermarked.jpg"); ```
`gravity` 옵션으로 위치를 지정하고(north, south, east, west, center 등), 정확한 픽셀 좌표가 필요하면 `top`과 `left`를 직접 지정해요. 여러 레이어를 동시에 합성하는 것도 가능하고요. SVG를 Buffer로 만들어서 `input`에 넣으면 텍스트나 도형을 직접 만들어서 합성할 수도 있어요. (OG 이미지 동적 생성에 꽤 실용적이에요.)
보정도 됩니다. `blur()`로 가우시안 블러(시그마 값 0.3~1000), `sharpen()`으로 선명하게, `modulate()`로 밝기/채도/색조 조절. `negate()`로 색 반전, `normalize()`로 대비 자동 조정, `gamma()`로 감마 보정, `greyscale()`로 흑백 변환까지.
메타데이터와 스트림 — 서버 사이드의 진짜 강점
`metadata()` 메서드로 원본의 크기, 포맷, 색 공간, DPI, 채널 수, EXIF 방향 정보 같은 걸 가져올 수 있어요. `stats()` 메서드를 쓰면 각 채널별 통계(최소값, 최대값, 평균, 표준편차)도 확인 가능하고요. 이미지 분석이나 자동 보정을 구현할 때 유용하죠.
```javascript 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); } else { await image.toFile(outputPath); } } ```
원본 크기에 따라 처리를 달리하는 것도 이렇게 간단해요.
스트림 처리가 진짜 강점이에요. `sharp()` 인스턴스 자체가 Duplex 스트림이거든요. 파이프라인으로 연결해서 쓸 수 있어요.
```javascript 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); }); ```
`http://localhost:3000/800` 같은 URL로 요청하면 800px 너비의 WebP 이미지를 동적으로 생성해서 응답하는 거예요. 파일 시스템을 거치지 않고 메모리 안에서 바로 처리하니까 빠르고요.
배치 처리와 성능 — 동시성 제어가 핵심
여러 이미지를 한꺼번에 처리해야 할 때는 동시성 제어가 중요해요. 한 번에 너무 많은 이미지를 동시에 처리하면 메모리가 터지거든요.
```javascript 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)); const concurrency = 5; // 한 번에 5개씩 for (let i = 0; i < imageFiles.length; i += concurrency) { const batch = imageFiles.slice(i, i + concurrency); await Promise.all(batch.map(async file => { await sharp(path.join(inputDir, file)) .resize(1200, null, { withoutEnlargement: true }) .toFormat(format, { quality: 80 }) .toFile(path.join(outputDir, file.replace(/\.[^.]+$/, `.${format}`))); })); } } ```
동시에 5개씩 처리하는 방식으로 메모리 사용량을 조절하는 패턴이에요. Node.js의 `fs` 모듈로 파일 목록을 읽고, Sharp로 변환하는 조합이 꽤 자주 쓰여요.
새 이미지를 처음부터 만들 수도 있어요. `sharp()`에 `create` 옵션을 넘기면 빈 캔버스에서 시작하거든요. SVG 합성과 결합하면 OG 이미지를 동적으로 생성하거나 플레이스홀더 이미지를 만들 때 활용할 수 있고요.
성능 관련 팁도 몇 가지 있어요. `sharp()` 인스턴스는 한 번만 사용하도록 설계되어 있어서, 같은 입력으로 여러 출력을 만들어야 하면 `clone()`을 써야 해요. JPEG를 JPEG로 저장하면서 리사이징만 한다면 `.jpeg()`을 명시적으로 호출하지 않아도 입력 포맷을 유지해주고요. `sharp.cache()`와 `sharp.concurrency()`로 캐시 크기와 동시 처리 스레드 수를 조절할 수도 있어요. 메모리가 넉넉하면 캐시를 키우고, CPU 코어가 많으면 동시성을 높이는 식이죠.
실전 — clone()으로 썸네일 세 벌 한 번에 생성
지금까지 나온 기능을 종합하면 이런 실전 패턴이 나와요.
```javascript async function generateThumbnails(inputPath, outputDir) { const image = sharp(inputPath); const metadata = await image.metadata(); const variants = [ { name: "sm", width: 320 }, { name: "md", width: 640 }, { name: "lg", width: 1280 }, ]; const baseName = path.basename(inputPath, path.extname(inputPath)); await Promise.all(variants.map(async ({ name, width }) => { if (width >= metadata.width) return null; return image.clone().resize(width).webp({ quality: 80 }) .toFile(path.join(outputDir, `${baseName}-${name}.webp`)); })); } ```
원본 이미지 하나에서 320, 640, 1280px 세 가지 크기의 WebP 썸네일을 만들어요. 원본이 320px보다 작으면 sm은 건너뛰고요. `clone()`으로 입력 이미지를 한 번만 읽고 여러 출력을 동시에 생성하니까 효율적이에요.
libvips 기반이라 프로덕션 환경에서도 안심하고 쓸 수 있다는 점이 Sharp의 가장 큰 장점이에요. 이미지 처리가 필요한 Node.js 프로젝트라면, 다른 거 찾아볼 필요 없이 Sharp부터 시작하면 돼요.