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와 FFmpeg.wasm을 함께 쓴다
핵심은 WebCodecs API와 FFmpeg.wasm을 조합한 하이브리드 인코딩 엔진입니다.
브라우저가 WebCodecs(H.264)를 지원하는가?
├── YES → WebCodecs 엔진 (가속 여부는 환경별 확인)
└── NO → FFmpeg.wasm 엔진 (CPU)
WebCodecs 실패 시 → 자동으로 FFmpeg.wasm 폴백
WebCodecs는 저수준 인코딩·디코딩 API입니다. 브라우저가 제공하는 코덱 구현을 사용하며 하드웨어 가속 여부는 기기·코덱·설정에 따라 달라집니다. API가 존재하는 것과 원하는 H.264 설정이 지원되는 것은 별도로 확인해야 합니다.
FFmpeg.wasm은 FFmpeg를 WebAssembly로 컴파일한 라이브러리입니다. 목표 코덱 설정을 WebCodecs로 처리할 수 없는 환경의 대안으로 검토할 수 있습니다. 멀티스레드 모드(@ffmpeg/core-mt)를 써서 CPU 연산 속도를 높였습니다.
WebCodecs는 다음 순서로 영상을 처리한다
WebCodecs 엔진 기준 처리 흐름입니다.
1. mp4box.js → 입력 영상 디먹싱, 비디오 트랙 샘플 추출
2. VideoDecoder → 각 프레임 디코딩, VideoFrame 시퀀스 생성
3. VideoEncoder → H.264 High Profile 재인코딩
(실제 인코더 동작과 지원 설정 확인)
4. mp4-muxer → 인코딩된 프레임을 MP4로 패키징 → Blob → 다운로드
CRF 값으로 압축 품질을 조절한다
UI에서는 CRF(Constant Rate Factor)와 비슷한 방향의 품질 슬라이더를 제공합니다. 다만 WebCodecs의 비트레이트 설정과 FFmpeg 인코더의 CRF 모드는 같은 방식이 아닙니다. 18(최고 품질)부터 40(최대 압축)까지 슬라이더로 조정할 수 있습니다.
FFmpeg의 libx264 같은 인코더에서 CRF는 일정한 품질을 목표로 하고 결과 크기는 장면 복잡도에 따라 달라집니다. WebCodecs 표준에는 같은 의미의 범용 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-프레임을 사용하거나 더 좋은 화질을 보장하는 것은 아닙니다. 실제 출력과 기기별 인코더 설정을 확인해야 합니다.
레벨은 프레임 크기뿐 아니라 초당 처리량과 비트레이트 등의 제한을 정의합니다. 아래 표는 해상도별 선택을 단순화한 기준이며, 같은 해상도라도 프레임률에 따라 필요한 레벨이 달라질 수 있습니다. 레벨이 낮으면 일부 기기에서 재생이 안 될 수 있고, 너무 높으면 하드웨어 가속 디코더를 못 쓰는 경우가 있습니다. 그래서 해상도별로 적합한 레벨을 자동으로 선택하게 했습니다.
| 픽셀 수 | 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도 이 기준을 넘지 않도록 조정합니다.
5MB라는 목표는 프레임을 얼마나 남길지의 문제다
파일 크기는 대략 재생 시간과 평균 비트레이트의 곱으로 생각할 수 있습니다. 30초 영상을 5MB 이내로 만들고 싶다면, 십진 단위로 5,000,000 × 8 / 30을 계산해 전체 평균 비트레이트 예산이 약 1.33Mbps라는 출발점을 얻습니다. 여기에서 오디오와 컨테이너 비용을 빼야 비디오가 쓸 몫이 나옵니다.
그림에서 WebCodecs가 맡는 곳은 코덱 처리입니다. MP4 파일을 읽고 쓰는 디먹서·먹서는 별도로 필요합니다. 프레임의 timestamp와 duration을 이어받고 오디오가 있다면 시간 축을 맞춰야 합니다. 영상만 작아졌는데 소리가 밀리거나 길이가 달라졌다면 성공한 압축이라고 보기 어렵습니다.
목표 비트레이트를 정해도 출력 크기가 정확히 맞지는 않습니다. 결과 파일의 실제 크기·재생 시간·첫 프레임·오디오를 확인하고, 상한을 넘으면 해상도나 비트레이트를 조정할 경로가 필요합니다. 본문의 5MB는 운영상 정한 업로드 목표이며 모든 기기에서 안전하다는 경계값은 아닙니다.
빠른 인코더도 프레임을 무한히 받아줄 수는 없다
디코더가 프레임을 만드는 속도가 인코더의 처리 속도보다 빠르면 큐가 쌓입니다. 압축된 입력 파일이 작아도 디코딩한 프레임은 메모리를 많이 차지하므로 encodeQueueSize 같은 신호를 보고 투입 속도를 조절해야 합니다. 처리한 VideoFrame은 사용이 끝나면 close()해 자원을 반환합니다.
지원 여부는 브라우저 이름보다 실제 설정으로 확인하는 편이 낫습니다.
const config: VideoEncoderConfig = {
codec: "avc1.640029",
width: 1920,
height: 1080,
bitrate: 1_000_000,
framerate: 30,
};
const support = typeof VideoEncoder === "undefined"
? { supported: false }
: await VideoEncoder.isConfigSupported(config);
이 검사는 출발 조건만 확인합니다. 실행 중 메모리 부족이나 코덱 오류까지 없어진다는 보장은 아닙니다. 실패 후 폴백할 때는 기존 디코더·인코더·프레임을 정리하고, 큰 입력을 자동으로 처음부터 다시 처리해도 되는지 사용자에게 상태를 보여줘야 합니다.
로컬에서는 되는데 배포하면 안 된다
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" }
]
}
]
}
주의할 점이 하나 있습니다.
이때 crossOriginIsolated도 실행 환경에서 확인해야 합니다. 헤더 두 개를 적었다고 모든 외부 리소스와 임베드 환경에서 격리가 성립하는 것은 아닙니다. COEP(Cross-Origin-Embedder-Policy)를 require-corp로 설정하면, 페이지에서 로드하는 모든 서브리소스도 CORS 헤더나 Cross-Origin-Resource-Policy 헤더를 응답해야 합니다. 외부 CDN 폰트나 이미지를 쓰는 페이지라면, 해당 리소스들이 이 요건을 만족하지 않을 경우 로드가 차단됩니다. FFmpeg.wasm을 쓰는 페이지는 가급적 외부 서브리소스 의존도를 낮추는 게 좋습니다.
빠른 구현에도 설계 기준은 필요했다
사실 처음엔 1~2시간이면 완성할 수 있지 않을까 싶어 도전했는데, 완성까지 5시간이 걸렸습니다.
처음부터 정확히 설계하고 프롬프트를 입력했다면 더 짧아졌을 겁니다. 그래도 기능을 요청하는 것만으로는 부족하고, 구현 기준을 먼저 정해야 한다는 점을 배웠습니다.
구현하면서 가장 인상적이었던 건 WebCodecs를 지원하는 브라우저에서의 처리 속도였습니다. 제 작업 환경에서는 20MB 영상도 수 초 안에 처리할 수 있었지만, 이를 모든 기기의 처리 시간으로 일반화할 수는 없습니다. 목표 코덱과 기기에서의 실행 결과에 따라 FFmpeg.wasm 폴백이 필요합니다. 그리고 avc1.640029 같은 코덱 문자열, CRF 공식, SharedArrayBuffer 제약처럼 문서만 봐서는 바로 와닿지 않는 세부사항들이 실제 구현 난이도를 높이더라고요.
라이브러리는 GitHub에 공개해 뒀습니다. 비슷한 고민을 하고 계신 분이라면 한 번 열어보셔도 좋겠습니다.