이미지/비디오 품질 유지 + 용량 최소화를 위한 미디어 처리 파이프라인 설계.
클라이언트(WASM) 전처리 + 서버(NestJS) 후처리 2단 구조.
---
```
[클라이언트 - 브라우저] [서버 - NestJS]
유저가 이미지 선택
↓
Web Worker + WASM 모듈
- 리사이즈 (최대 2560px)
- WebP 변환
- EXIF 메타데이터 제거
- 용량 절감 (10MB → 1~2MB)
↓
전처리된 파일 업로드 ───────────────────→ API 수신
↓
BullMQ 작업 큐 (Redis)
↓
Worker 프로세스
- 반응형 사이즈 생성 (sm/md/lg/xl)
- WebP/AVIF 포맷 변환
- 블러 플레이스홀더 (LQIP) 생성
- GIF → MP4/WebM 변환
- 비디오 트랜스코딩
↓
Supabase Storage 저장
↓
프론트: <picture> + srcset ←──────────── 완료 응답 + URL 반환
```
| 레이어 | 목적 | 도구 |
|--------|------|------|
| 클라이언트 (WASM) | **전송 최적화** — 업로드 전 용량 절감 | Web Worker + WASM (photon-rs / wasm-vips) |
| 서버 (Sharp) | **배포 최적화** — 다양한 디바이스/브라우저용 에셋 생성 | Sharp, FFmpeg, BullMQ |
클라이언트에서 아무리 잘 줄여도 반응형 사이즈 세트, AVIF 변환, 블러 플레이스홀더 등은 서버에서 생성해야 한다.
클라이언트는 서버의 일을 대신하는 게 아니라, 서버에 보내기 전 불필요하게 큰 파일을 다이어트시키는 것.
---
- 업로드 전 이미지 용량을 대폭 줄여 전송 시간 절감
- 메인 스레드 블로킹 없이 Web Worker에서 처리
- 서버가 처리할 원본 자체가 가벼워져 변환 시간도 단축
| 라이브러리 | 특징 | 비고 |
|-----------|------|------|
| `wasm-vips` | libvips의 WASM 빌드, 서버 Sharp와 동일 엔진 | 결과물 일관성 좋음 |
| `photon-rs` | Rust 기반, 가볍고 커스터마이징 자유 | 직접 빌드 경험 가능 |
| Squoosh 코덱 | Google 제작, WebP/AVIF 인코딩 품질 좋음 | 코덱 단위로 가져올 수 있음 |
```typescript
// 프론트엔드 업로드 핸들러 (메인 스레드)
async function handleImageUpload(file: File) {
// 1. Web Worker에 파일 전달
const worker = new Worker('/workers/image-processor.js');
worker.postMessage({
file,
config: {
maxWidth: 2560,
format: 'webp',
quality: 85,
stripMetadata: true,
}
});
// 2. Worker에서 처리 완료 후 결과 수신
const processedBlob = await new Promise<Blob>((resolve) => {
worker.onmessage = (e) => resolve(e.data.blob);
});
// 3. 전처리된 파일을 서버로 업로드
const formData = new FormData();
formData.append('file', processedBlob, 'image.webp');
await fetch('/api/media/upload', { method: 'POST', body: formData });
}
```
```typescript
// Web Worker (image-processor.js)
import init, { resize, to_webp, strip_metadata } from 'photon-rs';
self.onmessage = async (e) => {
const { file, config } = e.data;
await init(); // WASM 모듈 초기화
const buffer = await file.arrayBuffer();
// WASM으로 이미지 처리
let image = new PhotonImage(new Uint8Array(buffer));
if (config.stripMetadata) {
image = strip_metadata(image);
}
if (image.get_width() > config.maxWidth) {
const ratio = config.maxWidth / image.get_width();
const newHeight = Math.round(image.get_height() * ratio);
image = resize(image, config.maxWidth, newHeight, 'lanczos3');
}
const result = to_webp(image, config.quality);
self.postMessage({
blob: new Blob([result], { type: 'image/webp' }),
originalSize: file.size,
processedSize: result.byteLength,
});
};
```
- Web Worker는 메인 스레드와 별도로 동작하므로 UI 블로킹 없음
- 여러 장 동시 업로드 시 Worker 여러 개 기동하거나 단일 Worker에서 순차 처리
- 브라우저에서는 Bull Queue 같은 작업 큐 불필요 — Web Worker 자체가 큐 역할
- WASM 모듈은 초기 로드 시 한 번만 초기화하면 이후 호출은 빠름
---
**효과:** 용량 30% ↓
**복잡도:** 낮음
**예상 작업량:** 0.5일
```typescript
import sharp from 'sharp';
const COMPRESSION_CONFIG = {
webp: { quality: 82, effort: 4 },
avif: { quality: 65, effort: 4 },
jpeg: { quality: 85, mozjpeg: true },
png: { compressionLevel: 8 },
};
async function convertToWebP(inputBuffer: Buffer): Promise<Buffer> {
return sharp(inputBuffer)
.webp(COMPRESSION_CONFIG.webp)
.toBuffer();
}
```
---
**효과:** 모바일 속도 ↑
**복잡도:** 낮음
**예상 작업량:** 1일
| 이름 | 너비 | 용도 |
|------|------|------|
| sm | 640px | 모바일 썸네일 |
| md | 1024px | 태블릿, 카드 |
| lg | 1920px | 데스크탑 |
| xl | 2560px | 히어로, 레티나 |
```typescript
const RESPONSIVE_SIZES = {
sm: 640,
md: 1024,
lg: 1920,
xl: 2560,
} as const;
async function generateResponsiveSizes(
inputBuffer: Buffer,
formats: ('webp' | 'avif')[] = ['webp']
) {
const results: Record<string, Record<string, Buffer>> = {};
for (const [sizeName, width] of Object.entries(RESPONSIVE_SIZES)) {
results[sizeName] = {};
for (const format of formats) {
results[sizeName][format] = await sharp(inputBuffer)
.resize(width, null, { withoutEnlargement: true })
[format](COMPRESSION_CONFIG[format])
.toBuffer();
}
}
return results;
}
```
```tsx
<picture>
<source
srcSet={`
${image.sizes.sm.avif} 640w,
${image.sizes.md.avif} 1024w,
${image.sizes.lg.avif} 1920w,
${image.sizes.xl.avif} 2560w
`}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
type="image/avif"
/>
<source
srcSet={`
${image.sizes.sm.webp} 640w,
${image.sizes.md.webp} 1024w,
${image.sizes.lg.webp} 1920w,
${image.sizes.xl.webp} 2560w
`}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
type="image/webp"
/>
<img
src={image.original.url}
loading="lazy"
style={{ backgroundImage: `url(${image.placeholder})` }}
alt={image.alt}
/>
</picture>
```
---
**효과:** UX 개선
**복잡도:** 낮음
**예상 작업량:** 0.5일
```typescript
async function generatePlaceholder(inputBuffer: Buffer): Promise<string> {
const placeholder = await sharp(inputBuffer)
.resize(20, null, { withoutEnlargement: true })
.blur(10)
.webp({ quality: 20 })
.toBuffer();
return `data:image/webp;base64,${placeholder.toString('base64')}`;
}
```
```typescript
import { encode } from 'blurhash';
async function generateBlurHash(inputBuffer: Buffer): Promise<string> {
const { data, info } = await sharp(inputBuffer)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });
return encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // x components
3 // y components
);
}
```
---
**효과:** 용량 85% ↓
**복잡도:** 중간
**예상 작업량:** 1~2일
```
GIF 업로드
↓
┌───────────────────────────────────────────┐
│ 1. 원본 GIF 보관 (폴백) │
│ 2. MP4 변환 (H.264, 호환성) │
│ 3. WebM 변환 (VP9, 효율) │
│ 4. 첫 프레임 추출 → 정적 썸네일 (WebP) │
│ 5. 포스터 이미지 생성 │
└───────────────────────────────────────────┘
↓
프론트: <video autoplay loop muted playsinline>
(GIF처럼 보이지만 용량 80-90% 감소)
```
```typescript
import ffmpeg from 'fluent-ffmpeg';
import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg';
ffmpeg.setFfmpegPath(ffmpegPath);
async function convertGifToMp4(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-movflags', 'faststart',
'-pix_fmt', 'yuv420p',
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
])
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
async function convertGifToWebM(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-c:v', 'libvpx-vp9',
'-crf', '30',
'-b:v', '0',
])
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
async function extractFirstFrame(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions(['-vframes', '1', '-f', 'image2'])
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
```
```tsx
<div className="relative">
<video
src={media.video.mp4}
poster={media.poster}
autoPlay
loop
muted
playsInline
>
<source src={media.video.webm} type="video/webm" />
<source src={media.video.mp4} type="video/mp4" />
</video>
</div>
```
---
**효과:** UX 개선
**복잡도:** 중간
**예상 작업량:** 1~2일
```
비디오 업로드
↓
┌───────────────────────────────────────────┐
│ 1. 원본 보관 │
│ 2. 트랜스코딩 (720p, 1080p) │
│ 3. 썸네일 추출 (여러 시점) │
│ 4. 프리뷰 클립 생성 (6초, 무음) │
│ 5. 호버용 저해상도 프리뷰 │
└───────────────────────────────────────────┘
↓
프론트: 정적 썸네일 → 호버 시 프리뷰 재생
```
```typescript
async function transcodeVideo(
inputPath: string,
outputPath: string,
resolution: 720 | 1080
): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '23',
'-vf', `scale=-2:${resolution}`,
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', 'faststart',
])
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
async function generatePreviewClip(
inputPath: string,
outputPath: string,
startSeconds: number = 0,
duration: number = 6
): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.setStartTime(startSeconds)
.setDuration(duration)
.outputOptions([
'-an', // 무음
'-vf', 'scale=-2:360', // 저해상도
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '28',
'-movflags', 'faststart',
])
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
```
---
**효과:** 용량 추가 ↓
**복잡도:** 낮음
**예상 작업량:** 0.5일
```typescript
async function convertToAvif(inputBuffer: Buffer, width?: number): Promise<Buffer> {
let pipeline = sharp(inputBuffer);
if (width) {
pipeline = pipeline.resize(width, null, { withoutEnlargement: true });
}
return pipeline
.avif(COMPRESSION_CONFIG.avif)
.toBuffer();
}
```
---
**효과:** 응답 속도 ↑
**복잡도:** 중간
**예상 작업량:** 1일
```
API 엔드포인트
↓
원본 저장 + BullMQ에 작업 등록 → 즉시 응답 (업로드 ID 반환)
Worker 프로세스 (별도)
↓
큐에서 작업 꺼내서 처리
- 반응형 사이즈 생성
- 포맷 변환 (WebP, AVIF)
- 블러 플레이스홀더
- GIF → 비디오 (해당 시)
↓
Supabase Storage 저장
↓
작업 완료 상태 업데이트
```
```typescript
// media.module.ts
import { BullModule } from '@nestjs/bull';
@Module({
imports: [
BullModule.registerQueue({
name: 'media-processing',
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
},
}),
],
providers: [MediaService, MediaProcessor],
controllers: [MediaController],
})
export class MediaModule {}
```
```typescript
// media.service.ts
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
@Injectable()
export class MediaService {
constructor(
@InjectQueue('media-processing') private mediaQueue: Queue,
) {}
async uploadImage(file: Express.Multer.File) {
// 1. 원본 저장
const originalUrl = await this.storageService.upload(
`media/${id}/original.${ext}`,
file.buffer,
);
// 2. 비동기 처리 작업 등록
await this.mediaQueue.add('process-image', {
mediaId: id,
originalPath: originalUrl,
mimeType: file.mimetype,
});
// 3. 즉시 응답 — 변환은 백그라운드에서 진행
return { id, status: 'processing', original: originalUrl };
}
}
```
```typescript
// media.processor.ts
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('media-processing')
export class MediaProcessor {
@Process('process-image')
async handleImageProcessing(job: Job) {
const { mediaId, originalPath } = job.data;
const inputBuffer = await this.storageService.download(originalPath);
// 1. 반응형 사이즈 + 포맷 변환
const sizes = await generateResponsiveSizes(inputBuffer, ['webp', 'avif']);
// 2. 블러 플레이스홀더
const placeholder = await generatePlaceholder(inputBuffer);
// 3. 결과물 저장
for (const [sizeName, formats] of Object.entries(sizes)) {
for (const [format, buffer] of Object.entries(formats)) {
await this.storageService.upload(
`media/${mediaId}/${format}/${sizeName}.${format}`,
buffer,
);
}
}
// 4. DB 상태 업데이트
await this.mediaRepository.update(mediaId, {
status: 'completed',
placeholder,
sizes: Object.keys(sizes),
formats: ['webp', 'avif'],
});
}
@Process('process-gif')
async handleGifProcessing(job: Job) {
const { mediaId, originalPath } = job.data;
// MP4, WebM 변환 + 포스터 추출
await convertGifToMp4(originalPath, mp4Path);
await convertGifToWebM(originalPath, webmPath);
await extractFirstFrame(originalPath, posterPath);
// 저장 + DB 업데이트
}
}
```
---
Supabase Storage 내 파일 구조:
```
media/
{id}/
original.jpg
webp/
sm.webp
md.webp
lg.webp
xl.webp
avif/
sm.avif
md.avif
lg.avif
xl.avif
placeholder.txt
video/
output.mp4
output.webm
poster.webp
preview.mp4
```
---
```typescript
interface ImageUploadResponse {
id: string;
status: 'processing' | 'completed' | 'failed';
original: {
url: string;
width: number;
height: number;
size: number;
};
}
```
```typescript
interface ImageMediaResponse {
id: string;
status: 'completed';
original: {
url: string;
width: number;
height: number;
size: number;
};
formats: {
webp: string;
avif?: string;
};
sizes: {
sm: { url: string; width: number };
md: { url: string; width: number };
lg: { url: string; width: number };
xl: { url: string; width: number };
};
placeholder: string; // base64 블러 이미지
blurhash?: string; // BlurHash 문자열
}
```
```typescript
interface AnimatedMediaResponse {
id: string;
type: 'gif' | 'video';
original: {
url: string;
duration: number;
width: number;
height: number;
};
video: {
mp4: string;
webm: string;
};
poster: string;
preview?: string; // 6초 프리뷰 (비디오용)
placeholder: string;
}
```
---
| 용도 | 도구 | 이유 |
|------|------|------|
| 클라이언트 이미지 전처리 | **Web Worker + WASM** (photon-rs / wasm-vips) | 업로드 전 용량 절감, UI 블로킹 없음 |
| 서버 이미지 처리 | **Sharp** (libvips) | 고성능, WebP/AVIF 지원, 이미 사용 중 |
| 비디오/GIF 변환 | **FFmpeg** (fluent-ffmpeg) | 업계 표준, 모든 포맷 지원 |
| 작업 큐 | **BullMQ** (Redis) | 서버 비동기 처리, NestJS 통합 용이 |
| 저장소 | **Supabase Storage** | 이미 사용 중 |
```bash
pnpm add sharp
pnpm add bullmq @nestjs/bull
pnpm add fluent-ffmpeg @ffmpeg-installer/ffmpeg
pnpm add blurhash
pnpm add @aspect-build/aspect-photo
```
---
| Phase | 작업 | 효과 | 복잡도 | 예상 작업량 |
|-------|------|------|--------|------------|
| 0 | 클라이언트 WASM 전처리 | 업로드 속도 ↑ | 중간 | 1~2일 |
| 1 | WebP 자동 변환 | 용량 30% ↓ | 낮음 | 0.5일 |
| 2 | 반응형 이미지 | 모바일 속도 ↑ | 낮음 | 1일 |
| 3 | 블러 플레이스홀더 | UX 개선 | 낮음 | 0.5일 |
| 4 | GIF → MP4/WebM | 용량 85% ↓ | 중간 | 1~2일 |
| 5 | 비디오 프리뷰 | UX 개선 | 중간 | 1~2일 |
| 6 | AVIF 변환 | 용량 추가 ↓ | 낮음 | 0.5일 |
| 7 | BullMQ 비동기 큐 | 응답 속도 ↑ | 중간 | 1일 |
---
- Supabase Storage 연동
- Sharp 라이브러리 (썸네일 400x400 생성)
- 이미지 검증 (MIME, Magic Bytes, 크기 10MB)
- 허용 포맷: JPEG, PNG, GIF, WebP
- GIF 썸네일 스킵 (애니메이션 보존)
- 클라이언트 WASM 전처리
- WebP/AVIF 자동 변환
- 반응형 이미지 (여러 사이즈)
- GIF → 비디오 변환
- 비디오 업로드/처리
- 블러 플레이스홀더 (LQIP)
- BullMQ 비동기 큐 처리
---
- [web.dev - Serve images in modern formats](https://web.dev/serve-images-webp/)
- [web.dev - Replace animated GIFs with video](https://web.dev/replace-gifs-with-videos/)
- [Sharp documentation](https://sharp.pixelplumbing.com/)
- [FFmpeg documentation](https://ffmpeg.org/documentation.html)
- [photon-rs (Rust WASM image processing)](https://github.com/nicholasgasior/photon)
- [wasm-vips](https://github.com/nicholasgasior/wasm-vips)
- [BullMQ documentation](https://docs.bullmq.io/)