실패 67건 중 앱이 고칠 수 있는 건 하나였다
광고 실패가 좀 보여서 어제 PostHog 와 AdMob 을 같이 열어 봤습니다. 10월 25일부터 11월 8일까지 남은 ad_failed 67건 중 66건은 재고 없음(no-fill)·기기 WebView·오프라인이었고, 모두 같은 세션 안에서 재시도로 회복돼 있었습니다. 앱에서 손댈 수 있는 실패가 아닙니다.
남은 한 종류가 iPad 였습니다. 곡 화면을 나갈 때 뜨는 전면광고(song_exit)에서 표시 실패가 이렇게 찍혀 있었습니다.
[googleMobileAds/unknown] Ad too large for window. Ad size: {1373, 954}, window size {954, 1373}.
[googleMobileAds/unknown] Ad too large for window. Ad size: {1180, 820}, window size {820, 1180}.
광고 크기와 창 크기의 가로·세로가 정확히 뒤집혀 있습니다. iPhone 판 Utakoto 는 세로 고정이지만 iPad 는 네 방향을 다 지원합니다. 전면광고는 받는 순간의 방향으로 만들어지기 때문에, 가로로 들고 있을 때 미리 받아 둔 광고를 세로로 돌린 뒤 띄우면 창보다 커서 뜨지 않습니다.
전면광고는 실패해도 바로 다음 화면으로 넘어가니 쓰는 사람이 막히지는 않습니다. 받아 둔 광고 하나가 노출 없이 버려질 뿐입니다. 보상형은 더 나쁩니다. 출석 +50P 나 포인트 부족 시트에서 광고 버튼을 눌렀는데 광고도 보상도 없는 일이 같은 방식으로 생길 수 있습니다.
Utakoto 출석 체크 화면(스토어 스크린샷)입니다. 보상형 광고로 포인트를 받는 진입점 중 하나가 여기이고, 이번 수정이 iPad 에서 지키려는 버튼도 이 자리에 있습니다.
받은 방향을 기억해 두고, 바뀌면 버린다
고친 방법은 단순합니다. iPad 에서만 광고를 받은 순간의 창 방향을 기억해 두고, 지금 방향과 다르면 그 광고를 버리고 지금 방향으로 새로 받습니다. 방향 판정과 회전 구독은 광고 공용 모듈(sdk.ts)에 넣었습니다.
/** 받아 둔 광고가 지금 창 방향과 맞지 않아 띄우면 실패할 상태인가. 받은 방향을 모르면(null) 막지 않는다. */
export function adOrientationMismatch(
loadedIn: AdOrientation | null,
now: AdOrientation = windowOrientation(),
tracks: boolean = tracksAdOrientation(),
): boolean {
return tracks && loadedIn != null && loadedIn !== now;
}
export function onAdOrientationChange(listener: (orientation: AdOrientation) => void): () => void {
if (!tracksAdOrientation()) return () => {};
let last = windowOrientation();
const sub = Dimensions.addEventListener('change', ({ window }) => {
const next = windowOrientation(window);
if (next === last) return;
last = next;
listener(next);
});
return () => sub.remove();
}
tracksAdOrientation() 은 Platform.OS === 'ios' && Platform.isPad 라서, iPhone 과 Android 는 구독 자체를 하지 않습니다. 창 크기만 바뀌고 방향이 그대로면 무시합니다.
회전 이벤트보다 광고 트리거가 먼저 오는 경우를 위해 띄우기 직전에도 한 번 더 봅니다. 맞지 않으면 이번 트리거는 광고 없이 넘기고 ad_failed{reason: 'orientation_mismatch'} 를 남긴 뒤 지금 방향으로 받습니다. 보이는 결과는 실패할 때와 같지만, 얼마나 자주 일어나는지 따로 셀 수 있게 됐습니다. 표시 실패에는 창 크기(954x1373 같은 문자열)도 붙입니다.
고치다가 찾은 것: 같은 인스턴스에 load() 를 다시 부르면 아무 일도 없다
"버리고 다시 받기"는 이미 앱에 있었습니다. 9월 28일 노출률 작업(a4ac925)에서, 받은 지 55분 지난 광고는 버리고 새로 받게 했습니다. 받아 둔 광고는 1시간 뒤 만료되고, 만료된 광고는 띄워도 노출로 잡히지 않기 때문입니다. 회전 처리도 같은 경로를 타는데, 들여다보니 그 만료 처리가 동작하지 않고 있었습니다.
React Native 래퍼(react-native-google-mobile-ads)의 MobileAd.load 에는 if (this._loaded || this._isLoadCalled) return 이라는 조건이 있습니다. 이미 받은 광고가 있는 인스턴스에 load() 를 다시 부르면 아무것도 하지 않습니다. 그런데 만료 처리는 플래그만 내리고 같은 인스턴스에 load() 를 불렀습니다. LOADED 가 다시 오지 않으니 전면 풀은 loading=true 로 굳어 그 세션의 전면광고가 끊겼고, 보상형은 ready=false 로 남아 버튼이 꺼진 채였습니다. 앱을 55분 넘게 켜 둔 세션에서만, 그리고 iPad 만이 아니라 모든 기기에서 생기는 문제였습니다.
그래서 버릴 때는 인스턴스째 버리도록 바꿨습니다. 전면 풀 쪽은 만료와 회전이 같은 함수를 씁니다.
function dropLoaded(): void {
loaded = false;
loadedAt = 0;
loadedOrientation = null;
loading = false;
ad?.removeAllListeners();
ad = null;
notify();
}
보상형은 React 훅이라, 버릴 때 generation 상태를 올립니다. 인스턴스를 만드는 effect 의 의존성에 이 값이 있어서, 바뀌면 이전 리스너를 정리하고 새 인스턴스로 다시 받습니다. 예전 만료 처리의 adRef.current.load() 한 줄이 setGeneration((g) => g + 1) 로 바뀌었습니다.
왜 이 범위였나
iPad 의 화면 회전 정책 자체는 범위 밖으로 뒀습니다. 회전을 막는 쪽은 Info.plist 를 바꾸는 네이티브 변경이라 빌드가 필요하고, 이번 수정은 네이티브 변경 없이 OTA(runtime 1.11.0)로만 내기로 했습니다. 그래서 Dimensions 만으로 되는 범위에서 멈췄습니다. 배너도 범위 밖입니다. 자기 크기를 매번 받기 때문입니다.
다음은 요청 수입니다. 제약에 "요청 수를 불필요하게 늘리지 않는다"고 적었습니다. 다시 받는 건 받아 둔 광고가 있고 방향이 실제로 바뀌었을 때만이고, 전면광고는 거기에 더해 미리 받는(eager) 배치가 화면에 떠 있을 때만입니다. 노래방처럼 시트가 열릴 때 받는 배치는 그때 새로 받으니 회전 때 따로 받지 않습니다. 새 로드도 기존 초기화 대기(waitForAdSlot)를 거칩니다.
광고가 떠 있는 동안의 회전은 건드리지 않고, 호출자는 어떤 경우에도 onDone/onClosed 를 정확히 한 번 받는다는 조건도 제약에 있습니다. 띄우기 직전 검사에서 이번 트리거를 넘길 때 onDone()·onClosed?.() 를 부르고 끝나는 것이 그 조건 때문입니다. 받은 방향을 모를 때(null) 막지 않는 이유는 기록에 따로 남아 있지 않습니다.
확인한 것과 남은 것
같은 커밋에 방향 판정 테스트를 넣었습니다. 창 모양으로 방향을 읽는지, 가로로 받은 광고가 세로가 되면 불일치로 보는지, iPhone 이거나 받은 방향을 모를 때는 막지 않는지, 창 크기 문자열을 반올림해 만드는지입니다. 이 작업에는 plan 이 따로 없어서, 테스트를 돌린 결과나 기기에서 확인한 항목은 기록에 남아 있지 않습니다.
출시는 같은 날 했습니다. intent 목록(6b113d2)에 main 의 e34cf44 로 OTA 510f0a87(runtime 1.11.0, Android·iOS)을 발행했다고 적혀 있습니다.
판정 기준은 OTA 2주 뒤 PostHog 에서 show_error 중 "too large for window" 가 0건인지입니다. orientation_mismatch 는 실패를 막은 횟수라 0이 목표가 아니라, 얼마나 자주 일어나는지 보는 용도로 남겨 둡니다.
열린 질문이 하나 있습니다. iPadOS 26 의 창 크기 조절에서도 Dimensions change 이벤트가 오는지는 실기기가 없어 시뮬레이터로만 보겠다고 intent 에 적어 뒀습니다. 이벤트가 안 와도 띄우기 직전 검사가 창 방향을 다시 읽는 구조이긴 한데, 실제로 그런지는 판정 기간의 기록으로 봐야 합니다.
Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.