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 기준 - 즉 단일 HTML - 에 맞춰야 한다.

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

엔진 빌드 시도와 좌절

처음부터 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)로 전환하면 용량은 줄지만, 이 경우 렌더링 품질과 일관성이 크게 떨어졌다.

기타 시도

Phaser.js, Pixi.js 같은 웹 게임 프레임워크도 검토했으나, 라이브러리 자체가 수백 KB 규모이고 base64 에셋과 합치면 용량 제한을 맞추기 빠듯했다.

결국 폰트 + 엔진 런타임만으로 약 4MB라는 현실 앞에서 프레임워크 의존을 포기하고, HTML/CSS/Vanilla JS로 처음부터 만들기로 결정했다. DOM 기반 렌더링은 Canvas에 비해 게임 표현력이 제한되지만, 텍스트 중심 UI에서는 오히려 유리하고 코드 용량도 압도적으로 작다.

Python 빌드 파이프라인 - 모든 에셋을 하나의 HTML로

Playable Ad 프로젝트의 핵심 인프라는 build.py다. 개발 중에는 CSS, JS, 이미지를 일반적인 파일 참조로 분리해두고, 빌드 시 모든 것을 하나의 HTML에 인라인한다.

1단계: CSS 인라인

build.py
for link in soup.find_all("link", rel="stylesheet"):
    href = link.get("href")
    css_path = (html_path.parent / href).resolve()
 
    with open(css_path, "r", encoding="utf-8") as css_file:
        css_text = css_file.read()
 
    # CSS 내부 url() 경로를 HTML 기준으로 보정
    css_text_fixed = re.sub(r'url\(([^)]+)\)', fix_url, css_text)
 
    style_tag = soup.new_tag("style")
    style_tag.string = css_text_fixed
    link.insert_after(style_tag)
    link.decompose()

<link rel="stylesheet">를 찾아 <style> 태그로 교체한다. CSS 내부의 url() 경로를 HTML 기준으로 재계산하는 fix_url 함수가 핵심인데, CSS 파일이 css/ 하위에 있고 이미지가 assets/ 하위에 있으면 상대 경로가 달라지기 때문이다.

build.py
def fix_url(match):
    raw_url = match.group(1).strip().strip('"').strip("'")
    fixed_path = os.path.normpath(
        os.path.join(relative_depth_from_html, raw_url)
    )
    return f'url("{fixed_path.replace(os.sep, "/")}")'

2단계: JS 인라인

build.py
for script in soup.find_all("script", src=True):
    src = script["src"]
    js_path = (html_path.parent / src).resolve()
 
    with open(js_path, "r", encoding="utf-8") as js_file:
        js_text = js_file.read()
 
    inline_script = soup.new_tag("script")
    inline_script.string = js_text
    script.insert_after(inline_script)
    script.decompose()

<script src="...">를 인라인 <script> 블록으로 교체한다.

3단계: 에셋 base64 인코딩

build.py
def replace_with_base64(match):
    quote = match.group(1)
    asset_path_str = match.group(2)
 
    normalized_asset = asset_path_str.lstrip("./").lstrip("/")
    asset_usage_counter[normalized_asset] += 1
 
    asset_path = (html_path.parent / normalized_asset).resolve()
    mime_type, _ = mimetypes.guess_type(asset_path)
 
    with open(asset_path, "rb") as f:
        b64_data = base64.b64encode(f.read()).decode("utf-8")
 
    return f'{quote}data:{mime_type};base64,{b64_data}{quote}'
 
# HTML 속성 내 에셋 경로
html_str = re.sub(
    r"""(["'])((\.?/)?assets/[^"']+\.\w+)["']""",
    replace_with_base64, html_str
)
 
# CSS url() 내 에셋 경로
html_str = re.sub(
    r"""url\((["']?)(\.?/)?assets/([^)"']+)\1\)""",
    replace_css_url, html_str
)

정규식으로 assets/ 경로를 찾아 data:mime/type;base64,... 형태로 치환한다. HTML 속성(src="assets/...")과 CSS url(assets/...)을 별도 패턴으로 처리해야 한다. MIME 타입은 Python mimetypes 모듈로 자동 추론한다.

4단계: 압축 및 패키징

build.py
html_str_minified = htmlmin.minify(
    html_str,
    remove_empty_space=True,
    remove_comments=True
)
 
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
    zipf.write(output_path, arcname="index.html")

htmlmin으로 공백과 주석을 제거하고, 광고 네트워크 업로드용 ZIP으로 압축한다.

빌드 리포트

빌드 스크립트는 마지막에 에셋별 사용량 리포트를 출력한다.

=== Build Report ===
Output HTML : index.html
HTML Size   : 4505.23 KB (4.40 MB)
ZIP Created : playable.zip
 
--- Asset Usage (Base64 Embedded) ---
assets/NanumGothic-Bold.woff2: 2회
assets/expression/boy/happy.png: 3회
...

동일 에셋이 HTML 속성과 CSS url() 양쪽에서 참조되면 base64 문자열이 중복 삽입된다. 이 리포트로 중복을 식별하고, 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의 오디오는 일반 웹 개발과 다른 고유한 제약이 있다.

  1. 외부 파일 로딩 불가 - 모든 오디오가 base64로 인코딩되어야 한다
  2. 자동 재생 정책 - 사용자 인터랙션 없이는 재생이 차단된다
  3. iOS 무음 스위치 - 물리 무음 스위치에 따라 소리가 활성화/비활성화되어야 한다
  4. 광고 컨테이너 라이프사이클 - 광고가 보이지 않을 때 오디오를 정지해야 한다

Unity Ads 리젝과 오디오 시스템 리라이트

처음 구현은 단순했다. HTMLAudioElementcloneNode() + play()로 SFX를 재생하고, audio.volumesetInterval로 조작하여 fade in/out을 구현했다.

audioBase.js (초기 구현)
function playSfx(audio) {
  const clone = audio.cloneNode();
  clone.play().catch(() => {});
}
 
function playBgmWithFade(audio) {
  currentBgm = audio.cloneNode();
  currentBgm.loop = true;
  currentBgm.volume = 0.0;
  currentBgm.play().then(() => fadeIn(currentBgm));
}

이 구현은 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 분기 문제로 비교적 단순하게 해결되었지만, 나머지 두 가지가 핵심이었다. 초기 구현에는 pauseAllAudio() / resumeAllAudio() 같은 전역 오디오 제어가 아예 존재하지 않았다. 화면 잠금 시 오디오를 정지할 방법이 없었고, HTMLAudioElement만으로는 재생 중인 모든 소스를 일괄 관리하기도 어려웠다. 이 리젝을 계기로 오디오 시스템을 Web Audio API 기반으로 전면 리라이트했다.

아키텍처 - HTMLAudioElement는 데이터 컨테이너

리라이트된 시스템은 HTMLAudioElement를 데이터 컨테이너로만 사용하고, 실제 재생은 모두 Web Audio API로 처리하는 하이브리드 구조다.

audios.js     : Audio("data:audio/wav;base64,...")  // 데이터 컨테이너
     |
audioBase.js  : AudioContext + decodeAudioData()     // 실제 재생 엔진
     |
game.js       : playSfx(sfx), playBgmWithFade(bgm)  // 호출부

HTMLAudioElement만으로는 GainNode를 통한 fade in/out, 다수의 SFX 동시 재생, AudioContext.suspend()/resume()을 통한 라이프사이클 제어가 불가능하다. Web Audio API는 이 모든 것을 프로그래밍 가능한 오디오 그래프로 제공한다. 특히 광고 컨테이너 환경에서는 MRAID viewableChange 이벤트에 맞춰 전체 오디오를 일괄 정지/재개해야 하므로, AudioContext 단위의 suspend()/resume()이 결정적으로 유용하다.

AudioContext 잠금 해제

audioBase.js
var audioCtx = null;
 
function getAudioContext() {
  if (!audioCtx) {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  }
  return audioCtx;
}
 
function unlockAudioContext() {
  var ctx = getAudioContext();
  if (ctx.state === 'suspended') { ctx.resume(); }
 
  // iOS: 무음 버퍼 재생으로 AudioContext 완전 활성화
  var buf = ctx.createBuffer(1, 1, 22050);
  var src = ctx.createBufferSource();
  src.buffer = buf;
  src.connect(ctx.destination);
  src.start(0);
}
 
document.addEventListener('touchstart', unlockAudioContext, { once: true });
document.addEventListener('touchend', unlockAudioContext, { once: true });
document.addEventListener('click', unlockAudioContext, { once: true });

iOS Safari는 사용자 제스처 없이 AudioContext를 사용하면 suspended 상태로 시작한다. touchstart, touchend, click 세 가지 이벤트에 { once: true }로 잠금 해제 함수를 등록한다.

빈 버퍼(1 샘플, 22050Hz)를 재생하는 트릭이 중요하다. 이것이 iOS의 오디오 세션을 완전히 활성화한다.

base64 디코딩과 캐싱

audioBase.js
var audioBufferCache = {};
var pendingDecodes = {};
 
function decodeAudioElement(audio) {
  var src = audio.src;
  if (audioBufferCache[src]) return Promise.resolve(audioBufferCache[src]);
  if (pendingDecodes[src]) return pendingDecodes[src];
 
  var ctx = getAudioContext();
  var commaIdx = src.indexOf(',');
  var base64 = src.substring(commaIdx + 1);
  var binary = atob(base64);
  var len = binary.length;
  var bytes = new Uint8Array(len);
  for (var i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); }
 
  var promise = new Promise(function(resolve, reject) {
    ctx.decodeAudioData(bytes.buffer,
      function(buffer) {
        audioBufferCache[src] = buffer;
        delete pendingDecodes[src];
        resolve(buffer);
      },
      function(err) { delete pendingDecodes[src]; reject(err); }
    );
  });
 
  pendingDecodes[src] = promise;
  return promise;
}
  • audioBufferCache: 디코딩 완료된 AudioBuffer를 data URI 키로 캐시
  • pendingDecodes: 디코딩 진행 중인 Promise를 보관하여 동일 오디오의 중복 디코딩 방지

base64 문자열을 atob()으로 바이너리로 변환한 뒤 Uint8Array로 만들어 decodeAudioData()에 전달한다. 첫 재생 시 약간의 지연이 발생하지만, 캐시 덕분에 두 번째부터는 즉시 재생된다.

BGM Fade In/Out

audioBase.js
function playBgmWithFade(audio) {
  stopCurrentBgm(true);  // 기존 BGM fade out
  lastBgmAudio = audio;
 
  var ctx = getAudioContext();
  decodeAudioElement(audio).then(function(buffer) {
    var source = ctx.createBufferSource();
    var gain = ctx.createGain();
    source.buffer = buffer;
    source.loop = true;
    source.connect(gain);
    gain.connect(ctx.destination);
    gain.gain.setValueAtTime(0, ctx.currentTime);
    gain.gain.linearRampToValueAtTime(1.0, ctx.currentTime + 0.6);
    source.start(0);
    currentBgmSource = source;
    currentBgmGain = gain;
  });
}

GainNodelinearRampToValueAtTime으로 0.6초 동안 fade in/out을 구현한다. BGM 전환 시 이전 BGM을 fade out하고 새 BGM을 fade in하여 자연스러운 전환을 만든다.

전역 Pause/Resume

audioBase.js
function pauseAllAudio() {
  audioMuted = true;
  // 모든 SFX 강제 정지
  activeSfxSources.forEach(function(s) {
    try { s.stop(); } catch(e) {}
  });
  activeSfxSources = [];
  // BGM 정지
  if (currentBgmSource) {
    try { currentBgmSource.stop(); } catch(e) {}
    currentBgmSource = null;
    currentBgmGain = null;
  }
  // AudioContext suspend
  if (audioCtx && audioCtx.state === 'running') { audioCtx.suspend(); }
}
 
function resumeAllAudio() {
  audioMuted = false;
  if (audioCtx && audioCtx.state === 'suspended') { audioCtx.resume(); }
  // BGM이 재생 중이었으면 처음부터 다시 재생
  if (bgmWasPlaying && lastBgmAudio) {
    playBgmWithFade(lastBgmAudio);
  }
}

BufferSourceNode.stop()은 일회성이다 - 한 번 정지하면 재사용할 수 없다. 그래서 resume 시 새 소스를 생성하여 처음부터 다시 재생한다. try-catchstop() 호출을 감싸야 하는 이유도 이 때문인데, 이미 정지된 소스에 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 이하로 줄어들 수 있다.

빌드 후 용량 검증

build.py
output_size_mb = output_path.stat().st_size / 1024 / 1024
 
if output_size_mb > 5.0:
    print(f"[WARNING] 파일 크기 {output_size_mb:.2f}MB - 5MB 제한 초과!")
elif output_size_mb > 2.0:
    print(f"[WARNING] 파일 크기 {output_size_mb:.2f}MB - Facebook 권장 초과")

빌드 스크립트에 플랫폼별 용량 검증을 추가하면, 에셋 추가 시 즉시 초과를 감지할 수 있다.

마무리

Playable Ad 개발은 5MB 단일 HTML이라는 극단적인 제약 속에서 진행되는 작업이다. 이 제약이 기존 엔진/프레임워크 사용을 불가능하게 만들고, Vanilla JS + DOM이라는 선택을 강제한다.

하지만 그 안에서 구축한 시스템들(Python 빌드 파이프라인, 크로스 플랫폼 CTA 분기, Transform Scale 반응형, Web Audio API 오디오 시스템)은 Playable Ad뿐 아니라 유사한 제약 환경(오프라인 HTML 콘텐츠, 이메일 내 인터랙티브 요소, 임베디드 웹뷰 등)에서도 활용할 수 있는 보편적인 패턴이다.