레시피 목록

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

2026년 4월 15일 5분 읽기
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를 비우는 순간 화면이 검게 되고, 새 영상이 로드되기까지 그 공백이 유지됩니다. 네트워크 상태에 따라 이 공백이 길어질 수 있었고, 빠르게 영상을 넘길수록 깜빡임이 쌓였습니다.

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

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

기술적으로는 맞는 말입니다. iOS의 AVQueuePlayer는 영상을 큐에 미리 올려두고 전환 시 즉시 스왑하니까 black frame이 구조적으로 발생하지 않습니다. 디코더 제어도 WebView보다 훨씬 자유롭고요.

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

결론적으로 네이티브는 기술적으로는 더 유리하지만 현실적인 제약이 있었고, 네이티브 전환은 콘텐츠 복잡도가 올라가거나 WebView의 한계를 명확히 넘어서는 시점에 재검토하는 게 맞다고 판단했습니다.

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

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

미리 준비해두고 스왑한다

primary / secondary 두 개의 video를 두고, standby 슬롯에서 다음 영상을 미리 준비합니다. 준비가 완료된 시점에 active 슬롯을 스왑하니까 black frame 없이 전환이 가능하죠.

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

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

loadeddata만 기다리는 대신, loadedmetadata·canplay·loadeddata 중 먼저 도착하는 이벤트로 준비 완료를 처리합니다. timeout 시에도 readyState >= 1이면 진행하니까 네트워크 조건에 관계없이 멈추지 않습니다.

연타는 pending 하나로 흡수한다

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

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

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

전환 시작 시 이전 cleanup timer를 즉시 해제하고, cleanup 실행 시 "지금 stale 슬롯이 맞는지" 검증한 뒤 release합니다.

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

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

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

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

왜 HLS는 아니었나

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

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

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

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

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

이번 단계는 HLS 도입보다 전환 파이프라인 개선이 ROI가 높았습니다.

남은 것과 배운 것

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

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

물론 이번 개선이 완벽한 해답은 아닙니다. 아직 다듬어야 할 부분도 남아 있고, 환경이 달라지면 전혀 다른 선택이 맞을 수도 있죠. 그래도 여러 트레이드오프를 직접 겪으며 결정을 내리는 과정에서 미처 몰랐던 개념들을 하나씩 알아가는 것 자체가 값진 경험이었습니다.

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