npm 주간 1,200만 다운로드 — Sharp가 Node.js 이미지 처리의 사실상 표준인 이유

Sharp

서버에서 이미지를 다뤄야 하는 일, 생각보다 자주 생기잖아요. 사용자가 올린 프로필 사진을 썸네일로 줄이거나, 상품 이미지를 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부터 시작하면 돼요.