
토스는 어떻게 광고 속에 게임을 넣었을까
안녕하세요, 토스에서 광고 SDK와 게임 광고 서비스를 개발하는 Frontend Developer 정윤호입니다.
토스 지면에 게임 광고가 적었던 이유
토스 지면(앱 안에서 광고가 노출되는 자리)에는 게임 광고 집행이 유독 적었습니다. 원인은 소재 유형이었어요. 게임 회사의 사용자 확보(UA, User Acquisition) 마케팅에서, 앱을 설치하기 전에 광고 안에서 게임을 직접 해 보는 플레이어블 광고(Playable Ads)가 주목받는 광고 유형인데, 토스 지면이 이 유형을 받지 못했어요.

광고에 실려 나가는 콘텐츠를 업계에서는 소재(크리에이티브)라고 불러요. 광고 플랫폼 Moloco는 자사 상위 미국 게임 광고주 사례에서 플레이어블 소재가 노출 천 회당 설치 수(IPM, Installs Per Mille)를 49%, 광고비 대비 수익(ROAS, Return On Ad Spend)을 25% 끌어올렸다고 소개해요. 게임 광고주는 성과로 매체를 선택하니, 이 소재 유형을 받지 못하는 지면에 예산을 배정할 이유가 약했던 거예요.
그래서 문제를 다시 정의했습니다. "게임 광고 영업이 안 됐다"가 아니라 "게임 광고주가 쓰는 소재 유형을 우리 지면이 받지 못했다"는 것으로요.
보통의 앱은 광고 네트워크의 상용 SDK(Software Development Kit)를 붙여 플레이어블 지원을 통째로 얻어요. 토스는 광고 시스템을 서버부터 직접 만들어 왔기 때문에 그 위에 서드파티 SDK를 얹으면 통합도 통제도 어긋나요. 광고를 선택하는 시스템을 직접 만든 이상, 화면에 띄우는 쪽도 직접 만드는 게 좋은 방향이라고 판단했어요.
그렇게 플레이어블 광고를 직접 구현한 여정을 소개할게요.
플레이어블 광고의 구조
우선 플레이어블 광고가 어떻게 돌아가는지부터 볼게요. 플레이어블 광고 한 편이 화면에 뜨기까지의 흐름은 이렇습니다.

소재는 HTML 파일 하나에 게임 한 판이 통째로 담긴 형태이고, 등록 시점에 단일 HTML, 5MB 제한을 검사받아요. 광고를 화면에 붙이고 떼는 권한, 클릭과 노출을 기록하는 권한은 네이티브 앱에 있어요.
브릿지 JS(JavaScript)는 광고 소재와 네이티브 앱 사이의 통신을 맡아요. 둘은 서로를 모르는 코드라 직접 대화할 방법이 없어요. 소재가 호출할 API(Application Programming Interface)를 제공하고, 광고의 상태를 들고, 메시지를 양방향으로 나르는 게 브릿지 JS의 일이에요.

광고 한 편이 뜨고 닫히기까지의 이벤트 순서예요. 사용자에게는 광고 한 편이 떴다 닫히는 단순한 경험이지만, 그 뒤에서는 구성요소들이 이렇게 이벤트를 주고받고 있어요.
브릿지 JS 규약으로 MRAID를 선택한 이유
위에서 설명했던 브릿지 JS가 소재에게 제공할 API 규약은 어떻게 정해야 할까요? 소재는 토스만 보고 만들어지는 자산이 아니라서, 저희 마음대로 정할 수 없었어요.
제작사는 같은 소재를 Meta, Google 같은 광고 네트워크(광고주와 지면을 연결해 광고를 유통하고 중개하는 사업자) 여러 곳에 제출해요. 실제로 소재는 각 네트워크의 규약에 맞춰 만들어지는데, 그 규약이 서로 달라서 지면마다 소재를 다시 만들어야 해요.
클릭(CTA, Call To Action)은 설치로 이어지는 전환의 입구라, 소재 성과 지표 가운데 중요한 이벤트예요. 소재마다 클릭을 알리는 함수가 다르면 집계 입구가 갈라져서 일관성과 신뢰도가 함께 흔들려요. 그래서 모든 규약을 받는 만능 컨테이너 대신, 규약 하나를 선택해 깊게 지원하기로 했어요.
선택한 표준은 MRAID(Mobile Rich Media Ad Interface Definitions)입니다. 표의 Meta와 Google은 광고주 접점부터 소재를 그리는 SDK까지 수직 통합한 덕분에, 독자 규약을 강제할 수 있는 예외적인 사업자예요. Moloco처럼 Meta 규약을 따라가는 곳도 있어요. 그래도 그 밖의 인앱 광고 생태계에서는 IAB(Interactive Advertising Bureau) Tech Lab이 관리하는 MRAID가 표준으로 널리 쓰이고, AppLovin처럼 지원을 필수로 요구하는 네트워크가 있을 만큼 소재 수급에도 유리했어요.
결정적이었던 건 공급 구조예요. 스펙상 소재는 브릿지 JS를 쓸 때 <script src="mraid.js"> 한 줄만 선언하고, 실제 브릿지 JS 파일은 광고를 띄우는 호스트가 공급해요. 인터페이스는 표준으로 고정하되 구현은 호스트가 진화시킬 수 있다는 뜻이라 저희에게 꼭 맞았어요. 그림 1에서 본 브릿지 JS 주입이 바로 이 구조의 구현이에요. 스펙이 정한 파일 이름을 따라, 지금부터는 브릿지 JS를 mraid.js라고 부를게요.
다만 MRAID 전체를 구현하지는 않았어요. 기준은 하나였어요. "실제로 서빙할 소재가 호출하는 API만 만든다." 스펙에는 배너 확장(expand), 사진 저장(storePicture) 같은 기능이 가득한데, 기능마다 유지 비용이 함께 따라오기 때문이에요.
호출하는 소재가 없는 API는 구현하지 않되, 미지원 호출이 들어와도 크래시 대신 supports() false와 error 이벤트로 답하게 했어요. Google 모바일 광고 SDK도 getLocation()은 항상 -1을 반환하고 storePicture는 제거했다고 명시할 만큼, 부분 구현은 업계의 일반적인 선택이에요.
이 기준 덕분에 나중에 MRAID 3.0을 지원할 때도 스펙 전체를 따라갈 필요가 없었어요. 3.0 소재가 실제로 새로 호출하는 API는 두 개뿐이었고, 그 둘만 더해서 표준의 메이저 버전 업그레이드가 백여 줄로 끝났어요.
mraid.js의 네 가지 모듈
그렇게 좁힌 API 표면을 받치는 mraid.js의 내부를 모듈 네 개로 구성했어요.

mraid.js는 표준 API를 구현한 객체를 window.mraid 전역으로 정의하고, 소재는 이 전역을 호출해요. 게임 엔진과 에셋을 걷어내면, 소재가 사용하는 표준 패턴은 이 형태예요.
<!-- 소재 쪽 표준 패턴: mraid.js는 선언만 하고, 실제 파일은 호스트가 공급한다 -->
<script src="mraid.js"></script>
<script>
function start() {
mraid.addEventListener('viewableChange', (v) => (v ? game.resume() : game.pause()));
game.start();
}
// 아직 초기화 전이면 ready를 기다렸다가 시작한다
mraid.getState() === 'loading' ? mraid.addEventListener('ready', start) : start();
// CTA 클릭 통지는 mraid.open() 하나로
cta.addEventListener('click', () => mraid.open(LANDING_URL));
</script>소재는 mraid.js의 존재를 전제하고, 시작 시점은 상태와 이벤트로 묻고, 밖으로 나가는 요청은 mraid.open() 하나로 보내요. 이 계약을 호스트 쪽에서 받치는 게 네 모듈이에요.
송신부는 자바스크립트에서 네이티브로 가는 방향의 규약이 전부예요.
// JS → Native: 커맨드 형식은 하나로 통일, 채널만 플랫폼별 분기
sendToNative(command: string, payload: unknown = {}) {
const msg = JSON.stringify({ command, payload });
// iOS WKWebView
if (typeof window.webkit?.messageHandlers?.mraid?.postMessage === 'function') {
window.webkit.messageHandlers.mraid.postMessage(msg);
return;
}
// Android JavascriptInterface
if (typeof window.mraidBridge?.postMessage === 'function') {
window.mraidBridge.postMessage(msg);
return;
}
// 브릿지가 없으면 경고와 error 이벤트만 남긴다 (광고는 계속 동작)
}메시지는 { command, payload } JSON 문자열 하나로 통일하고 플랫폼 채널만 분기해요. 브릿지가 없으면 경고만 남기고 광고는 계속 동작해요. 제작사가 일반 브라우저에서 소재를 미리보기로 여는 상황까지가 정상 동작 범위이기 때문이에요.
수신부는 네이티브의 호출을 mraid.nativeInvoke(event, payload)라는 단일 진입점으로 받아요. iOS와 Android가 문자 그대로 같은 규약을 호출하고, 이벤트와 페이로드 쌍은 TypeScript 판별 유니온(Discriminated Union) 타입으로 강제해요. 구현이 계약에서 어긋나면 컴파일 단계에서 드러나요.
// Native → JS: 이벤트 이름과 페이로드 타입을 쌍으로 묶어 강제한다 (게시용으로 축약한 타입)
type NativeInvokeProps =
| { event: 'ready'; payload: InitialState } // 초기 상태를 한 번에 전달
| { event: 'stateChange'; payload: { state: MraidStateType } }
| { event: 'viewableChange'; payload: { viewable: boolean } }
| { event: 'audioVolumeChange'; payload: { volumePercentage: number | null } };본체에서 중요했던 결정은 상태 소유권이에요. 실제로 오가는 상태는 loading → default → hidden인데, mraid.js는 네이티브가 준 값을 검증 없이 반영하고 이벤트만 발사해요. 광고 뷰가 화면에 있는지, 사용자가 백그라운드로 나갔는지는 네이티브만 알기 때문이에요. 그림 2의 ready가 이 원칙의 출발점이에요. 네이티브가 초기 상태를 한 번에 밀어 넣으면, mraid.js는 상태를 default로 바꾸는 stateChange부터 준비 완료를 알리는 ready, 노출 상태를 알리는 viewableChange까지 연쇄로 발사해요.
mraid.close()도 같은 원칙 위에 있어요. 광고를 닫는 함수처럼 보이지만 실제로는 요청일 뿐이라 제거 권한은 네이티브에 있고, 최소 시청 시간 전의 닫기 요청은 무시돼요. 거꾸로 네이티브가 광고 뷰를 걷어낼 때는 stateChange('hidden')이나 viewableChange(false)를 먼저 발사해서, 소재가 사운드를 멈추고 씬을 정지할 기회를 줘요.
MRAID 표준을 벗어나는 요구 다루기
mraid.js가 브릿지로서 표준에 없는 요구까지 감당해야 할 때도 있어요. 대표가 엔드카드(End Card)예요. 게임이 끝난 뒤 설치를 한 번 더 권하는 마무리 화면인데, MRAID에는 없는 토스 지면만의 기능이에요. 광고주는 이 화면을 소재에서 쓰고 싶어 하고, 보여줄 타이밍은 게임 진행을 아는 소재만 알아요. 소재가 호스트에게 알릴 통로가 필요했어요.

이 통로를 표준 API인 window.mraid에 끼워 넣어도 될까요? 저희는 끼워 넣지 않았어요. mraid.js는 주입될 때 전역 객체를 둘 설치해요. 표준 API를 구현한 window.mraid, 그리고 표준 밖 기능을 담당하는 window.tossPlayableAds예요.
// mraid.js 진입점: 표준과 확장을 별도 전역으로 나눠 설치한다 (게시용으로 축약)
window.mraid = new MraidSDK(); // MRAID 표준 API
window.tossPlayableAds = new TossPlayableAdsSDK(); // 토스 지면 전용 확장 API표준을 지키는 쪽과 넘어서는 쪽을 분리해 두면, 어느 한쪽을 바꿔도 다른 쪽이 흔들리지 않아요.

엔드카드를 쓰려는 소재는 플레이어블 광고가 종료되는 순간 tossPlayableAds를 호출하고, 요청을 받은 네이티브가 엔드카드 UI를 노출해요.
// 플레이어블 광고가 종료되면 소재가 엔드카드 노출을 요청한다
tossPlayableAds.sendEvent('show_endcard');빈 화면을 거르는 rendered 신호
웹뷰에 빈 화면이 떠 있는 동안도 광고 노출일까요? 노출(Impression)은 돈과 직결되는 숫자라 답을 대충 정할 수 없었어요.
캔버스 게임 엔진은 HTML 로드가 끝난 뒤에도 폰트와 이미지를 디코딩하고 씬을 조립해요. 첫 프레임까지 사용자에게 보이는 건 아무것도 없는 화면이라, 이때 노출 판정을 시작하면 빈 화면만 보고 이탈한 사용자도 노출로 집계돼요.

그래서 표준 밖 신호를 하나 만들었어요. 그림 2에서 ready와 나란히 달리던 rendered, 웹이 "실제로 그려졌다"를 스스로 감지해 네이티브로 보고하는 신호예요.
이 신호는 네이티브가 노출 판정을 시작하는 선행 조건으로 삼도록 설계했어요. 최소 시청 시간을 재는 스킵 타이머도 rendered를 받은 뒤에야 시작해요. 판정이 이르면 빈 화면을 보던 시간이 시청 시간에 포함되고, 늦으면 이미 게임 중인 사용자가 광고를 닫지 못해요.
문제는 "그려졌다"를 한 번에 알려 주는 단일 신호가 브라우저에도 표준에도 없다는 것이었어요.
그래서 각자 다른 것을 보장하는 신호 다섯 개를 순서대로 통과시키기로 했어요.
// "그려졌다"를 판정하는 5중 신호 파이프라인 (게시용으로 축약)
async function autoDetectRendered() {
if (renderedFired) return;
if (document.readyState === 'loading') {
await new Promise<void>((resolve) =>
document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }));
}
detectStartTs = now();
// 1~3. 폰트 + 이미지 디코드 + 첫 페인트를 함께 기다리되, 2초 타임아웃과 경쟁
const tasks = [waitForFonts(), waitForImages(), waitForPaintHint()];
await Promise.race([
Promise.all(tasks),
new Promise<void>((resolve) => setTimeout(resolve, MAX_WAIT_MS)),
]);
await stableDOM(STABLE_MS); // 4. DOM 변동이 200ms 멈출 때까지 대기
await rafN(RAF_COUNT); // 5. requestAnimationFrame 2회로 실제 프레임 그려짐 보장
if (!renderedFired) fireRendered('auto');
}
// 이와 별도로 2.3초 안전망 타이머가 fireRendered('timeout')을 보장한다다섯 신호가 각각 보장하는 범위는 이렇습니다.
어느 하나도 충분조건이 아니지만 순서대로 통과시키면 "빈 화면인데 노출로 셌다"의 확률이 실용적인 수준까지 내려가요. 준비 신호(폰트, 이미지, 첫 페인트)를 2초 타임아웃과 경쟁시킨 건, 이미지 하나가 판정 전체를 붙잡아 두지 못하게 하기 위해서예요.

이와 별도로 2.3초 안전망 타이머가 항상 돌아요. 감지가 다 실패해도 source: 'timeout'으로 반드시 한 번 발사해요. 감지 로직의 실패가 광고 노출 자체를 막으면 안 되기 때문이에요.
rendered는 어떤 경로로 몇 ms 만에 감지됐는지를 { source, latencyMs }에 실어 보내요. 타임아웃 폴백 비율이 올라가면 어느 신호가 약한지 추적할 수 있어요.
소재를 믿지 않는 구조
소재는 외부에서 제작한 코드인데 저희 SDK와 같은 window, 같은 자바스크립트 컨텍스트를 공유해요. iframe 같은 문서 경계도 없어요.
그래서 등록 검사를 통과한 소재도 ‘검증된 코드’로 취급하지 않기로 했어요. 악의를 가정한다는 뜻이 아니에요. 문제 대부분은 검사가 걸러 주지 못하는 습관에서 나와요. 테스트용 코드가 남아 있거나, 다른 네트워크용 분기가 토스 지면에서 실행되는 식이에요.
방어 1. SDK를 얼려서 배포해요. 소재는 여러 네트워크에 동시에 제출되기 때문에, 제작사가 로컬 테스트용 mraid 흉내 객체를 번들에 남겨 두는 일이 드물지 않아요. 그 객체가 window.mraid를 덮어쓰면 설치 버튼을 눌러도 아무 일도 일어나지 않고 클릭 로그도 남지 않아요. 화면이 깨지는 고장보다 알아채기 어려운 종류예요.
// 소재가 mraid를 덮어쓰거나 재정의하지 못하게 얼려서 설치한다
if (window.mraid == null) {
const mraid = new MraidSDK();
Object.defineProperty(window, 'mraid', {
value: Object.freeze(mraid), // 메서드 교체 차단
writable: false, // 재할당 차단
configurable: false, // 프로퍼티 재정의 차단
enumerable: true,
});
}Object.freeze는 메서드 교체를 막고, defineProperty는 window.mraid = {}처럼 통째로 갈아끼우는 경로를 막아요. 물론 Object.freeze는 얕아서 작정한 우회까지 막지는 못해요. 여기서 막으려는 건 침입이 아니라 사고이고, 실수로 생기는 덮어쓰기는 전부 이 층에서 일어나요.
방어 2. 핸들러를 하나씩 격리해요. 앞서 소개한 이벤트 관리자(EventManager)의 역할이에요. 소재가 등록한 이벤트 핸들러는 언제든 예외를 던질 수 있어요. 목록을 그냥 순회하면 첫 예외에서 순회가 끊기고, 닫힐 때 사운드를 멈추는 핸들러가 못 돌면 광고는 사라졌는데 소리는 계속 나요.
// 핸들러 하나의 예외가 나머지 핸들러의 실행을 막지 못하게 한다
handlers.forEach((handler) => {
try {
const result = handler(...args);
// async 예외는 동기 try/catch에 잡히지 않아서 rejected Promise를 따로 수거한다
if (result && typeof result.then === 'function') result.catch(reportCreativeError);
} catch (e) {
reportCreativeError(e);
}
});방어 3. 밖으로 나가는 경로는 mraid.open() 하나예요. 광고 웹뷰에서는 소재가 외부 리소스를 불러오는 네트워크 요청과 다른 페이지로의 이동을 전부 막아요. 소재가 넘긴 URL 인자도 신뢰하지 않아서, 실제 랜딩은 광고 서버가 내려준 URL 기준이에요. 클릭이라는 과금 이벤트의 집계가 mraid.open() 한 곳으로 고정돼요.
서버가 내려주는 mraid.js
여기까지가 웹뷰 안에서 벌어지는 일이에요. 남은 문제는 이 mraid.js를 어떻게 배포하느냐였어요.
처음에는 네이티브 앱에 mraid.js의 CDN(Content Delivery Network) URL이 하드코딩되어 있었어요. 버그를 고친 파일을 오늘 CDN에 올려도, 앱에 박힌 URL을 바꾸려면 네이티브 릴리스, 앱 심사, 유저 업데이트를 전부 기다려야 했어요. 자바스크립트 한 줄에 몇 주가 걸릴 수 있는 구조였어요.
mraid.js는 앱 안에서 실행되지만 앱과 함께 배포될 필요는 없는 코드예요. 스펙이 공급 권한을 호스트에게 준 이상, ‘어떤 mraid.js를 쓸지’도 광고 마크업처럼 서버가 내려주면 돼요.

서빙 경로는 스케줄러가 60초마다 갱신하는 인메모리 캐시만 읽어요. 운영자가 값을 바꾸면 최대 60초 안에 모든 서버 인스턴스에 반영되고, 유저 화면은 그다음 광고 요청부터 새 버전을 받아요. 스펙 자체가 mraid.js를 호스트 앱이 공급하는 파일로 정의하고 역할도 광고 웹뷰 안의 브릿지로 한정하기 때문에, 심사를 우회하는 장치는 아니에요.
다만 앱 배포라는 관문이 사라진 만큼 다른 장치가 필요했어요. 배포된 파일은 수정하지 않고, 버전을 새긴 새 파일로만 교체하는 불변 파일 원칙을 뒀어요. URL을 바꾸는 내부 API는 변경자와 사유를 감사 로그로 남겨 "누가, 언제, 왜 바꿨는가"에 즉답해요.
단일 버전 전략과 두 개의 계약
서버가 URL을 내려주면 ‘어느 앱 버전에 어느 mraid.js를 줄 것인가’가 따라와요. 버전 구간별 매핑 테이블은 그리다 멈췄어요. 핫픽스마다 모든 분기를 패치해야 하고, 설정 실수 하나가 특정 버전 구간의 광고를 전부 멈출 수 있었어요. 그래서 반대로 모든 앱 버전에 단일 최신 mraid.js를 내려주기로 했어요. ‘버전 조합을 어떻게 관리할 것인가’라는 운영 문제가 ‘무엇을 절대 바꾸지 않을 것인가’라는 계약 문제로 바뀐 거예요.
계약 1. 공개 API에는 추가만 허용해요. 지금 이 순간에도 mraid.getState()를 호출하는 소재가 유저 화면에서 돌고 있어요. 메서드를 없애거나 이름을 바꾸면 하루아침에 깨져요.

계약 2. 네이티브는 모르는 커맨드를 조용히 무시해요. 새 커맨드를 아직 모르는 구버전 앱 위에서 새 mraid.js가 실행되는 일은 단일 버전 전략의 일상이에요. 크래시하면 전략이 무너지고, 조용히 무시하면 아무 일도 일어나지 않아요. 알 수 없는 값은 버린다는 규약은 IAB의 OpenRTB 스펙도 권장하는 패턴이에요.
계약은 프로세스로도 지켜요. 저희 레포는 changeset(버전 영향도를 적는 변경 선언 파일) 없는 소스 변경이 push에서 차단되고, 하위 호환을 깨는 major 변경이 감지되면 네이티브 팀 합의가 필요하다는 코멘트가 PR(Pull Request)에 자동으로 달려요. major란 계약을 깨겠다는 선언이라 팀 간 합의의 문제이기 때문이에요.
얻은 것은 속도와 단순함이에요. 몇 주가 걸리던 배포가 서버 값 하나를 바꾸는 일이 됐고, 버전 조합 관리도 사라졌어요. 대신 빠른 배포는 잘못된 배포도 빠르게 퍼뜨려요. 앞서 본 불변 파일 원칙과 감사 로그가 그 대비책이에요. 빠르게 배포할 수 있게 만든 건 서버 값 하나지만, 마음 놓고 배포하게 만든 건 계약 두 개였어요.
마무리
게임 광고를 늘리려면 게임 광고주가 쓰는 소재 유형부터 받아야 했고, 그래서 MRAID 기반 플레이어블 SDK를 만들었어요. 실제 소재가 호출하는 범위만 구현했고, 빈 화면은 5중 신호로 걸렀어요. 소재는 믿지 않는 구조로 받고, 배포는 서버 값 하나와 계약 두 개로 돌아가요.
기억해 둘 만한 것들을 추리면 아래와 같아요.
