본문으로 건너뛰기

이미지 처리 계획

관리인
관리인2026년 2월 6일12 분
# Media Optimization Pipeline

이미지/비디오 품질 유지 + 용량 최소화를 위한 미디어 처리 파이프라인 설계.
클라이언트(WASM) 전처리 + 서버(NestJS) 후처리 2단 구조.

---

## Architecture Overview

```
[클라이언트 - 브라우저]                    [서버 - 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 변환, 블러 플레이스홀더 등은 서버에서 생성해야 한다.
클라이언트는 서버의 일을 대신하는 게 아니라, 서버에 보내기 전 불필요하게 큰 파일을 다이어트시키는 것.

---

## Phase 0: 클라이언트 WASM 전처리

### 목적

- 업로드 전 이미지 용량을 대폭 줄여 전송 시간 절감
- 메인 스레드 블로킹 없이 Web Worker에서 처리
- 서버가 처리할 원본 자체가 가벼워져 변환 시간도 단축

### WASM 모듈 선택지

| 라이브러리 | 특징 | 비고 |
|-----------|------|------|
| `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 모듈은 초기 로드 시 한 번만 초기화하면 이후 호출은 빠름

---

## Phase 1: WebP 자동 변환

**효과:** 용량 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();
}
```

---

## Phase 2: 반응형 이미지

**효과:** 모바일 속도 ↑  
**복잡도:** 낮음  
**예상 작업량:** 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>
```

---

## Phase 3: 블러 플레이스홀더 (LQIP)

**효과:** 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')}`;
}
```

### BlurHash 대안

```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
  );
}
```

---

## Phase 4: GIF → MP4/WebM 변환

**효과:** 용량 85% ↓  
**복잡도:** 중간  
**예상 작업량:** 1~2일

### GIF 업로드 파이프라인

```
GIF 업로드
    ↓
┌───────────────────────────────────────────┐
│ 1. 원본 GIF 보관 (폴백)                     │
│ 2. MP4 변환 (H.264, 호환성)                │
│ 3. WebM 변환 (VP9, 효율)                   │
│ 4. 첫 프레임 추출 → 정적 썸네일 (WebP)       │
│ 5. 포스터 이미지 생성                       │
└───────────────────────────────────────────┘
    ↓
프론트: <video autoplay loop muted playsinline>
       (GIF처럼 보이지만 용량 80-90% 감소)
```

### FFmpeg 변환

```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>
```

---

## Phase 5: 비디오 업로드/프리뷰

**효과:** 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();
  });
}
```

---

## Phase 6: AVIF 변환

**효과:** 용량 추가 ↓  
**복잡도:** 낮음  
**예상 작업량:** 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();
}
```

---

## Phase 7: BullMQ 비동기 큐 처리

**효과:** 응답 속도 ↑  
**복잡도:** 중간  
**예상 작업량:** 1일

### 구조

```
API 엔드포인트
  ↓
  원본 저장 + BullMQ에 작업 등록 → 즉시 응답 (업로드 ID 반환)
  
Worker 프로세스 (별도)
  ↓
  큐에서 작업 꺼내서 처리
    - 반응형 사이즈 생성
    - 포맷 변환 (WebP, AVIF)
    - 블러 플레이스홀더
    - GIF → 비디오 (해당 시)
  ↓
  Supabase Storage 저장
  ↓
  작업 완료 상태 업데이트
```

### NestJS 모듈 구성

```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 업데이트
  }
}
```

---

## Storage 구조

Supabase Storage 내 파일 구조:

```
media/
  {id}/
    original.jpg              # 원본 보관
    webp/
      sm.webp                 # 640px
      md.webp                 # 1024px
      lg.webp                 # 1920px
      xl.webp                 # 2560px
    avif/
      sm.avif
      md.avif
      lg.avif
      xl.avif
    placeholder.txt           # base64 블러 이미지 또는 BlurHash 문자열
    video/                    # GIF/비디오인 경우
      output.mp4
      output.webm
      poster.webp
      preview.mp4             # 6초 프리뷰 (비디오용)
```

---

## API 응답 구조

### 이미지 업로드 응답 (즉시)

```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 문자열
}
```

### GIF/비디오 업로드 응답

```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        # optional

# 클라이언트 (WASM 모듈 — 선택)
pnpm add @aspect-build/aspect-photo  # 또는 wasm-vips, photon-rs WASM 빌드
```

---

## 구현 우선순위

| 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/)
이미지 처리 계획