OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 84 / 90
stack-analysis
CHAPTER 84 / 90
읽기 약 2
FUNCTION

미디어: 이미지/비디오 업로드 + 트랜스코딩


핵심 개념

S3 직접 업로드·Sharp·FFmpeg·HLS·Cloudflare Stream — 미디어 처리.

본문

이미지 처리 파이프라인

📋 코드 (5줄)
1. 클라이언트 → presigned URL 받음
2. 클라이언트 → S3 직접 업로드
3. S3 트리거 → Lambda/Worker 호출
4. Worker — 다양한 사이즈 생성 + 최적화
5. CloudFront로 배포

이미지 multi-size + WebP/AVIF

TYPESCRIPT📋 코드 (57줄)
import sharp from 'sharp';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';

const SIZES = [
  { name: 'thumb',  width: 200, height: 200 },
  { name: 'small',  width: 400, height: null },
  { name: 'medium', width: 800, height: null },
  { name: 'large',  width: 1600, height: null },
];

const FORMATS = ['webp', 'avif'];


const imageWorker = new Worker('image-process', async (job) => {
  const { srcKey } = job.data;

  const original = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: srcKey }));
  const buffer = Buffer.from(await original.Body!.transformToByteArray());

  // EXIF rotation 자동 적용
  const base = sharp(buffer).rotate();

  // 메타데이터
  const meta = await base.metadata();

  for (const size of SIZES) {
    if (size.width > meta.width!) continue;  // 원본보다 크면 skip

    for (const format of FORMATS) {
      const resized = await base.clone()
        .resize(size.width, size.height, { fit: 'inside', withoutEnlargement: true })
        .toFormat(format as any, { quality: format === 'avif' ? 60 : 80 })
        .toBuffer();

      const dstKey = `${srcKey}_${size.name}.${format}`;
      await s3.send(new PutObjectCommand({
        Bucket: BUCKET,
        Key: dstKey,
        Body: resized,
        ContentType: `image/${format}`,
        CacheControl: 'public, max-age=31536000, immutable',
      }));
    }
  }

  // DB 갱신
  await db.media.update({
    where: { srcKey },
    data: {
      processed: true,
      width: meta.width,
      height: meta.height,
      sizes: SIZES.map(s => s.name),
      formats: FORMATS,
    },
  });
});

클라이언트 — <picture> 태그

TSX📋 코드 (27줄)
function ResponsiveImage({ src, alt }: { src: string; alt: string }) {
  const cdn = `https://cdn.example.com/${src}`;

  return (
    <picture>
      <source
        type="image/avif"
        srcSet={`
          ${cdn}_small.avif 400w,
          ${cdn}_medium.avif 800w,
          ${cdn}_large.avif 1600w
        `}
        sizes="(max-width: 768px) 100vw, 800px"
      />
      <source
        type="image/webp"
        srcSet={`
          ${cdn}_small.webp 400w,
          ${cdn}_medium.webp 800w,
          ${cdn}_large.webp 1600w
        `}
        sizes="(max-width: 768px) 100vw, 800px"
      />
      <img src={`${cdn}_medium.webp`} alt={alt} loading="lazy" />
    </picture>
  );
}

비디오 — Cloudflare Stream (권장)

TYPESCRIPT📋 코드 (35줄)
// 가장 간단한 솔루션 — 트랜스코딩·HLS·CDN 자동
const res = await fetch('https://api.cloudflare.com/client/v4/accounts/.../stream', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${CF_API_TOKEN}` },
  body: formData,
});

const { uid, playback } = await res.json();


// 클라이언트
import Hls from 'hls.js';

function VideoPlayer({ src }: { src: string }) {
  const ref = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    if (Hls.isSupported()) {
      const hls = new Hls();
      hls.loadSource(src);
      hls.attachMedia(ref.current);
    } else if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
      ref.current.src = src;  // Safari
    }
  }, [src]);

  return <video ref={ref} controls />;
}


// 비용 (Cloudflare Stream)
// - 저장: $5 / 1000분
// - 전송: $1 / 1000분 (시청)
// → 1000명이 30분 영상 시청 = $5 (저장 1달) + $30 (전송) = $35

자체 트랜스코딩 (FFmpeg)

TYPESCRIPT📋 코드 (31줄)
// 더 저렴하지만 운영 부담
import ffmpeg from 'fluent-ffmpeg';

const videoWorker = new Worker('video-process', async (job) => {
  const { srcPath } = job.data;

  // HLS 생성 (다중 해상도)
  return new Promise((resolve, reject) => {
    ffmpeg(srcPath)
      .outputOptions([
        '-codec:v libx264',
        '-codec:a aac',
        '-hls_time 6',
        '-hls_playlist_type vod',
        '-master_pl_name master.m3u8',
        '-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2"',
      ])
      .output('1080p/playlist.m3u8')
      .videoFilter('scale=-2:1080')
      .videoBitrate('5000k')
      .output('720p/playlist.m3u8')
      .videoFilter('scale=-2:720')
      .videoBitrate('2800k')
      .output('480p/playlist.m3u8')
      .videoFilter('scale=-2:480')
      .videoBitrate('1400k')
      .on('end', resolve)
      .on('error', reject)
      .run();
  });
});

썸네일 + 미리보기 (비디오)

TYPESCRIPT📋 코드 (28줄)
async function generateThumbnail(videoPath: string) {
  return new Promise<string>((resolve, reject) => {
    ffmpeg(videoPath)
      .screenshots({
        timestamps: ['10%'],  // 10% 지점
        filename: 'thumb.jpg',
        folder: '/tmp',
        size: '1280x720',
      })
      .on('end', () => resolve('/tmp/thumb.jpg'))
      .on('error', reject);
  });
}


// 비디오 호버 미리보기 (10초 클립)
async function generatePreviewClip(videoPath: string) {
  return new Promise<string>((resolve, reject) => {
    ffmpeg(videoPath)
      .seekInput('00:00:05')
      .duration(10)
      .videoFilter('scale=-2:480')
      .output('/tmp/preview.mp4')
      .on('end', () => resolve('/tmp/preview.mp4'))
      .on('error', reject)
      .run();
  });
}

다음 챕터

CH.85 "좋아요/북마크/공유 시스템".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 미디어 처리 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

미디어 처리 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 미디어 처리
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
미디어 처리 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
미디어: 이미지/비디오 업로드 + 트랜스코딩 이 3가지만 확실히 잡으세요
1.이미지는 multi-size + WebP/AVIF — 100KB 미만 + 화질 유지
2.비디오는 Cloudflare Stream이 가장 간단 — HLS·CDN 자동
3.`<picture>` 태그로 브라우저 자동 선택 (AVIF → WebP → JPEG)


공유하기
진행도 84 / 90