WebCodecs + Web Workers + OPFS: 브라우저에서 비디오 처리하는 실전 경로
브라우저에서 비디오 처리하면 FFmpeg WASM이 기본 선택. 문제없다. 하지만 25MB 다운로드 사이즈, 기본은 싱글스레드, 블랙박스. 워크로드에 따라 새로운 브라우저 네이티브 API——WebCodecs, Web Workers, OPFS——로 더 빠르고 작고 디버깅하기 쉬운 걸 만들 수 있다.
이 글은 실제로 동작하는 것: 비디오 파일을 디코딩해서 지정한 타임스탬프로 프레임을 추출하고 로컬 스토리지에 쓴다. FFmpeg에 손대지 않는다. 끝에 실측 데이터.
3개 API가 각자 맡은 역할
WebCodecs(VideoDecoder, VideoEncoder, AudioDecoder, AudioEncoder)——하드웨어 가속된 코덱 인터페이스. <video>의 "재생하고 캡처" 경로를 우회한다. EncodedVideoChunk(예: 날것의 H.264 NAL unit)를 주면 VideoFrame 객체를 반환. 캔버스에 렌더링도, 트랜스코딩도, 다운스트림에 넘긴다.
Web Workers——독립 스레드에서 CPU 집약 작업. 코덱 파싱, 프레임 변환, 컨테이너화 로직은 전부 여기로. 메인 스레드를 막지 않는다. Worker 안에서도 OPFS를 직접 쓸 수 있다.
OPFS(Origin Private File System)——진짜 고처리량의 웹용 파일 시스템. localStorage는 동기식에 5MB 상한, IndexedDB는 비동기인데 바이너리 blob이 절망적으로 느리다. OPFS는 파일을 위해 만들어졌다. Worker 안에서 createSyncAccessHandle()로 동기 액세스 핸들을 가져올 수 있다. 이게 성능의 목숨.
구축할 아키텍처
만든 것: 사용자가 비디오 파일을 페이지에 드래그하면, 매초 1프레임씩 JPEG로 추출해 OPFS에 쓰고, 마지막에 다운로드 링크를 준다. 실제 시나리오는 비디오 라이브러리의 썸네일 생성.
메인 스레드:
- 파일 입력
- Worker 생성
- UI 업데이트(진행, 결과)
Worker:
- OPFS 경로에서 파일 가져오기 (postMessage 경유가 아님——너무 느림)
- WebCodecs VideoDecoder로 디코딩
- Canvas 렌더링 + toBlob으로 JPEG화
- JPEG를 OPFS에 쓰기
- 진행을 메인 스레드에 보고
"OPFS 경로에서 가져오기"가 포인트. postMessage로 500MB 파일을 전송(Transferable로 해도)은 느리고 메모리 복사가 일어난다. 파일을 먼저 OPFS에 쓰고 경로를 Worker에 넘기는 게 훨씬 빠르다.
1단계: 기능 감지
function checkSupport() {
const issues = []
if (!('VideoDecoder' in window)) {
issues.push('WebCodecs VideoDecoder 미지원. Chrome 94+ 이상 필요.')
}
if (!window.showOpenFilePicker && !window.FileSystemHandle) {
issues.push('File System Access API 미지원.')
}
if (!navigator.storage.getDirectory) {
issues.push('OPFS 미지원. Chrome 102+ 이상 필요.')
}
// Worker 자체는 거의 표준이지만, Worker 안에서 동기 OPFS를 쓸 수 있는지 확인
// (구형 Chrome은 Worker를 지원해도 동기 OPFS가 없음)
return issues
}
const issues = checkSupport()
if (issues.length > 0) {
console.warn('기능 갭:', issues)
// FFmpeg WASM으로 폴백
}
2026년 브라우저 지원: Chrome/Edge 102+, Safari 16.4+(WebCodecs 부분적), Firefox 130+(버전에 따라 아직 flag 뒤). 프로덕션에서는 항상 기능 감지하고, 안 되면 FFmpeg WASM으로 폴백.
2단계: 파일을 OPFS에 올리기
async function stageFileToOpfs(file) {
const root = await navigator.storage.getDirectory()
const dir = await root.getDirectoryHandle('input', { create: true })
const fileHandle = await dir.getFileHandle(file.name, { create: true })
const writable = await fileHandle.createWritable()
await file.stream().pipeTo(writable)
return `${file.name}` // OPFS 루트의 'input' 폴더 내 경로
}
여기는 비동기 createWritable(). Worker 안에서는 동기 createSyncAccessHandle()을 쓰면 작은 쓰기가 많을 때 더 빠르다.
3단계: Worker
// worker.js
let decoder = null
let canvas = null
let ctx = null
self.onmessage = async (e) => {
const { type, data } = e.data
if (type === 'init') {
await init(data)
} else if (type === 'extract') {
await extractFrames(data)
}
}
async function init({ codec, canvasWidth, canvasHeight }) {
// Canvas 준비 (Worker 안에서는 OffscreenCanvas)
canvas = new OffscreenCanvas(canvasWidth, canvasHeight)
ctx = canvas.getContext('2d')
// WebCodecs 디코더
decoder = new VideoDecoder({
output: (frame) => handleFrame(frame),
error: (e) => console.error('디코더 에러:', e)
})
decoder.configure({
codec, // 예: 'avc1.640028'
optimizeForLatency: false
})
self.postMessage({ type: 'ready' })
}
let frameCount = 0
let lastExtractedSecond = -1
async function handleFrame(frame) {
const timestampSeconds = frame.timestamp / 1_000_000 // WebCodecs는 마이크로초
// 매초 1프레임만 추출
const currentSecond = Math.floor(timestampSeconds)
if (currentSecond !== lastExtractedSecond) {
lastExtractedSecond = currentSecond
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 })
const buffer = await blob.arrayBuffer()
// OPFS에 쓰기
const root = await navigator.storage.getDirectory()
const outputDir = await root.getDirectoryHandle('thumbnails', { create: true })
const fileHandle = await outputDir.getFileHandle(`thumb_${String(currentSecond).padStart(6, '0')}.jpg`, { create: true })
const syncHandle = await fileHandle.createSyncAccessHandle()
syncHandle.write(new Uint8Array(buffer))
syncHandle.close()
frameCount++
self.postMessage({ type: 'progress', frameCount, second: currentSecond })
}
frame.close() // 중요: VideoFrame은 반드시 닫기. 안 그러면 GPU 메모리 누수
}
async function extractFrames({ chunks }) {
for (const chunk of chunks) {
decoder.decode(new EncodedVideoChunk({
type: chunk.keyframe ? 'key' : 'delta',
timestamp: chunk.timestamp,
data: chunk.data
}))
}
await decoder.flush()
self.postMessage({ type: 'done', frameCount })
}
몇 가지 함정:
frame.close()는 필수. VideoFrame은 GPU 버퍼에 대한 참조를 잡는다. 닫지 않으면 VRAM이 계속 누수되고, 100프레임쯤에서 디코더가 조용히 실패하기 시작.try/finally로 처리.- Worker 안의 OffscreenCanvas. Worker 안에서 렌더링하려면 필요. Chrome 69+, Safari 16.4+, Firefox 105+.
createSyncAccessHandle()은 다른 OPFS 작업을 블록. 열어둔 채로 비동기 작업을 넘기지 마라——쓰고 닫고 다음으로.
4단계: 디코더에 먹일 chunk를 어디서 얻는가
여기가 제일 성가신 단계. WebCodecs는 디먹싱을 안 한다. 날것의 codec chunk를 원한다. MP4 컨테이너를 직접 파싱해서 H.264 NAL unit을 추출해야 한다.
두 가지 길:
mp4box.js사용(npm)——순수 JS MP4 파서. 샘플을 뽑아 WebCodecs에 바로 넘길 수 있다.- FFmpeg WASM을 디먹싱에만 사용——chunk를 추출해서 WebCodecs에 넘겨 디코딩. 낭비처럼 들리지만, FFmpeg WASM은 "디코딩+렌더링"보다 디먹싱이 훨씬 빠르므로 실제로는 합리적.
패턴 1의 mp4box.js 코드:
import mp4box from 'mp4box'
async function getCodecChunks(file) {
const arrayBuffer = await file.arrayBuffer()
arrayBuffer.fileStart = 0 // mp4box.js 관례
const mp4boxFile = mp4box.createFile()
mp4boxFile.onReady = (info) => {
mp4boxFile.setExtractionOptions(info.videoTracks[0].id, null, {
nbSamples: 100 // 배치 사이즈
})
mp4boxFile.start()
}
const chunks = []
mp4boxFile.onSamples = (trackId, ref, samples) => {
for (const sample of samples) {
chunks.push({
keyframe: sample.is_sync,
timestamp: sample.cts * 1_000_000 / sample.timescale,
data: sample.data
})
}
}
mp4boxFile.appendBuffer(arrayBuffer)
mp4boxFile.flush()
return {
codec: mp4boxFile.getInfo().videoTracks[0].codec,
chunks
}
}
HLS/DASH 스트림의 경우 이 단계를 완전히 건너뛸 수 있다——세그먼트를 페치하고 있으므로 날것의 chunk가 손에 있다. FlowPick은 내부적으로 일부 경로에서 이렇게 한다.
실측: WebCodecs vs FFmpeg WASM vs 네이티브
테스트 시나리오: 30분짜리 1080p H.264 비디오에서 매초 1프레임, 총 1800프레임 추출.
| 수단 | 실측 시간 | 피크 메모리 | 다운로드 사이즈 |
|---|---|---|---|
| 네이티브 FFmpeg(CLI) | 18s | 80MB | 없음(설치됨) |
| FFmpeg WASM(싱글스레드) | 145s | 220MB | 25MB |
| FFmpeg WASM(멀티스레드) | 52s | 280MB | 25MB |
| WebCodecs + Worker + OPFS | 31s | 95MB | <100KB |
WebCodecs 승리:
- 하드웨어 가속——H.264 디코딩이 진짜 GPU에서 돈다. WASM이 아니라
- 25MB짜리 WASM 바이너리가 없다
- 메모리 사용량이 낮다 (FFmpeg 내부 버퍼 없음)
- Worker는 진짜 멀티스레드 (FFmpeg WASM의 pthreads는 개선했지만 여전히 오버헤드)
FFmpeg WASM 승리:
- 어떤 codec이든 먹는다 (WebCodecs는 브라우저가 지원하는 것만——대개 H.264, H.265, VP8/VP9, AV1)
- 컨테이너화/디먹싱이 가능 (WebCodecs는 코덱만)
- 엣지 케이스에 강하다
- 같은 코드가 어디서든 돈다 (기능 감지 불필요)
내 견해: codec을 알고 성능이 필요하면 WebCodecs. 폭이 필요하면 FFmpeg WASM. FlowPick은 둘 다 쓴다——범용 리먹스 경로는 FFmpeg WASM, 특정 작업(썸네일 생성, 입력이 H.264로 확정하고 빠르게 하고 싶을 때)은 WebCodecs.
OPFS 성능의 미학
"OPFS가 네이티브 디스크만큼 빠르다"는 설이 있다. 거짓. IndexedDB나 localStorage보다는 빠르지만, 오버헤드가 있다:
| 작업 | OPFS(Worker 동기) | IndexedDB | 네이티브 디스크 |
|---|---|---|---|
| 1KB 쓰기 | 0.05ms | 1.2ms | 0.01ms |
| 1MB 쓰기 | 1.1ms | 8.5ms | 0.4ms |
| 100MB 쓰기 | 95ms | 850ms | 35ms |
| 1MB 읽기 | 0.8ms | 4.5ms | 0.2ms |
OPFS는 네이티브 디스크보다 3-5배 느리지만, IndexedDB의 바이너리 처리보다 5-10배 빠르다. 비디오 처리 워크플로에서는 "충분"——병목은 코덱에 있고 I/O가 아니다.
몇 가지 진짜 이점:
- Worker 안의
createSyncAccessHandle()——동기 I/O는 매 쓰기마다 await 오버헤드가 없다 - 파일이 메모리보다 커도 된다——5GB 파일이 스트리밍으로 OPFS를 통과. 메모리에 다 안 올려도 된다
- 세션을 넘어 영속——처리한 비디오가 리로드 후에도 그 자리에
Worker 풀로 병렬 디코딩
여러 비디오를 배치 처리(전체 디렉토리 썸네일 만들기 등)할 때는 스레드 풀을 쓴다:
class WorkerPool {
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: size }, () => new Worker(workerUrl))
this.queue = []
this.busy = new Set()
}
async run(task) {
const worker = await this.getIdle()
this.busy.add(worker)
return new Promise((resolve, reject) => {
worker.onmessage = (e) => {
this.busy.delete(worker)
if (e.data.type === 'done') resolve(e.data.result)
else if (e.data.type === 'error') reject(e.data.error)
}
worker.postMessage(task)
})
}
async getIdle() {
if (this.busy.size < this.workers.length) {
return this.workers.find(w => !this.busy.has(w))
}
// Worker가 빌 때까지 대기
return new Promise(resolve => {
this.queue.push(resolve)
})
}
}
8코어 머신에 8개 Worker를 띄우면 8개 파일을 병렬 처리——각각 스레드를 독점하고 각각 하드웨어 가속으로 디코딩. 내 M2 MacBook에서 실측: 싱글코어 240프레임/초, 전체 약 1900프레임/초. 매초 30분짜리 비디오를 처리하는 셈.
다만 함정: 하드웨어 디코더의 동시성엔 한계가 있다. 대부분의 GPU는 동시에 2-4개 스트림만 디코딩. 그 이상은 소프트웨어 디코딩으로 폴백해서 오히려 느려진다. 타겟 하드웨어에서 테스트.
실제로 겪은 함정
함정 1: frame.close() 잊기. 닫지 않은 VideoFrame은 VRAM을 누수. 100프레임쯤 누수하면 디코더가 조용히 실패. try/finally로:
try {
// ... frame으로 작업 ...
} finally {
frame.close()
}
함정 2: 디코딩 순서 ≠ 표시 순서. B 프레임이 있는 H.264는 DTS 순으로 디코딩, PTS 순으로 표시. 디코딩 순으로 프레임을 처리하면 썸네일이 순서 섞임. WebCodecs는 둘 다 준다——frame.timestamp는 PTS, 디코딩은 DTS로 진행.
함정 3: codec 문자열 포맷. WebCodecs는 'avc1.640028'(constraint 바이트 포함)을 요구. MP4에는 'avc1.640028', 'avc1.64.0028', 'avc1.42E01E' 등으로 저장되는 경우가 있다. 디코더 설정 전에 정규화.
함정 4: Worker 모듈 로드. Worker는 모든 브라우저에서 임의 URL에서 import할 수 있는 건 아니다. importScripts()를 쓰거나 Worker 생성자에서 "type": "module" 지정(Chrome 80+, Safari 15+).
함정 5: OPFS 쿼터. 브라우저는 OPFS를 디스크 여유 공간의 퍼센트로 제한. 5GB 쓰기가 디스크 꽉 차서 실패할 수 있다. QuotaExceededError를 catch하고 showSaveFilePicker() 스트리밍 쓰기로 폴백.
언제 뭘 쓸까
FFmpeg WASM을 쓸 때:
- 모든 codec에 대응하고 싶다 (HEVC, VP9, AV1, MPEG-TS, MKV, FLV)
- 컨테이너 변환을 하고 싶다 (TS → MP4, MP4 → WebM)
- 필터 체인이 필요하다 (오버레이, 스케일, 크롭, 결합)
- 신뢰성이 속도보다 중요
WebCodecs를 쓸 때:
- codec이 H.264/H.265/VP9/AV1로 앎
- 프레임 단위 조작을 한다 (썸네일, 모션 감지, 프레임 추출)
- 실시간 성능이 필요 (라이브 비디오 필터링, 프레임 분석)
- 메모리 예산이 빠듯 (25MB짜리 WASM 다운로드하고 싶지 않다)
Web Workers를 쓸 때:
- CPU 무거운 작업이 있고 메인 스레드를 막고 싶지 않다
- 독립 단위(파일, 세그먼트)를 병렬 처리하고 싶다
OPFS를 쓸 때:
- 5MB(localStorage 상한)를 넘는다
- 중간 상태를 영속화하고 싶다 (리로드마다 재디코딩하고 싶지 않다)
- 큰 파일을 스트리밍 처리하고 싶다. 메모리에 다 올리고 싶지 않다
참고 자료
- WebCodecs 사양——실제 사양. 의외로 읽기 쉽다
- MDN WebCodecs API——실용 문서, 예제 포함
- WebCodecs samples 저장소——공식 동작 샘플
- OPFS 사양——File System Access API, OPFS 포함
- mp4box.js——GPAC의 JS MP4 파서. WebCodecs 입력의 사실상 표준
정리
WebCodecs + Workers + OPFS는 성능 민감 시나리오에서 FFmpeg WASM의 진짜 대안. 완전한 대안은 아니다——FFmpeg WASM은 codec 폭과 엣지 케이스에서 여전히 이긴다——하지만 codec을 알고, 하드웨어 가속, 프레임 레벨 처리 워크로드에서 WebCodecs는 4-5배 빠르고 250배 작다. 다음은 스트리밍 다운로드가 합법인지.
완전한 codec, 해상도, 파일 사이즈에서의 벤치마크 데이터는 브라우저 비디오 처리 성능 벤치마크에서.
추천 글
- 브라우저에서 비디오 리먹스: fMP4, ISOBMFF, 트랜스코딩하지 않는 이유——이 글이 codec 레이어, 저 글이 컨테이너 레이어. 보완 관계
- 브라우저 비디오 처리 성능 벤치마크——이 글의 데이터의 완전판
- FlowPick은 어떻게 브라우저에서 수백 개의 비디오 세그먼트를 하나의 MP4로 합치는가——FlowPick이 어디서 FFmpeg WASM을 쓰고 어디서 WebCodecs를 쓰는가
- DASH 딥: SegmentTemplate, ContentProtection, 시점 전환 MPD——WebCodecs에 넘길 chunk가 어디서 오는가
- HLS 딥: 암호화, 멀티트랙, 기억해야 할 EXT-X 태그——세그먼트의 공급원
- 3개월 만에 FlowPick을 만든 전 과정——개발 스토리. WebCodecs vs FFmpeg WASM 결정도 수록