video-encoder 라이브러리 개발기
시작은 임시방편이었다
이전 글(모바일 WebView에서 video 태그를 다루며 겪은 것들)에서 iOS WebView 크래시 문제를 다룬 적이 있습니다. 20MB 영상 여러 개를 동시에 핸들링하는 환경이 문제였고, 근본 해결책인 서버 사이드 압축 파이프라인이 준비되기 전까지는 급한 불부터 꺼야 했습니다.
그래서 직접 만들었습니다. 브라우저에서 바로 동작하고, 서버 없이 영상을 5MB 이하로 줄여주는 도구. 그게 video-encoder입니다.
Claude Code로 만들기로 했다
사실 사내에선 무료툴을 찾아보자는 얘기도 있었고, 실제로 몇몇 후보도 있었습니다. 그런데 무료툴보단 사내에서 직접 관리할 수 있는 게 좋기도 했고, 새로운 첼린지도 하고 싶었습니다. 그래서 퇴근하고 사이드 프로젝트로 비디오 인코더를 만들기로 결정했죠.
"만들어줘"라는 한 줄의 함정
처음엔 단순하게 접근했습니다.
"WebCodecs로 영상 압축 기능 만들어줘"
결과물은 빠르게 나왔습니다. 그런데 실제로 돌려보면 버그가 자주 났습니다. 고쳐달라고 하면 고쳐집니다. 그러면 다른 곳에서 사이드이펙트가 터집니다. 그걸 또 고치면 처음 버그가 되살아납니다.
원인은 단순했습니다. Claude Code가 코드를 작성하는 기준이 없었던 겁니다. 전체 구조에 대한 합의 없이 기능 단위로만 요청하니, 그때그때 최적인 방식으로 코드를 뽑아냈고, 결과적으로 일관성 없는 코드베이스가 돼버렸습니다.
설계를 먼저, 구현은 그 다음에
방법을 바꿨습니다. 코드를 요청하기 전에 전체 기능과 기술 스택을 CLAUDE.md에 먼저 정리했습니다.
# video-encoder
## 기능 목록
- 영상 파일 드래그 앤 드롭 업로드
- CRF 기반 품질 조절 슬라이더
- 해상도 선택 (원본 / 1080p / 720p / 480p)
- 오디오 제거 옵션
- 실시간 압축 진행률
- 압축 전후 파일 크기 비교 및 다운로드
## 기술 스택
- Vite + TypeScript
- 인코더 1순위: WebCodecs API
- 인코더 폴백: FFmpeg.wasm
- 디먹싱: mp4box.js
- 먹싱: mp4-muxer
이 파일을 기반으로 "CLAUDE.md 참고해서 엔진 선택 로직부터 구현해줘"처럼 기능 단위로, 순서대로 요청했습니다.
결과는 달랐습니다. 구조에 대한 합의가 먼저 있으니 맥락을 유지하면서 코드를 작성하더라고요. 사이드이펙트도 줄었고, 의도한 구조대로 코드가 쌓였습니다.
기술 구현
하이브리드 엔진 구조
핵심은 WebCodecs API와 FFmpeg.wasm을 조합한 하이브리드 인코딩 엔진입니다.
브라우저가 WebCodecs(H.264)를 지원하는가?
├── YES → WebCodecs 엔진 (GPU 가속)
└── NO → FFmpeg.wasm 엔진 (CPU)
WebCodecs 실패 시 → 자동으로 FFmpeg.wasm 폴백
WebCodecs API는 Chrome 94+ / Edge 94+에서 지원하는 저수준 영상 인코딩·디코딩 API입니다. GPU를 직접 활용하니까 속도가 빠르고 메인 스레드 블로킹도 적습니다.
FFmpeg.wasm은 FFmpeg를 WebAssembly로 컴파일한 라이브러리입니다. WebCodecs를 지원하지 않는 환경(Firefox, Safari)에서도 동작하죠. 멀티스레드 모드(@ffmpeg/core-mt)를 써서 CPU 연산 속도를 높였습니다.
영상 처리 파이프라인
WebCodecs 엔진 기준 처리 흐름입니다.
1. mp4box.js → 입력 영상 디먹싱, 비디오 트랙 샘플 추출
2. VideoDecoder → 각 프레임 디코딩, VideoFrame 시퀀스 생성
3. VideoEncoder → H.264 High Profile 재인코딩
(B-프레임 + CABAC으로 압축 효율 극대화)
4. mp4-muxer → 인코딩된 프레임을 MP4로 패키징 → Blob → 다운로드
CRF 기반 품질 제어
압축 품질은 CRF(Constant Rate Factor) 방식으로 조절합니다. 18(최고 품질)부터 40(최대 압축)까지 슬라이더로 조정할 수 있습니다.
고정 비트레이트 방식과 달리 CRF는 영상 복잡도에 따라 비트를 유동적으로 배분합니다. 정적인 장면은 적은 비트를, 움직임이 많은 장면은 더 많은 비트를 써서 동일 파일 크기 대비 화질을 최적화하는 거죠.
CRF를 비트레이트로 변환하는 공식은 이렇습니다.
bitrate = 0.85 × 2^((18 - CRF) / 8) (단위: Mbps)
CRF가 1 올라갈 때마다 비트레이트가 약 8.3% 줄고, 8 올라갈 때마다 절반이 됩니다. CRF 18이면 약 0.85Mbps, CRF 26이면 0.42Mbps가 되는 식이죠. 이렇게 지수적으로 줄어드니까 슬라이더 한쪽 끝으로 갈수록 파일 크기 변화가 극적으로 커집니다.
avc1.640028이 대체 무슨 뜻일까
WebCodecs에서 H.264를 쓸 때는 코덱 문자열을 직접 지정해야 합니다. avc1.640028 같은 형태인데, 처음엔 이게 뭘 의미하는지 전혀 몰랐습니다.
avc1.PPCCLL
│ │ └── Level (16진수)
│ └───── Constraints
└──────── Profile (64 = High)
64는 H.264 High Profile을 의미합니다. High Profile은 B-프레임과 CABAC 엔트로피 코딩을 지원해서, 같은 비트레이트에서 Baseline이나 Main Profile보다 화질이 좋습니다.
레벨은 해상도와 비트레이트의 상한을 정의합니다. 레벨이 낮으면 일부 기기에서 재생이 안 될 수 있고, 너무 높으면 하드웨어 가속 디코더를 못 쓰는 경우가 있습니다. 그래서 해상도별로 적합한 레벨을 자동으로 선택하게 했습니다.
| 픽셀 수 | Level | 최대 해상도 |
|---|---|---|
| 921,600 이하 | 3.1 (0x1F) | 1280×720 |
| 2,073,600 이하 | 4.1 (0x29) | 1920×1080 |
| 8,294,400 이하 | 5.1 (0x33) | 3840×2160 |
| 그 이상 | 5.2 (0x34) | 4096×2160+ |
1080p 영상이면 코덱 문자열은 avc1.640029가 됩니다.
해상도 스케일링
원본을 유지하거나 1080p / 720p / 480p로 다운스케일할 수 있습니다. 해상도를 낮추면 인코딩 비트레이트도 자동으로 재계산해서 화질과 파일 크기의 균형을 맞춥니다.
| 해상도 | 목표 비트레이트 |
|---|---|
| 4K (2160p+) | 25 Mbps |
| 1080p | 10 Mbps |
| 720p | 6 Mbps |
| 480p | 3 Mbps |
| 원본 | 원본의 80% (최대 30Mbps) |
원본보다 낮은 해상도를 선택하면 이 테이블 기준으로 비트레이트를 재설정하고, CRF도 이 기준을 넘지 않도록 조정합니다.
로컬에서는 되는데 배포하면 안 된다
FFmpeg.wasm 멀티스레드 모드(@ffmpeg/core-mt)를 쓰면 여러 스레드를 병렬로 돌려서 인코딩 속도를 높일 수 있습니다. 로컬에서는 잘 돌아갔습니다. 그런데 배포하고 나니 갑자기 동작하지 않았습니다.
원인은 SharedArrayBuffer였습니다.
멀티스레드 WebAssembly는 스레드 간 메모리를 공유하려고 SharedArrayBuffer를 사용합니다. 그런데 2018년 스펙터(Spectre) 취약점이 공개된 이후, 브라우저들이 보안을 이유로 SharedArrayBuffer 사용을 기본 차단했습니다. 쓰려면 페이지가 "cross-origin isolated" 상태여야 합니다.
cross-origin isolated를 활성화하려면 서버 응답 헤더에 두 가지가 필요합니다.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Vercel 배포라면 vercel.json에 헤더를 추가하면 됩니다.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
]
}
]
}
주의할 점이 하나 있습니다. COEP(Cross-Origin-Embedder-Policy)를 require-corp로 설정하면, 페이지에서 로드하는 모든 서브리소스도 CORS 헤더나 Cross-Origin-Resource-Policy 헤더를 응답해야 합니다. 외부 CDN 폰트나 이미지를 쓰는 페이지라면, 해당 리소스들이 이 요건을 만족하지 않을 경우 로드가 차단됩니다. FFmpeg.wasm을 쓰는 페이지는 가급적 외부 서브리소스 의존도를 낮추는 게 좋습니다.
마치며
사실 처음엔 1~2시간이면 완성할 수 있지 않을까 싶어 도전했는데, 완성까지 5시간이 걸렸습니다.
처음부터 정확히 설계하고 프롬프트를 입력했다면 더 짧아졌을 겁니다. 그래도 AI에게 요청만 하는 게 만능이 아니라는 걸 확인할 수 있었던 시간이라 나쁘지 않았습니다.
구현하면서 느낀 점은, WebCodecs는 브라우저가 지원하는 환경에서는 정말 빠르다는 겁니다. GPU를 직접 쓰니까 20MB 영상도 수 초 안에 처리됩니다. 다만 Safari와 Firefox는 아직 지원이 불완전해서 FFmpeg.wasm 폴백 없이는 쓸 수 없는 API고요. 그리고 avc1.640029 같은 코덱 문자열, CRF 공식, SharedArrayBuffer 제약처럼 문서만 봐서는 바로 와닿지 않는 세부사항들이 실제 구현 난이도를 높이더라고요.
라이브러리는 GitHub에 공개해 뒀습니다. 비슷한 고민을 하고 계신 분이라면 한 번 열어보셔도 좋겠습니다.