영상 최적화의 대상은 영상만이 아니었다
Frontend Engineer

매시간 황금알(플레이허브 v2.1)의 거위는 사용자의 현재 상태와 다음 행동을 알려주는 UI였지만, 여러 앱의 WebView에서 영상이 늦게 나타나거나 인라인으로 재생되지 않았습니다. 스틸 이미지, 스프라이트, 해상도 선택, 인코딩 최적화를 거치며 최적화 대상을 사용자 경험 전체로 넓혀 간 과정을 소개합니다.
시작하며
버즈빌은 여러 모바일 앱 안에서 사용자가 광고에 참여하고 포인트 같은 보상을 받을 수 있는 서비스를 만듭니다. 매시간 황금알은 이 과정을 조금 더 즐겁게 만들기 위해 개발한 웹 기반 리워드 서비스로, 그 앱들 안에서 열립니다. 팀의 취향만으로 캐릭터를 정하지 않도록 일반 사용자에게 여러 시안의 첫인상과 참여 의향을 물었고, 디자인을 다듬으며 미션을 마치면 황금알을 받는 흐름에 어울리는 거위를 중심 캐릭터로 정했습니다.
거위의 움직임 자체가 UI였습니다. 거위는 처음 들어온 사용자를 맞이하고, 진행 상황에 맞춰 행동을 바꾸며, 복권이나 보상을 받을 차례를 알려줬습니다.
그래서 버튼과 안내 문구 사이에서 배경이 투명한 거위 영상을 인라인으로 재생했습니다. 그런데 이 화면은 일반 브라우저뿐 아니라 여러 앱의 WebView에서도 열렸고, 그곳에서는 거위가 한동안 빈자리로 남거나 영상이 갑자기 전체 화면으로 열렸습니다. 광고 참여와 보상 지급은 끝까지 동작해도 사용자가 따라가던 흐름은 이미 끊긴 뒤였습니다.
처음에는 영상을 더 잘 재생하면 된다고 봤습니다. 기기와 네트워크가 충분하면 더 선명한 영상을 제공하고, 로딩이 느리면 파일을 줄이거나 다음 영상을 미리 받아 두며, 호환되지 않는 형식은 다른 형식으로 바꾸는 식이었습니다. 하지만 실제 WebView에서는 그것만으로 풀리지 않는 문제가 이어졌고, 그때마다 무엇을 최적화해야 하는지 다시 물어야 했습니다.
이 거위 경험은 프로덕트 디자이너 Joy와 함께 만들었습니다. Joy는 매시간 황금알의 UI와 캐릭터 연출을 맡아, 사용자 의견을 확인하며 거위 캐릭터와 UI·프로토타입을 다듬고 각 진행 상태에 필요한 3D 영상과 스틸 이미지를 제작했습니다. 저는 이 에셋을 미션·광고·보상 상태와 연결하고, 여러 앱의 WebView에서도 같은 흐름을 유지하도록 재생 로직과 대체 경로를 구현했습니다.
1. 영상이 뜨기 전, 무엇을 보여줄까
첫 프레임이 나타나기까지 브라우저는 데이터를 받으며 파일 구조 해석, 코덱 준비, 첫 프레임 디코딩과 화면 합성을 거칩니다. 느린 네트워크에서는 데이터 수신이, 저사양 WebView에서는 투명 영상의 디코딩과 합성이 더 큰 부담이 될 수 있습니다.
저는 영상과 같은 자리에 가벼운 스틸 이미지 커버를 먼저 그렸습니다. loadeddata, canplay, playing 중 하나가 발생해 노출 조건을 충족하면 커버를 제거했습니다. 커버를 영상 요소와 따로 둔 것은 로딩 중뿐 아니라 저사양 기기나 저전력 모드, 네트워크 실패처럼 영상을 보여 주기 어려울 때도 같은 자리에 보여 줄 스틸 이미지가 필요했기 때문입니다.
커버가 네트워크와 디코딩 시간을 없애지는 않았지만, 사용자가 그 시간을 빈 화면으로 마주하지 않게 했습니다. 현재 영상에는 <video preload="auto">를 썼지만, 재생 흐름은 브라우저가 데이터를 미리 받아 두었다고 가정하지는 않았습니다.
늦게 뜨는 것과 재생할 수 없는 것은 달랐습니다
브라우저는 <source>에 등록된 영상 파일을 순서대로 시도하므로, 첫 파일이 열리지 않았다고 곧바로 스틸 이미지에 고정하면 다음 파일을 선택할 기회까지 막을 수 있습니다. 구현해 보니 ‘영상이 보이지 않는다’는 현상은 여러 상태로 나뉘었습니다.
| 재생 상태 | 대응 |
|---|---|
| 첫 프레임을 준비하는 중 | 스틸 이미지 커버를 유지 |
브라우저가 다른 <source>를 시도하는 중 | 다음 후보를 기다림 |
| 영상 노출 조건을 충족함 | 스틸 이미지 커버를 제거하고 영상을 노출 |
| 자동재생만 거절됨 | 영상 요소를 유지하고 다시 재생할 기회를 남김 |
| 끝내 재생할 수 없음 | 스틸 이미지로 전환 |
후보 실패, 재생 불가, 자동재생 거절은 상태를 알리는 방식도 다릅니다. 후보가 실패하면 그 <source>에서만 error 이벤트가 발생하고 <video>로 버블링되지 않습니다. 모든 후보가 실패해도 video.error는 null이고 networkState만 NETWORK_NO_SOURCE가 됩니다. MediaError 가운데 MEDIA_ERR_NETWORK·MEDIA_ERR_DECODE는 자원을 쓸 수 있다고 확인된 뒤 수신이 끊기거나 디코딩에 실패할 때 생깁니다. 자동재생 거절은 autoplay 속성만 쓰면 알리는 이벤트가 없습니다.
구현에서는 후보 소진을 마지막 <source>에서 발생한 error 이벤트로, 이 두 오류를 <video>의 error 이벤트와 video.error.code로 판별했습니다. 자동재생은 play()를 직접 호출해 시작했고, 그 Promise가 NotAllowedError로 거부되면 자동재생 거절로 분류했습니다. 앱이 백그라운드에서 돌아오면 focus, pageshow, visibilitychange를 확인해 재생을 다시 시도했습니다.
2. 인라인 영상이 어려운 WebView에서는 재생 방식을 바꿨습니다
투명 영상을 안정적으로 제공하기 위해 iOS 또는 Safari 환경에는 알파 채널을 포함한 HEVC(MOV 컨테이너)를, Android를 포함한 그 외 환경에는 VP9(WebM 컨테이너)를 제공했습니다.
그러나 일반 브라우저에서 잘 재생되던 파일이 일부 iOS WebView에서는 playsinline을 설정해도 전체 화면으로 열렸습니다. 인라인 재생은 호스트 앱의 WebView 설정에도 영향을 받아, 프론트엔드 코드만 바꿔서는 모든 앱에서 같은 결과를 만들 수 없었습니다. iPhone의 WKWebView는 페이지의 playsinline과 호스트 앱의 allowsInlineMediaPlayback이 모두 켜져야 인라인으로 재생하며, 이 설정의 기본값은 iPhone에서 false입니다(iPad는 true).
호스트 앱에서 인라인 재생 관련 설정을 변경하는 방법이 가장 좋아 보였습니다. 그러나 바로 수정하기 어려운 앱에서 거위의 움직임을 보여줄 방법이 필요해, 저는 근본 해결책과 프론트엔드 대안을 비교했습니다.
| 선택 | 장점 | 비용과 한계 |
|---|---|---|
| 호스트 앱 설정 수정 | 인라인 재생 문제를 원인부터 해결 | 여러 앱의 소유권과 배포 일정을 함께 맞춰야 함 |
| 스틸 이미지 고정 | 가장 단순하고 안정적 | 상태를 표현하던 움직임이 사라짐 |
| 움직이는 WebP | 일반 이미지처럼 표시 가능 | 일부 저사양 WebView에서 첫 반복 재생이 끊김 |
| 스프라이트 시트 | 영상 디코더 없이 움직임 유지 | 파일 크기와 메인 스레드 작업이 늘어남 |
움직임을 살리도록 영상의 WebP 버전을 만들었고, 이를 WebView의 대체 재생 경로에 연결했습니다. 인라인 재생 제약을 피하면서도 구현이 크게 복잡해지지 않으리라 기대했지만, 내부 실기기 QA에서 일부 저사양 iOS WebView의 첫 반복 재생이 끊겼고 기기가 뜨거워지는 현상도 발견했습니다.
인라인 영상 문제가 확인된 WebView에서는 영상 대신 스프라이트 시트를 쓰기로 했습니다. 스프라이트 시트는 영상의 프레임을 이미지로 뽑아 한 장에 배열한 에셋입니다. 저는 CSS로 보이는 위치를 바꾸는 플레이어를 구현해 움직임을 재현했습니다. 이 선택의 목표는 발열 개선이 아니라 화면 안의 움직임을 유지하는 것이었습니다. WebP의 첫 반복이 끊겼던 저사양 iOS WebView에서도 스프라이트가 첫 반복부터 끊김 없이 재생되는 것을 실기기 QA로 확인했습니다.
![]()
시트가 여러 장이면 현재 시트와 다음 시트만 미리 요청했고, 프레임은 requestAnimationFrame에 맞춰 바꿨습니다. 메인 스레드가 잠시 바빠져도 밀린 프레임을 하나씩 재생하지 않고 흐른 시간에 맞는 프레임으로 건너뛰어 연출 전체가 조금씩 늦어지지 않게 했습니다. 스프라이트 에셋은 모든 상태에 준비했지만, 네트워크 문제로 불러오지 못하면 스틸 이미지를 사용했습니다.
스프라이트는 파일 크기와 메인 스레드 부담 때문에 일부 WebView에만 적용했습니다. 대상은 실기기 QA에서 인라인 재생 문제가 드러난 호스트 앱의 정적 목록으로 판별했습니다. 목록에 없는 새 호스트 앱은 기본적으로 인라인 영상 경로를 탔습니다. 판별이 틀린 것이 QA나 제보로 확인되면 목록을 고쳐 다음 진입부터 반영했습니다. 목록 방식이라 새 앱의 인라인 재생 문제는 확인되기 전까지 남을 수 있습니다.
3. 더 큰 영상을 골라 주는 로직은 정말 필요했을까
영상을 재생할 수 있는 환경에는 720×720과 1080×1080(프로젝트 명칭대로 이하 2x, 3x) 중 무엇을 보낼지도 정해야 했습니다. 파일이 크면 움직임이 시작되기까지 스틸 이미지에 머무는 시간이 길어집니다.
iPhone의 레티나 디스플레이에서는 해상도 차이가 느껴질 것이라 판단하여 처음에는 기기와 네트워크가 받쳐 주면 3x가 낫다고 봤습니다. 다만 고사양 기기도 큰 파일을 받는 시간은 필요했고, 앱 안의 WebView가 쓸 수 있는 자원도 일반 브라우저와 같다고 볼 수 없었습니다. 그래서 데이터 절약 설정, 화면 밀도, 네트워크 종류와 대역폭, 저전력 모드 여부, 이전 재생 기록을 확인해 둘 중 하나를 고르는 로직을 구현하고 검증했습니다. 검증 단계의 이 로직은 조건이 충분하면 첫 진입에도 3x를 골랐습니다.
단순화한 판단 기준은 다음과 같습니다.
function selectVideoTier(environment): "2x" | "3x" {
if (environment.saveData) return "2x"
if (environment.devicePixelRatio < HIGH_DENSITY_DPR) return "2x"
if (environment.isSlowNetwork || environment.networkIsUnknown) return "2x"
if (environment.recent3xFailed) return "2x"
return "3x"
}
NetworkInformation은 Safari(iOS·macOS)와 Firefox에서 제공되지 않습니다. 스니펫과 달리 실제 로직은 브라우저가 주는 정보 외에 호스트 앱이 넘겨주는 정보 등도 함께 봤고, NetworkInformation을 읽지 못해도 2x로 고정하지 않았습니다. 그래서 iOS WebView에서도 다른 조건이 맞으면 3x를 시도했고, 문제가 있는 것으로 판단되면 다시 2x로 돌아갔습니다.
3x를 고른 뒤 첫 프레임이 준비되면 3x 선호를, 오류가 나고 내려갈 2x가 있으면 2x 선호를 브라우저 저장소에 기록했습니다. 모바일 기기는 이동이 가능하므로 조금 전의 상태 판단을 계속 유지하지 않고 30분 뒤에 무효화했습니다. 그 안에 다시 들어오면 다른 조건은 새로 확인하되, 3x 선호 기록이 있으면 3x를 우선했습니다. 같은 기기도 Wi-Fi와 LTE, 지하철과 실내를 오가므로 한 번의 결과는 짧은 재진입에만 참고하려 했습니다. 스니펫에서는 저전력 모드 여부와 3x 선호 기록을 읽는 분기를 생략했습니다.
재생 도중에는 2x에서 3x로 바꾸지 않았습니다. 짧은 캐릭터 영상에서 소스를 교체하면 깜빡이거나 처음부터 재생될 수 있고, 말풍선과 영상의 타이밍도 어긋날 수 있었습니다.
선택 로직을 구현하는 동안 Joy는 기존 3x 영상을 바탕으로 2x 버전을 제작했습니다. 그 뒤 같은 위치에서 즉시 바꿔 볼 수 있는 비교 페이지를 두고, 당시 확인할 수 있던 iPhone과 데스크톱에서 실제 제품과 같은 크기로 재생했습니다. 저는 선택 경로와 파일 크기를 확인하고, Joy는 밝은·어두운·체크무늬 배경에서 알파 경계와 움직임을 살폈습니다.
결과는 예상과 달랐습니다. 당시 확인한 기기와 제품 안의 표시 크기에서는 두 해상도의 화질 차이가 크지 않았고, 2x 파일은 모든 플랫폼에서 용량이 더 작았습니다. 영상이 화면에서 선명하게 보이는 데 필요한 가로·세로 픽셀 수는 표시 크기(CSS 픽셀)에 devicePixelRatio를 곱한 값이므로, 다른 화면에서도 같은 결론이 나는지는 두 값을 함께 봐야 합니다.

3x 파일은 2x보다 약 1.9배 컸습니다(전체 에셋 평균이 아닌 한 장면 기준).
일부 2x 영상은 24fps라 18fps였던 3x 영상보다 움직임이 오히려 부드러웠습니다. 사용자가 느끼는 품질은 해상도 하나로 설명되지 않았습니다. 다만 이 장면들은 해상도와 fps가 함께 달랐습니다. fps를 맞춘 2x·3x 비교도 따로 했는데, iPhone 같은 레티나 디스플레이에서는 해상도 차이가 조금 느껴졌지만 용량 차이만큼 크지는 않았습니다.
결국 팀은 2026년 7월, 3x 영상을 사용자에게 제공하기 전에 2x 하나로 통일했습니다. 환경 신호에 따라 3x를 고르는 경로도 닫았습니다. 완성한 로직이 실제로 필요한지 확인하고 쓰지 않기로 결정하는 것까지가 최적화였습니다.
4. 보이지 않는 픽셀과 눈에 보이는 테두리
2x로 통일한 뒤에도 일부 Android WebM 파일은 같은 해상도인데 유난히 컸습니다. 파일을 분석한 결과, 완전히 투명한 영역에도 프레임마다 다른 RGB 값이 남아 있었습니다. 알파 값이 0이라 눈에는 보이지 않아도 인코더에는 압축할 데이터였습니다.
Android WebM 13개 전체에서 완전히 투명한 픽셀의 RGB 값을 하나의 단색(매트 색)으로 정리하고, 코덱·인코더 설정·fps는 그대로 둔 채 같은 형식으로 다시 인코딩했습니다.
| 범위 | 정리 전 | 정리 후 | 감소 |
|---|---|---|---|
| Android 거위 영상(WebM) 전체 13개 | 20.93MB | 7.94MB | 62.1% |
제품에서는 사용되지 않는 투명한 픽셀이 차지하는 용량과 리소스 소비가 적지 않았습니다. 이 부분을 단순화하여 불필요한 리소스 소모를 큰 폭으로 줄일 수 있었습니다.
5. 무엇을 자동화하고 무엇을 눈으로 볼 것인가
소스 선택과 에셋 누락에 대한 테스트는 자동화할 수 있지만, WebView의 투명도와 인라인 재생, 캐릭터 테두리는 실기기에서 봐야 했습니다. 저는 에셋이 제품 상태에 맞게 선택되고 대체 경로로 전환되는지를 코드와 테스트로 확인했습니다.
| 검증 수단 | 확인한 내용 |
|---|---|
| 단위 테스트 | 화면별 소스 선택, 기록 만료, 대체 방식의 우선순위, 스틸 이미지 커버 제거 |
| 파일 검사 | 설정에 등록된 영상·이미지·스프라이트가 실제로 존재하는지 확인 |
| 비교 페이지 | 같은 표시 크기에서 2x·3x 화질과 인코딩 결과 비교 |
| 브라우저 실행 점검 | 음소거·인라인 재생, 파일 형식, 오류 시 대체 이미지 전환 |
| 실기기 QA | WebView 전체 화면 전환, 투명도, 알파 경계, 반복 재생, 앱 복귀 |
최종적으로 2절의 정적 목록에 없는 환경에는 스틸 이미지 뒤에서 준비한 2x 영상을, 목록에 오른 WebView에는 스프라이트를, 스프라이트를 불러오지 못한 경우에는 스틸 이미지를 썼습니다. Android 영상에는 투명 영역의 RGB 값을 정리한 에셋을 썼고, 이 구성을 자동화 검사와 대상 실기기 QA로 확인했습니다.
이 작업을 거치며 달라진 기준
제품에서는 영상 자체보다 영상이 맡은 역할이 더 중요했고, 판단 기준도 다음처럼 바뀌었습니다.
- 영상이 준비되는 동안 사용자가 볼 수 있는 스틸 이미지를 적용합니다.
- 실패 원인에 따라 영상, 스프라이트, 스틸 이미지 중 알맞은 표현을 선택합니다.
- 가장 큰 에셋도, 이미 만든 로직도 실제 가치를 만드는지 다시 확인합니다.
- 수치와 비교 페이지로 후보를 거르고, 마지막 판단은 실기기에서 합니다.
질문의 중심도 옮겨 갔습니다.
처음에는 영상 자체를 보았습니다.
이 환경에서 원래 영상을 어떻게든 재생할 수 있는가?
작업을 거친 뒤에는 사용자 경험을 보게 됐습니다.
영상이 늦거나 재생되지 않아도 다음 행동을 안내하는 화면을 남길 수 있는가?
결국 최적화할 대상은 영상 파일 하나가 아니라 영상을 포함한 전체 경험이었습니다. 화질, 파일 크기, 기기 자원, 재생 실패 가능성의 우선순위를 사용자 흐름 안에서 정하고, 대체 방식도 처음부터 경험의 일부로 설계해야 했습니다.
같은 제품에서 광고가 준비되기를 기다리는 문제는 광고 프리로딩 글에서 다룹니다.
참고 자료
미디어 재생과 생명주기
- WHATWG HTML: 미디어 요소의 자원 선택 알고리즘 (1절
<source>오류 동작의 출처. 모든 소스 실패 시 오류 위치 설명이 MDN<video>문서와 다릅니다.) - MDN:
<video>element - MDN:
HTMLMediaElementevents - MDN:
HTMLMediaElement.play() - MDN: Autoplay guide for media and Web Audio APIs
- web.dev: Fast playback with audio and video preload
- MDN:
requestAnimationFrame() - MDN: Page Visibility API


