2026-09-08
브라우저에서 영상 변환이 왜 이렇게 오래 걸릴까 (그리고 사실상 즉시 끝나는 작업들)
브라우저에서 도는 변환 도구에 영상을 던져 넣고 버튼을 누른 다음, 생각보다 한참을 기다린 적이 있을 것이다. 고장난 게 아니다. 그 기다림은 애초에 이 도구를 고른 이유에 붙은 청구서다. 아무것도 업로드하지 않았으니, 아무것도 남의 장비에서 변환되지 않았다. 지금 렌더팜은 당신 노트북이고, 게다가 한쪽 팔이 묶인 채로 일하고 있다.
이 글은 그 청구서의 항목별 내역이다. 몇 초가 어디로 사라지는지, 사실상 공짜인 작업은 무엇인지, 그리고 벽은 어디에 있는지. 벽이라고 쓴 이유는 그게 "오래 걸림"이 아니라 "안 됨"이기 때문이다. 아래 숫자는 니어비드 엔진 작업에서 직접 측정한 값이고, 그 숫자에 붙는 단서도 숨기지 않고 다 적었다.
첫 프레임 이전 — 엔진부터 도착해야 한다
서버에서 도는 변환기에는 FFmpeg이 이미 깔려 있다. 브라우저에서 도는 변환기는 그걸 당신에게
보내줘야 한다. 니어비드의 영상 엔진은 직접 빌드한 GPL-free FFmpeg을 WebAssembly로 컴파일한 것이고,
그 결과물은 25MB짜리 .wasm 파일 하나다. 브라우저는 이 파일을 내려받고, 컴파일하고,
인스턴스화한 다음에야 비로소 당신 영상을 쳐다볼 수 있다.
다행인 건 이게 대체로 첫 방문 비용이라는 점이다. 고정된 URL 하나에서 내려오는 정적 파일이라 브라우저가 다른 자산과 똑같이 캐시하고, 같은 날 두 번째 변환에서는 다운로드 자체가 없다. 파일이 로컬에 있을 때 남는 인스턴스화 비용은 크지 않다. 실제 Chromium을 Playwright로 띄워 측정한 값은 회차에 따라 563ms에서 702ms 사이였다(성능 측정 회차에서 싱글스레드 빌드 597ms, 멀티스레드 빌드 702ms, 실제로 배포하는 GPL-free 멀티스레드 코어를 따로 잰 회차에서 563ms). 그리고 니어비드는 파일을 고르는 순간부터, 즉 당신이 무슨 작업을 할지 정하기도 전에 이 로딩을 백그라운드로 시작해 둔다. 기다림이 카드 읽는 시간과 겹치게 하려는 것이다.
정리하면 엔진 다운로드는 실재하는 비용이지만, 변환이 느릴 때 당신이 체감하는 비용은 이게 아니다. 그건 더 아래에 있다.
어셈블리를 들어낸 FFmpeg
이 대목은 보통 "WebAssembly니까 느리죠"라는 한 줄로 넘어간다. 실제로는 그보다 구체적이고, 그 구체적인 내용이 빌드 스크립트에 그대로 적혀 있다.
FFmpeg 속도의 상당 부분은 핵심 루프에 손으로 짜 넣은 CPU별 어셈블리에서 나온다. WebAssembly
빌드는 그걸 하나도 쓸 수 없고, 우리 코어가 파생된
ffmpeg.wasm의 빌드 스크립트도 그 사실을
숨기지 않는다. FFmpeg의 configure에 --disable-asm이 들어가고, 함께 링크되는
라이브러리 몇 개에도 같은 플래그가 들어간다. 그 자리를 대신하는 게 -msimd128, 즉
WebAssembly SIMD다. 위로용 장식이 아니라 진짜 벡터 명령어 집합이지만, 의도적으로 보수적으로 잡힌
집합이다. V8의 설명은 이 제안이 "Fixed-Width 128-bit SIMD
operations"로 한정되어 있으며 그렇게 한 이유가 "portable performance"를 보장하기 위해서라고
적는다. MDN도 그 결과물인
v128 값
타입을 정확히 128비트로 문서화한다.
맞바꾼 것은 "portable"이라는 단어 안에 있다. 같은 바이너리가 어디서든 예측 가능하게 돌도록 폭을 128비트 하나로 고정한 것 — 당신 CPU가 실제로 지원하는 가장 넓은 벡터에, 그 CPU에 맞춰 수년간 다듬어진 코덱 전용 어셈블리를 얹은 native FFmpeg 대신에 말이다. 영상 인코더는 결국 대부분 움직임 추정이고, 움직임 추정은 결국 같은 산술 루프를 수백만 번 도는 일이다. 사라진 초는 거기서 나온다.
스레드는 있다. 다만 HTTP 헤더 두 줄이 그걸 결정한다
멀티스레딩은 여기서 쓸 수 있는 가장 큰 지렛대인데, 페이지가 마음대로 당길 수 있는 지렛대가
아니다. Emscripten은 pthreads API를
SharedArrayBuffer 위에 구현하고,
SharedArrayBuffer는 Spectre 이후 다시 열릴 때부터 cross-origin isolation 뒤에 잠겨 있다.
MDN의
문장은 짧다. "To use shared memory your document must be in a secure context and cross-origin
isolated." 실무적으로는 그 페이지에 서버가
Cross-Origin-Opener-Policy: same-origin과
Cross-Origin-Embedder-Policy: require-corp를 붙여 보내야 한다는 뜻이다.
중요한 건 이게 "부드럽게 성능이 떨어지는" 종류의 조건이 아니라는 점이다. 우리 측정에서 그 헤더가
없는 페이지에 멀티스레드 코어를 올리자, 스레드 하나로 조용히 물러나는 대신 29ms 만에
ReferenceError: SharedArrayBuffer is not defined를 던지고 멈췄다. 되거나, 안 되거나
둘 중 하나다. (니어비드의 처리 화면이 별도 경로에 따로 사는 이유이기도 하다. 이 헤더들은 서드파티
임베드를 통째로 막기 때문에, 그런 게 하나도 없는 페이지에만 붙일 수 있다.)
헤더가 제대로 붙었을 때의 보상은 확실하지만 무한하지는 않다. 같은 클립, 같은 브라우저, 같은 기기에서 cross-origin isolation 여부만 바꿨을 때 싱글스레드 13,426ms 대 멀티스레드 6,940ms, 약 1.93배였다. 4코어 기준으로 대략 절반이지 열 배가 아니다.
작업별로 실제 얼마나 드나
추정치가 아니라 측정치다. 대상은 640x360, 30fps, 8초짜리 합성 테스트 영상이고,
hardwareConcurrency: 4를 보고하는 샌드박스 클라우드 VM 위의 실제 Chromium에서 돌렸다.
- 트림(스트림 복사): 멀티스레드 32ms, 싱글스레드 56ms. 사실상 즉시.
- 정지 프레임 추출: 한 프레임까지만 디코딩하고 이미지로 쓴다. 재인코딩보다는 트림 쪽 가족이다.
- GIF 변환(8초, 축소된 크기): 158ms. 픽셀 수가 작아서 싼 것이지, 나오는 파일이 작아서 싼 게 아니다.
- WebM 재인코딩: 멀티스레드 6,940ms, 싱글스레드 13,426ms. 640x360 8초에 대해서 그렇다. 4코어에서 대략 실시간, 1코어에서는 실시간보다 느리다.
이제 단서를 전부 붙인다. 단서 없이 인용된 벤치마크는 그냥 숫자일 뿐이니까. 이건 4코어 클라우드 VM이지 휴대폰이 아니다. 저사양 모바일은 더 느리고, 우리 엔진 기록은 "휴대폰과 클라우드 vCPU의 격차는 여기서 측정하지 않았다"고 명시해 두었다. 그래서 배수를 적지 않는다. 클립은 640x360이었다. 인코딩 비용은 대체로 픽셀 수를 따라가므로 1080p는 프레임당 픽셀이 몇 배, 4K는 거기서 또 몇 배다. 그리고 합성 테스트 패턴은 실제 촬영본이 아니다. 고정된 인터뷰 화면과 나뭇잎 사이를 흔들리며 지나가는 핸드헬드는 움직임 추정기에 전혀 다른 양의 일을 준다.
일반화되는 건 정확한 밀리초가 아니라 모양이다. 복사는 재인코딩보다 대략 200배 싸다. 이 비율은 브라우저에서 돌아서 생긴 게 아니다. 그래서 가장 쓸모 있는 습관은 하나로 요약된다 — 내 문제를 실제로 해결하는 것 중에 가장 싼 작업을 고르는 것.
메모리는 벽이고, 단단한 벽이다
느린 건 기다리면 된다. 메모리 천장은 기다린다고 넘어가지 않으니, 어디에 있는지 알아둘 값어치가 있다.
니어비드는 파일을 통째로 메모리에 읽어 들인 다음 그 사본을 WebAssembly 힙에 넘긴다. 즉 한 파일이 잠깐 램에 두 번 이상 존재한다. 그 위에 또 하나가 얹힌다. 멀티스레드 ffmpeg.wasm 빌드는 시작 시점에 1024MB 힙을 고정으로 잡고 메모리 증가를 켜지 않는다. 상위 빌드 스크립트의 주석이 이유를 직접 적어두었다 — 증가 옵션은 "is not recommended when using threads, thus we use a large initial memory". 싱글스레드 빌드는 필요할 때 늘어나지만, 빠른 쪽은 그러지 않는다. 그리고 그 모든 것 위에 플랫폼 한계가 있다. 지금의 32비트 WebAssembly에서 4GB가 천장인데, 32비트 포인터가 주소를 매길 수 있는 범위가 거기까지이기 때문이다.
그래서 니어비드는 파일 크기 상한을 밝히고, 무언가 시작되기 전에 먼저 막는다. 4분 동안 진행 막대를 보다가 터지는 것보다 낫기 때문이다. 데스크톱은 파일당 500MB, 휴대폰과 램 4GB 이하로 보고되는 기기는 50MB다. 넘으면 그 자리에서 거절된다. 이건 이론상 최대치가 아니라 일부러 보수적으로 잡은 숫자다. 인코딩 도중에 wasm 힙이 바닥나는 것보다는 문 앞에서 안 된다는 말을 듣는 쪽이 낫다.
더 빨리 끝내는 여섯 가지
- 트림으로 될 일이면 변환하지 마라. 파일이 필요한 곳에서 이미 재생되고 길이만 문제라면, 스트림 복사가 화질 손실 0으로 몇 밀리초 만에 끝낸다. 다만 스트림 복사는 당신이 입력한 정확한 초가 아니라 키프레임에 내려앉는다. FFmpeg 문서는 "in most formats it is not possible to seek exactly, so ffmpeg will seek to the closest seek point before position"이라고 적고, 스트림 복사에서는 그 앞부분이 버려지지 않고 "will be preserved"된다고 덧붙인다. 구간을 조금 넉넉히 잡아라.
- 변환하기 전에 먼저 자르라. 재인코딩 비용은 대체로 길이에 비례한다. 90초짜리를 필요한 12초로 먼저 줄이면 일의 8분의 7이 사라진다.
- 도착지가 작으면 해상도를 낮춰라. 인코딩 비용은 픽셀 수를 따라간다. 채팅창에서 볼 영상에 휴대폰 카메라가 찍은 픽셀이 전부 필요하지는 않다.
- 비트레이트를 낮춰라. 낮은 품질 프리셋은 결과 파일만 작은 게 아니라 인코더가 할 일 자체가 적다.
- 소리만 필요하면 오디오만 뽑아라. 오디오 인코딩은 영상 인코딩 옆에서 반올림 오차 수준이다. 움직임을 추정할 프레임이 없다.
- 그림 한 장만 필요하면 프레임을 뽑아라. 한 타임스탬프까지만 디코딩하는 건 전부 디코딩하는 것의 일부만 든다.
요령이 아닌 것도 하나 있다. 큰 작업은 데스크톱에서 하라. 어셈블리가 살아 있는 native FFmpeg을, 진짜 코어가 여럿 달리고 1GB 힙 제약도 없는 기기에서 돌리면 어떤 브라우저보다 빠르다. 브라우저 안 처리가 옳은 선택인 경우는 따로 있다. 파일이 민감할 때, 소프트웨어를 설치할 수 없는 컴퓨터 앞일 때, 아니면 대안이 모르는 서버에 영상을 올리는 것일 때다. 100기가를 일괄 트랜스코딩할 때가 아니다.
유일하게 빠른 경로, 그리고 그게 늘 있지는 않은 이유
짚고 갈 예외가 하나 있다. "여기서 MP4가 어떤 때는 순식간이고 어떤 때는 아예 선택지에 없는 이유"에 대한 정직한 답이기도 하다.
니어비드의 FFmpeg 코어는 GPL-free로 빌드되어 있고, 그래서 H.264/H.265 인코더가 없다. libx264와 libx265는 GPL 전용이고, FFmpeg 안에 이를 대신할 비-GPL 대체물은 없다. MP4 입력을 디코딩하는 건 문제없지만, 그 엔진으로 MP4를 만들어내는 건 애초에 불가능하다. 그 빌드의 전말은 따로 한 편의 글이다. 그래서 MP4 출력은 완전히 다른 곳에서 온다. WebCodecs API인데, MDN의 표현으로는 "enables web developers to encode and decode video and audio in the browser efficiently (using hardware acceleration)"하는 물건이다. 우리 코덱이 아니라 브라우저 자신의 코덱이고, MDN 코덱 가이드는 H.264를 "one of the most widely supported codecs across browsers, operating systems, and consumer devices"라고 적는다. 이 경로가 열려 있으면 WebAssembly 경로보다 훨씬 빠르다. WebAssembly 경로가 아니기 때문이다.
함정은 이 가용성이 약속이 아니라 브라우저별·기기별 질문이라는 데 있다. 니어비드는 실행 시점에 H.264 영상과 AAC 오디오 양쪽에 대해 VideoEncoder.isConfigSupported()로 확인하고, 당신이 넣은 그 파일을 읽을 수 있는지도 따로 확인한 다음에야 MP4 선택지를 보여준다. 안 보인다면 지금 쓰는 브라우저가 그걸 못 하는 것이다. 중간에 실패할 버튼을 보여주느니 카드를 숨기는 쪽을 택했지만, 그 말은 곧 같은 파일을 두 사람이 두 기기에서 열었을 때 서로 다른 선택지를 볼 수 있다는 뜻이다. 버그가 아니라 실제 한계다.
정직한 요약
브라우저에서 영상을 변환하는 게 서버보다 느린 이유는, 일이 당신 기기로 넘어왔고 그 일을 할
엔진이 제약을 달고 도착했기 때문이다. 25MB짜리 WebAssembly 코어를 먼저 올려야 하고, FFmpeg은
--disable-asm으로 빌드되어 CPU별 어셈블리 대신 128비트 고정폭 SIMD를 쓰며, 스레딩은
페이지가 cross-origin isolated일 때만 존재한다 — 될 때는 약 1.93배, 안 될 때는 폴백이 아니라 즉시
실패다. 우리 측정에서 640x360 8초 클립은 스트림 복사로 32ms, WebM 재인코딩으로 4코어에서
6,940ms가 걸렸다. 기억할 숫자는 복사와 재인코딩 사이의 약 200배라는 비율이고, 밀리초 값들은 클라우드
VM 하나에서 작은 합성 클립 하나로 잰 값이지 당신 휴대폰에 대한 약속이 아니다. 단단한 경계는
메모리 쪽이다. 4GB 플랫폼 천장 아래 고정 1024MB wasm 힙, 그래서 파일은 데스크톱 500MB, 휴대폰과
저메모리 기기 50MB에서 멈추고, 인코딩 중간이 아니라 시작 전에 거절된다. 문제를 해결하는 것 중 가장
싼 작업을 고르고, 변환 전에 먼저 자르고, 그러고도 남는 기다림은 있는 그대로 받아들이면 된다. 파일이
지금 앉아 있는 이 기기를 한 번도 떠나지 않는 값이다.