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

매시간 황금알(aka, 플레이허브 v2.1)의 거위는 단순한 장식이 아니라 사용자의 현재 상태와 다음 행동을 알려주는 UI였습니다. 하지만 여러 앱의 WebView에서 영상이 늦게 나타나거나 인라인으로 재생되지 않는 문제가 발생했습니다. 스틸 이미지와 스프라이트, 해상도 선택과 인코딩 최적화를 거치며 최적화의 대상을 영상 파일이 아닌 사용자 경험 전체로 넓혀 간 과정을 소개합니다.
시작하며
버즈빌은 여러 모바일 앱 안에서 사용자가 광고에 참여하고 포인트 같은 보상을 받을 수 있는 서비스를 만듭니다. 매시간 황금알은 이 과정을 조금 더 즐겁게 만들기 위해 개발한 웹 기반 리워드 화면입니다.
이 화면이 보여주려던 것은 큰 당첨보다 일상에서 가볍게 만나는 ‘소소한 행운’이었습니다. 팀의 취향만으로 캐릭터를 정하지 않기 위해 일반 사용자에게 여러 시안의 첫인상과 참여 의향을 물었고, 이후 디자인을 다듬으면서 미션을 마치면 황금알을 받는 흐름과 잘 어울리는 거위를 중심 캐릭터로 정했습니다.
거위는 화면 한쪽을 장식하는 마스코트가 아니었습니다. 처음 들어온 사용자를 맞이하고, 진행 상황에 맞춰 행동을 바꾸며, 복권이나 보상을 받을 차례를 알려줬습니다. 거위의 움직임 자체가 사용자의 현재 상태와 다음 행동을 설명하는 UI였습니다.
이 경험은 매시간 황금알의 UI와 캐릭터 연출을 맡은 프로덕트 디자이너 Joy와 이를 제품 상태와 WebView 재생 구조에 연결한 프론트엔드 엔지니어인 제가 함께 만들었습니다. Joy는 사용자 의견을 확인하며 거위 캐릭터와 UI·프로토타입을 다듬고, 각 진행 상태에 필요한 3D 영상과 스틸 이미지를 제작했습니다. 저는 이 에셋을 미션·광고·보상 상태와 연결하고, 여러 앱의 WebView에서도 같은 흐름을 유지하도록 재생 로직과 대체 경로를 구현했습니다. 이후 해상도와 인코딩 후보, 선택·대체 경로를 비교 페이지와 실제 기기에서 함께 검증했습니다.
거위는 진행 상태와 다음 행동을 함께 안내했습니다.
그래서 배경이 투명한 거위 영상을 버튼과 안내 문구 사이에서 인라인으로 재생했습니다. 문제는 이 화면이 일반 브라우저뿐 아니라 여러 앱의 WebView 안에서도 열린다는 점이었습니다. 움직여야 할 거위가 한동안 빈자리로 남거나, 화면 안에서 재생돼야 할 영상이 갑자기 전체화면으로 열렸습니다. 광고 참여와 보상 지급 기능은 끝까지 동작하더라도 사용자가 따라가던 흐름은 이미 끊긴 뒤였습니다.
처음에는 이 문제를 영상을 더 잘 재생하면 해결할 수 있다고 생각했습니다. 기기와 네트워크가 충분하면 더 선명한 영상을 제공하고, 로딩이 느리면 파일을 줄이거나 다음 영상을 미리 받아 두며, 호환되지 않는 형식은 다른 형식으로 바꾸면 된다고 봤습니다. 영상 최적화라면 화질을 높이고 용량을 줄이며 재생 성공률을 높이는 것이 우선이라고 생각하는 것은 자연스러운 출발점이었습니다.
이 관점이 틀린 것은 아니었지만 충분하지는 않았습니다. 실제 WebView에서는 영상을 더 잘 만드는 것만으로 해결되지 않는 문제가 이어졌고, 그때마다 무엇을 최적화해야 하는지 다시 질문해야 했습니다.
이 글은 영상 한 편을 재생하는 방법보다, 영상이 늦거나 재생되지 않을 때도 사용자 경험을 이어 가기 위해 판단 기준을 바꿔 간 과정에 관한 이야기입니다. 성공한 최적화뿐 아니라 잘되지 않은 시도와, 그 과정에서 달라진 기준도 함께 소개합니다.
1. 영상이 뜨기 전, 무엇을 보여줄까
웹 영상의 첫 프레임이 화면에 나타나기까지는 여러 과정이 겹쳐 일어납니다. 브라우저가 영상 데이터를 받는 동안 파일 구조를 해석하고 코덱을 준비하며, 첫 프레임을 디코딩해 화면에 합성합니다. 느린 네트워크에서는 데이터 수신이, 저사양 WebView에서는 투명 영상의 디코딩과 합성이 더 큰 부담이 될 수 있습니다.
어느 단계가 가장 느렸는지 정확히 재는 일도 중요하지만, 첫 프레임이 늦는 동안 화면을 비워 둘 이유는 없었습니다. 저는 영상과 같은 자리에 가벼운 스틸 이미지 커버를 먼저 그렸습니다. loadeddata, canplay, playing 중 하나가 발생해 영상 노출 조건을 충족했다고 판단하면 커버를 걷고 영상을 드러냈습니다.
영상은 뒤에서 준비하되 화면은 먼저 시작했습니다. 네트워크와 디코딩 시간을 없앤 것은 아니지만, 사용자가 그 시간을 빈 화면으로 마주하지 않게 했습니다.
현재 영상에는 <video preload="auto">를 사용했지만, 브라우저가 데이터를 미리 받았다는 전제에 재생 흐름을 의존시키지 않았습니다.
늦게 뜨는 것과 재생할 수 없는 것은 달랐습니다
영상이 바로 보이지 않는다고 모두 실패로 처리하지는 않았습니다. 브라우저는 <source>에 등록된 영상 파일을 순서대로 살펴봅니다. 첫 번째 파일이 열리지 않았을 때 곧바로 스틸 이미지에 고정하면, 브라우저가 다음 파일을 선택할 기회까지 막을 수 있습니다. 그래서 <video> 요소에서 최종 재생 오류가 확인됐을 때만 스틸 이미지로 바꿨습니다.
자동재생이 거절된 경우도 파일을 재생할 수 없는 오류와 구분했습니다. 영상 요소는 유지한 채 다시 재생할 수 있게 했습니다. 앱이 백그라운드에서 돌아왔을 때는 focus, pageshow, visibilitychange를 확인해 재생을 다시 시도했습니다.
처음에는 하나였던 ‘영상이 보이지 않는다’는 현상이 구현하면서 여러 상태로 나뉘었습니다. 아직 준비 중인지, 다른 소스를 찾는 중인지, 자동재생만 거절됐는지, 끝내 재생할 수 없는지를 구분해야 알맞은 다음 행동을 선택할 수 있었습니다.
| 재생 상태 | 대응 |
|---|---|
브라우저가 다른 <source>를 찾는 중 | 다음 후보를 기다림 |
| 자동재생만 거절됨 | 영상 요소를 유지하고 다시 재생할 기회를 남김 |
| 영상 노출 조건을 충족함 | 스틸 이미지 커버를 제거하고 영상을 노출 |
첫 프레임 전후에 최종 MediaError가 확인됨 | 스틸 이미지로 전환 |
2. 인라인 영상이 어려운 WebView에서는 재생 방식을 바꿨습니다
이 제품에서는 투명 영상을 안정적으로 제공하기 위해 iOS 또는 Safari 환경에는 알파 채널을 포함한 HEVC 영상을 MOV 컨테이너로, 그 외 환경에는 VP9 영상을 WebM 컨테이너로 제공했습니다.
그러나 일반 브라우저에서 잘 재생되던 파일도 앱 안의 WebView에서는 다르게 동작했습니다. 일부 iOS WebView에서는 playsInline을 설정해도 영상이 전체화면으로 열렸습니다. 인라인 재생은 웹 코드뿐 아니라 호스트 앱이 WebView를 설정한 방식에도 영향을 받았습니다. 프론트엔드 코드만 바꿔 모든 앱에서 같은 결과를 만들 수는 없었습니다.
호스트 앱을 바로 수정하기 어려운 환경에서도 거위의 움직임을 보여줄 방법이 필요했습니다. 저는 근본 해결책과 프론트엔드에서 적용할 수 있는 대안을 함께 비교했습니다.
| 선택 | 장점 | 비용과 한계 |
|---|---|---|
| 호스트 앱 설정 수정 | 인라인 재생 문제를 원인부터 해결 | 여러 앱의 소유권과 배포 일정을 함께 맞춰야 함 |
| 스틸 이미지 고정 | 가장 단순하고 안정적 | 상태를 표현하던 움직임이 사라짐 |
| 움직이는 WebP | 일반 이미지처럼 표시 가능 | 일부 저사양 WebView에서 첫 반복 재생이 끊김 |
| 스프라이트 시트 | 영상 디코더 없이 움직임 유지 | 파일 크기와 메인 스레드 작업이 늘어남 |
Joy는 문제 환경에서도 움직임을 살릴 수 있도록 영상의 WebP 버전을 만들었고, 저는 이를 WebView의 대체 재생 경로에 연결했습니다. <video>의 인라인 재생 제약을 피하면서도 구현을 크게 복잡하게 만들지 않을 것으로 기대했습니다. 그러나 내부 실기기 QA에서 일부 저사양 iOS WebView의 첫 반복 재생이 끊겼고, 기기가 뜨거워지는 현상도 발견했습니다. 온도를 계측한 것은 아니므로 이 현상을 모든 WebView의 공통 문제로 일반화하지 않았고, 문제가 확인된 환경에서만 다음 방법을 찾았습니다.
비교 끝에 문제가 확인된 WebView에서는 영상 대신 스프라이트 시트를 사용하기로 했습니다. 스프라이트 시트는 영상의 각 프레임을 이미지로 뽑아 여러 장을 한 장에 배열한 에셋입니다. 저는 CSS로 보이는 위치를 바꾸는 플레이어를 구현해 움직임을 재현했습니다. 영상처럼 보이지만 <video>도, 영상 디코더도 사용하지 않습니다. 인라인 영상이라는 제약을 정면으로 해결하는 대신 이미지 렌더링 문제로 바꾼 셈입니다. 이 선택의 목표는 발열 개선이 아니라, <video> 없이 화면 안의 움직임을 유지하는 것이었습니다.
![]()
한 장의 이미지에 여러 프레임을 배열하고, CSS로 보이는 위치를 바꿔 움직임을 만들었습니다.
재생 방식에도 한 가지 장치를 더했습니다. 현재 시트와 다음 시트만 미리 요청하고, 브라우저가 화면을 새로 그리는 시점을 알려주는 requestAnimationFrame에 맞춰 프레임을 바꿨습니다. 메인 스레드가 잠시 바빠지더라도 밀린 프레임을 모두 재생하지 않고, 흐른 시간에 맞는 프레임으로 건너뛰었습니다. 연출 전체가 조금씩 늦어지는 현상을 막기 위해서였습니다.
스프라이트는 영상보다 무조건 나은 해법이 아니었습니다. 파일이 커지고 메인 스레드가 프레임을 직접 바꿔야 했습니다. 그래서 인라인 영상 문제가 확인된 WebView에만 적용했고, 스프라이트 에셋을 준비하지 않은 단계에는 스틸 이미지를 사용했습니다. 가장 화려한 방식 하나를 강제하기보다 환경마다 지킬 수 있는 움직임의 수준을 정했습니다.
3. 3x를 골라 주는 로직은 정말 필요했을까
재생할 수 없는 환경의 대체 방식을 마련한 뒤에는, 재생 가능한 환경에 어느 정도 크기의 영상을 보낼지 결정해야 했습니다. 영상이 재생되더라도 파일이 크면 움직임이 시작되기까지 스틸 이미지에 머무는 시간이 길어집니다. 2026년 7월 720×720 하나로 통일하기 전에는 720×720과 1080×1080을 후보로 둔 선택 로직을 구현하고 검증했습니다. 프로젝트에서는 두 에셋을 각각 2x와 3x라고 불렀습니다.
이 초기 가정은 2x와 3x를 고르는 로직에 가장 직접적으로 반영됐습니다. 기기와 네트워크가 받쳐 준다면 3x를 주는 편이 낫다고 봤습니다. 다만 고사양 기기라도 큰 파일을 받는 시간은 필요했고, 앱 안의 WebView가 쓸 수 있는 자원도 일반 브라우저와 같다고 볼 수 없었습니다. 그래서 데이터 절약 설정, 화면 밀도, 네트워크 종류와 대역폭을 확인해 2x와 3x 중 하나를 골랐습니다. 조건이 충분하면 첫 진입에도 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은 모든 브라우저에서 제공되는 API가 아닙니다. 네트워크 상태를 읽지 못한 경우를 좋은 환경으로 낙관하지 않고 2x를 선택했습니다.
3x를 선택한 뒤에는 loadeddata, canplay, playing 같은 첫 프레임 준비 신호와 오류 여부를 다음 선택의 힌트로 삼았습니다. 3x가 준비되면 3x 선호를, 하위 에셋이 있는 상태에서 오류가 나면 2x 선호를 기록했습니다. 2x의 성공을 3x 승격 신호로 사용하지는 않았습니다. 짧은 재진입 동안만 참고하기 위해 기록은 브라우저 저장소에 남기고 30분 뒤 무효화했습니다. 같은 기기도 Wi-Fi와 LTE, 지하철과 실내를 오가기 때문에 한 번의 결과를 영구적인 기기 등급으로 만들고 싶지 않았습니다.
재생 도중 2x에서 3x로 바꾸지도 않았습니다. 짧은 캐릭터 영상에서 소스를 교체하면 깜빡이거나 처음부터 재생될 수 있고, 말풍선과 영상의 타이밍도 어긋날 수 있었습니다. 이 로직의 목적은 최고 화질을 끝까지 찾아가는 것이 아니라, 다음 진입에서 더 나은 후보를 안전하게 고르는 데 있었습니다.
선택 로직을 구현하는 동안 Joy는 기존 1080×1080 영상을 바탕으로 720×720 버전을 별도로 제작했습니다. 두 후보가 준비된 뒤에는 같은 위치에서 즉시 바꿔 볼 수 있는 비교 페이지를 두고, 당시 확인할 수 있던 iPhone과 데스크톱에서 실제 제품과 같은 크기로 재생했습니다. 저는 선택 경로와 파일 크기를 확인하고, Joy는 밝은 배경과 어두운 배경, 체크무늬 배경에서 알파 경계와 움직임을 살폈습니다.

같은 표시 크기로 맞추면 해상도 차이는 작았지만, 이 장면의 3x 파일은 2x보다 약 1.9배 컸습니다. 이는 한 장면의 비교이며 전체 에셋의 평균을 뜻하지 않습니다.
결과는 예상과 달랐습니다. 당시 확인한 기기와 제품 안의 표시 크기에서는 두 해상도의 차이가 크지 않았고, 720×720 에셋은 모든 플랫폼에서 더 작았습니다. 일부 720×720 영상은 24fps로, 18fps였던 1080×1080 영상보다 움직임이 오히려 부드러웠습니다. 사용자가 느끼는 품질은 해상도 하나로 설명되지 않았습니다.
결국 팀은 1080×1080 영상을 사용자에게 제공하기 전에 720×720 하나로 통일했습니다. 환경 신호에 따라 3x를 선택하는 경로도 더 이상 사용하지 않았습니다. 복잡한 로직을 완성한 일이 끝이 아니라, 그 로직이 실제로 필요한지 확인하고 제품에서 쓰지 않기로 결정하는 것까지가 최적화였습니다.
4. 보이지 않는 픽셀이 용량을 쓰고 있었습니다
720×720으로 통일했다고 전송 비용 문제가 끝난 것은 아니었습니다. 같은 해상도인데도 일부 Android WebM은 유난히 컸습니다. 파일을 분석한 결과 완전히 투명한 영역에도 프레임마다 다른 RGB 값이 남아 있었습니다. 알파 값이 0이라 사용자 눈에는 보이지 않지만 인코더에는 여전히 압축할 데이터였습니다.
Joy가 완전히 투명한 픽셀의 RGB 값을 하나의 매트 색으로 정리하고 같은 형식으로 다시 인코딩했습니다. 결과는 다음과 같았습니다.
| 범위 | 정리 전 | 정리 후 | 감소 |
|---|---|---|---|
| Android 거위 영상 13개 | 20.93MB | 7.94MB | 62.1% |
이는 당시 에셋 파일의 합계입니다. 실제 사용자별 전송량이나 로딩 시간이 같은 비율로 줄었다는 뜻은 아닙니다. 다만 화면에 보이지 않는 색상값을 정리한 전후의 파일 크기는 분명히 비교할 수 있었습니다.
iOS 영상도 Joy가 비트레이트를 조정해 용량을 줄였습니다. 이어 더 강하게 압축한 후보를 만들었고, Joy와 저는 실제 기기에서 함께 확인했습니다. 파일 크기와 사전 검사 결과는 괜찮아 보였지만 캐릭터 테두리가 나빠졌습니다. 이 추가 후보는 배포하지 않고 기존 파일을 유지했습니다.
Android의 투명 픽셀 정리는 효과가 있었고, iOS의 추가 압축 후보는 제품에 남기지 않았습니다. 두 결과를 함께 겪으며 수치는 후보를 거르는 데 유용하지만 마지막 판단은 실제 화면에서 해야 한다는 기준이 분명해졌습니다.
5. 무엇을 자동화하고 무엇을 눈으로 볼 것인가
여러 시도를 거치며 모든 문제를 같은 방법으로 검증할 수 없다는 것도 배웠습니다. 소스 선택과 에셋 누락은 자동화할 수 있지만, WebView의 투명도와 인라인 재생, 캐릭터 테두리의 품질은 실제 기기에서 봐야 했습니다. 저는 에셋이 제품 상태에 맞게 선택되고 대체 경로로 전환되는지를 코드와 테스트로 확인했습니다. Joy는 해상도와 인코딩이 다른 후보를 만들고 실제 화면의 투명도, 경계, 움직임을 확인했습니다. 서로의 결과를 같은 비교 페이지와 실기기에서 맞춰 본 뒤 팀이 제품에 남길 경로를 결정했습니다.
| 검증 수단 | 확인한 내용 |
|---|---|
| 단위 테스트 | 화면별 소스 선택, 기록 만료, 대체 방식의 우선순위, 스틸 이미지 커버 제거 |
| 파일 검사 | 설정에 등록된 영상·이미지·스프라이트가 실제로 존재하는지 확인 |
| 비교 페이지 | 같은 표시 크기에서 2x·3x 화질과 인코딩 결과 비교 |
| 브라우저 실행 점검 | 음소거·인라인 재생, 파일 형식, 오류 시 대체 이미지 전환 |
| 실기기 QA | WebView 전체화면 전환, 투명도, 알파 경계, 반복 재생, 앱 복귀 |
수치가 좋아도 실기기 화면이 나쁘면 되돌렸고, 이미 만든 로직도 체감할 만한 가치가 없으면 제거했습니다. 자동화할 수 있는 것은 빠르게 반복하고, 눈으로 봐야 하는 문제에는 실제 기기를 사용하는 식으로 검증의 역할을 나눴습니다.
이렇게 합의한 기준을 여러 WebView와 제품 상태에서 반복할 수 있는 재생 구조로 옮겼습니다.
최종적으로 인라인 영상이 안정적으로 동작한 환경에서는 스틸 이미지 뒤에서 720×720 영상을 준비하고, 인라인 영상 문제가 확인된 WebView에서는 준비된 스프라이트를 사용했습니다. 스프라이트 에셋이 없는 단계에는 스틸 이미지를 남겼습니다. Android 영상에는 투명 영역의 RGB 값을 정리한 에셋을 사용했습니다. 이 구성을 자동화 검사와 대상 실기기 QA로 확인했습니다.
이 작업을 거치며 달라진 기준
작업을 시작할 때 세운 기준은 분명했습니다. 더 좋은 기기에는 더 큰 영상을 제공하고, 원래 영상을 더 잘 재생하는 방법을 찾는 것이었습니다.
하지만 실제 제품에서는 영상 자체보다 영상이 맡은 역할이 더 중요했습니다. 영상이 늦으면 스틸 이미지가 먼저 상태를 보여주고, 영상을 재생할 수 없는 WebView에서는 스프라이트가 움직임을 이어 갔습니다. 더 큰 영상의 차이가 눈에 띄지 않으면 3x 선택 경로를 닫았고, 압축 수치가 좋아도 실제 화면이 나쁘면 결과를 되돌렸습니다.
이 과정을 거치며 영상 최적화를 판단하는 기준도 달라졌습니다.
- 영상이 준비되는 시간과 그동안 사용자가 보는 화면을 함께 설계합니다.
- 기기가 재생할 수 있다는 이유만으로 가장 큰 에셋을 제공하지 않습니다.
- 실패 원인에 따라 영상, 스프라이트, 스틸 이미지 중 알맞은 표현을 선택합니다.
- 구현을 마친 뒤에도 그 복잡성이 실제 가치를 만드는지 다시 확인합니다.
매시간 황금알의 거위 영상을 다루며 질문의 중심도 달라졌습니다.
처음에는 영상 자체를 보았습니다.
이 환경에서 원래 영상을 어떻게든 재생할 수 있는가?
작업을 거친 뒤에는 사용자 경험을 보게 됐습니다.
영상이 늦거나 재생되지 않아도 다음 행동을 안내하는 화면을 남길 수 있는가?
좋은 최적화는 가장 선명한 영상을 끝까지 재생하는 일이 아니었습니다. 해상도가 높은 영상도 늦게 나타나거나 WebView에서 갑자기 전체화면으로 열린다면 좋은 결과라고 보기 어렵습니다. 반대로 스틸 이미지나 스프라이트가 현재 상태와 다음 행동을 안내하는 화면을 남긴다면 더 나은 선택이 될 수 있습니다.
결국 최적화해야 했던 대상은 영상 파일 하나가 아니라 영상을 포함한 전체 경험이었습니다. 화질, 파일 크기, 기기의 자원, 재생 실패 가능성의 우선순위를 사용자 흐름 안에서 정하고, 대체 방식도 처음부터 경험의 일부로 설계해야 했습니다.
참고 자료
미디어 재생과 생명주기
- MDN:
<video>element - MDN:
HTMLMediaElementevents - MDN:
HTMLMediaElement.play() - web.dev: Fast playback with audio and video preload
- MDN:
requestAnimationFrame() - MDN: Page Visibility API


