Sharp를 사용한 이미지 처리 파이프라인
백그라운드 처리에 안전한 파이프라인에서 업로드된 이미지를 크기 조정, 형식 변환 및 검증합니다
Sharp를 사용한 이미지 처리 파이프라인은(는) CoddyKit의 무료 NestJS Enterprise Backend APIs 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 NestJS Enterprise Backend APIs 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
Why Sharp for Image Pipelines
When users upload images to your NestJS API, you rarely want to store the raw file as-is. A 12-megapixel phone photo can be 8 MB of JPEG that you serve to mobile clients on slow networks.
Sharp is the de-facto Node.js image processing library. It wraps libvips, a low-level C library that processes images in a streaming, low-memory way.
- Fast: 4-5x faster than ImageMagick bindings.
- Memory-safe: libvips streams pixels in chunks instead of decoding the whole image into RAM.
- Format-rich: reads/writes JPEG, PNG, WebP, AVIF, GIF, TIFF, SVG.
In this lesson you will build a resize-transcode-validate pipeline that is safe to run on uploaded, untrusted images.
The Sharp Pipeline Mental Model
A Sharp instance is a lazy pipeline. Method calls like .resize() and .webp() only queue operations. Nothing is decoded or encoded until you call a terminal method like .toBuffer() or .toFile().
Because it is lazy, you can build the pipeline once and execute it multiple times. The input can be a file path, a Buffer, or a readable stream.
import sharp from 'sharp';
async function run(): Promise<void> {
// 1x1 red pixel PNG, base64-encoded
const pngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
const input = Buffer.from(pngBase64, 'base64');
// Pipeline: queue resize + WebP, then execute with toBuffer()
const output = await sharp(input)
.resize(64, 64)
.webp({ quality: 80 })
.toBuffer();
console.log('Output bytes:', output.length);
console.log('Is WebP:', output.slice(8, 12).toString() === 'WEBP');
}
run().catch((err) => console.error(err));Reading Metadata Before You Trust It
Never resize or store an upload before inspecting it. sharp(input).metadata() reads only the header, so it is cheap and tells you the real format, dimensions, and channel count.
This is your first validation gate. If format is missing or unexpected, the bytes are not a real image regardless of the file extension or the client-supplied MIME type.
import sharp, { Metadata } from 'sharp';
const ALLOWED = new Set(['jpeg', 'png', 'webp', 'avif']);
async function validateImage(buffer: Buffer): Promise<Metadata> {
const meta = await sharp(buffer).metadata();
if (!meta.format || !ALLOWED.has(meta.format)) {
throw new Error(`Unsupported image format: ${meta.format ?? 'unknown'}`);
}
if (!meta.width || !meta.height) {
throw new Error('Image has no readable dimensions');
}
return meta;
}
// 1x1 red pixel PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
validateImage(png)
.then((m) => console.log(`OK: ${m.format} ${m.width}x${m.height}`))
.catch((e) => console.error(e.message));Guarding Against Decompression Bombs
A small file can declare enormous dimensions (a decompression bomb): a 40,000 x 40,000 PNG that is a few KB on disk but allocates billions of pixels when decoded, exhausting memory.
Reject oversized dimensions before decoding, using the header metadata. Also call .limitInputPixels() so libvips itself refuses to fully decode anything above a cap.
- Check pixel count from metadata (
width * height). - Set a hard libvips cap with
limitInputPixels.
import sharp from 'sharp';
const MAX_PIXELS = 24_000_000; // ~24 MP cap
async function safeDecode(buffer: Buffer): Promise<Buffer> {
const meta = await sharp(buffer).metadata();
const pixels = (meta.width ?? 0) * (meta.height ?? 0);
if (pixels > MAX_PIXELS) {
throw new Error(`Image too large: ${pixels} pixels exceeds ${MAX_PIXELS}`);
}
// Hard cap inside libvips as a second line of defence
return sharp(buffer, { limitInputPixels: MAX_PIXELS })
.rotate() // auto-orient from EXIF before stripping it
.toBuffer();
}
safeDecode(Buffer.from('not an image'))
.then(() => console.log('decoded'))
.catch((e) => console.error('rejected:', e.message));Resize Strategies: fit and position
The resize options control how the source maps into the target box. The key option is fit:
cover(default): fills the box, cropping overflow. Great for square avatars.contain: fits inside the box, padding withbackground.inside: shrinks to fit without cropping or padding; never enlarges past the box.outside: ensures the box is fully covered, may exceed one dimension.
Set withoutEnlargement: true so you never upscale a small original into a blurry larger image.
import sharp from 'sharp';
function buildResize(input: Buffer) {
return sharp(input).resize({
width: 512,
height: 512,
fit: 'cover',
position: 'attention', // crop toward the most salient region
withoutEnlargement: true,
});
}
export { buildResize };Transcoding to Modern Formats
Storing uploads as WebP or AVIF cuts bytes dramatically versus JPEG at the same visual quality. A common enterprise pattern: normalize every upload to WebP for delivery and keep one archival original.
Each encoder has its own options. Use effort to trade CPU time for smaller files, and quality for the visual/size balance.
import sharp from 'sharp';
export type Variant = 'thumb' | 'display';
export function transcode(input: Buffer, variant: Variant): Promise<Buffer> {
const size = variant === 'thumb' ? 160 : 1280;
return sharp(input)
.resize({ width: size, height: size, fit: 'inside', withoutEnlargement: true })
.webp({
quality: variant === 'thumb' ? 70 : 82,
effort: 4, // 0 (fast) .. 6 (slow, smallest)
})
.toBuffer();
}Stripping Metadata and Fixing Orientation
Uploaded photos carry EXIF metadata that can include GPS coordinates and device info. By default Sharp strips all metadata on output, which is exactly what you want for privacy.
But EXIF also stores the orientation flag. If you strip metadata without applying it, portrait photos come out sideways. The fix: call .rotate() with no arguments early in the pipeline. It bakes the EXIF orientation into the pixels, then the flag can be safely dropped.
If you must keep an ICC color profile, pass .keepIccProfile() or .withMetadata({ icc: '...' }) while still dropping the rest.
import sharp from 'sharp';
export function normalize(input: Buffer): Promise<Buffer> {
return sharp(input)
.rotate() // apply EXIF orientation into the pixels
.resize({ width: 1080, fit: 'inside', withoutEnlargement: true })
.keepIccProfile() // preserve color accuracy, drop everything else
.webp({ quality: 80 })
.toBuffer(); // EXIF (incl. GPS) is gone from the output
}Wrapping the Pipeline in a NestJS Service
Isolate all Sharp logic behind an injectable service. Controllers stay thin and the pipeline becomes unit-testable. The service exposes one method that validates, guards, and produces the output variants.
Notice we re-read metadata() from the same buffer; Sharp instances are cheap to create, so building a fresh one per operation is idiomatic.
import { Injectable, BadRequestException } from '@nestjs/common';
import sharp from 'sharp';
const ALLOWED = new Set(['jpeg', 'png', 'webp', 'avif']);
const MAX_PIXELS = 24_000_000;
export interface ProcessedImage {
thumb: Buffer;
display: Buffer;
width: number;
height: number;
}
@Injectable()
export class ImageProcessingService {
async process(buffer: Buffer): Promise<ProcessedImage> {
const meta = await sharp(buffer).metadata();
if (!meta.format || !ALLOWED.has(meta.format)) {
throw new BadRequestException('Unsupported image format');
}
const pixels = (meta.width ?? 0) * (meta.height ?? 0);
if (pixels === 0 || pixels > MAX_PIXELS) {
throw new BadRequestException('Invalid image dimensions');
}
const base = () =>
sharp(buffer, { limitInputPixels: MAX_PIXELS }).rotate();
const [thumb, display] = await Promise.all([
base().resize({ width: 160, height: 160, fit: 'cover' }).webp({ quality: 70 }).toBuffer(),
base().resize({ width: 1280, fit: 'inside', withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(),
]);
return { thumb, display, width: meta.width!, height: meta.height! };
}
}Keeping It Background-Safe with a Queue
Image processing is CPU-bound. Doing it inline in the HTTP request blocks the Node event loop and stalls other requests. The enterprise pattern is to offload to a background worker.
- The controller stores the raw upload and enqueues a job (e.g. BullMQ on Redis).
- A worker process consumes the job, runs the Sharp pipeline, and writes the variants.
- The API responds
202 Acceptedimmediately with a job/asset id.
This keeps request latency low and lets you scale workers independently of web dynos. Sharp also releases the event loop during native work, but heavy concurrent jobs still belong off the request path.
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
export interface ImageJob {
assetId: string;
storageKey: string; // where the raw upload lives
}
@Injectable()
export class ImageUploadService {
constructor(@InjectQueue('image') private readonly queue: Queue) {}
async enqueue(job: ImageJob): Promise<void> {
await this.queue.add('process', job, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: true,
});
}
}Streaming Large Files Without Buffering
Calling .toBuffer() holds the entire output in memory. For large originals or high-throughput pipelines, stream instead: pipe an upload stream into a Sharp transform and pipe the result out to storage.
A Sharp instance is itself a duplex stream, so it slots directly into Node's stream pipeline. This keeps peak memory bounded regardless of image size.
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import sharp from 'sharp';
export async function streamResize(
inputPath: string,
outputPath: string,
): Promise<void> {
const transform = sharp()
.rotate()
.resize({ width: 1280, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 });
await pipeline(
createReadStream(inputPath),
transform,
createWriteStream(outputPath),
);
}Tuning Concurrency and Caches
Sharp uses a thread pool for the heavy native work. Under load you can tune it globally:
sharp.concurrency(n)sets libvips worker threads per operation. In a worker process, setting it to the number of cores is typical; in a busy web process, lowering it prevents CPU starvation of request handling.sharp.cache()controls the libvips operation cache. For one-shot, untrusted uploads you often disable it to bound memory.
Set these once at process startup, not per request.
import sharp from 'sharp';
import os from 'node:os';
// Call once during bootstrap (e.g. in main.ts before app.listen)
export function configureSharp(): void {
sharp.concurrency(Math.max(1, os.cpus().length - 1));
sharp.cache({ memory: 64, files: 0, items: 100 });
console.log('Sharp concurrency:', sharp.concurrency());
}Quick Check: Orientation and Privacy
A user reports that portrait photos from their iPhone appear rotated 90 degrees after your pipeline, and your security team also requires that GPS data never be stored. Which single change to the pipeline best addresses both?
Recap: A Production Image Pipeline
You built a safe, modern image pipeline with Sharp in NestJS. The essentials:
- Validate first: read
metadata()and allow only known formats with real dimensions. - Guard memory: reject decompression bombs by pixel count and set
limitInputPixels. - Normalize:
.rotate()to apply EXIF orientation, then rely on default stripping to drop GPS/EXIF. - Resize and transcode: choose the right
fit, usewithoutEnlargement, and output WebP/AVIF variants. - Stay off the request path: enqueue CPU-bound work to a background worker and return 202.
- Stream large files and tune concurrency/cache once at startup.
This combination gives you fast, private, memory-safe media handling that scales independently of your HTTP tier.
자주 묻는 질문
“Sharp를 사용한 이미지 처리 파이프라인” 강의는 무료인가요?
네 — “Sharp를 사용한 이미지 처리 파이프라인” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 NestJS Enterprise Backend APIs 강의 전체를 잠금 해제할 수 있습니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.
“Sharp를 사용한 이미지 처리 파이프라인”에서 뭘 배우나요?
백그라운드 처리에 안전한 파이프라인에서 업로드된 이미지를 크기 조정, 형식 변환 및 검증합니다 브라우저에서 직접 실행하는 실습 코드로 NestJS Enterprise Backend APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
NestJS Enterprise Backend APIs을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 NestJS Enterprise Backend APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“Sharp를 사용한 이미지 처리 파이프라인” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 NestJS Enterprise Backend APIs 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 NestJS Enterprise Backend APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- Multer 인터셉터를 사용한 멀티파트 업로드
- StreamableFile로 대용량 응답 스트리밍하기
- 사전 서명 URL을 사용한 S3 직접 업로드
- Sharp를 사용한 이미지 처리 파이프라인