stack-analysis
CHAPTER 84 / 90
읽기 약 2분
FUNCTION
미디어: 이미지/비디오 업로드 + 트랜스코딩
핵심 개념
S3 직접 업로드·Sharp·FFmpeg·HLS·Cloudflare Stream — 미디어 처리.
본문
이미지 처리 파이프라인
1. 클라이언트 → presigned URL 받음
2. 클라이언트 → S3 직접 업로드
3. S3 트리거 → Lambda/Worker 호출
4. Worker — 다양한 사이즈 생성 + 최적화
5. CloudFront로 배포이미지 multi-size + WebP/AVIF
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> 태그
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 (권장)
// 가장 간단한 솔루션 — 트랜스코딩·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)
// 더 저렴하지만 운영 부담
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();
});
});썸네일 + 미리보기 (비디오)
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