guksulog
포스트 목록
#오픈소스

video-encoder 라이브러리 개발기

2026년 5월 1일 6분 읽기

시작은 임시방편이었다

이전 글(모바일 WebView에서 video 태그를 다루며 겪은 것들)에서 iOS WebView 크래시 문제를 다룬 적이 있어요. 20MB 영상 여러 개를 동시에 핸들링하는 환경이 문제였고, 근본 해결책인 서버 사이드 압축 파이프라인이 준비되기 전까지 급한 불을 꺼야 했죠.

그래서 직접 만들었어요. 브라우저에서 바로 동작하고, 서버 없이 영상을 5MB 이하로 줄여주는 도구. 그게 video-encoder예요.

Claude Code로 만들기로 했다

사실 사내에선 무료툴을 찾아보자는 얘기도 있었고, 실제로 몇몇 후보도 있었어요. 그런데 무료툴보단 사내에서 직접 관리할 수 있는 게 좋기도 했고, 새로운 첼린지도 하고 싶었어요. 그래서 퇴근하고 사이드 프로젝트로 비디오 인코더를 만들기로 결정했죠.

초기 시행착오: "만들어줘"의 함정

처음엔 단순하게 접근했어요.

"WebCodecs로 영상 압축 기능 만들어줘"

결과물은 빠르게 나왔어요. 그런데 실제로 돌려보면 버그가 자주 나왔어요. 고쳐달라고 하면 고쳐지는데, 다른 곳에서 사이드이펙트가 터졌죠. 그걸 또 고치면 처음 버그가 다시 살아나고요.

원인은 단순했어요. Claude Code가 코드를 작성하는 기준이 없었던 거예요. 전체 구조 합의 없이 기능 단위로 요청하니까, 그때그때 최적인 방식으로 코드를 뽑아내서 결과적으로 일관성 없는 코드베이스가 돼버렸어요.

설계를 먼저, 구현은 그 다음에

방법을 바꿨어요. 코드 요청 전에 전체 기능과 기술 스택을 CLAUDE.md에 먼저 정리했어요.

code
# video-encoder

## 기능 목록
- 영상 파일 드래그 앤 드롭 업로드
- CRF 기반 품질 조절 슬라이더
- 해상도 선택 (원본 / 1080p / 720p / 480p)
- 오디오 제거 옵션
- 실시간 압축 진행률
- 압축 전후 파일 크기 비교 및 다운로드

## 기술 스택
- Vite + TypeScript
- 인코더 1순위: WebCodecs API
- 인코더 폴백: FFmpeg.wasm
- 디먹싱: mp4box.js
- 먹싱: mp4-muxer

이 파일을 기반으로 "CLAUDE.md 참고해서 엔진 선택 로직부터 구현해줘"처럼 기능 단위로 순서대로 요청했어요.

결과는 달랐어요. 구조에 대한 합의가 먼저 있으니까 맥락을 유지하면서 코드를 작성하더라고요. 사이드이펙트도 줄었고, 의도한 구조대로 코드가 쌓였어요.

기술 구현

하이브리드 엔진 구조

핵심은 WebCodecs API와 FFmpeg.wasm을 조합한 하이브리드 인코딩 엔진이에요.

code
브라우저가 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 엔진 기준 처리 흐름이에요.

code
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를 비트레이트로 변환하는 공식은 이래요.

code
bitrate = 0.85 × 2^((18 - CRF) / 8)  (단위: Mbps)

CRF가 1 올라갈 때마다 비트레이트가 약 8.3% 줄고, 8 올라갈 때마다 절반이 돼요. CRF 18이면 약 0.85Mbps, CRF 26이면 0.42Mbps가 되는 식이죠. 이렇게 지수적으로 줄어드니까 슬라이더 한쪽 끝으로 갈수록 파일 크기 변화가 극적으로 커져요.

H.264 코덱 프로필과 레벨 자동 선택

WebCodecs에서 H.264를 쓸 때는 코덱 문자열을 직접 지정해야 해요. avc1.640028 같은 형태인데, 이게 뭘 의미하는지 처음엔 전혀 몰랐어요.

code
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
1080p10 Mbps
720p6 Mbps
480p3 Mbps
원본원본의 80% (최대 30Mbps)

원본보다 낮은 해상도를 선택하면 이 테이블 기준으로 비트레이트를 재설정하고, CRF도 이 기준을 넘지 않도록 조정해요.

네 번째 시행착오: SharedArrayBuffer

FFmpeg.wasm 멀티스레드 모드(@ffmpeg/core-mt)를 쓰면 여러 스레드를 병렬로 돌려서 인코딩 속도를 높일 수 있어요. 그런데 로컬에서는 잘 되는데 배포 후에 갑자기 동작하지 않는 문제가 생겼어요.

원인은 SharedArrayBuffer 때문이었어요.

멀티스레드 WebAssembly는 스레드 간 메모리를 공유하려고 SharedArrayBuffer를 사용해요. 그런데 2018년 스펙터(Spectre) 취약점이 공개된 이후, 브라우저들이 보안 이유로 SharedArrayBuffer 사용을 기본 차단했거든요. 쓰려면 페이지가 "cross-origin isolated" 상태여야 해요.

cross-origin isolated를 활성화하려면 서버 응답 헤더에 두 가지가 필요해요.

code
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Vercel 배포라면 vercel.json에 헤더를 추가하면 돼요.

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에 공개해 뒀어요.