전면광고는 재고가 없거나, 직전에 띄운 지 얼마 안 됐거나, 표시 단계에서 실패하면 언제든 안 뜹니다. 그런데 "광고가 닫히면 다음 화면으로"라고 짜 두면 광고가 안 뜬 날에는 닫히는 일도 없으니 사용자가 그 자리에서 멈춥니다. 보상형 광고도 같아서, 못 띄웠을 때 아무것도 안 하면 버튼을 눌러도 반응이 없는 고장으로 보입니다. 광고를 보든 안 보든 앱의 흐름은 그대로 이어져야 하고, 이 글은 그렇게 만드는 방법을 실제로 쓰는 코드로 정리합니다.
증상 — 버튼을 눌러도 아무 일이 없다
이 결함은 오류 로그를 남기지 않습니다. 앱이 죽지도 않고, 예외도 안 납니다. 사용자 쪽에서는 이렇게 보입니다.
- 목록에서 항목을 눌렀는데 상세 화면으로 안 넘어간다. 한 번 더 누르면 넘어가기도 한다.
- 저장 버튼을 눌렀는데 완료 화면이 안 나온다.
- 게임에서 "광고 보고 이어하기"를 눌렀는데 광고도 안 뜨고 이어지지도 않는다.
공통점은 다음 동작이 광고 콜백 안에만 들어 있다는 것입니다. 흔히 이렇게 씁니다.
fun openDetail(activity: Activity) {
val ad = interstitialAd ?: return // 재고가 없으면 여기서 끝
ad.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
navigateToDetail() // 다음 화면은 여기에만 있다
}
}
ad.show(activity)
}
광고가 떠서 사용자가 닫으면 잘 넘어갑니다. 그러나 interstitialAd가 null이면 첫 줄에서 빠져나가고, show()가 실패하면 onAdDismissedFullScreenContent가 불리지 않습니다. 어느 쪽이든 navigateToDetail()은 실행되지 않습니다.
원인 — 광고가 안 뜨는 길은 생각보다 많다
광고가 뜨는 길은 하나인데 안 뜨는 길은 여럿입니다. 아래는 실제로 운영 중인 앱들의 광고 코드에서 "안 뜨는 갈래"를 모은 것입니다.
| 안 뜨는 이유 | 코드에서 일어나는 일 | 닫힘 콜백 |
| 재고 없음(NO_FILL, 코드 3) | 로드가 실패해 광고 객체가 null로 남는다 | 안 불림 |
| 네트워크 없음 | 로드 실패(코드 2). 지하철·비행기 모드에서 흔하다 | 안 불림 |
| 빈도·간격 제한 | "N번째마다, 최소 몇 분 간격" 같은 자체 규칙이 막는다 | 안 불림 |
| 자체 차단·원격 스위치 | 비정상 클릭 방어, 원격 설정의 광고 끄기 같은 앱 쪽 판단 | 안 불림 |
| 이미 다른 광고를 띄우는 중 | 겹쳐 뜨지 않도록 잠가 둔 상태 | 안 불림 |
| 표시 실패 | onAdFailedToShowFullScreenContent가 대신 불린다 | 안 불림 |
표의 마지막 열이 이 글의 요점입니다. 광고가 안 뜬 모든 경우에 onAdDismissedFullScreenContent는 불리지 않습니다. 그 콜백에만 다음 동작을 두면 위 여섯 갈래 전부에서 사용자가 멈춥니다.
개발자 폰에서는 재현이 안 된다
이 결함이 오래 살아남는 이유는 만드는 사람 손에서는 광고가 거의 늘 뜨기 때문입니다.
- 개발 빌드는 구글 테스트 광고 단위를 씁니다. 테스트 단위는 재고 걱정 없이 대부분 광고를 돌려줍니다.
- 개발자는 자기 앱의 실광고를 누르지 않습니다(누르면 무효 클릭입니다). 그러니 "광고를 한 번 누른 사용자에게 한동안 광고를 쉬는" 류의 방어 규칙도 개발자 폰에서는 한 번도 발동하지 않습니다.
- 개발자는 보통 좋은 와이파이에서 테스트합니다.
반대로 실사용자는 재고가 없는 시간대, 끊기는 통신, 간격 제한에 걸리는 연속 사용을 일상으로 겪습니다. 광고가 안 뜨는 쪽이 사용자에게는 오히려 흔한 경로입니다.
해결 1 — 콜백형: 안 뜨는 모든 return 에서도 부른다
2026-09-13에 광고가 들어간 제 앱 12개를 전수 점검했습니다. 기준은 하나였습니다. 광고를 보든 안 보든 실제 동작에는 문제가 없어야 한다. 통과한 코드는 두 형태로 나뉘었고, 첫째가 콜백형입니다.
콜백형은 "광고 처리가 끝나면 이걸 불러 달라"고 다음 동작을 넘겨받는 형태입니다. 규칙은 함수가 끝나는 모든 갈래에서 그 콜백을 정확히 한 번 부르는 것입니다. 게임 앱(똥피하기)의 전면광고 함수에서 줄인 코드입니다.
/**
* 전면 광고를 보여 주고 닫히면 onClosed 를 부른다.
* 어떤 경로로 끝나든 onClosed 는 반드시 한 번 불린다.
*/
fun showInterstitial(onClosed: () -> Unit) {
if (!adsAllowed()) { // 자체 차단
interstitial = null
onClosed() // ← 1
return
}
val ad = interstitial
if (ad == null || showing) { // 재고 없음 · 이미 띄우는 중
loadInterstitial() // 다음 기회를 위해 받아 둔다
onClosed() // ← 2
return
}
showing = true
ad.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
interstitial = null
showing = false
loadInterstitial()
onClosed() // ← 3 정상 경로
}
override fun onAdFailedToShowFullScreenContent(error: AdError) {
interstitial = null
showing = false
loadInterstitial()
onClosed() // ← 4 표시 실패
}
}
ad.show(activity)
}
호출부는 이 함수에 "다음 판 시작"을 걸어 둡니다. 원래 코드의 주석에 이렇게 적혀 있습니다. "한 갈래라도 빠뜨리면 광고가 없는 기기에서 처음부터 버튼이 먹통이 된다. 광고 때문에 게임을 못 하게 되는 것보다 나쁜 건 없다."
사이즈 비교 앱에서는 같은 함수가 화면 전환의 길목에 있습니다. 여기는 간격·횟수 제한까지 붙어 있어서 갈래가 더 많은데, 마지막을 else로 받아 빠지는 길이 없게 했습니다.
fun showInterstitialIfReady(context: Context, onDone: () -> Unit) {
if (!adsAllowed(context)) {
interstitialAd = null
onDone() // 조용히 return 하면 사용자가 그 자리에 갇힌다
return
}
val ad = interstitialAd
val paced = pacing.shouldShow(now, adReady = ad != null, shownToday = shownToday(now))
if (ad != null && activity != null && paced) {
interstitialAd = null // show 전에 비운다(FAQ 참고)
ad.fullScreenContentCallback = /* 닫힘 → onDone(), 표시 실패 → onDone() */
ad.show(activity)
} else {
onDone() // 재고 없음 · 간격 제한 · 액티비티 없음 전부 여기로
}
}
실패 콜백 기본값을 닫힘 콜백과 같게
여러 앱이 쓰는 공통 라이브러리에서는
FullScreenContentCallback을 만들어 주는 함수의
onShowFailed 기본값을 아예
onDismissed로 잡아 두었습니다. 표시 실패를 따로 처리하지 않는 호출부도 저절로 흐름이 이어집니다.
fun fullScreenCallback(
kind: String,
onDismissed: () -> Unit = {},
onShowFailed: (AdError) -> Unit = { onDismissed() },
): FullScreenContentCallback
해결 2 — 콜백 없는 형: 이동을 광고와 떼어 놓는다
둘째 형태는 더 단순합니다. 화면 이동을 광고 코드 밖에 두는 것입니다. 공고 알림 앱의 목록 → 상세 전환이 이렇습니다.
onNoticeClick = { notice ->
sharedViewModel.selectedNotice = notice
onDetailViewed() // 광고: 뜰 수도, 안 뜰 수도
navController.navigate(Screen.Detail.route) // 이동: 광고와 무관하게 항상
}
광고 함수는 띄울지 말지만 판단하고 돌아옵니다. 안 띄우면 그냥 return해도 됩니다. 이동은 그 다음 줄에서 무조건 일어나니까요. 광고가 뜨면 상세 화면 위에 덮였다가 닫히면 상세 화면이 보입니다.
fun onDetailViewed(activity: Activity) {
if (!adsAllowed(activity)) { interstitialAd = null; return }
val ad = interstitialAd
if (!gate.onOpportunity(System.currentTimeMillis(), adReady = ad != null)) {
if (ad == null) loadInterstitial(activity)
return // 여기서 빠져도 이동은 이미 예약돼 있다
}
interstitialAd = null
ad!!.fullScreenContentCallback = fullScreenCallback("interstitial") {
gate.markShown(System.currentTimeMillis())
loadInterstitial(activity)
}
ad.show(activity)
}
| 형태 | 지킬 것 | 잘 맞는 자리 |
| 콜백형 | 모든 return과 실패 콜백에서 다음 동작을 정확히 한 번 호출 | 광고가 끝난 뒤에 일어나야 하는 동작(다음 판 시작, 결과 화면) |
| 콜백 없는 형 | 이동 코드를 광고 함수 밖, 호출 바로 다음 줄에 둔다 | 화면 전환점(목록 → 상세) |
새로 짠다면 콜백 없는 형이 실수할 여지가 적습니다. 갈래를 세어 볼 필요가 없기 때문입니다.
해결 3 — 보상형은 못 띄우면 본 것처럼 보상한다
보상형 광고는 한 단계 더 생각해야 합니다. 사용자가 "광고를 보겠다"고 직접 버튼을 눌렀기 때문입니다.
게임 앱의 보상형은 원래 "광고를 못 띄우면 보상 없이 조용히 빠진다"는 계약이었습니다. 문제는 버튼이 광고를 미리 받아 둔 순간 뜨고, 그 뒤 차단 규칙이 광고를 막아도 사라지지 않았다는 점입니다. 사용자에게는 버튼이 떠 있는데 눌러도 아무 일이 안 일어나는 고장으로 보이는 구조였습니다. 게다가 광고를 한 번 누른 사용자에게 2시간 광고를 쉬게 하는 방어 규칙을 넣으면서, 이 경우가 실사용자에게 흔해질 참이었습니다. 배너를 한 번 누른 사람이 게임에서 죽으면 "이어하기"가 먹통이 되는 것입니다.
그래서 2026-09-13에 계약을 바꿨습니다.
| 상황 | 보상 | 이유 |
끝까지 봤다(onUserEarnedReward) | 준다 | 정상 |
| 중간에 닫았다 | 안 준다 | 사용자가 고른 것이다. 닫고도 받으면 광고를 볼 이유가 없다 |
| 재고 없음 · 차단 · 표시 실패 | 준다 | 사용자 탓이 아니다 |
| 이미 띄우는 중에 또 눌렀다 | 무시 | 연타로 두 번 받는 길을 막는다 |
fun showRewarded(onClosed: (earned: Boolean) -> Unit) {
if (showing) return // 연타는 무시
if (!adsAllowed(activity)) {
rewarded = null
grantWithoutAd("blocked", onClosed)
return
}
val ad = rewarded ?: run {
grantWithoutAd("no_inventory", onClosed)
return
}
showing = true
var earned = false
ad.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
rewarded = null; rewardedReady = false; showing = false
loadRewarded()
onClosed(earned) // 끝까지 봤는지 그대로
}
override fun onAdFailedToShowFullScreenContent(error: AdError) {
rewarded = null; showing = false
loadRewarded()
grantWithoutAd("show_failed", onClosed)
}
}
ad.show(activity) { earned = true } // OnUserEarnedRewardListener
}
/** 광고 없이 보상한다. 버튼을 내리는 것까지가 한 동작이다. */
private fun grantWithoutAd(why: String, onClosed: (earned: Boolean) -> Unit) {
rewardedReady = false // 안 내리면 판마다 먹통 버튼이 다시 뜬다
Log.i(TAG, "보상형 없이 보상 지급: why=$why")
recordIssue("ads", "rewarded_granted_without_ad", "why=$why")
onClosed(true)
}
grantWithoutAd가 이유를 기록으로 남기는 것도 일부러입니다. 광고 없이 보상하는 일이 너무 잦으면 재고나 차단 설정을 봐야 한다는 신호이기 때문입니다.
덤 — NO_FILL 때 카운터를 쓰면 노출이 조용히 밀린다
흐름은 아니지만 같은 자리에서 나는 실수가 하나 더 있습니다. "7번째 전환마다 전면 1회"를 이렇게 짜는 경우입니다.
views++
if (views % 7 == 0) showIfReady() // 7번째에 재고가 없으면?
7번째에 재고가 없어 못 띄우면 다음 기회는 14번째입니다. NO_FILL은 사용자가 적은 앱에서 드물지 않아서 노출이 조용히 반으로 줄 수 있고, 실패는 로그에도 안 남습니다. 6개 앱이 함수 이름과 상수만 다른 같은 코드를 갖고 있어서 공통 클래스로 모으면서 고쳤습니다. 띄우지 못했으면 카운터를 소비하지 않습니다.
fun onOpportunity(now: Long, adReady: Boolean): Boolean {
views++
if (views < every) return false
if (!allowed(now, adReady)) return false // 재고 없음·간격 제한: 카운터 유지
views = 0 // 실제로 띄울 때만 리셋
return true
}
이 규칙은 화면으로는 확인할 방법이 없어서(안 뜬 게 규칙 때문인지 재고 때문인지 구분이 안 됩니다) 단위 테스트로 고정해 두었습니다.
@Test
fun `재고가 없어 못 띄우면 카운터를 소비하지 않는다`() {
val gate = BokInterstitialGate(every = 3, minGapMs = 0L)
gate.onOpportunity(now = 1000L, adReady = true)
gate.onOpportunity(now = 2000L, adReady = true)
assertFalse(gate.onOpportunity(now = 3000L, adReady = false))
assertTrue("바로 다음 기회에 떠야 한다",
gate.onOpportunity(now = 4000L, adReady = true))
}
확인 방법 — 광고가 안 뜨는 상황을 일부러 만든다
개발자 폰에서는 광고가 늘 뜨므로, 안 뜨는 상황을 손으로 만들어야 검증이 됩니다.
- 비행기 모드로 앱을 처음부터 켠다. 미리 받아 두는 로드가 실패해 광고 객체가 null로 남습니다. 재고 없음과 같은 갈래를 탑니다. 이 상태에서 전환 버튼과 "광고 보고 이어하기"를 눌러 다음 화면으로 넘어가는지 봅니다.
- 광고 단위 ID를 비운 빌드를 만든다. 로드 함수가 ID가 비어 있으면 바로 돌아오게 짜 두면, 빌드 옵션 하나로 "광고가 영영 없는 앱"이 됩니다. 콜백형 함수의 null 갈래를 확인하기 좋습니다.
- 간격 제한을 극단값으로. 최소 간격을 하루로 잡은 빌드에서 전환을 연달아 하면 두 번째부터 전부 "제한에 걸려 안 뜸" 갈래입니다. 원격 설정으로 빈도를 조절한다면 배포 없이 할 수 있습니다.
- 원격 광고 끄기 스위치가 있다면 켜 본다. 자체 차단 갈래가 그대로 재현됩니다.
- 로그로 어느 갈래를 탔는지 본다. 갈래마다 이유를 남겨 두면 눈으로 추측할 필요가 없습니다.
adb logcat -s GameAds
I GameAds : 보상형 없이 보상 지급: why=no_inventory
표시 실패 갈래는 손으로 만들기 어렵습니다.
onAdFailedToShowFullScreenContent는 로드는 됐는데 띄우는 순간 실패해야 불립니다. 일부러 재현하기가 까다로우니, 그 콜백 안의 코드가 닫힘 콜백과 같은 뒷정리와 같은 다음 동작을 하는지 코드로 대조하는 편이 확실합니다. 위의 "실패 기본값 = 닫힘" 방식이 그래서 유용합니다.
광고가 왜 로드되지 않는지 오류 코드(0·1·2·3·8)로 원인을 가르는 법은 애드몹 광고가 안 나올 때 — 오류 코드별 해결에 따로 정리했습니다. 이 글은 원인과 상관없이 "안 떴을 때 앱이 어떻게 버티느냐"를 다뤘습니다.
정리
- 광고가 안 뜬 모든 경우에
onAdDismissedFullScreenContent는 불리지 않습니다. 다음 동작을 거기에만 두면 사용자가 멈춥니다.
- 안 뜨는 갈래는 재고 없음, 네트워크, 간격 제한, 자체 차단, 중복 표시 잠금, 표시 실패까지 여럿입니다.
- 콜백형이면 모든
return과 실패 콜백에서 다음 동작을 한 번씩 부릅니다.
- 콜백 없는 형이면 이동을 광고 함수 밖, 바로 다음 줄에 둡니다. 실수할 여지가 더 적습니다.
- 보상형은 못 띄우면 본 것처럼 보상하고, 중간에 닫은 경우만 보상하지 않습니다. 보상한 뒤 버튼도 내립니다.
- "N번째마다" 규칙은 못 띄웠을 때 카운터를 리셋하지 않아야 노출이 밀리지 않습니다.
- 개발자 폰에서는 광고가 늘 뜹니다. 비행기 모드·빈 광고 ID·극단 간격으로 안 뜨는 갈래를 직접 밟아 보세요.
자주 묻는 것
표시에 실패하면 onAdDismissedFullScreenContent 도 이어서 불리지 않나요?
불리지 않는다고 보고 짜야 합니다. 광고가 화면에 올라오지 못했으니 닫힐 것도 없습니다. 그래서 onAdFailedToShowFullScreenContent 안에서도 닫힘과 같은 뒷정리(잠금 해제, 다음 광고 미리 받기)와 다음 동작을 해야 합니다. 둘 중 하나가 빠지면 잠금이 안 풀려 그 뒤로 광고가 영영 안 뜨거나, 사용자가 멈춥니다.
광고를 못 띄웠는데 보상을 주면 애드몹 쪽에 문제가 되지 않나요?
그 경우에는 광고가 아예 나가지 않으므로 애드몹에는 요청·노출·클릭이 가지 않습니다. 보상은 앱 안의 기능이라 앱이 정하는 것입니다. 다만 비행기 모드로 보상을 반복해서 받는 식의 악용 여지는 생깁니다. 그 대가와 "버튼이 먹통인 앱"이라는 인상 중 무엇이 더 큰지는 앱마다 판단할 문제인데, 게임에서는 후자가 더 크다고 봤습니다.
같은 전면광고가 두 번 뜨려다 실패합니다.
InterstitialAd는 한 번만 show()할 수 있습니다. 광고 객체를 닫힘 콜백에서만 비우면, show()와 콜백 사이에 같은 함수가 다시 불렸을 때 같은 객체를 또 띄우려다 The ad has already been shown(코드 1)으로 실패합니다. 제 성경 앱에서 실사용자에게 실제로 났습니다. show() 직전에 보관 변수를 먼저 비우세요. 그리고 이 실패도 위의 "표시 실패" 갈래로 흘러가 다음 동작이 이어지게 해 두면, 혹시 남은 경로가 있어도 사용자는 멈추지 않습니다.
앱을 종료할 때 전면광고를 띄우면 흐름 문제가 없지 않나요?
흐름 이전에 정책 문제입니다. 애드몹은 앱을 켤 때와 나갈 때 전면광고를 띄우는 것을 금지합니다(Disallowed interstitial implementations). 뒤로가기 두 번이나 종료 확인창에 붙이는 것도 여기 해당합니다. 전면광고는 저장·완료 같은 작업이 끝난 시점이나 목록 → 상세 같은 화면 전환점에만 두세요.