애드몹 광고 로드 실패를 기록으로 모아 보면, 앱 코드를 아무리 고쳐도 사라지지 않는 문구가 꽤 섞여 있습니다. 기기의 WebView 상태, 사용자의 통신, 광고 서버의 응답에서 나온 것들입니다. 이걸 전부 앱의 실패로 세면 정작 손봐야 할 결함이 그 아래에 묻힙니다. 반대로 계정이 멈춘 것 같은 큰 사건은 재고 없음과 같은 코드로 오기 때문에, 코드 번호가 아니라 문구로 갈라야 합니다.
증상 — 실패 목록이 길어서 읽을 수가 없다
광고 로드 콜백(onAdFailedToLoad)에서 받은 LoadAdError의 코드와 메시지를 그대로 기록해 두면 이런 줄이 쌓입니다.
banner_load_failed code=0 Unable to obtain a JavascriptEngine
interstitial_load_failed code=0 Unable to obtain a JavascriptEngine
banner_load_failed code=3 Received error HTTP response code: 400
banner_load_failed code=0 Error while connecting to ad server: Unable to resolve host ...
interstitial_load_failed code=0 Internal error.
banner_load_failed code=3 No fill.
처음 보면 전부 고쳐야 할 오류처럼 보입니다. 저는 여러 앱의 이런 기록을 매일 모아 메일로 받는데, 2026-08-25 하루에 1,207건이 실렸고 그중 사람이 손댈 수 있는 것은 두 줄뿐이었습니다. 그 두 줄은 목록 맨 밑에 있었습니다. 할 일이 없는 줄은 그냥 쓸모없는 게 아니라, 할 일이 있는 줄을 가려서 해롭습니다.
문구별 분류 — 원인이 어느 쪽에 있나
위 문구들을 원인이 있는 쪽으로 나누면 이렇습니다. 같은 코드 안에 성격이 다른 문구가 함께 들어 있다는 점을 먼저 봐 주세요.
| 문구 | 코드 | 원인 쪽 | 앱에서 할 일 |
|---|---|---|---|
Unable to obtain a JavascriptEngine | 0 | 기기(WebView) | 없음. 집계에서 접는다 |
Error while connecting to ad server: Unable to resolve host | 0 | 통신(기기가 망에 없음) | 없음. 접는다 |
Network error | 2 | 통신 | 없음. 접는다 |
No fill. | 3 | 광고 서버(줄 광고가 없음) | 없음. 접는다 |
Received error HTTP response code: 400 | 3 | 광고 서버(요청 거절) | 날짜와 기기 수를 본 뒤 기한을 두고 접는다 |
Internal error. | 0 | 광고 서버 쪽 일시 오류. 계정 정지 때도 온다 | 날짜와 기기 수를 본 뒤 기한을 두고 접는다 |
Publisher data not found | 3 | 계정 | 접지 않는다. 콘솔을 연다 |
Unable to obtain a JavascriptEngine
광고 SDK가 광고를 그릴 WebView 쪽 자바스크립트 엔진을 얻지 못했다는 뜻으로 읽힙니다. 기기의 WebView 상태에 달린 문제라 제 앱에서는 재현도 수정도 할 수 없었습니다. SDK 안에서 정확히 어떤 조건에 이 문구가 나오는지는 확인하지 못했고, 그래서 제 집계에서는 "기기 환경(WebView 없음 등)"이라는 한 갈래로만 분류합니다.
기기 쪽 문제라고 본 근거는 분포입니다. 한 앱의 최근 10일 광고 실패 기록에서 이 문구는 기기 3대에서만 나왔고, 배너와 전면이 같은 기기에서 같은 건수로 짝지어 났습니다. 광고 형식과 무관하게 그 기기에서는 광고가 통째로 안 되고, 나머지 기기에서는 이 문구가 한 번도 없었다는 뜻입니다. 앱 코드나 광고 단위 설정이 원인이면 이렇게 기기 몇 대에만 갇히지 않습니다.
Unable to resolve host 와 Network error
둘 다 사용자의 통신 상태입니다. 다만 앞의 것은 코드 2(네트워크 오류)가 아니라 코드 0으로 옵니다. 코드 2만 통신 문제로 걸러 두면 이 문구가 "내부 오류"로 남습니다. 기기가 인터넷을 잃으면 광고만 실패하는 게 아니라 같은 기기에서 같은 날 다른 API 호출도 함께 실패하므로, 광고 쪽만 접고 나머지를 앱의 실패로 올리면 한 사건을 반만 접은 셈이 됩니다.
No fill 과 Publisher data not found
No fill은 애드몹이 줄 광고가 없었다는 뜻이고, 사용자가 적은 앱에서는 매일 납니다. 이걸 앱의 잘못으로 세면 매일 헛경보가 옵니다.
문제는 계정이 멈췄을 때도 같은 코드 3이 온다는 점입니다. 2026-08-24 계정이 정지된 상태에서 실기기에 찍힌 문구가 code=3 Publisher data not found였습니다. 코드만 보고 "3이면 재고 없음"으로 분류해 두었더니, 계정이 통째로 멈춘 날에도 감시가 조용히 넘어가는 구조였습니다. 가장 크게 알려야 할 사건이 가장 조용한 자리로 가고 있던 것입니다.
가르는 방법 — 날짜 × 기기 수
HTTP 400과 Internal error는 문구만으로는 판단이 안 됩니다. 광고 단위를 지웠거나 앱 ID가 어긋난 설정 문제일 수도 있기 때문입니다. 이럴 때는 실패 기록을 날짜와 기기로 나눠 봅니다.
| 분포 | 읽는 법 |
|---|---|
| 하루에 기기 한두 대, 같은 날 다른 기기에는 광고 노출이 잡힌다 | 그 기기의 요청만 거절된 것. 설정 문제가 아니다 |
| 여러 기기에서 며칠째 이어지고 노출이 같이 줄어든다 | 설정 문제 의심. 광고 단위·앱 ID를 확인한다 |
| 여러 앱에서 같은 날 동시에 난다 | 계정 단위 사건 의심 |
실제로 이렇게 판정했습니다. 한 앱이 Received error HTTP response code: 400으로 나흘 연속 실패 목록에 올랐는데, 날짜별로 나누니 10월 3일에 기기 한 대(2건), 10월 5일에 기기 한 대(13건)였습니다. 10월 2일에는 500도 1건 있었습니다. 같은 날 광고 노출 이벤트는 정상으로 잡혔고, 배포한 바이너리 안의 애드몹 앱 ID와 광고 단위가 같은 게시자 것으로 맞는지도 확인했습니다. 설정이 틀렸다면 모든 기기에서 매일 실패했을 텐데 하루 한 대였으니, 광고 서버가 그 기기의 요청을 거절한 것으로 보고 접었습니다. 그 뒤로 이 문구는 0건입니다.
Internal error는 같은 방법으로 정반대 결론이 난 적이 있습니다. 계정이 정지된 8월 23·24일 이틀 동안 이 문구가 앱 5개에서 동시에 났습니다. 반면 10월 5일에 다른 앱에서 난 3건은 하루, 기기 2대였고 다음 날 노출이 돌아왔습니다. 문구는 같은데 앞의 것은 계정 사건이고 뒤의 것은 일시 오류입니다. 문구가 아니라 분포가 둘을 갈랐습니다.
한 대가 하루에 13건을 내면 목록에서는 큰 사고처럼 보입니다. 건수 말고 기기 수를 세어야 합니다.
코드 — 문구로 분류하기
앱 쪽: 코드와 메시지를 함께 본다
여러 앱이 같이 쓰는 광고 공통 코드에서 줄인 것입니다. 코드 0·2·3은 앱에서 고칠 수 없는 쪽으로 두되, 계정 문구가 있으면 코드가 3이어도 따로 건집니다.
private const val CODE_INTERNAL_ERROR = 0
private const val CODE_NETWORK_ERROR = 2
private const val CODE_NO_FILL = 3
private const val MSG_PUBLISHER_DATA_NOT_FOUND = "Publisher data not found"
fun isAccountProblem(message: String?): Boolean =
message?.contains(MSG_PUBLISHER_DATA_NOT_FOUND, ignoreCase = true) == true
fun isOurFaultCode(code: Int, message: String? = null): Boolean {
if (isAccountProblem(message)) return true // 코드가 3이어도 계정 문제
return code != CODE_NO_FILL &&
code != CODE_NETWORK_ERROR &&
code != CODE_INTERNAL_ERROR
}
fun loadFailed(kind: String, error: LoadAdError) {
val accountProblem = isAccountProblem(error.message)
recordIssue(
where = "ads",
kind = if (accountProblem) "ad_account_problem" else kind,
detail = "code=${error.code} ${error.message}",
ourFault = accountProblem || isOurFaultCode(error.code, error.message),
)
}
계정 문제일 때 기록 종류를 ad_account_problem 하나로 바꾸는 것이 요점입니다. 배너·전면·보상형 각각의 실패로 흩어 두면 "배너가 좀 실패하네"로 보이지만, 실제로는 계정이 멈춘 것이라 대응이 전혀 다릅니다. 판정 함수가 LoadAdError 대신 숫자와 문자열을 받는 것은 단위 테스트로 고정하기 위해서입니다.
@Test
fun `계정 문제는 code가 3이어도 우리 잘못이다`() {
assertTrue(isOurFaultCode(3, "Publisher data not found"))
assertTrue(isOurFaultCode(3, "Error: publisher data not found for this app"))
assertFalse(isOurFaultCode(3, null)) // 문구 없는 3 은 평범한 재고 없음
}
집계 쪽: 문구를 원인으로 바꾸고, 원인으로 접는다
기록을 읽는 쪽(파이썬)에서는 먼저 영어 원문을 "어느 쪽 탓인지"가 붙은 짧은 말로 바꾸고(DETAIL_NOISE), 그 말이 들어 있는 줄은 개별로 싣지 않습니다. 원인이 앱 밖으로 확정된 것은 기한 없이(ENV_CAUSES), 조사해서 원인을 아는 사건은 기간을 정해(KNOWN_INCIDENTS) 접습니다.
import re
from datetime import date
DETAIL_NOISE = [
(re.compile(r"Unable to obtain a JavascriptEngine"),
"기기의 WebView 를 못 씀 — 기기 탓"),
(re.compile(r"Error while connecting to ad server.*"),
"광고서버 접속 실패 — 통신 탓"),
(re.compile(r"(?:UnknownHostException.*|Unable to resolve host.*)"),
"이름 해석 실패(기기가 망에 없음) — 통신 탓"),
]
ENV_CAUSES = {
"기기 탓": "기기 환경(WebView 없음 등)",
"통신 탓": "사용자 통신 상태",
"Network error": "사용자 통신 상태",
"No fill": "광고 재고 없음",
}
KNOWN_INCIDENTS = (
{
"since": date(2026, 10, 1),
"until": date(2026, 11, 5),
"label": "광고서버 거절·일시 오류 — 단위·계정 정상 확인",
"detail_has": ("Received error HTTP response code",
"code=0 Internal error"),
},
)
def muted(text: str, oldest: date) -> str | None:
"""접을 것이면 접는 이유를 돌려준다."""
for key, why in ENV_CAUSES.items():
if key in text:
return why
for inc in KNOWN_INCIDENTS:
if inc["since"] <= oldest <= inc["until"] and \
any(s in text for s in inc["detail_has"]):
return inc["label"]
return None
세 가지를 지켰습니다.
- 접은 것은 버리지 않습니다. 접는 이유를 문자열로 돌려받아, 이유별 건수를 맨 아래 한 줄로 남깁니다. 조용히 버리면 나중에 "왜 안 보이지"로 헤맵니다.
- 판단이 필요했던 문구에는 기한을 둡니다.
HTTP 400과Internal error는 기한이 지나면 저절로 다시 올라옵니다. 그 전에도 건수가 하루 수십 건, 여러 기기로 번지면 맨 아래 건수 한 줄이 커지는 것으로 드러납니다. - 좁게 잡습니다.
Internal error는code=0까지 붙여서 맞춥니다. 낱말만으로 잡으면 무관한 새 오류까지 삼킬 수 있습니다.Publisher data not found는 어느 목록에도 넣지 않았습니다.
오류 코드(0·1·2·3·8)별로 무엇을 고쳐야 하는지는 애드몹 광고가 안 나올 때 — 오류 코드별 해결에, 계정이 정지됐던 경위는 애드몹 계정 정지 글에 따로 적었습니다. 이 글은 고칠 수 없는 문구를 가려내 집계에서 빼는 쪽만 다뤘습니다.
정리
Unable to obtain a JavascriptEngine은 기기의 WebView 쪽 문제로 읽힙니다. 기기 몇 대에서만, 배너와 전면이 짝지어 납니다.Unable to resolve host는 통신 문제인데 코드 0으로 옵니다. 코드 2만 걸러서는 안 잡힙니다.No fill과Publisher data not found는 같은 코드 3입니다. 앞은 접고 뒤는 반드시 울리게 합니다.HTTP 400·Internal error는 날짜와 기기 수로 봅니다. 하루 한 대이고 같은 날 노출이 정상이면 설정 문제가 아닙니다.- 접을 때는 이유별 건수를 남기고, 판단이 들어간 항목에는 기한을 둡니다.
자주 묻는 것
코드 0이면 전부 무시해도 되나요?
아닙니다. 코드 0에는 기기의 WebView 문제, 통신 두절, 광고 서버의 일시 오류가 함께 들어 있고, 계정이 정지됐을 때도 Internal error가 코드 0으로 왔습니다. 코드로는 앱의 잘못이 아니라는 것까지만 알 수 있고, 계정 단위 사건인지는 여러 앱·여러 기기에서 같은 날 났는지를 봐야 갈립니다.
HTTP 400이 여러 기기에서 매일 나오면요?
그때는 접어 둔 것을 풀고 설정을 봐야 합니다. 광고 단위가 삭제됐거나 앱에 들어간 애드몹 앱 ID가 어긋났을 수 있습니다. 하루 한 대라는 분포가 "설정 문제가 아니다"의 근거였으므로, 분포가 달라지면 결론도 달라집니다.
No fill을 앱의 실패로 세면 안 되나요?
세어도 되지만 사용자가 적은 앱에서는 매일 나는 값이라 경보로 쓰면 매일 울립니다. 계정 문구가 없는 코드 3은 평범한 재고 없음으로 두고, 건수 추이만 따로 보는 편이 낫습니다.
접어 두면 진짜 문제를 놓치지 않나요?
놓칠 수 있어서 장치를 둡니다. 접은 건수를 이유별로 한 줄 남기는 것, 판단이 들어간 항목에 기한을 두는 것, 계정 문구는 절대 접지 않는 것입니다. 접힌 건수 한 줄이 갑자기 커지면 그게 다시 들여다볼 신호입니다.