Playable Ad의 기술적 제약

Playable Ad는 사용자가 앱을 설치하기 전에 게임의 핵심 루프를 직접 체험할 수 있는 인터랙티브 광고 포맷입니다. Google Ads, Meta(Facebook), Unity Ads 등 주요 광고 네트워크가 이 포맷을 지원합니다.

각 플랫폼의 에셋 규격은 조금씩 다르지만, 공통적으로 5MB 이하외부 리소스 참조 금지라는 핵심 제약을 공유합니다.

플랫폼파일 형식크기 제한CTA 메커니즘
Google AdsZIP (다중 파일 가능)5MBExitApi.exit()
Meta (Facebook)단일 HTML 또는 ZIP5MBFbPlayableAd.onCTAClick()
Unity Ads단일 HTML (인라인)5MBmraid.open(url)

Google Ads는 ZIP 내 다중 파일을 허용하지만, Unity Ads는 모든 에셋이 인라인된 단일 HTML을 요구합니다. Meta는 단일 HTML과 ZIP 모두 지원합니다. 세 플랫폼을 하나의 결과물로 커버하려면 가장 제약이 강한 Unity Ads 기준에 맞추는 것이 좋습니다.

base64 인코딩 시 원본 대비 약 33% 오버헤드가 발생하므로, 실제 에셋 예산은 5MB 제한 기준 러프하게 약 3.5MB 이하 입니다.

엔진 빌드 시도와 좌절

처음부터 Vanilla JS로 시작한 것은 아니었습니다. 오히려 처음에는 기존 엔진/프레임워크의 웹 빌드를 적극적으로 검토했습니다.

Unity WebGL

Unity로 빌드하면 .wasm + .js + .data 파일이 생성되는데, Unity 런타임과 한국어 폰트만 합쳐도 약 4MB를 넘겼습니다. 게임 로직과 에셋을 추가할 공간이 전혀 남지 않았습니다.

Flutter Web (CanvasKit)

Flutter의 CanvasKit 렌더러는 Skia를 WASM으로 컴파일한 것인데, CanvasKit 바이너리 자체가 약 1.5MB이고 여기에 Flutter 엔진 코드와 폰트를 더하면 마찬가지로 4MB를 훌쩍 넘겼습니다. HTML 렌더러(--web-renderer html)로 전환하면 용량은 줄지만, 이 경우 렌더링 품질과 일관성이 크게 떨어졌습니다.

기타 시도

위에서 적은 이슈는 사실 조금만 찾아봐도 나오는 알려진 이슈입니다. 근데 본인은 그걸 직접 경험하며 깨달음 그래서 대세는 Vanilla JS 혹은 아주 가벼운 렌더러(three.js. PixiJS 등)를 얹는 것이 대세라고 합니다.

Unity의 경우, Luna Playable 같은 플레이어블 전용 파이프라인 혹은 Mason 같은 전용 도구들도 별도로 존재합니다.

제가 맡은 테스크의 경우, 텍스트 기반 게임이었기 떄문에, 그냥 HTML/CSS/Vanilla JS로 처음부터 제작했습니다. DOM 기반 렌더링은 Canvas에 비해 게임 표현력이 제한되지만, 텍스트 중심 UI에서는 오히려 유리하고 코드 용량도 압도적으로 작습니다.

빌드 파이프라인

바닐라 웹 프로젝트여도, 용량 제한이 존재하는 단일 html이라는 조건을 만족하기 위해서는 특수한 빌드 파이프라인이 필요했습니다. 이를 파이썬 soup 파서를 활용하여 직접 구현하였습니다. 이를 통해 일반 웹 프로젝트 처럼 css와 js 파일을 분리하고, 외부 에셋을 임포트하는 방식으로 프로젝트를 구성해도, 최종 결과물을 보장할 수 있게 구성하였습니다. (자세한 내용은 회사 코드 유출이기 떄문에 생략)

Ads SDK 크로스 플랫폼 CTA 분기

CTA(Call to Action)는 Playable Ad의 최종 목표입니다. 사용자가 광고 체험을 마친 뒤 앱스토어로 이동하여 실제 앱을 설치하도록 유도하는 버튼입니다.

각 광고 네트워크는 서로 다른 JavaScript API를 제공합니다. 하나의 HTML 파일로 세 플랫폼을 모두 지원하려면, 런타임에 어떤 환경인지 감지하여 분기해야 합니다.

플랫폼별 CTA 메커니즘

세 플랫폼의 CTA API는 각각 다릅니다.

  • Unity Ads: MRAID(Mobile Rich Media Ad Interface Definitions) 3.0 규격을 준수해야 합니다. MRAID는 IAB가 정의한 모바일 광고 표준 API이며, Unity Ads SDK가 WebView에 mraid 객체를 주입합니다. CTA는 mraid.open(url)로 앱 스토어에 직접 연결해야 하며, 스토어 URL을 코드에서 직접 지정합니다.
  • Meta(Facebook): 자체 API인 FbPlayableAd를 제공합니다. 공식 문서에서 “mraid.js 또는 유사한 프레임워크 없이 작동해야 합니다”라고 명시하므로 MRAID에 의존하면 안 됩니다. CTA는 FbPlayableAd.onCTAClick()을 호출하며, 스토어 URL은 광고 대시보드에서 설정합니다.
  • Google Ads: exitapi.js 스크립트를 통해 ExitApi.exit()를 제공합니다. 이 스크립트를 포함하지 않으면 Google Ads가 자체 CTA 버튼(“설치”)을 자동 추가합니다.

handleCTA 함수

game.js
var STORE_URL_ANDROID =
  "https://play.google.com/store/apps/details?id=com.example.mygame";
var STORE_URL_IOS =
  "https://apps.apple.com/app/id1234567890";
 
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
  || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
 
function getStoreURL() {
  return isIOS ? STORE_URL_IOS : STORE_URL_ANDROID;
}
 
function handleCTA() {
  if (typeof mraid !== 'undefined') {
    mraid.open(getStoreURL());
  } else if (typeof FbPlayableAd !== 'undefined') {
    FbPlayableAd.onCTAClick();
  } else if (typeof ExitApi !== 'undefined') {
    ExitApi.exit();
  }
}

분기 전략은 단순합니다 - 전역 객체의 존재 여부를 typeof로 검사합니다.

플랫폼전역 객체CTA 호출스토어 URL
Unity Adsmraidmraid.open(url)코드에 직접 지정
Meta (Facebook)FbPlayableAdFbPlayableAd.onCTAClick()대시보드에서 설정
Google AdsExitApiExitApi.exit()Google이 관리

Unity Ads 환경에서는 SDK가 주입한 mraid 객체가 감지되어 mraid.open(url)이 호출됩니다. 스토어 URL을 코드에서 직접 전달해야 하므로, iOS/Android 분기도 코드에서 처리합니다. Meta 환경에서는 FbPlayableAd가, Google Ads 환경에서는 ExitApi가 감지됩니다.

iOS 감지

iPad가 macOS로 위장하는 경우(navigator.platform === 'MacIntel')를 대비해 maxTouchPoints > 1 검사를 추가합니다.

MRAID 라이프사이클 - viewableChange

CTA 외에도 광고의 가시성(viewability) 상태를 처리해야 합니다. Unity Ads의 공식 문서에서는 “광고는 플레이어블 콘텐츠를 시작하기 전에 MRAID viewableChange 이벤트를 기다려야 합니다”라고 명시합니다. 사용자가 광고를 보고 있는지, 화면이 전환되었는지에 따라 오디오 재생도 제어해야 합니다.

game.js
(function() {
  var root = document.getElementById('root');
 
  function onViewable(viewable) {
    if (viewable) {
      root.style.visibility = 'visible';
      resumeAllAudio();
    } else {
      pauseAllAudio();
    }
  }
 
  // MRAID 환경 (Unity Ads)
  if (typeof mraid !== 'undefined') {
    root.style.visibility = 'hidden';
    if (mraid.isViewable()) { onReady(); }
    mraid.addEventListener('viewableChange', onViewable);
  }
 
  // 비-MRAID 환경 폴백 (Meta, Google Ads, 일반 브라우저)
  document.addEventListener('visibilitychange', function() {
    if (document.hidden) { pauseAllAudio(); }
    else { resumeAllAudio(); }
  });
  window.addEventListener('blur', function() { pauseAllAudio(); });
  window.addEventListener('focus', function() { resumeAllAudio(); });
})();

MRAID 환경에서는 mraid.isViewable()viewableChange 이벤트로 광고의 가시성을 추적합니다. MRAID가 없는 환경(Meta, Google Ads, 일반 브라우저)에서는 visibilitychange, blur, focus 세 가지 표준 웹 API로 폴백하여, 어떤 환경에서든 오디오가 적절히 정지/재개되도록 합니다.

Web Audio API 오디오 시스템

Playable Ad의 오디오는 일반 웹과 다른 제약이 있습니다. 외부 파일을 불러올 수 없어 모든 사운드를 base64로 인코딩해야 하고, 사용자 인터랙션 전에는 자동 재생이 막히며, iOS 물리 무음 스위치와 광고 컨테이너의 가시성(보이지 않을 때 정지) 라이프사이클까지 직접 챙겨야 합니다.

처음에는 단순하게 갔습니다. HTMLAudioElementcloneNode()로 복제해 재생하고, volumesetInterval로 깎아 fade를 흉내 냈습니다. 그런데 이 구현이 Unity Ads 심사에서 리젝됐습니다.

Unity Ads Moderation - Rejected

  • Install button does not lead to iOS Store
  • Does not respect device lock actions. If a user locks the screen, the sound should stop. If the screen is unlocked, the sound should restart
  • Does not respect physical mute button on iOS. Flipping the switch should activate and de-activate sound

첫 번째(CTA)는 가볍게 고쳤지만, 나머지 둘이 문제였습니다. 초기 구현에는 재생 중인 모든 소스를 일괄로 멈추고 되살리는 전역 제어가 아예 없었기 때문입니다. HTMLAudioElement만으로는 다수 소스 일괄 관리도, 화면 잠금이나 무음 스위치에 맞춘 정지/재개도 깔끔하게 되지 않습니다. 그래서 이 리젝을 계기로 오디오 시스템을 Web Audio API 기반으로 전면 리라이트했습니다.

HTMLAudioElement는 base64 데이터를 담는 컨테이너로만 두고, 실제 재생은 전부 AudioContext로 처리하는 하이브리드 구조로 갔습니다. base64는 decodeAudioData()로 풀어 AudioBuffer로 캐싱해 중복 디코딩을 막고, fade는 GainNodelinearRampToValueAtTime으로, 라이프사이클 제어는 AudioContextsuspend()/resume()으로 처리했습니다. 덕분에 광고 컨테이너의 MRAID viewableChange에 맞춰 전체 오디오를 한 번에 정지/재개할 수 있게 된 것이 핵심이었습니다.

빠지면 깨지는 디테일도 몇 개 있었습니다. iOS Safari는 제스처 없이 만든 AudioContextsuspended 상태로 시작하므로, 첫 touch/click에서 resume()과 함께 1샘플짜리 무음 버퍼를 한 번 재생해 오디오 세션을 완전히 깨워야 합니다. 또 BufferSourceNode.stop()은 일회성이라, 한 번 정지한 소스는 재사용할 수 없고 재개 시 새 소스를 만들어 처음부터 틀어야 합니다.

구체적인 구현 코드는 회사 코드라 생략하겠습니다.

base64 환경의 에셋 용량 관리

최종 빌드 결과물의 용량 분포를 살펴보면 이미지가 절반 이상을 차지합니다.

리소스대략적 용량 (base64)비율
캐릭터 이미지~2.0MB약 40%
기타 UI 이미지~800KB약 18%
오디오~740KB약 16%
폰트 (woff2)~590KB약 13%
HTML + CSS + JS 코드~600KB약 13%

폰트 최적화

NanumGothic-Bold.woff2:  440KB -> ~590KB (base64)
NanumGothic-Bold.ttf:    2.1MB -> ~2.8MB (base64)

woff2를 사용하면 ttf 대비 약 2.2MB를 절약합니다. 이것만으로도 전체 용량의 약 50%를 줄이는 효과가 있습니다.

더 나아가 서브셋 폰팅으로 실제 사용되는 문자만 추출하면 크게 줄일 수 있습니다.

pyftsubset NanumGothic-Bold.ttf \
  --text-file=used_chars.txt \
  --output-file=NanumGothic-Bold-subset.woff2 \
  --flavor=woff2

Playable Ad에서 실제 사용되는 문자가 약 200-300자 수준이라면, 서브셋 폰트는 50KB 이하로 줄어들 수 있습니다.