글 목록

모바일 WebView에서 video 태그를 다루며 겪은 것들

2026년 4월 15일 7분 읽기
WebView비디오

시작은 20MB짜리 영상이었다

최근 모먼트라는 영상 섹션을 개발했습니다. 최대 10개의 영상을 인스타그램 스토리와 비슷한 UI로 보여주는 기능입니다.

처음 모먼트 기능이 도입됐을 때 영상 크기 제한은 20MB였습니다.

20MB는 상당히 큰 용량이라 부담스러웠지만, 커머스팀에서 최대한 줄일 수 있는 영상 크기가 20MB라고 전달받았습니다. 그리고 기능 자체를 빠르게 출시하는 게 우선이라 영상 용량 정책은 "일단 넉넉하게" 잡고 시작했죠.

그런데 iOS WebView 환경에서 20MB 영상 여러 개를 동시에 핸들링하는 건 생각보다 훨씬 가혹한 조건이었습니다. 실제로 사용자 제보와 내부 테스트에서 앱 종료와 기기 발열 문제가 반복해서 확인됐습니다.

급한 불부터 끄려고 압축 도구를 만들었다

근본 해결은 서버 사이드 영상 압축 파이프라인 구축이었지만, 인프라 작업에는 시간이 필요했습니다. 그래서 직접 영상 압축 웹 도구를 만들어 임시로 운영했습니다.

video-encoder — 브라우저에서 바로 영상을 압축할 수 있는 임시 도구

콘텐츠 업로드 전에 이 도구를 거쳐 압축한 영상만 등록하는 방식으로 운영했습니다. 물론 이건 영구적인 해결책이 아니었습니다. 업로더 입장에서 추가 단계가 생기고, 압축 품질도 일정하지 않았습니다.

하지만 이 임시 조치 덕분에 한 가지는 명확해졌습니다. 영상을 5MB 이하로 줄이면 크래시와 발열 문제가 눈에 띄게 줄어든다는 것이었죠.

문제는 코드가 아니라 환경이었다

본격적인 개선 이야기 전에, iOS WebView라는 환경 자체의 제약을 짚어둘 필요가 있습니다.

먼저 디코더 수 제한입니다. iOS WebView는 동시에 활성화할 수 있는 비디오 디코더 수에 제약이 있습니다. 여러 개의 <video> 태그를 동시에 재생 상태로 두면 메모리 경고가 발생하거나 디코더가 강제로 해제됩니다.

autoplay 정책도 걸립니다. iOS Safari 기반 WebView는 사용자 인터랙션 없이 자동 재생이 제한되고, muted 속성을 붙여도 WebView 설정에 따라 동작이 달라지는 경우가 있습니다.

미디어 이벤트도 예상대로 발생하지 않았습니다. canplay, loadeddata 같은 미디어 이벤트가 데스크탑 브라우저와 달리 iOS WebView에서는 발생 시점이 불규칙하거나 아예 발생하지 않는 경우가 있습니다.

마지막은 메모리 압박입니다. 백그라운드 전환이나 앱 스위칭 시 WebView가 메모리를 회수하면서 비디오 버퍼가 날아갑니다. 복귀 후 재생이 처음부터 다시 시작되는 현상이 여기서 비롯되죠.

이런 환경에서 "인스타그램 스토리처럼 부드러운 전환"을 만드는 건 단순한 UX 문제가 아니었습니다. 플랫폼의 물리적 제약과 싸우는 일이었습니다.

video 태그 하나로는 깜빡임을 지울 수 없었다

초기 구현은 단순했습니다. <video> 태그 하나를 두고, 영상이 넘어갈 때마다 src를 해제하고 다시 세팅하는 방식이었죠.

javascript
// 초기 구현 — 단순하지만 문제가 있었다
videoRef.current.src = "";
videoRef.current.load();
videoRef.current.src = nextVideoUrl;
videoRef.current.play();

src를 비우는 순간 화면이 검게 되고, 새 영상이 로드되기까지 그 공백이 유지됩니다. 네트워크 상태에 따라 이 공백이 길어질 수 있었고, 빠르게 영상을 넘길수록 깜빡임이 쌓였습니다.

그렇다면 네이티브로 가는 게 정답이었을까?

깜빡임 문제가 반복되면서 자연스럽게 나온 질문이 있었습니다. "그냥 이 화면을 네이티브로 구현하면 되지 않나?"

네이티브의 AVQueuePlayer는 다음 재생 항목을 큐로 관리할 수 있어 제어 수단이 늘어납니다. 다만 네트워크와 미디어 준비 상태에 따라 전환 지연은 여전히 생길 수 있습니다. 네이티브로 옮긴다는 것만으로 black frame이 사라진다고 보장할 수는 없습니다.

하지만 모먼트 화면만 네이티브로 분리하기엔 현재 회사 사정에 맞지 않았습니다.

네이티브 구현의 장점은 있었지만 당시에는 전환이 어려웠습니다. 콘텐츠가 복잡해지거나 WebView의 한계가 더 뚜렷해지면 다시 검토하기로 했습니다.

두 개의 슬롯을 번갈아 쓰기로 했다

이번에는 탭형 UI 구조는 유지하면서 전환 엔진만 교체했습니다.

미리 준비해두고 스왑한다

primary / secondary 두 개의 video를 두고, standby 슬롯에서 다음 영상을 미리 준비합니다. 다음 프레임을 준비하는 동안 현재 화면을 유지해 black frame을 줄이려는 구조입니다. 준비 완료를 무엇으로 판단하느냐가 중요합니다.

code
primary (active)    → 현재 재생 중인 슬롯
secondary (standby) → 다음 영상 사전 준비
ready → swap        → 준비 완료 시 active 슬롯 교체

먼저 도착하는 이벤트를 믿기로 했다

당시에는 loadedmetadata·canplay·loadeddata 중 먼저 도착하는 이벤트를 사용하고 timeout 시 readyState >= 1이면 진행하는 타협을 했습니다. 하지만 HAVE_METADATA(1)은 길이와 크기를 아는 상태이지 첫 프레임을 그릴 수 있다는 뜻은 아닙니다. 검은 화면을 피하려면 적어도 현재 프레임 데이터 상태를 확인하고, 지원 환경에서는 requestVideoFrameCallback으로 프레임 제출 시점도 관찰해야 합니다. 제한 시간 안에 준비되지 않으면 기존 프레임이나 poster를 유지하고 재시도 경로를 보여주는 편이 안전합니다.

연속 탭 요청은 pending 하나로 관리한다

전환 중 요청은 pending으로 1개만 유지하고, 다음 인덱스 계산은 현재 인덱스가 아닌 pending || current 기준으로 수행합니다.

javascript
// 연타 시 pending을 기준으로 다음 인덱스를 계산
const nextIndex = getNextIndex(pendingIndex ?? currentIndex, direction);

정말 stale 슬롯이 맞는지 확인하고 놓아준다

전환 시작 시 이전 cleanup timer를 즉시 해제하고, cleanup 실행 시 "지금 stale 슬롯이 맞는지" 확인한 뒤 리소스를 해제합니다.

멈춘 영상이 다음 영상까지 멈추게 만들었다

showPauseIcon을 ref로 동기화하고, 좌/우 탭 시 ref를 먼저 false로 반영한 뒤 전환합니다. "멈춘 영상에서 다음으로 넘겼더니 다음 영상도 멈춤" 버그를 이걸로 제거했습니다.

예전 코드는 지우지 않고 남겨뒀다

기존 싱글 버퍼 전환 코드는 삭제하지 않고 주석으로 파일 안에 보존했습니다. 새 엔진에서 문제가 생기면 주석 해제만으로 빠르게 복구할 수 있습니다.

늦게 끝난 준비 작업이 최신 요청을 덮어쓰면 안 된다

사용자가 A를 보다가 B, C를 빠르게 누른 경우를 생각해보겠습니다. B의 로딩이 C보다 늦게 끝나면, 먼저 시작한 B의 완료 콜백이 나중에 실행될 수 있습니다. 콜백마다 active 슬롯을 바꾸면 사용자는 C를 골랐는데 B가 나타납니다. 슬롯을 두 개로 줄여도 비동기 작업의 순서는 정리되지 않습니다.

현재 A를 유지하면서 B 준비를 C 요청으로 대체하고 최신 요청만 화면을 교체하는 흐름

이때 준비 작업에 세대 번호를 붙이면 검증 조건이 분명해집니다. 전환 요청이 올 때마다 번호를 올리고, 준비 완료 콜백은 자신이 시작할 때 받은 번호가 아직 최신인지 확인합니다. 이전 요청이라면 active 슬롯을 바꾸지 않습니다. 아래는 전체 플레이어가 아니라 이 조건만 보여주는 예입니다.

ts
let generation = 0;

async function switchTo(url: string) {
  const mine = ++generation;
  await prepareStandby(url); // timeout·취소·실패는 별도 처리
  if (mine !== generation) return;
  swapSlots();
}

번호 비교는 화면 반영을 막을 뿐 이전 로딩을 취소하지는 않습니다. 새 요청이 같은 standby 요소를 재사용한다면 기존 리스너와 timeout도 제거해야 합니다. 특히 오래된 cleanup이 지금 active인 요소의 src를 지우지 않도록 슬롯의 역할과 세대 번호를 함께 확인해야 합니다.

파일 크기와 디코딩 메모리를 따로 본다

20MB를 5MB로 줄인 경험은 유효하지만, 파일 크기는 압축된 데이터의 크기입니다. 실제 메모리에는 디코딩한 프레임과 버퍼가 들어갑니다. 예를 들어 1920×1080 이미지를 RGBA로 펼친 단순 계산만 해도 프레임 하나가 약 8.3MB입니다. 실제 비디오의 픽셀 포맷과 버퍼 수는 다르지만, 작은 파일이 반드시 적은 메모리를 뜻하지 않는 이유는 보여줍니다.

그래서 같은 파일 크기에서도 해상도·프레임률·동시 준비 개수를 바꿔 확인해야 합니다. 두 video 요소가 실제 하드웨어 디코더 두 개와 일대일 대응한다는 보장도 없습니다. 저사양 실기기에서 전환을 반복하고, 준비 중 백그라운드 전환과 재생 거부까지 확인하는 것이 필요합니다. play()의 Promise가 reject됐을 때 사용자 입력으로 다시 시작할 수 있는 UI도 남겨야 합니다.

왜 HLS는 아니었나

HLS도 후보였지만, 이번 문제의 최적해는 아니었습니다.

우선 콘텐츠 특성이 맞지 않았습니다. 모먼트는 짧은 클립이고 각 영상은 5MB 제한입니다. 긴 재생 구간에서 adaptive bitrate 이점이 큰 HLS의 장점이 상대적으로 작습니다.

문제의 본질도 달랐습니다. 이번 이슈는 "비트레이트"가 아니라 "아이템 간 전환 타이밍/상태관리" 문제였습니다. HLS로 바꿔도 전환 경합과 첫 프레임 타이밍 문제는 여전히 해결이 필요합니다.

운영 복잡도 역시 무시할 수 없었습니다. HLS를 도입하면 인코딩 파이프라인, manifest 관리, CDN 정책 같은 백엔드/인프라 작업이 커집니다.

그리고 iOS WebView의 현실이 있습니다. iOS에서 중요한 건 동시 활성 디코더 수를 낮게 유지하는 겁니다. Dual Buffer는 미디어 요소를 두 슬롯으로 제한하면서 서버 구조 변경 없이 리스크를 통제할 수 있습니다.

당시에는 HLS를 도입하는 것보다 전환 과정을 개선하는 편이 투입 시간 대비 효과가 컸습니다.

전환은 나아졌지만 더 다듬을 부분이 남았다

정량 지표는 추가 수집 중이지만, 구조적으로 해결된 것들은 명확합니다.

  • 전환 시 black frame 감소
  • 빠른 연타 시 멈춤 현상 완화
  • pause/next 상태 불일치 버그 제거
  • 롤백 안전장치 확보

물론 이번 개선이 완벽한 해답은 아닙니다. 아직 다듬어야 할 부분도 남아 있고, 환경이 달라지면 전혀 다른 선택이 맞을 수도 있죠. 그래도 각 방식의 이점과 제약을 비교하며, 몰랐던 개념을 실제 문제와 연결해볼 수 있었습니다.

비슷하게 WebView에서 영상을 다루고 있다면, 깜빡임을 없애는 방법을 찾기 전에 지금 동시에 살아 있는 디코더가 몇 개인지부터 세어보면 좋겠습니다. 저는 거기서부터 답이 갈렸습니다.

더 확인할 자료