이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

728x90
반응형
SMALL

한 번도 게시된 적이 없는 신규 앱은 Google Play Developer API 로 프로덕션 출시를 끝까지 마칠 수 없습니다. Play 는 그런 앱을 통째로 draft 상태로 보고, API 에는 draft 출시를 만드는 것까지만 허용합니다. 권한이나 번들이 잘못된 것이 아니므로 다시 올릴 필요는 없고, 첫 출시 한 번만 Play Console 에서 사람이 제출하면 됩니다. 그 검토를 통과한 뒤부터는 같은 API 호출이 정상으로 동작합니다.

증상 — 업로드와 승격은 됐는데 출시에서 400

배포 스크립트로 내부 테스트 트랙에 올리고, 그 버전을 프로덕션으로 승격하는 데까지는 문제가 없습니다. 프로덕션 출시의 상태를 completed 로 바꿔 커밋하는 순간 이 응답이 옵니다.

HttpError 400
Only releases with status draft may be created on draft app.

저는 이 문구를 신규 앱을 낼 때마다 만났습니다. 2026-09-08 에 앱 두 개가 같은 날 같은 자리에서 막혔고, 9월 12일과 10월 2일에 낸 앱도 똑같았습니다. 네 번 모두 "이미 올라간 draft 를 실제 출시로 바꾸는" 호출의 커밋 단계였습니다.

잘못 읽기 쉬운 방향
400 이라서 서비스 계정 권한이나 AAB 를 의심하게 되는데 둘 다 아닙니다. 업로드와 승격이 이미 성공했다는 것이 그 증거입니다. 막힌 것은 게시 한 번이고, 만들어 둔 draft 는 그대로 살아 있습니다.

원인 — 앱 자체가 draft 다

문구에 draft 가 두 번 나옵니다. 앞의 것은 출시(release)의 상태이고 뒤의 것은 앱의 상태입니다. 한 번도 검토를 통과해 게시된 적이 없는 앱이 draft app 이고, 이런 앱에는 상태가 draft 인 출시만 API 로 만들 수 있다는 뜻입니다.

하려는 일draft app 에서
internal 트랙에 올리기된다(제 앱들은 completed 로 올렸고 테스터에게 바로 나갔다)
production 에 draft 출시 만들기된다
production 출시를 completed 로 바꾸기400 으로 거절

콘솔 쪽에도 짝이 되는 현상이 있습니다. 신규 앱은 게시 개요의 '검토를 위해 전송'이 잠겨 있고, 프로덕션 트랙에 출시가 생겨야 열립니다. 즉 API 로 draft 를 만들어 두는 일과 콘솔에서 제출하는 일이 둘 다 필요합니다.

이 제약은 API 쪽에 있어서 fastlane supply 나 Gradle Play Publisher 처럼 같은 API 를 부르는 도구를 써도 같은 문구를 만납니다.

해결 순서 — API 는 draft 까지, 제출은 콘솔에서

1. API 로 internal 버전을 production draft 로 승격

제 배포 스크립트는 google-api-python-client 로 edits.insert → tracks.update → commit 을 부릅니다. 승격은 internal 트랙에서 가장 높은 versionCode 를 읽어 production 에 draft 로 얹는 것입니다. 키 경로와 패키지명은 예시 값입니다.

from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]
creds = service_account.Credentials.from_service_account_file(
    "keys/play-service-account.json", scopes=SCOPES)
svc = build("androidpublisher", "v3", credentials=creds, cache_discovery=False)

pkg = "com.example.myapp"
edit = svc.edits().insert(body={}, packageName=pkg).execute()
eid = edit["id"]

src = svc.edits().tracks().get(packageName=pkg, editId=eid, track="internal").execute()
codes = []
for r in src.get("releases", []):
    codes.extend(r.get("versionCodes") or [])
vc = max(codes, key=int)   # internal 에 올라간 버전 중 가장 높은 것

release = {"versionCodes": [vc], "status": "draft", "name": "1.0.1"}
svc.edits().tracks().update(
    packageName=pkg, editId=eid, track="production",
    body={"releases": [release]},
).execute()
svc.edits().commit(packageName=pkg, editId=eid).execute()

여기까지는 draft app 에서도 통과합니다. 재빌드 없이 승격하므로 폰으로 확인한 그 바이너리가 그대로 프로덕션 draft 가 됩니다. 중간에 실패하면 edits.delete 로 edit 을 버려 반쯤 반영된 상태가 남지 않게 해 둡니다.

2. 막히는 호출은 이것이다

draft 를 실제 출시로 바꾸는 코드는 상태만 고쳐 다시 보내는 것입니다. 신규 앱에서는 마지막 줄이 400 을 돌려줍니다.

cur = svc.edits().tracks().get(packageName=pkg, editId=eid, track="production").execute()
rel = dict([r for r in cur["releases"] if r.get("status") == "draft"][0])
rel["status"] = "completed"
svc.edits().tracks().update(
    packageName=pkg, editId=eid, track="production",
    body={"releases": [rel]},
).execute()
svc.edits().commit(packageName=pkg, editId=eid).execute()   # draft app 이면 400

스크립트에는 이 예외를 문구로 가려 안내를 찍는 분기를 넣어 두었습니다. 다음에 같은 자리에서 권한부터 뒤지지 않기 위해서입니다.

except Exception as e:
    if "draft app" in str(e):
        log("이 앱은 아직 한 번도 게시된 적이 없습니다.")
        log("첫 프로덕션 출시는 Play Console 에서만 가능합니다.")
        log("(draft 는 그대로 있습니다. 다시 올릴 필요 없습니다.)")
    raise

3. 콘솔에서 세 가지를 한다

2026-10-02 에 근무 기록 앱의 첫 프로덕션을 제출할 때 실제로 한 순서입니다.

  1. 국가/지역 선택. 신규 앱의 프로덕션은 출시 국가가 기본 0개입니다. 저는 전체를 선택했습니다. API 로 draft 를 만들어도 이 값은 채워지지 않았습니다.
  2. 프로덕션 출시에서 버전 수정 → 다음 → 저장. API 로 만든 draft 가 이미 들어 있으니 내용을 새로 넣을 것은 없고, 화면을 끝까지 넘겨 저장합니다.
  3. 게시 개요에서 '검토를 위해 변경사항 N개 제출'. 잠겨 있던 버튼이 프로덕션 출시가 생기면서 열립니다. 이것을 눌러야 검토로 갑니다.

그 앱은 10월 2일에 제출했고 10월 7일 17:29(KST)에 'Your update is live' 메일과 함께 처음 공개됐습니다. 이 앱이 그랬다는 기록이고, 검토 기간은 앱마다 다릅니다.

그 뒤 달라지는 것

첫 검토를 통과해 한 번 게시되면 앱에서 draft 딱지가 떨어집니다. 그 뒤로는 위 2번의 호출, 즉 production draft 를 completed 로 바꿔 커밋하는 것이 정상으로 동작합니다. 일부 사용자에게만 내보내는 단계적 출시도 같은 호출에서 상태를 inProgress 로, userFraction 을 0.1 같은 값으로 주면 됩니다.

저는 첫 출시가 지난 뒤에도 프로덕션은 항상 draft 로만 올리고, 출시는 별도 명령으로 나눠 두었습니다. Play 프로덕션은 내보낸 버전을 지울 수 없어서, draft 로 머무는 동안 확인할 시간을 두는 편이 안전하기 때문입니다.

트랙의 releases 는 통째로 교체됩니다
tracks.update 의 releases 배열은 기존 값에 더해지는 것이 아니라 그 배열로 바뀝니다. 출시할 때는 바꾸려는 출시 하나만 실어 보냅니다. 이전 completed 출시를 같이 실어 보내면 거절당했습니다.

자주 묻는 것

AAB 를 다시 올리거나 versionCode 를 올려야 하나요?

아닙니다. 오류가 나도 프로덕션 draft 는 그대로 남아 있습니다. 실패한 edit 만 버려지고 이전에 커밋한 승격은 유지됩니다. 콘솔로 가서 그 draft 를 제출하면 됩니다.

서비스 계정 권한을 더 주면 API 로도 되나요?

제 서비스 계정은 관리자 권한이었는데도 같은 응답이 왔습니다. 권한이 아니라 앱 상태에 걸린 제약입니다.

콘솔에서 저장까지 했는데 검토로 안 넘어갑니다.

출시를 저장하는 것과 검토로 보내는 것은 별개 단계입니다. 게시 개요에서 '검토를 위해 변경사항 N개 제출'을 눌렀는지, 그리고 출시 국가가 0개로 남아 있지 않은지 확인하세요.

이미 게시된 앱인데 API 커밋이 검토로 안 갑니다.

그건 다른 경우입니다. 정책 위반으로 거부된 상태의 앱은 API 가 변경사항을 검토로 보내지 못했습니다. 그때는 커밋에 changesNotSentForReview=True 를 주어 반영만 하고, 콘솔 게시 개요에서 사람이 전송해야 했습니다. 문구가 다르니 응답 본문으로 구분하세요.

728x90
반응형
LIST
728x90
반응형
SMALL

애드몹 앱 인증이 실패하는데 app-ads.txt 파일에는 아무 문제가 없는 경우가 있습니다. 애드몹은 내가 파일을 올려 둔 주소를 보는 것이 아니라, Play 스토어 등록정보에 적힌 개발자 웹사이트의 도메인 루트에서 파일을 찾기 때문입니다. 그 칸에 개인정보처리방침 주소처럼 다른 도메인이 들어가 있으면 파일이 멀쩡해도 인증은 실패합니다. 새 앱을 막 공개했다면 크롤 지연이라고 넘기기 쉬운데, 기다리기 전에 스토어 페이지의 웹사이트부터 확인하는 순서를 정리합니다.

증상 — 파일은 있는데 일치하지 않는다고 한다

애드몹 콘솔의 앱 인증 화면에 아래 두 문구가 나옵니다. 앞의 것은 앱 이름 자리에 패키지명이 들어간 형태입니다.

com.example.workpay (unreviewed)(Android)을(를) 확인할 수 없습니다

app-ads.txt 파일을 설정했지만 세부정보가 AdMob 계정의 정보와
일치하지 않는 것 같습니다

제 경우는 이랬습니다. 2026-10-07에 새 앱이 처음 프로덕션에 공개됐고, 같은 날 저녁 애드몹에서 스토어 연결(앱 설정 → 앱 스토어 세부정보 '추가' → 스토어 URL 검색)을 마쳤습니다. 연결은 됐고 승인 상태는 '검토 필요'였는데, 앱 인증만 위 문구로 실패했습니다.

헷갈리는 지점은 이것입니다. 같은 애드몹 계정의 다른 앱들은 전부 인증이 '확인됨'이었고, 그 앱들이 쓰는 app-ads.txt는 그대로 열리고 있었습니다. 게시자 ID도 같은 계정이니 같습니다. 파일을 아무리 들여다봐도 고칠 곳이 없습니다.

원인 — 애드몹은 스토어의 개발자 웹사이트 도메인을 본다

애드몹은 app-ads.txt를 찾을 때 스토어 등록정보에 적힌 개발자 웹사이트를 출발점으로 삼습니다. 그 주소의 도메인 루트에 /app-ads.txt가 있는지를 봅니다. 내가 파일을 어디에 올렸는지는 애드몹이 따로 알지 못합니다.

실패한 앱은 Play의 개발자 웹사이트 칸에 개인정보처리방침 주소가 들어가 있었습니다. 방침은 GitHub Pages에 올려 두었고, 그 호스트의 루트에는 app-ads.txt가 없었습니다.

앱Play 개발자 웹사이트애드몹이 찾는 위치앱 인증
기존 앱들https://example.comhttps://example.com/app-ads.txt (있음)확인됨
새 앱https://example.github.io/privacy-policies/workpay/https://example.github.io/app-ads.txt (없음)실패

같은 계정, 같은 파일, 같은 게시자 ID에서 이 앱만 달랐던 것은 개발자 웹사이트 하나였습니다. 등록정보를 채울 때 웹사이트 칸에 방침 주소를 그대로 넣은 것이 원인입니다. 둘 다 "앱과 관련된 URL"이라 섞어 넣기 쉽습니다.

"공개 직후라 크롤이 늦는 것"으로 넘기지 마세요.
새 앱은 공개와 스토어 연결, 앱 인증이 한꺼번에 몰려 있어서 실패를 보면 시간 문제로 읽게 됩니다. 그런데 웹사이트가 다른 도메인을 가리키고 있으면 며칠을 기다려도 결과가 바뀔 이유가 없습니다. 아래 확인은 몇 분이면 끝나니 기다리기 전에 먼저 하세요.

확인 순서

순서볼 것정상어긋나면
1스토어 페이지에 표시된 개발자 웹사이트app-ads.txt를 올린 도메인방침 주소 등 다른 도메인이면 이것이 원인
2그 도메인 루트의 /app-ads.txt파일 내용이 그대로 열린다404면 애드몹도 못 찾는다
3파일 안의 게시자 ID애드몹 계정의 게시자 ID와 같다다르면 파일을 고친다
4인증이 되는 다른 앱과의 차이웹사이트가 같은 도메인다른 항목이 곧 원인 후보

1. 스토어 페이지의 웹사이트

앱의 Play 스토어 페이지에는 개발자 웹사이트 링크가 실려 있습니다. 브라우저로 열어 눈으로 봐도 되고, 페이지 HTML에서 주소를 찾아도 됩니다. 인증이 되는 앱과 안 되는 앱을 나란히 놓으면 바로 갈립니다. 제 앱들은 다른 앱이 전부 자기 도메인이었고 문제의 앱만 방침 주소였습니다.

# 방침을 올린 호스트가 스토어 페이지에 웹사이트로 실려 있는지 본다
curl -s "https://play.google.com/store/apps/details?id=com.example.workpay&hl=ko" \
  | grep -o "https://example\.github\.io[^\"]*" | sort -u

방침 링크도 같은 페이지에 실리므로, 결과에 주소가 나온다는 것만으로는 판단할 수 없습니다. 웹사이트 항목이 어느 주소인지를 봐야 합니다. 확실한 값은 뒤에 나오는 edits.details.get이 돌려줍니다.

2. 도메인 루트의 파일

1번에서 본 주소의 호스트만 남기고 뒤에 /app-ads.txt를 붙여 요청합니다. 경로가 붙은 주소가 아니라 루트입니다.

curl -i https://example.github.io/app-ads.txt     # 스토어에 적힌 도메인
curl -i https://example.com/app-ads.txt           # 파일을 올린 도메인

앞의 것이 404이고 뒤의 것이 200이면 원인이 확정입니다. 파일 한 줄의 형식은 아래와 같고, 게시자 ID 자리만 자기 계정 값입니다.

google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0

해결 — 개발자 웹사이트를 파일이 있는 도메인으로

고칠 곳은 app-ads.txt가 아니라 Play 등록정보입니다. 방법은 둘입니다.

방법 1. Play Console

Play Console의 스토어 설정에서 연락처 정보의 웹사이트를 app-ads.txt가 있는 도메인으로 바꿔 저장합니다. 개인정보처리방침 주소는 별도 항목이므로 그대로 둡니다.

방법 2. Play Developer API

앱이 여러 개이거나 배포를 스크립트로 한다면 API가 편합니다. 웹사이트는 edits.details의 contactWebsite 필드입니다. 수정본(edit)을 열고, 현재 값을 읽고, 바꾸고, 커밋하는 순서입니다. 서비스 계정에는 해당 앱에 대한 Play Console 권한이 있어야 합니다.

from google.oauth2 import service_account
from googleapiclient.discovery import build

KEY = "play-service-account.json"          # 서비스 계정 키(예시 경로)
PKG = "com.example.workpay"
SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]

creds = service_account.Credentials.from_service_account_file(KEY, scopes=SCOPES)
svc = build("androidpublisher", "v3", credentials=creds, cache_discovery=False)

edit = svc.edits().insert(body={}, packageName=PKG).execute()
eid = edit["id"]
committed = False
try:
    cur = svc.edits().details().get(packageName=PKG, editId=eid).execute()
    print("현재:", cur.get("contactWebsite"))

    svc.edits().details().patch(
        packageName=PKG, editId=eid,
        body={"contactWebsite": "https://example.com"},
    ).execute()

    svc.edits().commit(packageName=PKG, editId=eid).execute()
    committed = True
finally:
    if not committed:                      # 실패하면 열어 둔 수정본을 버린다
        svc.edits().delete(packageName=PKG, editId=eid).execute()

details().get이 출력하는 현재 값이 곧 진단입니다. 여기에 방침 주소가 찍히면 원인이 맞습니다. 값을 바꿀 때는 update가 아니라 patch를 씁니다. 보낸 필드만 바꾸므로 연락처 이메일 같은 다른 값을 건드리지 않습니다.

커밋이 곧 반영은 아닙니다.
스토어 등록정보는 앱 바이너리와 별개로 처리되고, 커밋한 뒤 스토어 페이지에 나타나기까지 시간이 걸립니다. 1번 확인을 다시 해서 스토어 페이지의 웹사이트가 바뀐 것을 본 다음 애드몹으로 넘어가세요.

애드몹에서 다시 확인시키기

스토어에 새 웹사이트가 반영되면 애드몹의 앱 설정 화면에서 앱 인증 상태를 다시 봅니다. 그대로 실패로 남아 있으면 'Verify app → 업데이트 확인'으로 다시 읽게 합니다.

실제로 걸린 시간을 적어 둡니다. 2026-10-07 밤에 API로 contactWebsite를 바꿔 커밋했고, 10-09 오전에 스토어 페이지에 새 웹사이트가 실린 것을 확인한 뒤 애드몹 앱 설정을 다시 열어 보니 앱 인증이 '확인됨', 승인 상태가 '준비됨'으로 바뀌어 있었습니다. 제 경우에는 업데이트 확인을 따로 누르지 않았는데도 바뀌어 있었습니다. 그 사이에 바꾼 것은 웹사이트 하나뿐이고 app-ads.txt 파일은 손대지 않았습니다.

앱 인증과 별개로 광고 자체가 로드되지 않는 문제는 애드몹 광고가 안 나올 때 — 오류 코드별 해결에 따로 정리했습니다.

정리

  • 애드몹은 Play 스토어 등록정보의 개발자 웹사이트 도메인 루트에서 /app-ads.txt를 찾습니다.
  • 그 칸에 방침 주소 같은 다른 도메인이 들어가 있으면 파일이 멀쩡해도 '세부정보 불일치'로 실패합니다.
  • 같은 계정의 다른 앱은 인증되는데 한 앱만 실패한다면, 두 앱의 스토어 웹사이트를 먼저 비교하세요.
  • 고치는 곳은 Play Console 스토어 설정 또는 API edits.details.patch의 contactWebsite입니다.
  • 스토어 반영을 확인한 뒤 애드몹 앱 설정에서 인증 상태를 다시 봅니다. 그대로면 업데이트 확인을 누릅니다.
  • 새 앱 등록정보를 채울 때 웹사이트와 방침 주소를 같은 값으로 넣지 않도록 점검 항목에 넣어 두세요.

자주 묻는 것

방침을 올린 github.io 주소를 웹사이트로 계속 쓰고 싶습니다.

원리대로라면 그 호스트의 루트(https://example.github.io/app-ads.txt)에서 파일이 열리게 하면 됩니다. 다만 저는 이 방법을 써 보지 않았고, 이미 app-ads.txt가 있는 자기 도메인으로 웹사이트를 바꾸는 쪽을 택했습니다. 다른 앱들이 전부 그 도메인으로 인증돼 있어서 같은 형태로 맞추는 것이 가장 단순했습니다.

웹사이트를 바꾸면 개인정보처리방침 링크도 바뀌나요?

아닙니다. 개발자 웹사이트와 개인정보처리방침 URL은 Play에서 서로 다른 항목입니다. contactWebsite만 바꾸면 방침 주소는 그대로 남습니다.

app-ads.txt를 새로 만들어야 하나요?

같은 애드몹 계정의 다른 앱이 이미 인증돼 있다면 그 파일을 그대로 씁니다. 게시자 ID가 계정 단위라 앱마다 줄을 추가할 필요가 없습니다. 새 앱의 웹사이트가 그 파일이 있는 도메인을 가리키게만 하면 됩니다.

웹사이트 주소에 경로가 붙어 있어도 되나요?

이번에 실패한 주소가 경로가 붙은 형태였고, 애드몹이 본 곳은 그 경로 아래가 아니라 도메인 루트였습니다. 방침 폴더 안에 app-ads.txt를 넣는 것으로는 해결되지 않습니다. 파일은 루트에 두고, 웹사이트는 그 도메인으로 적으세요.

728x90
반응형
LIST
728x90
반응형
SMALL

이 오류는 앱이 파일로 쓰려는 이름을 폴더가 이미 차지하고 있을 때 납니다. 쓰기 전에 옛 파일을 지우는 정리 코드가 있어도 안 풀리는 경우가 있는데, 삭제 함수가 비어 있지 않은 폴더를 못 지우고도 예외 없이 실패 값만 돌려주기 때문입니다. 그러면 사용자가 몇 번을 다시 눌러도 같은 자리에서 같은 오류로 끝납니다. 정리 코드를 재귀 삭제로 바꿔 고친 과정과, 그 수정이 기존 테스트를 깨뜨린 이야기까지 실제 코드로 정리합니다.

증상 — 다시 눌러도 같은 자리에서 실패한다

예외 메시지는 이런 모양입니다. 경로 뒤에 open failed: EISDIR (Is a directory)가 붙습니다.

java.io.FileNotFoundException: /storage/.../files/sdcheck_probe_0.bin: open failed: EISDIR (Is a directory)

제 앱은 SD 카드의 실제 용량을 검사하는 앱입니다. 저장소의 앱 전용 폴더에 sdcheck_probe_0.bin, sdcheck_probe_1.bin… 같은 검사 파일을 써 넣고 되읽습니다. 그런데 sdcheck_probe_0.bin이라는 이름의 폴더가 그 자리에 있으면 첫 파일을 여는 순간 위 오류가 납니다.

화면에는 "검사를 시작하지 못했다"는 안내가 나옵니다. 앱이 죽지는 않습니다. 문제는 그다음입니다. 검사는 시작할 때마다 옛 검사 파일을 먼저 정리하는데, 정리를 거치고도 폴더가 그대로 남아서 다시 눌러도 똑같이 실패합니다. 그 저장소는 폴더를 손으로 지우기 전까지 영영 검사를 못 하는 상태가 됩니다.

예외 타입이 FileNotFoundException 인 이유
안드로이드(libcore)는 파일 열기 실패를 원인과 상관없이 FileNotFoundException으로 던집니다. 파일이 없어서가 아닙니다. 진짜 원인은 메시지의 EISDIR, 정확히는 cause에 실린 ErrnoException의 번호입니다.

원인 — File.delete()는 비어 있지 않은 폴더를 못 지운다

정리 코드는 원래 이랬습니다.

/** 검사 파일을 지운다. 중간에 멈춰도 카드에 쓰레기를 남기지 않는다. */
fun cleanUp(dir: File) {
    runCatching {
        dir.listFiles { f -> f.name.startsWith(FILE_PREFIX) }?.forEach { it.delete() }
    }
}

이름이 sdcheck_probe_로 시작하는 것을 전부 지웁니다. 파일이면 문제없습니다. 빈 폴더여도 지워집니다. 그러나 안에 무언가 들어 있는 폴더는 File.delete()가 지우지 못합니다. 그리고 이때 예외를 던지지 않고 false만 돌려줍니다.

그 이름을 차지한 것File.delete()이어지는 쓰기
파일지운다(true)성공
빈 폴더지운다(true)성공
비어 있지 않은 폴더못 지운다(false, 예외 없음)open failed: EISDIR

위 코드는 반환값을 보지 않습니다. runCatching으로 감싸 두었지만 예외가 나지 않으니 잡을 것도 없습니다. 정리는 "끝났다"고 돌아오고, 폴더는 그대로 있고, 바로 뒤의 RandomAccessFile(file, "rw")가 폴더를 파일로 열려다 실패합니다.

이 결함이 고약한 점은 스스로 회복하는 길이 없다는 것입니다. 정리는 검사 시작에도, 실패 처리에도, 앱 시작에도 들어 있었습니다. 전부 같은 함수라 전부 같은 자리에서 조용히 넘어갔습니다.

어떻게 찾았나

실패 원인을 errno 기준의 고정 키로 계측에 보내고 있었습니다. EISDIR은 is_directory라는 키로 갑니다.

private fun errnoKey(e: IOException): String? = when (CapacityTest.errnoNameOf(e)) {
    "EIO" -> "io_error_eio"            // 매체가 입출력을 거부했다
    "ENOSPC", "EDQUOT" -> "no_space"
    "EISDIR" -> "is_directory"         // 검사 파일 이름을 폴더가 차지하고 있다
    "EROFS" -> "read_only"
    "EACCES", "EPERM" -> "permission_denied"
    else -> null
}

이 is_directory가 2026-10-01과 10-05에 한 번씩 찍혔습니다. 확인해 보니 둘 다 제 테스트 폰이었고 실사용자에게서는 0건이었습니다. 그러니 사용자 피해가 확인된 사고는 아닙니다. 다만 같은 폰에서 날짜를 건너 다시 찍혔다는 것이 "한 번 걸리면 안 풀린다"는 구조를 보여 줬고, 코드를 따라가니 위의 delete()가 나왔습니다.

해결 — deleteRecursively()로, 범위는 접두어로 좁혀서

수정은 한 단어입니다.

fun cleanUp(dir: File) {
    runCatching {
        dir.listFiles { f -> f.name.startsWith(FILE_PREFIX) }?.forEach { it.deleteRecursively() }
    }
}

코틀린의 File.deleteRecursively()는 폴더면 안의 내용까지 지웁니다. 재귀 삭제는 범위를 잘못 잡으면 위험하므로 두 가지로 좁혀 두었습니다.

  • 대상 폴더는 getExternalFilesDirs()가 돌려주는 이 앱 전용 폴더입니다. 사용자의 사진·문서 폴더가 아닙니다.
  • 대상 이름은 sdcheck_probe_로 시작하는 것뿐입니다. 같은 폴더의 다른 파일에는 닿지 않습니다.

이 결함은 실패해도 예외가 없어서 눈으로는 다시 놓치기 쉽습니다. 그래서 단위 테스트로 고정했습니다. 비어 있지 않은 폴더가 사라지는지, 그리고 접두어가 다른 파일은 내용까지 그대로인지를 함께 봅니다.

@Test
fun `cleanUp removes a folder squatting on a probe file name`() {
    val dir = Files.createTempDirectory("sdcheck-clean").toFile()
    try {
        val squatter = File(dir, "sdcheck_probe_0.bin").apply { mkdirs() }
        File(squatter, "inner.txt").writeText("x")
        File(dir, "sdcheck_probe_1.bin").writeText("y")
        val mine = File(dir, "photo.jpg").apply { writeText("keep") }

        CapacityTest.cleanUp(dir)

        assertFalse(squatter.exists())
        assertEquals(listOf("photo.jpg"), dir.list()!!.toList())
        assertEquals("keep", mine.readText())
    } finally {
        dir.deleteRecursively()
    }
}

덤 — 이 수정이 기존 테스트 5개를 깨뜨렸다

고치고 테스트를 돌리니 다른 테스트 클래스에서 5개가 빨갛게 됐습니다. 쓰기 실패를 다루는 테스트들이었는데, 실패를 만드는 방법이 하필 방금 고친 결함 그 자체였습니다.

/** [index] 번째 검사 파일을 못 열게 막는다 — 비울 수 없는 폴더가 그 이름을 차지한다. */
private fun blockFile(index: Int) {
    val squat = File(dir, "sdcheck_probe_$index.bin")
    assertTrue(squat.mkdirs())
    File(squat, "keep").writeText("x")
}

검사 전에 폴더를 미리 만들어 두고, 검사가 그 이름을 열다 실패하게 하는 방식입니다. 예전에는 정리가 이 폴더를 못 지웠으니 통했습니다. 이제는 검사 시작 때 정리가 재귀로 치워 버리므로 실패가 일어나지 않습니다.

그래서 막는 시점을 "미리"에서 "여는 순간"으로 옮겼습니다. 검사 코드에 파일을 여는 자리를 바꿔 끼울 수 있는 이음매를 하나 두었습니다. 실제 앱에서는 그냥 엽니다.

internal var openForWrite: (File) -> RandomAccessFile = { RandomAccessFile(it, "rw") }

테스트는 이 자리에 끼어들어, 열기 직전에 그 이름으로 폴더를 만든 다음 진짜 열기를 그대로 부릅니다.

private fun blockFile(index: Int) {
    val name = "sdcheck_probe_$index.bin"
    val real = savedOpen
    CapacityTest.openForWrite = { f ->
        if (f.name == name) {
            assertTrue(f.mkdirs())
            File(f, "keep").writeText("x")
        }
        real(f)
    }
}

예외를 손으로 만들어 던지지 않는다는 점은 그대로입니다. 폴더를 파일로 열려다 나는 실패는 여전히 운영체제가 냅니다. 바뀐 것은 폴더가 생기는 시점뿐입니다.

테스트가 결함에 기대고 있을 수 있습니다.
실패를 주입하는 방법이 제품 코드의 약점을 이용하고 있으면, 그 약점을 고치는 순간 테스트가 깨집니다. 이때 테스트를 지우거나 수정을 되돌리지 말고, 실패를 주입하는 자리를 옮기면 됩니다.

확인 방법 — 폴더를 직접 심어 본다

단위 테스트만으로 끝내지 않고 실기기에서도 확인했습니다. 릴리스 빌드(R8 난독화)를 올린 테스트 폰에서 내부 저장소와 SD 카드 양쪽의 앱 전용 폴더에 검사 파일 이름으로 폴더를 심어 두었습니다. 방법은 단순합니다. 그 이름으로 폴더를 만들고 안에 파일을 하나 넣습니다. 빈 폴더는 예전 코드로도 지워지므로 재현이 안 됩니다.

# 앱 전용 폴더 경로는 기기·볼륨마다 다릅니다(SD 카드는 /storage/XXXX-XXXX/ 아래).
adb shell mkdir -p /sdcard/Android/data/<패키지>/files/sdcheck_probe_0.bin
adb shell "echo x > /sdcard/Android/data/<패키지>/files/sdcheck_probe_0.bin/keep"
adb shell ls -la /sdcard/Android/data/<패키지>/files/

그다음 두 지점을 봤습니다.

  1. 앱 시작 정리. 앱을 새로 켜면 모든 볼륨의 앱 전용 폴더를 한 번 정리합니다. 켠 뒤 ls로 보니 심어 둔 폴더가 사라져 있었습니다.
  2. 검사 시작. 앱이 켜진 상태에서 폴더를 다시 심고 검사를 시작했습니다. 폴더가 사라지고 같은 이름으로 검사 파일(822MB)이 써졌습니다.

내부 저장소와 SD 카드 둘 다 같은 결과였습니다. 계측 쪽에서는 reason 값이 is_directory인 실패가 더 찍히는지를 보면 됩니다.

정리

  • open failed: EISDIR (Is a directory)는 파일로 열려는 경로가 폴더일 때 납니다. 예외 타입은 FileNotFoundException입니다.
  • File.delete()는 비어 있지 않은 폴더를 못 지우고, 예외 없이 false만 돌려줍니다. 반환값을 안 보면 실패를 알 길이 없습니다.
  • "쓰기 전에 정리"가 이 함수에 기대고 있으면 한 번 걸린 뒤로는 계속 같은 자리에서 실패합니다.
  • deleteRecursively()로 바꾸되, 앱 전용 폴더와 이름 접두어로 범위를 좁힙니다.
  • 미리 막아 두는 방식의 테스트는 이 수정으로 깨집니다. 여는 순간에 막는 이음매로 옮깁니다.

자주 묻는 것

File.delete()가 실패하면 예외가 나지 않나요?

나지 않습니다. java.io.File.delete()는 성공 여부를 Boolean으로만 알려 줍니다. 실패 이유를 예외로 받고 싶으면 java.nio.file.Files.delete(path)를 쓰면 됩니다. 비어 있지 않은 폴더에서 DirectoryNotEmptyException을 던집니다(안드로이드는 API 26 이상). 어느 쪽을 쓰든 delete()의 반환값을 버리는 코드는 한 번 의심해 볼 만합니다.

deleteRecursively()는 위험하지 않나요?

대상을 넓게 잡으면 위험합니다. 이 글의 코드는 폴더 전체를 지우는 것이 아니라, 앱 전용 폴더 안에서 정해진 접두어로 시작하는 항목에만 재귀 삭제를 겁니다. 그리고 접두어가 다른 파일이 남는다는 것을 테스트로 고정해 두었습니다. 접두어 필터 없이 상위 폴더에 대고 부르는 일은 피하세요.

EISDIR 인지 코드에서 어떻게 구분하나요?

메시지 문자열을 읽지 말고 cause를 따라가 android.system.ErrnoException을 찾은 뒤 OsConstants.errnoName(errno)로 이름을 얻습니다. 열기 실패는 전부 FileNotFoundException으로 오기 때문에, 타입만 보면 공간 부족·매체 오류·이름 충돌이 한 덩어리로 묶입니다.

실사용자에게도 일어나는 일인가요?

제 계측에서는 확인되지 않았습니다. 찍힌 두 건은 모두 제 테스트 폰이었습니다. 다만 한 번 걸리면 앱이 스스로 풀 수 없는 구조였기 때문에, 건수와 상관없이 고쳤습니다.

728x90
반응형
LIST
728x90
반응형
SMALL

애드몹 전면광고의 표시 실패 문구 Timeout for show call succeed.(코드 0)는 광고를 띄우라고 했는데 뜨지도 실패하지도 않은 채 몇 초가 흘렀다는 뜻입니다. 그 몇 초 동안 사용자는 기다리지 않고 뒤로가기를 누르거나 버튼을 한 번 더 눌러 먼저 홈으로 가 버립니다. 그 뒤에 늦게 도착한 광고 콜백이 화면을 한 번 더 닫으면 시작 화면까지 스택에서 빠져, 앱이 죽지도 않았는데 아무것도 없는 빈 화면이 됩니다. 해결은 콜백 안에서 무조건 닫지 않고, 아래에 화면이 남아 있을 때만 닫는 것입니다.

증상 — 오류 문구 하나, 그리고 예외 없는 빈 화면

제가 운영하는 SD 카드 검사 앱의 계측에 2026-10-05, 실사용자 기기 1대에서 전면광고 표시 실패가 1건 남았습니다. 내용은 이것이 전부였습니다.

interstitial_show_failed  code=0  "Timeout for show call succeed."

먼저 분명히 해 둘 것이 있습니다. 관측된 것은 이 문구 1건뿐이고, 그 사용자가 실제로 빈 화면을 봤는지는 확인하지 못했습니다. 아래에 쓰는 빈 화면 경로는 이 문구를 따라 코드를 읽다가 찾은 것입니다. 다만 한번 그 경로를 타면 결과는 분명합니다.

  • 앱이 죽지 않는다. 예외도 없고 크래시 리포트에도 안 잡힌다.
  • 화면이 그려져야 할 자리가 비어 있다.
  • 사용자 눈에는 앱이 죽은 것과 같다.

원인 — 콜백은 "한 번" 오지만 "언제" 올지는 모른다

문제가 난 자리는 검사가 끝난 결과 화면의 Done 버튼입니다. 전면광고를 띄우고, 광고가 닫히면(또는 못 띄우면) 검사 화면을 닫아 홈으로 돌아가는 구조였습니다. 수정 전 코드는 한 줄입니다(수정 커밋의 diff 에서 그대로 가져온 줄입니다).

onDone = { interstitial.show { nav.popBackStack() } },

이 앱의 interstitial.show 는 차단·재고 없음·표시 실패 어느 경로로 끝나든 넘겨받은 콜백을 한 번 부르게 되어 있습니다. 그래서 광고가 안 떠도 사용자가 결과 화면에 갇히지는 않습니다. 이 규약은 지켜지고 있었습니다.

빠져 있던 것은 시간입니다. Timeout for show call succeed. 라는 문구는 전면이 뜨지도 실패하지도 않은 채 몇 초가 흐르는 구간이 있다는 것을 알려 줍니다. 그 구간에 화면은 결과 화면 그대로이고 버튼도 그대로 눌립니다. 사용자가 할 수 있는 일은 둘입니다.

사용자가 한 일그 사이 일어나는 일늦은 콜백이 도착했을 때
시스템 뒤로가기검사 화면이 닫히고 홈이 보인다홈을 pop 한다
Done 을 한 번 더 탭이미 띄우는 중이라 "표시 중" 가드가 콜백을 즉시 돌려준다. 그 콜백이 검사 화면을 닫아 홈이 보인다첫 번째 탭의 콜백이 홈을 pop 한다

두 경우 모두 사용자는 이미 홈에 있고, 뒤늦게 온 콜백이 nav.popBackStack() 을 한 번 더 부릅니다. 홈은 시작 화면이라 그 아래에는 아무 화면도 없습니다. 시작 화면까지 스택에서 빠지면 NavHost 는 그릴 것이 없어 빈 화면이 됩니다.

광고 쪽 코드는 틀리지 않았습니다. 틀린 것은 그 콜백이 언제 오든 pop 하던 호출부였습니다. 콜백을 넘길 때 "지금 검사 화면이 떠 있다"는 것을 전제로 삼았는데, 콜백이 실행되는 시점에는 그 전제가 이미 깨져 있을 수 있습니다.

"콜백이 안 오는 경우"와는 다른 문제입니다

이 블로그의 다른 글 「전면광고 안 뜨면 화면이 안 넘어간다 — 광고 콜백에만 흐름을 건 결함」은 광고가 안 떴을 때 콜백이 아예 안 와서 사용자가 그 자리에 갇히는 경우를 다뤘습니다. 그 글의 처방은 모든 경로에서 콜백을 한 번 부르라는 것이었습니다. 이 글은 그 처방을 지킨 뒤에 남는 문제입니다. 콜백은 약속대로 한 번 오는데 늦게 오고, 그 사이에 화면 스택이 바뀌어 있습니다. 앞의 것은 사용자가 멈추는 결함이고, 이것은 사용자가 먼저 움직였기 때문에 생기는 결함입니다.

해결 — 아래에 화면이 남아 있을 때만 pop 한다

닫을지 말지를 판정하는 함수를 하나 두었습니다.

internal fun shouldCloseTestScreen(currentRoute: String?, hasScreenBelow: Boolean): Boolean =
    hasScreenBelow && currentRoute != Routes.HOME

호출부는 콜백이 도착한 그 시점의 스택을 읽어 이 함수에 넘깁니다.

onDone = {
    interstitial.show {
        if (shouldCloseTestScreen(
                nav.currentDestination?.route,
                nav.previousBackStackEntry != null,
            )
        ) nav.popBackStack()
    }
},

조건은 둘입니다.

  • previousBackStackEntry != null — 주 조건입니다. previousBackStackEntry 는 NavGraph 항목을 건너뛰기 때문에 시작 화면(홈)에 있을 때 null 입니다. "마지막 화면은 pop 하지 않는다"가 그대로 표현됩니다.
  • currentRoute != Routes.HOME — 겹가드입니다. 아래에 무언가 있다고 보고되더라도 지금 떠 있는 화면이 홈이면 닫지 않습니다.
"지금 검사 화면인가"로 묻지 않았습니다.
currentDestination.route 는 실제 경로가 아니라 등록한 패턴(test/{volumeIndex}/{mode})을 돌려줍니다. 이 문자열과 같은지를 조건으로 삼으면, 그 형태가 라이브러리 판에 따라 달라졌을 때 정상 경로에서 거짓이 되어 Done 버튼이 먹통이 됩니다. 빈 화면을 고치려다 버튼을 죽이는 셈입니다. "홈이 아니다"로 물으면 그 위험이 없습니다. 홈 경로는 앱이 직접 넘기는 상수이기 때문입니다.

광고를 띄우는 함수는 손대지 않았습니다. 사용자가 검사 도중 취소하는 onBack = { nav.popBackStack() } 도 그대로입니다. 그쪽은 버튼을 누른 순간 바로 실행되어 사이에 낄 시간이 없습니다.

확인 방법 — 재현하지 못한 것과 테스트로 고정한 것

이 부분은 한계를 그대로 적습니다. 실기기에서 "늦게 오는 콜백" 자체는 재현하지 못했습니다. 광고 표시가 몇 초간 응답 없이 머무는 상황을 손으로 만들 방법이 없었습니다. 실기기 릴리스 빌드에서 확인한 것은 Done 을 연달아 눌러도 홈으로 정상 복귀한다는 것까지입니다.

그래서 판정을 순수 함수로 떼어 단위 테스트로 고정했습니다. 이 결함은 실패해도 예외가 나지 않고 빈 화면으로 조용히 끝나기 때문에, 테스트가 없으면 다시 깨져도 알 길이 없습니다. 테스트 파일에서 이 판정에 해당하는 다섯 개입니다.

class CloseTestScreenTest {

    private val testRoute = "test/{volumeIndex}/{mode}"

    @Test
    fun `closes the test screen when home is still below it`() {
        assertTrue(shouldCloseTestScreen(testRoute, hasScreenBelow = true))
    }

    // 늦게 온 콜백. 이미 홈이면 아래에 화면이 없다 — pop 하면 NavHost 가 빈다.
    @Test
    fun `does not pop home when the callback arrives late`() {
        assertFalse(shouldCloseTestScreen(Routes.HOME, hasScreenBelow = false))
    }

    @Test
    fun `never pops the last screen whatever its route`() {
        assertFalse(shouldCloseTestScreen(testRoute, hasScreenBelow = false))
        assertFalse(shouldCloseTestScreen(null, hasScreenBelow = false))
    }

    // 겹가드: 홈 위에 무언가 있다고 보고돼도 지금 화면이 홈이면 닫지 않는다.
    @Test
    fun `does not pop while home is showing`() {
        assertFalse(shouldCloseTestScreen(Routes.HOME, hasScreenBelow = true))
    }

    // 경로 문자열의 형태가 바뀌어도 Done 은 먹어야 한다(먹통 방지).
    @Test
    fun `an unknown route above home still closes`() {
        assertTrue(shouldCloseTestScreen("test/0/FULL", hasScreenBelow = true))
        assertTrue(shouldCloseTestScreen(null, hasScreenBelow = true))
    }
}

마지막 테스트가 앞의 경고 상자에 대응합니다. 경로가 패턴이 아닌 실제 값으로 오거나 null 로 와도, 아래에 화면이 있으면 닫힙니다.

자기 앱을 점검할 때는 다음 순서가 빠릅니다.

  1. 광고 콜백 안에서 popBackStack(), finish() 처럼 화면을 닫는 호출을 찾는다.
  2. 그 콜백이 몇 초 뒤에 실행된다고 가정하고, 그때 화면 스택이 호출 시점과 같다는 보장이 있는지 본다.
  3. 보장이 없으면 콜백 안에서 현재 스택을 다시 읽어 조건을 건다.
  4. 표시 실패 콜백이 받는 오류 코드와 문구를 기록해 둔다. 이번 건도 그 기록이 없었다면 찾지 못했다.

정리

  • Timeout for show call succeed.(코드 0)는 전면광고가 뜨지도 실패하지도 않은 채 몇 초가 흐르는 구간이 있다는 뜻입니다.
  • 그 구간에 사용자가 뒤로가기나 재탭으로 먼저 홈에 가면, 늦게 온 콜백의 popBackStack() 이 시작 화면까지 빼 NavHost 가 빕니다.
  • 모든 경로에서 콜백을 한 번 부르는 규약을 지켜도 이 문제는 남습니다. 호출부가 콜백 도착 시점의 상태를 확인해야 합니다.
  • previousBackStackEntry != null 일 때만 pop 합니다. 시작 화면에서는 이 값이 null 입니다.
  • 조건은 "검사 화면인가"가 아니라 "홈이 아닌가"로 묻습니다. 경로 패턴 비교는 버튼을 먹통으로 만들 수 있습니다.

자주 묻는 것

이 문구가 뜨면 항상 빈 화면이 되나요?

아닙니다. 문구는 표시가 지연되다 실패로 끝났다는 것만 알려 줍니다. 사용자가 그 몇 초를 기다렸다면 콜백이 검사 화면을 닫고 홈으로 돌아가 정상입니다. 빈 화면은 그 사이에 사용자가 먼저 화면을 떠났을 때만 생깁니다. 제 경우도 문구는 1건 관측했지만 빈 화면이 실제로 났는지는 확인하지 못했습니다.

광고를 띄우는 함수에서 늦은 콜백을 버리면 되지 않나요?

그 방향은 택하지 않았습니다. 광고 함수의 규약은 "어떤 경로든 콜백을 한 번"이고, 이 규약 덕분에 광고가 안 떠도 사용자가 갇히지 않습니다. 그 규약은 지켜지고 있었고, 틀린 쪽은 언제 오든 pop 하던 호출부였습니다. 화면 스택이 어떤 상태인지는 호출부가 아는 정보이므로 판정도 호출부에 두었습니다.

Done 을 두 번 누르면 콜백이 두 번 오는 것 아닌가요?

호출이 두 번이니 콜백도 각각 한 번씩, 합쳐 두 번 옵니다. 두 번째 탭은 이미 띄우는 중이라 가드에 걸려 콜백이 즉시 오고, 첫 번째 탭의 콜백은 광고 쪽 결론이 난 뒤에 옵니다. 수정 전에는 이 두 번이 각각 pop 을 해서 검사 화면과 홈이 차례로 빠졌습니다. 수정 뒤에는 두 번째 pop 이 조건에 걸려 실행되지 않습니다.

왜 실기기 재현 없이 고쳤나요?

재현 조건이 광고 표시가 몇 초간 응답 없이 머무는 것인데, 이를 일부러 만들 수 없었습니다. 대신 닫을지 말지의 판정이 입력 두 개짜리 순수 함수라, 늦은 콜백이 도착했을 때의 입력(홈, 아래 화면 없음)을 테스트에 직접 넣어 결과를 고정했습니다. 실기기에서 늦은 콜백 경로가 실제로 막히는지는 검증하지 못한 채로 남아 있습니다.

728x90
반응형
LIST
728x90
반응형
SMALL

전면광고는 재고가 없거나, 직전에 띄운 지 얼마 안 됐거나, 표시 단계에서 실패하면 언제든 안 뜹니다. 그런데 "광고가 닫히면 다음 화면으로"라고 짜 두면 광고가 안 뜬 날에는 닫히는 일도 없으니 사용자가 그 자리에서 멈춥니다. 보상형 광고도 같아서, 못 띄웠을 때 아무것도 안 하면 버튼을 눌러도 반응이 없는 고장으로 보입니다. 광고를 보든 안 보든 앱의 흐름은 그대로 이어져야 하고, 이 글은 그렇게 만드는 방법을 실제로 쓰는 코드로 정리합니다.

증상 — 버튼을 눌러도 아무 일이 없다

이 결함은 오류 로그를 남기지 않습니다. 앱이 죽지도 않고, 예외도 안 납니다. 사용자 쪽에서는 이렇게 보입니다.

  • 목록에서 항목을 눌렀는데 상세 화면으로 안 넘어간다. 한 번 더 누르면 넘어가기도 한다.
  • 저장 버튼을 눌렀는데 완료 화면이 안 나온다.
  • 게임에서 "광고 보고 이어하기"를 눌렀는데 광고도 안 뜨고 이어지지도 않는다.

공통점은 다음 동작이 광고 콜백 안에만 들어 있다는 것입니다. 흔히 이렇게 씁니다.

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))
}

확인 방법 — 광고가 안 뜨는 상황을 일부러 만든다

개발자 폰에서는 광고가 늘 뜨므로, 안 뜨는 상황을 손으로 만들어야 검증이 됩니다.

  1. 비행기 모드로 앱을 처음부터 켠다. 미리 받아 두는 로드가 실패해 광고 객체가 null로 남습니다. 재고 없음과 같은 갈래를 탑니다. 이 상태에서 전환 버튼과 "광고 보고 이어하기"를 눌러 다음 화면으로 넘어가는지 봅니다.
  2. 광고 단위 ID를 비운 빌드를 만든다. 로드 함수가 ID가 비어 있으면 바로 돌아오게 짜 두면, 빌드 옵션 하나로 "광고가 영영 없는 앱"이 됩니다. 콜백형 함수의 null 갈래를 확인하기 좋습니다.
  3. 간격 제한을 극단값으로. 최소 간격을 하루로 잡은 빌드에서 전환을 연달아 하면 두 번째부터 전부 "제한에 걸려 안 뜸" 갈래입니다. 원격 설정으로 빈도를 조절한다면 배포 없이 할 수 있습니다.
  4. 원격 광고 끄기 스위치가 있다면 켜 본다. 자체 차단 갈래가 그대로 재현됩니다.
  5. 로그로 어느 갈래를 탔는지 본다. 갈래마다 이유를 남겨 두면 눈으로 추측할 필요가 없습니다.
    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). 뒤로가기 두 번이나 종료 확인창에 붙이는 것도 여기 해당합니다. 전면광고는 저장·완료 같은 작업이 끝난 시점이나 목록 → 상세 같은 화면 전환점에만 두세요.

728x90
반응형
LIST
728x90
반응형
SMALL

공통 라이브러리가 SDK를 compileOnly로 참조하면, 그 SDK를 앱이 직접 넣지 않아도 라이브러리 빌드와 앱 빌드가 둘 다 통과합니다. 문제는 실행 중에 그 클래스를 처음 쓰는 순간 NoClassDefFoundError가 난다는 점이고, 라이브러리가 앱을 죽이지 않으려고 이 오류를 잡아 두었다면 앱은 정상으로 돌면서 그 기능 하나만 꺼진 채로 남습니다. 사내 공통 라이브러리에 원격 설정을 붙이면서 이 구조를 어떻게 다뤘는지, compileOnly·implementation·api를 고르는 기준과 함께 정리합니다.

증상 — 빌드도 실행도 정상인데 그 기능만 안 먹는다

앱 21개가 사내 공통 라이브러리(AAR) 하나를 같이 씁니다. 광고 가드·계측·동의 처리 같은 것들이 들어 있고, 여기에 Firebase Remote Config로 앱을 다시 배포하지 않고 값을 바꾸는 기능(광고 차단 유지 시간, 광고 끄기 스위치, 최소 지원 버전)을 넣었습니다.

라이브러리는 Remote Config SDK를 compileOnly로 참조합니다. 이 상태에서 앱 쪽에 firebase-config가 없으면 이렇게 흘러갑니다.

단계결과이유
라이브러리 빌드통과컴파일할 때는 SDK가 클래스패스에 있다
앱 빌드통과앱 코드는 Remote Config 클래스를 직접 참조하지 않는다
앱 실행정상그 클래스를 쓰는 코드가 아직 안 돌았다
원격 설정 초기화NoClassDefFoundErrorAPK 안에 그 클래스가 없다
그 뒤앱은 계속 돈다라이브러리가 오류를 잡아 삼켰다

마지막 줄이 핵심입니다. 앱이 죽었다면 Crashlytics에 바로 잡혔을 텐데, 죽지 않으니 아무 신호도 없습니다. 이 앱만 원격 설정이 꺼진 채 인앱 기본값으로 돌고, 그걸 알게 되는 시점은 "원격으로 광고를 껐는데 그 앱만 안 꺼진다"를 겪은 뒤입니다. 사고가 났을 때 켜려고 만든 스위치가 바로 그 순간 안 먹는 구조입니다.

원인 — compileOnly는 "앱이 알아서 넣는다"는 약속이다

세 구성의 차이

구성라이브러리 컴파일앱 실행 시 포함앱 코드에서 보임배포된 POM에 실림
compileOnlyOX (앱이 직접 넣어야 함)XX
implementationOOXO (runtime)
apiOOOO (compile)

compileOnly는 배포되는 POM에 아예 실리지 않습니다. 그래서 앱의 Gradle은 이 라이브러리가 무엇을 필요로 하는지 알 방법이 없고, 빠뜨려도 경고 한 줄 없습니다. 약속은 문서와 주석에만 있습니다.

그럼 왜 compileOnly를 썼나

처음에는 implementation으로 두었습니다. 그런데 라이브러리에 firebase-bom 33.16.0을 달자 33.7.0을 쓰던 앱들의 Crashlytics가 19.4.4로 올라가 버렸습니다. Gradle은 같은 모듈의 버전이 여럿이면 높은 쪽으로 통일하기 때문입니다. 계측 코드 하나를 공통화하려다 앱 세 개의 SDK 버전을 말없이 바꾼 셈이라 되돌렸고, 그 뒤로 SDK 버전 결정권은 앱에 두기로 했습니다. 라이브러리 쪽 의존성 블록은 지금 이렇게 생겼습니다.

dependencies {
    // 전부 compileOnly — implementation 으로 두면 이 라이브러리가 앱들의 SDK 버전을 끌어올린다
    compileOnly(platform(libs.firebase.bom))
    compileOnly(libs.firebase.analytics)
    compileOnly(libs.firebase.crashlytics)
    compileOnly(libs.firebase.config)        // 원격 설정만 쓴다
    compileOnly(libs.play.services.ads)
    compileOnly(libs.user.messaging.platform)
    compileOnly(libs.play.review)
}

왜 하필 firebase-config만 빠지기 쉬운가

같은 compileOnly인데 위험도가 다릅니다. 갈리는 기준은 "앱들이 원래 갖고 있었나"입니다.

SDK앱들이 원래 갖고 있었나빠지면
firebase-analytics전부새 앱을 기존 앱에서 베껴 만들면 자연히 따라온다
play-services-ads전부위와 같다
UMP·인앱 리뷰쓰는 앱만그 기능을 부르지 않는 앱은 클래스가 로드되지도 않는다
firebase-config0개에서 시작모든 앱이 초기화 때 부르는데 앱마다 한 줄씩 새로 넣어야 했다

firebase-analytics는 이미 모든 앱에 있어서 새 앱을 만들 때 build.gradle을 베끼면 같이 옮겨집니다. firebase-config는 라이브러리가 새로 요구하기 시작한 의존성이라 베낄 원본에 없던 시기가 있었고, 그 시기의 파일을 원본으로 삼으면 빠집니다. 게다가 UMP처럼 "부르는 앱만" 필요한 것도 아닙니다. 원격 설정은 라이브러리가 앱 시작 때 스스로 초기화하므로 모든 앱이 반드시 가져야 하는 유일한 compileOnly입니다.

해결 — 앱 build.gradle에 한 줄

Kotlin DSL(버전 카탈로그):

# gradle/libs.versions.toml
[libraries]
firebase-config = { group = "com.google.firebase", name = "firebase-config" }
// app/build.gradle.kts
dependencies {
    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.analytics)
    // 원격 설정 — 공통 라이브러리가 compileOnly 로 참조하므로 앱이 직접 넣는다
    implementation(libs.firebase.config)
}

Groovy:

// app/build.gradle
dependencies {
    implementation platform('com.google.firebase:firebase-bom:33.16.0')
    implementation 'com.google.firebase:firebase-config'
}

버전을 안 적는 건 BoM이 정해 주기 때문입니다. BoM 버전은 앱이 이미 쓰는 값을 그대로 둡니다 — 그게 compileOnly로 간 이유였으니까요. 위 Groovy 예시의 BoM 버전은 예시일 뿐입니다.

"전 앱 일괄" 작업 때 Groovy 앱을 따로 확인하세요.
제 앱 중 하나만 Groovy build.gradle이고 나머지는 Kotlin DSL입니다. libs.firebase.config로 검색하면 그 앱은 문법이 달라서 결과에 안 나옵니다. 검색어를 두 형태 다 잡게 짜야 합니다(아래 확인 방법 참고).

라이브러리 쪽 — 없으면 "조용히"가 아니라 "티 나게" 꺼지게

앱 한 줄로 고쳐지는 문제지만, 다음 새 앱에서 또 빠질 수 있습니다. 그래서 라이브러리가 없다는 사실을 밖으로 내보내게 했습니다. 실제 초기화 코드의 뼈대입니다.

object BokRemote {

    /** 원격 설정이 이 기기에서 실제로 동작하는가. false 면 앱에 firebase-config 가 없다. */
    @JvmStatic @Volatile
    var available: Boolean = false
        private set

    fun init(context: Context) {
        // runCatching 은 Throwable 을 잡는다 — 의존성이 없는 앱에서 나는
        // NoClassDefFoundError 까지 여기서 걸린다. 그게 이 구조의 요점이다.
        runCatching {
            val rc = FirebaseRemoteConfig.getInstance()
            rc.setConfigSettingsAsync(...)
            rc.setDefaultsAsync(mapOf(KEY_CONFIG to DEFAULT_JSON))
            available = true
            rc.fetchAndActivate().addOnCompleteListener { task -> ... }
        }.onFailure { reportUnavailable(it) }
    }

    private fun reportUnavailable(e: Throwable) {
        available = false
        Log.w("BokRemote", "원격 설정을 쓸 수 없다(firebase-config 의존성 누락?): ${e::class.java.simpleName}")
        BokAnalytics.recordIssue("remote", "remote_config_unavailable", "cause=${e::class.java.simpleName}")
    }
}

앱을 죽이지 않는 건 의도입니다. 원격 설정은 있으면 좋은 것이지 앱의 기능이 아니라서, 없다고 사용자가 앱을 못 쓰게 할 이유는 없습니다. 대신 세 군데에 흔적을 남깁니다 — available 플래그, 로그캣 경고, 계측 이벤트 한 건.

감지 패턴 두 가지

패턴모양장점단점
첫 사용을 감싼다runCatching { 실제 호출 } 또는 try { } catch (e: NoClassDefFoundError)실제로 쓰는 경로를 그대로 검사한다다른 오류까지 같이 삼킬 수 있다
미리 확인한다Class.forName("…FirebaseRemoteConfig")쓰기 전에 분기할 수 있다문자열이라 오타를 컴파일러가 못 잡는다

제 라이브러리는 첫 번째를 씁니다. 어차피 초기화에서 곧바로 쓰니 미리 확인할 이득이 없고, 확인과 사용이 같은 코드라 둘이 어긋날 일이 없습니다.

함정 다섯 가지

① catch (e: Exception)으로는 안 잡힙니다. NoClassDefFoundError는 Exception이 아니라 Error(LinkageError) 계열입니다. 습관대로 Exception을 잡으면 앱이 그대로 죽습니다. Kotlin의 runCatching은 Throwable을 잡기 때문에 걸리고, 위 코드 주석이 그 점을 일부러 적어 둔 이유입니다.

② 잡았으면 반드시 기록합니다 — 단, 같은 라이브러리 안에서도 기준이 다릅니다. 이 라이브러리는 Crashlytics도 compileOnly로 쓰는데, 그쪽은 NoClassDefFoundError를 조용히 삼킵니다. 장애 기록을 Crashlytics로 보내는 경로라서, 그 전에 GA4로는 이미 보냈기 때문입니다. 잃는 게 "사본 하나"면 조용히 넘겨도 되고, 원격 설정처럼 잃는 게 기능 전체면 반드시 티를 내야 합니다. 같은 패턴을 복사해 붙이면 이 구분이 사라집니다.

③ available=false는 두 가지 뜻입니다. 기본값이 false이고 초기화 안에서 true가 되므로, "아직 초기화 전"과 "의존성 없음"이 같은 값입니다. 앱 시작 직후에 이 플래그를 보고 판단하면 정상 앱도 "없음"으로 읽힙니다. 그래서 판정은 플래그 하나가 아니라 로그 한 줄로 합니다(아래 확인 방법).

④ runCatching은 너무 넓습니다. 의존성이 있어도 SDK 안에서 다른 이유로 던진 오류까지 같은 길로 빠집니다. 그래서 원인으로 예외 클래스 이름을 같이 남깁니다. NoClassDefFoundError는 JDK 내장 클래스라 앱이 R8로 난독화돼도 이름이 그대로 남습니다. 우리 코드나 라이브러리 예외 이름은 한 글자로 뭉개지는 것과 대조적입니다.

⑤ 선택 의존성의 타입을 가드 바깥에 두지 않습니다. 위 코드에서 FirebaseRemoteConfig는 runCatching 안의 지역 변수로만 등장하고, 객체의 필드나 공개 함수의 시그니처에는 나오지 않습니다. 항상 로드되는 클래스가 그 타입을 바깥에 드러내면 오류가 가드 밖에서 터질 수 있고, 앱 쪽 코드가 그 타입을 참조하는 순간 compileOnly의 뜻도 깨집니다. Class.forName을 쓴다면 문자열 이름이 고정이라는 점도 기억해야 합니다 — 대상 클래스 이름이 난독화로 바뀌는 구성이면 존재하는데도 "없음"으로 읽힙니다.

확인 방법

1. 실기기 로그로 — 한 줄이면 갈린다

adb logcat -d -s BokRemote:V
찍힌 줄뜻
I BokRemote: fetch ok=true source=remote …의존성 있음 + 원격값 받음
I BokRemote: fetch ok=true source=default …의존성 있음, 원격값은 아직(인앱 기본값)
W BokRemote: 원격 설정을 쓸 수 없다(firebase-config 의존성 누락?): NoClassDefFoundError의존성 없음
아무것도 없음초기화가 안 돌았다 — 앱 시작 경로부터 의심

두 번째 줄처럼 ok=true여도 원격값을 받았다는 뜻은 아닙니다. 그 구분(source=default)은 Remote Config fetch는 성공인데 기본값으로 도는 경우에서 따로 다뤘고, 이 글은 그보다 한 단계 앞인 "SDK 자체가 없는 경우"입니다.

2. 앱 런타임 클래스패스로

./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep firebase-config

아무것도 안 나오면 빠진 겁니다. compileOnly는 라이브러리의 POM에 안 실리므로, 라이브러리를 통해 딸려 오는 일은 없습니다. 여기 나와야 하는 건 앱이 직접 넣은 줄입니다.

3. 여러 앱을 한 번에 — 두 문법을 다 잡게

grep -rnE "firebase[.-]config" --include="build.gradle*" .

점(libs.firebase.config, Kotlin DSL)과 하이픈('com.google.firebase:firebase-config', Groovy)을 정규식 하나로 같이 잡습니다. 공통 라이브러리를 쓰는 앱 목록(common-core로 검색)과 개수를 맞춰 보면 빠진 앱이 바로 드러납니다. 지금 이 라이브러리를 쓰는 앱 21개를 이렇게 대조하니 전부 들어 있었습니다.

정리

  • compileOnly 의존성은 POM에 안 실려 앱 빌드가 누락을 알 수 없습니다. 빠지면 실행 중에 NoClassDefFoundError가 납니다.
  • 라이브러리가 그 오류를 잡아 삼키면 앱은 멀쩡하고 그 기능만 꺼집니다. 가장 늦게 발견되는 형태입니다.
  • compileOnly를 쓰는 이유는 앱의 SDK 버전을 끌어올리지 않기 위해서입니다(Gradle은 높은 버전으로 통일합니다).
  • 위험한 건 앱들이 원래 갖고 있지 않던 compileOnly입니다. 기존 파일을 베껴도 안 따라옵니다.
  • 잡을 때는 Exception이 아니라 Throwable/NoClassDefFoundError를, 잡았으면 플래그·로그·계측으로 티를 냅니다.

자주 묻는 것

그냥 implementation으로 바꾸면 끝나지 않나요?

누락 문제는 사라지지만 다른 문제가 생깁니다. 라이브러리가 고른 SDK 버전이 앱들의 버전보다 높으면 Gradle이 앱 쪽을 끌어올립니다. 앱마다 다른 BoM을 쓰고 있다면 라이브러리를 올리는 것만으로 여러 앱의 SDK가 말없이 바뀝니다. 앱이 하나뿐이거나 버전을 늘 라이브러리에 맞춘다면 implementation이 더 단순한 선택입니다.

빌드 단계에서 누락을 막을 수는 없나요?

Gradle 표준 기능으로는 어렵습니다. compileOnly는 소비자에게 전달되지 않는 구성이라, 앱 빌드 시점에 "이 라이브러리가 저 SDK를 요구한다"는 정보 자체가 없습니다. 그래서 저는 실행 중 관측(available 플래그와 경고 로그)과, 여러 앱 build.gradle을 한 번에 훑는 검색 두 가지로 막습니다.

NoClassDefFoundError와 ClassNotFoundException은 뭐가 다른가요?

ClassNotFoundException은 Class.forName처럼 이름으로 직접 찾을 때 나는 체크 예외이고, Exception 계열이라 평범한 catch에 걸립니다. NoClassDefFoundError는 컴파일 때는 있던 클래스를 코드가 실행 중에 참조했는데 없을 때 나는 Error입니다. 그래서 Class.forName 방식은 ClassNotFoundException을, 첫 사용을 감싸는 방식은 NoClassDefFoundError를 잡아야 합니다. 둘을 바꿔 쓰면 잡히지 않습니다.

왜 앱을 그냥 죽이지 않나요? 그러면 바로 알 텐데요.

그 기능이 앱의 핵심이면 죽이는 게 맞습니다. 원격 설정은 사고 대응용 스위치라서 없어도 앱은 기본값으로 정상 동작합니다. 그런데 이걸로 앱을 죽이면 의존성 한 줄 누락이 사용자 전원의 크래시가 됩니다. 기능의 무게에 맞춰 실패 방식을 고르되, 조용히 꺼지는 쪽을 골랐다면 관측 장치를 반드시 같이 둔다가 제 기준입니다.

728x90
반응형
LIST
728x90
반응형
SMALL

Firebase Analytics 이벤트에 무엇이 실려 나가는지는 코드를 읽어서는 잘 안 보이고, 실기기 logcat의 Logging event 줄을 봐야 확실히 보입니다. 제 칼로리 기록 앱은 검색 이벤트에 예약 파라미터 SEARCH_TERM을 채워 두는 바람에, 사용자가 친 음식 검색어가 타이핑 중간 상태까지 그대로 GA4로 나가고 있었습니다. 코드 리뷰로는 지나쳤고, 실기기에서 한글을 직접 쳐 보며 로그를 띄웠을 때 처음 보였습니다. 원문을 빼고 결과 건수만 구간 문자열로 보내도록 고쳤습니다.

증상 — 로그에 "김", "김ㅊ", "김치"가 차례로 찍혔다

칼로리 기록 앱을 실기기에서 점검하던 중이었습니다. 음식 검색 칸에 "김치"를 치고 Firebase Analytics 디버그 로그를 띄워 두었는데, search 이벤트가 한 번이 아니라 여러 번 나갔고 매번 검색어가 붙어 있었습니다. 모양을 줄여 옮기면 이렇습니다.

Logging event: origin=app, name=search, params=Bundle[{..., search_term=김, ...}]
Logging event: origin=app, name=search, params=Bundle[{..., search_term=김ㅊ, ...}]
Logging event: origin=app, name=search, params=Bundle[{..., search_term=김치, ...}]

완성된 검색어만 나가는 것도 아니었습니다. 조합이 덜 된 김ㅊ까지 그대로 실렸습니다. 음식 검색어는 그 사람이 무엇을 먹는지를 드러내고, 자유 입력 칸이라 무엇이든 들어올 수 있습니다. 저는 검색어 원문은 수집하지 않는다는 원칙을 앱 전체에 세워 두었는데, 바로 그 원칙이 깨져 있었습니다.

원인 — 예약 파라미터가 "채워 넣으라"고 유도한다

문제의 코드는 이랬습니다.

fun logFoodSearch(query: String, resultCount: Int) {
    firebaseAnalytics.logEvent(
        FirebaseAnalytics.Event.SEARCH,
        bundleOf(
            FirebaseAnalytics.Param.SEARCH_TERM to query,
            "result_count" to resultCount.toLong()
        )
    )
}

읽어 보면 아주 자연스럽습니다. Event.SEARCH를 쓰면 짝이 되는 예약 파라미터 Param.SEARCH_TERM이 있고, 자동완성도 그걸 띄워 줍니다. 그러니 "검색 이벤트에는 검색어를 싣는 것"이 정석처럼 보입니다. 그 자연스러움 때문에 개인정보라는 생각이 안 듭니다.

타이핑 중간 상태까지 나간 이유는 호출 위치에 있었습니다.

queryFlow
    .debounce(250)
    .distinctUntilChanged()
    .onEach { q ->
        ...
        val results = searchFood(q)
        analytics.logFoodSearch(q, results.size)
        ...
    }

검색은 입력이 0.25초 멈추면 실행됩니다. 사용자에게는 결과가 바로바로 바뀌는 좋은 화면이지만, 계측은 입력이 잠깐 멈출 때마다 그 순간의 글자를 보냅니다. 한글은 자모를 하나씩 쳐서 글자를 만들기 때문에 중간 상태가 영어보다 많습니다.

지점코드에서 보이는 것실제로 나간 것
Param.SEARCH_TERM표준 이벤트의 표준 파라미터사용자가 친 음식 이름 원문
debounce(250)검색 반응 속도 조절멈출 때마다 이벤트 1건 — 김·김ㅊ·김치
result_count (Long)결과 건수GA4 측정기준으로는 아무도 못 읽는 값

마지막 줄은 덤으로 드러난 것입니다. 숫자로 보낸 result_count는 측정기준으로도 측정항목으로도 등록된 적이 없어서 아무도 읽지 못하던 값이었습니다. 숫자 파라미터가 측정기준으로 안 읽히는 문제는 GA4 커스텀 측정기준이 전부 (not set)인 이유에 따로 정리했습니다. 이 글은 그 반대편, 읽히면 안 되는 값이 나가고 있던 경우입니다.

해결 — 원문을 빼고 건수만 구간 문자열로

알고 싶었던 것은 "무엇을 찾았나"가 아니라 "검색이 결과를 돌려주는가"였습니다. 그래서 검색어를 통째로 빼고, 결과 건수만 구간 문자열로 보내도록 바꿨습니다.

fun logFoodSearch(query: String, resultCount: Int) {
    firebaseAnalytics.logEvent(
        FirebaseAnalytics.Event.SEARCH,
        bundleOf(
            "item_count" to countBucket(resultCount)
        )
    )
}

// GA4 커스텀 측정기준은 문자열만 읽는다 — 숫자로 보내면 전량 (not set)
private fun countBucket(n: Int): String = when {
    n <= 0 -> "0"
    n < 10 -> "1_9"
    n < 50 -> "10_49"
    else   -> "50_"
}

건수 파라미터 이름을 새로 만들지 않고 다른 앱에서 이미 쓰던 item_count를 그대로 쓴 데는 이유가 있습니다. 제 GA4 속성은 이벤트 범위 커스텀 측정기준이 50개 중 50개가 차 있어서 새 이름을 등록할 자리가 없었습니다. 뜻이 같으면 기존 이름을 쓰고, 앱은 데이터 스트림으로 가릅니다.

"0"을 독립된 칸으로 둔 것도 의도입니다. 원문 없이도 결과가 하나도 없는 검색이 얼마나 되는지는 이 칸으로 셀 수 있습니다.

같은 실수가 다른 앱에도 있었다

한 앱에서 찾고 나서 다른 앱들도 훑었더니, 사이즈 비교 앱의 브랜드 검색이 똑같은 형태였습니다.

// 전
fun logSearchBrand(query: String) {
    analytics?.logEvent(FirebaseAnalytics.Event.SEARCH) {
        param(FirebaseAnalytics.Param.SEARCH_TERM, query)
    }
}

// 후
fun logSearchBrand() {
    analytics?.logEvent(FirebaseAnalytics.Event.SEARCH, null)
}

"브랜드 이름이니 괜찮겠지" 싶지만, 칸이 자유 입력인 이상 브랜드 말고도 무엇이든 들어옵니다. 이 앱은 건수 구간도 붙이지 않고 파라미터를 아예 없앴습니다. 여기서 알고 싶은 건 "검색 기능을 쓰는가"뿐이었고, 새 측정기준을 등록할 자리도 없었으니 무엇을 더 실어도 읽을 사람이 없었습니다.

호출하는 쪽도 같이 고치세요.
함수 시그니처에서 query를 빼면 호출부가 컴파일 오류로 드러나서 빠뜨릴 일이 없습니다. 칼로리 앱처럼 query 인자를 남겨 두면 나중에 누군가 "있는 값이니 실어 보자"며 다시 넣기 쉽습니다.

확인 방법 — Logging event 줄을 직접 띄운다

Firebase Analytics는 기본 상태에서는 무엇을 보내는지 logcat에 자세히 찍지 않습니다. 로그 태그 두 개를 VERBOSE로 올리고, 대상 앱을 디버그 모드로 지정합니다.

adb shell setprop log.tag.FA VERBOSE
adb shell setprop log.tag.FA-SVC VERBOSE
adb shell setprop debug.firebase.analytics.app 내.앱.패키지명

# 앱을 조작한 뒤
adb logcat -d -s FA FA-SVC | grep "Logging event"

# 윈도우 명령 프롬프트라면
adb logcat -d -s FA FA-SVC | findstr "Logging event"

이벤트 이름과 파라미터가 Bundle 그대로 찍힙니다. 이 방법이 좋은 점이 두 가지 있습니다.

  • 입력한 글자로 바로 검색할 수 있습니다. 방금 친 "김치"를 grep에 넣어 보면 됩니다. 0건이어야 정상입니다. 고친 뒤에는 search 이벤트에 item_count=10_49 같은 구간 값만 남고 검색어는 나오지 않는 것을 이렇게 확인했습니다.
  • 값이 숫자로 나가는지 문자열로 나가는지도 보입니다. 측정기준용 값이 숫자로 나가고 있으면 여기서 걸러집니다.

다 끝나면 디버그 모드를 풀어 둡니다.

adb shell setprop debug.firebase.analytics.app .none.

난독화된 릴리스 빌드로 볼 것

디버그 빌드로만 확인하면 반쪽짜리입니다. 릴리스는 R8 난독화가 켜져 있어서, 예를 들어 예외 클래스 이름을 파라미터로 싣는 코드는 디버그에서는 멀쩡한 이름이 찍히다가 릴리스에서는 한 글자로 뭉개집니다. 사용자에게 나가는 것과 같은 바이너리로 봐야 사용자에게 나가는 값이 보입니다.

릴리스로 테스트하면 내 기기가 실사용자로 집계됩니다.
저는 디버그 빌드에만 versionNameSuffix = "-debug"를 붙여 두고, 감시 스크립트가 앱 버전에 "debug"가 들어간 데이터를 거르게 해 둡니다. 릴리스로 돌리면 이 표시가 없어서 테스트가 실사용 통계에 섞입니다. 언제 몇 시에 돌렸는지 적어 두면 나중에 걸러낼 수 있습니다.

앱을 두 번 켜 볼 것

같은 방법으로 공고 알림 앱에서 반대 방향의 결함도 찾았습니다. 이번에는 이벤트가 너무 많이 나가는 게 아니라 안 나가고 있었습니다. 목록 화면을 그릴 때 보내는 home_loaded가, 두 번째 실행부터는 목록이 멀쩡히 뜨는데도 logcat에 한 줄도 안 찍혔습니다.

if (!forceRefresh && fresh && allNotices.isNotEmpty()) {
    lastUpdated = last
    Analytics.logHomeLoaded(allNotices.size, filter = null)   // ← 이 줄이 없었다
    isLoading = false
    return@launch
}

6시간 안에 받은 캐시가 있으면 네트워크를 건너뛰고 일찍 빠져나가는 분기인데, 거기에 계측이 없었습니다. 이 앱은 대부분의 실행이 이 경로라서 home_loaded는 "네트워크를 친 실행"만 세고 있었습니다. 첫 실행은 캐시가 없으니 네트워크로 가고 이벤트도 정상으로 나갑니다. 앱을 두 번 켜야 보이는 결함이었습니다.

두 사례 모두 코드를 읽으면 정상으로 보였고 유닛테스트와 릴리스 빌드도 통과했습니다. 차이는 "기기에서 실제로 무엇이 나가는지 눈으로 봤느냐" 하나였습니다.

정리

  • FirebaseAnalytics.Event.SEARCH를 쓰면 Param.SEARCH_TERM을 채우는 게 자연스러워 보이지만, 그 값은 사용자가 친 원문입니다.
  • 입력 중 검색(debounce)에 계측을 붙이면 타이핑 중간 상태까지 나갑니다. 한글은 김ㅊ 같은 미완성 글자도 실립니다.
  • 원문 대신 결과 건수를 구간 문자열로 보내거나, 쓸 사람이 없으면 파라미터를 아예 뺍니다.
  • 확인은 setprop log.tag.FA VERBOSE·FA-SVC VERBOSE·debug.firebase.analytics.app 뒤 Logging event 줄로 합니다. 입력한 글자로 grep해서 0건인지 봅니다.
  • 난독화 릴리스로 보고, 캐시를 타는 기능은 두 번 켜서 봅니다.

자주 묻는 것

DebugView로 보면 되지 않나요?

DebugView에서도 이벤트와 파라미터가 보입니다. 다만 logcat은 기기에서 바로 찍히고 텍스트라서 방금 입력한 글자로 검색할 수 있습니다. 원문이 새는지 확인하는 목적에는 "그 글자가 로그에 있느냐"를 grep 한 줄로 묻는 쪽이 빠르고 확실합니다. 이벤트가 안 나가는 결함도 logcat에서 "그 줄이 없다"로 바로 보입니다.

검색어 말고 또 무엇을 봐야 하나요?

사용자가 직접 입력하는 칸과 건강·신체 값입니다. 같은 칼로리 앱에서 며칠 뒤 개인정보처리방침에 적은 약속과 계측 코드를 대조하다가, 식사 저장 이벤트에 칼로리·인분이, 목표 설정 이벤트에 성별·활동량·목표 칼로리 원값이 실려 나가던 것을 찾아 함께 뺐습니다. 다른 앱에서는 사용자가 입력한 메뉴 이름이 파라미터로 나간 일도 있어서, 그 뒤로는 실기기 점검 항목에 "입력한 문자열이 Logging event에 0건인가"를 넣어 두었습니다.

원문이 없으면 검색 품질은 어떻게 보나요?

구간 값 "0"이 그 역할을 합니다. 검색 이벤트 가운데 item_count=0의 비율이 "찾았는데 아무것도 안 나온 검색"의 비율입니다. 무엇을 찾다 실패했는지는 모르지만, 그 비율이 갑자기 오르면 검색이나 데이터 쪽이 망가졌다는 신호로는 충분합니다.

728x90
반응형
LIST
728x90
반응형
SMALL

한글이 든 PowerShell 스크립트를 실행했더니 문자열에 종결자가 없다는 구문 오류가 나는데, 파일을 열어 보면 따옴표가 멀쩡히 닫혀 있는 경우가 있습니다. 원인은 코드가 아니라 저장 방식입니다. Windows PowerShell 5.1은 BOM이 없는 .ps1 파일을 UTF-8이 아니라 시스템 코드페이지(한국어 윈도우는 CP949)로 읽고, 그 과정에서 한글 바로 뒤의 따옴표나 줄바꿈이 먹혀 버립니다. 같은 파일 안에서도 어떤 한글은 통과하고 어떤 한글은 깨지기 때문에 원인을 찾기가 유독 어렵습니다.

윈도우에서 한글이 깨지는 지점 전체(터미널 출력·파이썬·파일 저장)는 윈도우에서 클로드 코드 한글이 깨질 때 — CP949 정리에 지도처럼 정리해 두었습니다. 이 글은 그중 .ps1 파일 하나가 왜 그런 식으로 깨지는지, 어떻게 진단하고 내용을 망치지 않고 고치는지만 깊게 다룹니다.

증상 — 따옴표가 닫혀 있는데 닫히지 않았다고 한다

아래처럼 한 줄짜리 스크립트를 BOM 없는 UTF-8로 저장하고 Windows PowerShell 5.1로 실행하면 이렇게 나옵니다.

# run.ps1 (UTF-8, BOM 없음)
Write-Output "테스트"
PS> powershell -NoProfile -File .\run.ps1
위치 ...\run.ps1:1 문자:14
+ Write-Output "?뚯뒪??
+              ~~~~~~
문자열에 " 종결자가 없습니다.
    + CategoryInfo          : ParserError: (:) [], ParseException
    + FullyQualifiedErrorId : TerminatorExpectedAtEndOfString

영문 UI의 PowerShell이라면 같은 오류가 The string is missing the terminator: ".로 나옵니다. 오류 ID는 어느 언어든 TerminatorExpectedAtEndOfString입니다. 화면의 "?뚯뒪??를 보면 이미 한글이 깨져 있고, 닫는 따옴표가 사라져 있습니다.

제가 처음 이걸 겪은 건 작업 스케줄러로 돌리던 발행 스크립트였습니다. 토요일 오전 9시에 작업이 정상적으로 트리거됐는데 그날 글이 하나도 안 나갔고, 남은 흔적은 작업 결과 코드 1뿐이었습니다. 스크립트가 남기는 로그 파일이 0개였습니다. 첫 줄의 로그 기록조차 실행되지 않은 것인데, PowerShell은 파일 전체를 먼저 구문 분석하고 하나라도 오류가 있으면 아무 줄도 실행하지 않기 때문입니다. 그래서 이 문제는 "스크립트 중간에서 죽었다"가 아니라 "시작조차 안 했다"로 보입니다.

원인 — 3바이트 한글을 2바이트씩 끊어 읽는다

UTF-8에서 한글 한 글자는 3바이트입니다. CP949는 한글 한 글자를 2바이트로 씁니다. BOM이 없으면 PowerShell 5.1은 파일이 UTF-8이라는 걸 모르고 CP949 규칙대로 바이트를 두 개씩 짝지어 읽습니다.

저장된 바이트 (UTF-8)5.1이 읽는 방식 (CP949)결과
한글 2글자 = 6바이트2바이트씩 3번 → 짝이 딱 맞음글자는 깨지지만 뒤의 따옴표는 살아남음
한글 3글자 = 9바이트2바이트씩 4번 + 1바이트 남음남은 1바이트가 뒤의 따옴표를 짝으로 끌어가 ? 한 글자가 됨

위 예시의 "테스트"를 바이트로 보면 22 ED 85 8C EC 8A A4 ED 8A B8 22입니다. CP949로 읽으면 마지막에 남은 B8이 닫는 따옴표 22와 묶여 ?로 바뀌고, 따옴표는 사라집니다. 파서 입장에서는 문자열이 열리기만 하고 닫히지 않았으니 TerminatorExpectedAtEndOfString을 냅니다.

직접 여러 문자열로 실험해 보면 이렇게 갈립니다. 모두 BOM 없는 UTF-8로 저장해 powershell -NoProfile -File로 실행한 결과입니다.

파일 내용따옴표·줄바꿈 바로 앞 한글결과
Write-Output "발행 완료"완료 = 6바이트실행됨. 출력은 諛쒗뻾 ?꾨즺로 깨짐
Write-Output "한글"6바이트실행됨. 출력은 깨짐
Write-Output "테스트"9바이트종결자 오류
Write-Output "끝"3바이트종결자 오류
# 한글 주석입니다 다음 줄에 Write-Output "ok"주석입니다 = 15바이트오류 없이 아무것도 출력 안 됨

짝수·홀수로 설명했지만 이건 실험에서 그대로 맞아떨어진 경험칙이고, 바이트 조합에 따라 디코더가 짝을 다르게 끊을 수도 있습니다. 중요한 건 "한글이 있으면 깨진다"가 아니라 "어떤 한글은 운 좋게 통과한다"는 점입니다. 저도 BOM 없이 저장된 스크립트 중 일부는 멀쩡히 돌고 있었고, 그래서 한동안 인코딩을 의심하지 않았습니다.

가장 위험한 경우 — 오류 없이 줄이 사라진다

표의 마지막 줄이 가장 고약합니다. 한글 주석 끝에 남은 1바이트가 따옴표 대신 줄바꿈 문자를 먹으면, 다음 줄이 주석의 일부가 됩니다. 구문 오류가 없으니 아무 경고 없이 실행되고, 종료 코드도 0입니다. 그 줄의 명령만 조용히 빠집니다.

# 저장된 내용
# 한글 주석입니다
Write-Output "ok"

# 5.1이 실제로 본 내용 (줄바꿈이 사라져 한 줄이 됨)
# ?쒓? 二쇱꽍?낅땲??Write-Output "ok"

오류 위치가 엉뚱한 줄을 가리킨다

따옴표가 하나 사라지면 문자열이 다음 따옴표까지 이어집니다. 그래서 오류 위치가 한글이 있는 줄이 아니라 그 뒤의 엉뚱한 줄로 찍히고, 블록 안이라면 중괄호 오류까지 같이 나옵니다.

foreach ($i in 1..2) {
    Write-Output "테스트"     # ← 실제 원인은 2번째 줄
}
Write-Output "done"           # ← 오류는 4번째 줄로 보고됨
문자열에 " 종결자가 없습니다.                          (4번째 줄)
문 블록 또는 형식 정의에 닫는 '}'가 없습니다.          (1번째 줄)
TerminatorExpectedAtEndOfString / MissingEndCurlyBrace

오류가 가리키는 줄의 코드를 아무리 봐도 문제가 없는 이유가 이것입니다.

해결 — 내용을 UTF-8로 읽은 뒤 BOM을 붙여 다시 쓴다

BOM(바이트 순서 표시)은 파일 맨 앞의 3바이트 EF BB BF입니다. 이게 있으면 PowerShell 5.1도 파일을 UTF-8로 읽습니다. 고치는 방법은 둘입니다.

방법 A — 바이트 앞에 BOM만 붙인다 (가장 안전)

파일 내용을 글자로 해석하지 않고 바이트 그대로 둔 채 앞에 3바이트만 붙입니다. 디코딩 과정이 없으니 내용이 바뀔 여지가 없습니다. 단, 이미 BOM이 있는 파일에 또 붙이지 않도록 먼저 확인합니다.

$p = 'C:\scripts\run.ps1'
$b = [System.IO.File]::ReadAllBytes($p)
if (-not ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF)) {
    [System.IO.File]::WriteAllBytes($p, [byte[]](0xEF,0xBB,0xBF) + $b)
}

방법 B — UTF-8로 읽어서 BOM 포함으로 다시 쓴다

$p = 'C:\scripts\run.ps1'
$txt = Get-Content $p -Raw -Encoding UTF8
[System.IO.File]::WriteAllText($p, $txt, (New-Object System.Text.UTF8Encoding($true)))   # $true = BOM 포함
읽을 때 -Encoding UTF8을 빼면 BOM을 붙여도 고쳐지지 않습니다.
5.1의 Get-Content는 BOM 없는 파일을 실행할 때와 똑같이 CP949로 읽습니다. 그렇게 이미 깨진 글자를 BOM 포함으로 저장하면 깨진 상태가 UTF-8로 확정됩니다. 실제로 이렇게 고친 파일은 맨 앞에 EF BB BF가 붙어 있는데도 똑같이 종결자 오류로 실패했습니다. 따옴표가 이미 ?로 바뀐 채 저장됐기 때문입니다. 이렇게 망가졌다면 원본(깃 이력 등)에서 다시 가져와야 합니다.
또 [System.IO.File] 메서드에는 상대 경로 대신 전체 경로를 넘기세요. .NET의 현재 폴더는 PowerShell에서 cd로 옮긴 위치와 다를 수 있습니다.

처음부터 BOM으로 저장하게 하기

  • VS Code: 오른쪽 아래 상태 표시줄의 인코딩(UTF-8)을 누르고 Save with Encoding → UTF-8 with BOM. PowerShell 파일에만 적용하려면 설정에 아래를 넣습니다.
"[powershell]": {
    "files.encoding": "utf8bom"
}
  • AI 코딩 도구가 만든 .ps1: 파일 쓰기 도구는 대개 BOM 없는 UTF-8로 저장합니다. 저는 9월에 블로그 발행 자동화용 스크립트를 새로 만들면서 이 문제를 한 번 더 밟았습니다. 그 뒤로는 .ps1을 만들면 위의 방법 B로 한 번 더 저장하는 것을 규칙으로 두었습니다.

PowerShell 7(pwsh)은 사정이 다르다

PowerShell 7은 BOM이 없는 파일을 UTF-8로 읽고, 파일을 쓸 때 기본값도 BOM 없는 UTF-8입니다. 그래서 같은 파일이 pwsh에서는 멀쩡하고 5.1에서만 깨집니다. 문제는 윈도우에 기본으로 깔린 것은 5.1이고, 작업 스케줄러나 바로 가기에 powershell.exe로 등록돼 있으면 pwsh를 설치해도 5.1로 돈다는 점입니다. 실행 명령이 powershell.exe인지 pwsh.exe인지 먼저 확인하세요. 5.1에서 돌 가능성이 조금이라도 있으면 BOM을 붙여 두는 편이 양쪽 다 안전합니다.

덤 — 5.1에서 파일 쓰기 명령마다 기본 인코딩이 다르다

스크립트로 .ps1이나 텍스트 파일을 만들 때도 같은 함정이 있습니다. Windows PowerShell 5.1에서 한글이라는 두 글자를 각 명령으로 저장하고 바이트를 확인한 결과입니다.

명령 (5.1)저장 인코딩실제 앞부분 바이트
Set-Content (기본)시스템 ANSI = CP949C7 D1 B1 DB
Out-File, > (기본)UTF-16 LE + BOMFF FE 5C D5 00 AE
Set-Content -Encoding UTF8UTF-8 + BOMEF BB BF ED 95 9C

같은 PowerShell 안에서도 Set-Content와 Out-File의 기본값이 다릅니다. 5.1의 -Encoding UTF8은 항상 BOM을 붙이므로 .ps1을 만들 때는 오히려 맞는 선택입니다. Out-File로 만든 UTF-16 파일도 BOM이 있어서 PowerShell은 정상 실행하지만, 깃이나 다른 도구는 이진 파일로 취급하는 경우가 많아 권하지 않습니다.

확인 방법

1. BOM이 있는지 — 첫 3바이트를 본다

# 파일 하나
[System.IO.File]::ReadAllBytes('C:\scripts\run.ps1')[0..2]
# 239 187 191 이면 BOM 있음 (= EF BB BF)

# 폴더 전체
Get-ChildItem C:\scripts -Filter *.ps1 | ForEach-Object {
    $b = [System.IO.File]::ReadAllBytes($_.FullName)
    [pscustomobject]@{
        Name = $_.Name
        BOM  = ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF)
    }
}

제 스케줄러 스크립트 폴더는 지금 .ps1 19개가 전부 EF BB BF로 시작합니다. 사고 이후 일부만 고치지 않고 전부 맞췄습니다. 앞에서 본 것처럼 BOM 없이도 우연히 살아남는 파일이 있어서, 하나씩 확인하는 것보다 일괄로 통일하는 편이 낫습니다.

2. 실행하지 않고 구문만 검사한다

$t = $null; $e = $null
[System.Management.Automation.Language.Parser]::ParseFile('C:\scripts\run.ps1', [ref]$t, [ref]$e) | Out-Null
$e | Select-Object ErrorId, Message, @{n='Line'; e={$_.Extent.StartLineNumber}}

BOM 없는 파일에서는 TerminatorExpectedAtEndOfString이 나오고, BOM을 붙인 같은 파일에서는 오류가 0개로 나옵니다. 스케줄러에 걸린 스크립트가 로그 하나 없이 실패했다면 코드를 고치기 전에 이것부터 돌려 보세요.

구문 검사로는 "줄이 사라지는" 경우를 못 잡습니다.
주석이 다음 줄을 삼킨 경우는 구문상 아무 문제가 없어 오류가 0개로 나옵니다. 그래서 확인은 구문 검사가 아니라 1번의 BOM 검사가 기준입니다. 한글이 한 글자라도 들어 있는 .ps1은 BOM이 있어야 합니다.

3. 스케줄러 작업이라면 결과 코드를 본다

Get-ScheduledTaskInfo -TaskName '작업이름' | Select-Object LastRunTime, LastTaskResult

작업은 실행됐는데 LastTaskResult가 1이고 스크립트가 쓰는 로그가 하나도 없다면, 코드 버그보다 구문 분석 실패를 먼저 의심하는 게 빠릅니다.

정리

  • Windows PowerShell 5.1은 BOM 없는 .ps1을 CP949로 읽습니다.
  • 3바이트 한글이 2바이트씩 끊기면서 남은 1바이트가 뒤의 따옴표나 줄바꿈을 먹습니다.
  • 따옴표가 먹히면 TerminatorExpectedAtEndOfString, 줄바꿈이 먹히면 오류 없이 다음 줄이 사라집니다.
  • 어떤 한글은 운 좋게 통과해서 일부 파일만 실패합니다. 오류 줄 번호도 엉뚱한 곳을 가리킵니다.
  • 고칠 때는 바이트 앞에 EF BB BF를 붙이거나, -Encoding UTF8로 읽은 뒤 BOM 포함으로 다시 씁니다.
  • 5.1의 Set-Content 기본은 CP949, Out-File 기본은 UTF-16입니다.

자주 묻는 것

한글을 안 쓰고 영어 주석만 쓰면 BOM이 없어도 되나요?

됩니다. 영문·숫자·기호(ASCII)만 있는 파일은 UTF-8과 CP949에서 바이트가 똑같아서 어느 쪽으로 읽어도 결과가 같습니다. 다만 출력 문구 한 줄, 주석 한 글자라도 한글이 들어가는 순간 조건이 바뀝니다. 나중에 누가 한글을 넣을지 모르니 처음부터 BOM으로 두는 편이 안전합니다.

BOM을 붙였는데도 똑같은 오류가 납니다.

BOM을 붙이기 전에 파일을 CP949로 읽어서 다시 저장한 경우입니다. 이때는 따옴표가 이미 ?로 바뀐 채 저장돼 있어서 BOM으로 되살릴 수 없습니다. 파일을 메모장이나 VS Code로 열어 한글이 ?나 한자 비슷한 글자로 보이면 이 경우입니다. 깃 이력이나 백업에서 원본을 가져와 방법 A로 BOM만 붙이세요.

오류가 안 나는 스크립트는 BOM이 없어도 괜찮은 건가요?

아닙니다. 실행은 되더라도 한글 문자열은 이미 깨진 채로 처리되고 있습니다. 위 실험에서 "발행 완료"는 오류 없이 돌았지만 화면에는 諛쒗뻾 ?꾨즺로 찍혔습니다. 그 문자열을 파일 이름이나 메시지로 쓰고 있다면 결과물도 깨져 있습니다. 게다가 주석이 줄을 삼키는 경우처럼 오류 없이 명령이 빠지는 경우도 있으니, "돌아간다"는 사실이 안전하다는 뜻은 아닙니다.

.sh나 JSON 파일에도 BOM을 붙여야 하나요?

아니요. BOM이 필요한 건 Windows PowerShell 5.1이 읽는 .ps1(과 .psm1·.psd1) 쪽입니다. 셸 스크립트나 JSON·YAML 같은 파일은 BOM이 있으면 오히려 첫 줄을 잘못 읽는 도구가 많습니다. 폴더 전체에 일괄로 붙이지 말고 확장자를 골라서 붙이세요.

728x90
반응형
LIST
728x90
반응형
SMALL

공공데이터나 정부 정보를 보여 주는 안드로이드 앱은 구글 플레이 심사에서 '혼동을 야기하는 주장' 정책으로 반려되기 쉽습니다. 저는 2026년 9월 11일부터 16일까지 엿새 동안 세 개 앱에서 같은 사유로 네 번 반려를 받았는데, 고칠 때마다 원인이 달랐습니다. 출처 URL과 면책 문구를 넣은 뒤에도 출처 목록 불일치, 봇에게만 막힌 링크, URL 표기 방식 때문에 다시 걸렸습니다. 회차별 원인과 제출 전에 확인할 목록을 정리합니다.

증상 — 같은 정책, 다른 문장

네 번 모두 반려 사유로 붙은 정책 이름은 같았습니다. 구글 도움말 기준으로는 사기 행위 정책의 "1. 혼동을 야기하는 주장"(영문 Deceptive Behavior → Misleading Claims) 항목입니다. 정부 정보 앱에 대한 세부 요건은 별도 도움말 "정부 정보를 전달하는 앱의 자격요건"에 있습니다.

그런데 세부 사유는 회차마다 달랐습니다. 약국 찾기 앱의 첫 반려 때는 "출처가 불충분함", 등산 도장 앱 반려 때는 이런 문장이 왔습니다.

정부 정보의 출처에 제공된 URL/링크가 작동하지 않거나 액세스할 수 없습니다

문제는 이 문장이 어느 URL인지, 왜 열 수 없는지 알려 주지 않는다는 점입니다. 사람이 브라우저로 열면 전부 잘 열렸습니다.

정책이 요구하는 것

"정부 정보를 전달하는 앱의 자격요건" 도움말은 정부 기관과 관련 없는 앱이 정부 정보를 전달할 때 출처를 투명하게 밝히라고 하면서 두 가지를 적어 둡니다.

  • 앱 설명 및 스토어 등록정보 페이지에 간편하게 확인할 수 있는 정보 출처를 포함한다.
  • 앱이 정부 또는 정치 기관을 대표하지 않는다는 사실을 분명히 밝힌다.

문장만 보면 간단합니다. 실제 심사는 이 두 줄을 자동 점검과 사람 검토로 꽤 엄격하게 읽었고, 아래 네 번이 그 결과입니다.

원인 — 회차별 정리

회차앱 종류사유실제 원인
① 9/11복지 정보 앱(신규, 첫 심사)출처 URL·면책 누락설명문에 출처를 기관 이름으로만 적었다. 작동하는 .go.kr URL도, "정부를 대표하지 않는다"는 문장도 없었다
② 9/14약국 찾기 앱출처가 불충분함URL·면책은 있었다. 그런데 설명문 출처 목록은 4곳, 앱 출처 화면과 스크린샷은 5곳이었다. 공휴일을 대조하는 대체 소스(구글 캘린더 공휴일)도 목록에 없었다
③ 9/15약국 찾기 앱(재제출)출처 링크 접근 불가출처 중 응급의료포털(e-gen.or.kr) 주소가 사람 브라우저엔 200, 봇 User-Agent엔 403이었다
④ 9/16등산 도장 앱(업데이트)URL/링크가 작동하지 않거나 액세스할 수 없음세 가지가 겹쳤다. URL을 괄호와 한글 조사에 붙여 썼다 / 출처가 포털 루트 하나뿐이었다 / 앱 출처 카드에 링크가 아예 없었다

세무 일정 앱도 이보다 앞선 7월 31일에 같은 정책으로 거부된 적이 있습니다. 그때는 설명문 맨 위와 맨 아래에 정부 기관 URL을 노출하는 것으로 통과했습니다. 그 경험이 있었는데도 나중에 만든 복지 앱 설명문에는 옮겨지지 않았고, 그게 ①입니다.

② 설명문만 고치면 안 된다

두 번째 반려 직후 설명문 출처 목록을 앱 화면에 맞춰 늘려서 다시 올렸습니다. 그런데 같은 날 대조해 보니 고쳐진 것은 설명문뿐이었습니다. 앱의 출처 화면에는 여전히 구글 공휴일 소스가 없었고, 스크린샷 속 출처 화면도 예전 그대로였습니다. 결국 세 군데를 모두 맞췄습니다.

  • 스토어 설명문의 출처 목록
  • 앱 안의 출처(앱 정보) 화면
  • 그 화면이 찍힌 스토어 스크린샷

심사는 이 셋을 나란히 놓고 봅니다. 데이터를 실제로 받지 않고 원본 확인용으로만 적어 둔 출처, 평소에는 안 쓰는 대체(폴백) 소스도 목록에 있으면 셋 모두에 있어야 합니다.

③ 봇에게만 막힌 URL

세 군데를 맞춰서 다시 냈는데 이번에는 "출처 링크 접근 불가"로 돌아왔습니다. 브라우저로는 전부 열렸기 때문에 처음엔 원인을 못 찾았습니다. User-Agent를 구글봇으로 바꿔 요청하자 응급의료포털 주소만 403을 돌려줬습니다. 심사 쪽 자동 점검은 사람 브라우저가 아니라 봇으로 링크를 여는 것으로 보입니다.

이 일을 계기로 모든 앱 설명문에 들어 있는 URL 34개를 같은 방식으로 훑었고, 세무 일정 앱의 사회보험 관련 출처 주소 하나가 봇 요청에 아예 응답하지 않는 것도 찾았습니다. 그 앱은 아직 반려되지 않았지만 국민건강보험공단 주소로 바꿨습니다. 약국 앱은 응급의료포털을 출처 목록에서 빼고 앱·설명문·스크린샷을 5곳으로 다시 맞췄습니다.

④ 사람에게는 멀쩡한 문장이 기계에겐 깨진 URL

등산 도장 앱의 설명문 맨 위는 이런 식이었습니다.

공공데이터포털(https://www.data.go.kr)의 산 정보를 사용합니다.

사람 눈에는 자연스럽지만, URL을 자동으로 뽑는 쪽에서는 닫는 괄호와 조사까지 주소로 읽을 수 있습니다. 그러면 https://www.data.go.kr)의 같은 존재하지 않는 주소가 됩니다. 게다가 포털 루트만 적혀 있어서 어느 자료를 쓰는지도 알 수 없었고, 앱의 출처 카드에는 OpenStreetMap에만 링크가 있고 공공데이터포털 카드에는 링크가 없었습니다. 이 셋을 다 고쳐야 통과했습니다.

해결 — 이렇게 바꿔서 통과했다

설명문: URL은 줄 끝에 홀로, 자료별 상세 페이지로

[나쁜 예]
공공데이터포털(https://www.data.go.kr)의 자료를 사용합니다.

[바꾼 형태]
※ 이 앱은 정부 또는 공공기관을 대표하지 않으며, 공개된 공공데이터를 가공해 보여 줍니다.

데이터 출처
- 산림청 산정보
https://www.data.go.kr/data/<자료번호>/...
- 등산트레킹센터 100대 명산
https://www.data.go.kr/data/<자료번호>/...
- 주요 봉우리 위치
https://www.data.go.kr/data/<자료번호>/...
- 지도: OpenStreetMap
https://www.openstreetmap.org/copyright
  • URL 앞뒤에 괄호·조사·마침표를 붙이지 않고 한 줄에 URL만 둡니다.
  • 포털 첫 화면이 아니라 실제로 쓰는 자료의 상세 페이지를 적습니다.
  • 면책 문장과 출처는 설명문 맨 위에도 둡니다. 아래에만 두면 접힌 영역에 묻힙니다.

앱 화면: 출처는 이름이 아니라 누르면 열리는 링크

앱 정보 화면의 출처 카드를 설명문과 같은 개수, 같은 주소로 맞추고 각 카드를 탭하면 브라우저로 열리게 했습니다. 실기기에서 링크를 눌러 브라우저가 열리는 것까지 확인했습니다. 면책 문구도 같은 화면에 넣었습니다.

반려된 앱은 API로 심사에 못 보낸다

저는 배포와 등록정보 업로드를 Play Developer API 스크립트로 합니다. 그런데 앱이 반려 상태가 되면 API에서 변경사항을 바로 심사로 보내는 것이 막힙니다. 그래서 스크립트에 "심사로 보내지 않음" 옵션(Edits API의 changesNotSentForReview)을 붙여 변경만 반영해 두고, Play Console의 게시 개요에서 "검토를 위해 변경사항 전송"을 직접 눌렀습니다.

반려 상태에서는 대기열이 풀려 있으니, 고칠 것을 모아 한 번에 전송하는 게 낫습니다. 등산 도장 앱은 새 빌드·설명문·데이터 보안 양식 변경 세 건을 한 번에 보냈고, 같은 날 저녁 업데이트가 게시됐다는 메일을 받았습니다.
반대로 심사가 진행 중일 때 다른 변경을 전송하면 진행 중인 검토가 취소되고 다시 시작된다는 경고가 콘솔에 뜹니다. 데이터 보안 양식처럼 따로 고칠 것이 있으면 진행 중 검토가 끝난 뒤에 보내세요.

제출 전에 확인하는 방법

1. 봇 User-Agent로 모든 출처 URL 상태 코드 보기

curl -A "Googlebot/2.1 (+http://www.google.com/bot.html)" \
     -o /dev/null -s -w "%{http_code}\n" --max-time 15 \
     https://www.data.go.kr/

200이 아니면 빼거나 바꿉니다. 403은 봇 차단, 000은 시간 안에 응답이 없다는 뜻입니다. 설명문에서 URL을 뽑아 한꺼번에 돌리면 편합니다.

# urls.txt 에 설명문의 URL 을 한 줄에 하나씩
while read -r u; do
  code=$(curl -A "Googlebot/2.1 (+http://www.google.com/bot.html)" \
         -o /dev/null -s -w "%{http_code}" --max-time 15 "$u")
  echo "$code  $u"
done < urls.txt

같은 주소를 User-Agent 없이도 한 번 더 요청해 보세요. 브라우저로 200인데 봇으로 403이면 정확히 ③번 상황입니다.

2. 세 군데 출처 목록을 나란히 대조

설명문 출처, 앱 출처 화면, 스크린샷 속 출처 화면을 표로 놓고 개수와 주소가 같은지 확인합니다. 앱을 고쳤으면 그 화면이 찍힌 스크린샷도 다시 찍어야 합니다.

3. URL 주변 문자 확인

설명문에서 http로 시작하는 토큰 바로 뒤에 ), 의, 에서, . 같은 글자가 붙어 있지 않은지 봅니다. 줄 끝에 URL만 있으면 걱정할 일이 없습니다.

체크리스트

  • 설명문 맨 위에 "정부를 대표하지 않는다"는 면책 문장이 있다.
  • 출처를 기관 이름만이 아니라 작동하는 URL로 적었다.
  • URL은 포털 루트가 아니라 자료별 상세 페이지다.
  • URL은 줄 끝에 홀로 있고 괄호·조사에 붙어 있지 않다.
  • 모든 URL이 Googlebot User-Agent로 200이다.
  • 설명문 출처 목록 = 앱 출처 화면 = 스크린샷, 개수와 주소가 같다(폴백·원본 확인처 포함).
  • 앱 출처 화면의 각 항목이 탭하면 열리는 링크다.
  • 반려 상태라면 API는 "심사로 보내지 않음"으로 반영하고 콘솔 게시 개요에서 직접 전송한다.

자주 묻는 것

면책 문구와 URL을 넣었는데도 왜 또 반려되나요?

그 두 가지는 최소 조건일 뿐이었습니다. 제 경우 그 뒤에도 출처 목록 불일치, 봇 403, URL 표기 문제로 세 번 더 걸렸습니다. 반려 문장이 비슷해 보여도 원인은 다를 수 있으니 위 체크리스트를 처음부터 다시 훑는 것이 빠릅니다.

브라우저로는 열리는데 "링크가 작동하지 않는다"고 합니다.

봇 User-Agent로 요청해 보세요. 일부 공공기관 사이트는 사람 브라우저에는 200을 주고 봇에는 403을 주거나 아예 응답하지 않습니다. 이런 주소는 같은 자료를 제공하는 다른 공식 주소(공공데이터포털 자료 페이지나 기관 홈페이지)로 바꾸는 것이 확실합니다.

이미 게시된 앱도 걸리나요?

걸립니다. ④번 등산 도장 앱은 이미 스토어에 올라가 있던 앱의 업데이트 심사에서 반려됐습니다. 새 앱 첫 심사에서만 보는 항목이 아니므로, 정부 정보를 쓰는 앱이라면 다음 업데이트 전에 미리 맞춰 두는 편이 낫습니다.

728x90
반응형
LIST
728x90
반응형
SMALL

로컬 JVM 단위 테스트에서 android.util.Log는 진짜 로그를 찍지 않고, 호출되는 순간 RuntimeException을 던집니다. 그래서 로그가 catch 블록 안에 있으면 성공 경로는 멀쩡히 통과하고 실패 경로를 밟는 테스트만 골라서 깨집니다. 하필 테스트로 가장 지키고 싶은 곳이 실패 경로입니다. 제 경우 네트워크 재시도 인터셉터를 처음 테스트에 올렸을 때 테스트 4개가 이 이유로 깨졌고, 옵션 한 줄로 덮는 대신 로그를 생성자 파라미터로 빼서 해결했습니다.

증상 — 재시도 테스트가 전부 다른 예외로 깨졌다

공공데이터 API를 쓰는 알리미 앱에는 서버가 연결을 끊을 때를 대비한 재시도 인터셉터가 있었습니다. 원래는 API 클라이언트 안에 람다로 묻혀 있어서 한 번도 테스트된 적이 없었고, 2026-09-02에 계측을 붙이면서 별도 클래스로 꺼내 테스트를 달았습니다. 아래는 지금 코드에서 로그 줄만 처음 형태(Log.w 직접 호출)로 되돌려 옮긴 실패 처리 부분입니다.

} catch (e: IOException) {
    lastError = e
    val retryable = isRetryable(e)
    Log.w(TAG, "요청 실패 ($attempt/$limit) retryable=$retryable: ${e.message}")
    if (!retryable || attempt >= limit) {
        onOutcome(if (retryable) KIND_EXHAUSTED else KIND_SKIPPED, attempt, limit, e)
        throw e
    }
    sleep(backoffMs(attempt))
}

테스트는 MockWebServer로 서버가 연결을 진짜로 끊게 만들어 재시도 결말(살아남, 끝까지 실패, 한 번에 포기)을 확인하는 것이었습니다. 처음 돌렸더니 테스트 4개가 깨졌는데, 기대한 실패가 아니라 전부 같은 예외였습니다.

java.lang.RuntimeException: Method w in android.util.Log not mocked.
See http://g.co/androidstudio/not-mocked for details.

메서드 이름 자리만 다를 뿐 Log.e를 부르면 Method e, Log.d면 Method d로 나옵니다. 검색하면 Method e 쪽이 많이 보이는 건 catch 블록에서 Log.e를 가장 많이 쓰기 때문일 겁니다.

원인 — 테스트용 android.jar에는 메서드 몸통이 없다

로컬 단위 테스트(src/test)는 기기가 아니라 PC의 JVM에서 돕니다. 그런데 앱 코드는 android.* 클래스를 참조하니 컴파일은 돼야 합니다. 그래서 안드로이드 Gradle 플러그인은 프레임워크 API의 껍데기만 남긴 android.jar를 테스트 클래스패스에 넣어 줍니다. 공식 문서 설명으로는 public 메서드와 클래스는 다 있지만 메서드 안의 코드는 제거돼 있고, 어느 메서드든 호출하면 예외를 던집니다.

즉 Log.w가 로그를 못 찍는 게 아니라, 부르는 순간 RuntimeException입니다. 이게 catch 블록 안에 있을 때 무슨 일이 생기는지 경로별로 나눠 보면 이렇습니다.

테스트가 밟는 경로Log.w 호출결과
첫 시도에 성공안 부름통과 — 아무 문제 없어 보인다
끊겼다가 두 번째에 성공catch에서 부름RuntimeException — 재시도까지 가지도 못함
끝까지 끊김catch에서 부름RuntimeException — onOutcome이 안 불림
DNS 실패로 즉시 포기catch에서 부름RuntimeException — IOException 대신 엉뚱한 예외가 나감

핵심은 로그가 실패 경로에만 있다는 점입니다. 정상 흐름은 로그를 안 남기니 통과하고, 예외를 잡은 뒤에야 로그를 찍으니 실패 흐름만 폭발합니다. 게다가 터지는 건 RuntimeException이라 바깥의 catch (e: IOException)에도 안 걸리고, 그 뒤에 있는 결말 기록과 throw e를 통째로 건너뜁니다. 테스트가 보고 싶었던 것 — 몇 번째에 살아났나, 무엇을 기록했나, 어떤 예외가 나갔나 — 을 하나도 볼 수 없게 됩니다.

실패 경로는 테스트 말고는 밟을 방법이 거의 없습니다.
성공 경로는 앱을 켜기만 해도 매번 지나갑니다. 서버가 연결을 끊는 경로는 실기기에서 일부러 만들기 어렵고, 그래서 테스트가 유일한 확인 수단입니다. 그 유일한 수단이 로그 한 줄 때문에 막혀 있던 것입니다. 이 테스트가 결국 무엇을 찾아냈는지(재시도 조건이 unexpected end of stream을 못 잡고 있었다)는 OkHttp 재시도 인터셉터가 한 번도 안 돌았던 글에 따로 정리했습니다.

쉬운 해결이 위험한 이유 — isReturnDefaultValues

에러 문구로 검색하면 거의 첫 답이 이 설정입니다.

// app/build.gradle.kts
android {
    testOptions {
        unitTests.isReturnDefaultValues = true   // Groovy 는 unitTests.returnDefaultValues = true
    }
}

켜면 이 에러는 사라집니다. 껍데기 메서드가 예외 대신 반환 타입의 기본값(null, 0, false)을 돌려주게 바뀌기 때문입니다. Log.w는 int를 반환하니 0이 나오고, 로그는 원래 반환값을 안 쓰니 아무 일도 없는 것처럼 보입니다.

문제는 이 설정이 Log 하나가 아니라 안드로이드 API 전부에 걸린다는 점입니다.

호출실기기returnDefaultValues 켠 JVM 테스트
Log.w(tag, msg)로그를 찍음0 — 무해
TextUtils.isEmpty("")truefalse — 정반대
Uri.parse(url)Uri 객체null
Bundle().getString(key)넣은 값null

이 중 하나가 테스트 대상 코드 깊숙이 섞여 있으면, 테스트는 실기기와 다른 값으로 돌면서 초록불을 띄울 수 있습니다. 원래라면 "여기 안드로이드 API가 있다"고 크게 터져 줬을 자리가 조용해지는 것입니다. 공식 문서도 이 설정에 주의 문구를 붙여 두었습니다 — null과 0 반환값이 디버깅하기 어려운 회귀를 만들 수 있고, 실패해야 할 테스트가 통과할 수 있으니 최후의 수단으로만 쓰라고 합니다.

저는 이걸 켜지 않았습니다. 지금 깨진 테스트 4개를 살리는 대가로, 앞으로 다른 곳에서 날 진짜 실패를 미리 덮어 두는 셈이라서입니다.

해결 — 로그를 생성자 파라미터(이음매)로 뺀다

Log.w를 직접 부르지 않고, "경고를 남기는 함수"를 생성자로 받게 바꿨습니다. 기본값이 Log.w라서 앱 코드는 한 글자도 안 바뀝니다.

internal class RetryInterceptor(
    private val maxAttempts: Int = 3,
    private val sleep: (Long) -> Unit = { Thread.sleep(it) },
    private val logWarn: (String) -> Unit = { Log.w(TAG, it) },
    private val onOutcome: (kind: String, attempt: Int, maxAttempts: Int, e: IOException?) -> Unit,
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        ...
        } catch (e: IOException) {
            lastError = e
            val retryable = isRetryable(e)
            logWarn("요청 실패 ($attempt/$limit) retryable=$retryable: ${e.message}")
            ...

테스트에서는 리스트에 담는 람다를 넘깁니다. 버리지 않고 담아 두면, 필요할 때 "경고가 실제로 몇 번 남았나"까지 검증할 수 있습니다.

private val slept = mutableListOf<Long>()
private val warnings = mutableListOf<String>()

private fun client() = OkHttpClient.Builder()
    .addInterceptor(
        RetryInterceptor(
            sleep = { slept += it },            // 실제로 기다리지 않는다
            logWarn = { warnings += it },       // android.util.Log 를 안 부른다
            onOutcome = { kind, attempt, _, e -> outcomes += Triple(kind, attempt, e?.let { it::class.simpleName }) },
        )
    )
    .retryOnConnectionFailure(false)
    .build()

sleep도 같은 이유로 이음매입니다. 백오프 대기를 실제로 하면 테스트가 느려지니, 대기 시간을 리스트에 적기만 합니다. 그 덕에 "재시도 사이에 500ms, 1000ms를 쉬었다"까지 확인할 수 있게 됐습니다.

로그만이 아니다 — 같은 이유로 뺀 것들

이 인터셉터에서는 재시도 결말을 계측으로 보내는 부분도 직접 부르지 않고 onOutcome 콜백으로 받습니다. 계측 클래스가 Firebase를 물고 있어서 JVM 테스트에서는 클래스 로딩부터 실패하기 때문입니다. 정리하면 이렇습니다.

안드로이드 의존JVM 테스트에서뺀 방법
android.util.Log호출 시 not mockedlogWarn 파라미터
Firebase 기반 계측클래스 로딩 실패onOutcome 콜백
Thread.sleep동작은 하지만 느림sleep 파라미터
SharedPreferences런타임에 없음판정은 순수 함수로, 저장만 인터페이스로

마지막 줄은 다른 코드(광고 클릭 가드)에서 쓴 방법입니다. 클릭이 들어왔을 때 상태를 어떻게 바꿀지는 입력과 출력만 있는 순수 함수로 떼어 내고, 로그·계측·저장은 그 함수를 부르는 바깥 껍데기에만 남겼습니다. 판정 로직은 안드로이드 API 없이 테스트되고, 껍데기는 얇아서 테스트할 게 거의 없습니다.

이음매를 어디에 낼지 고르는 기준
"테스트로 확인하고 싶은 로직"과 "안드로이드에 닿는 부분"이 한 함수 안에 섞여 있으면 그 경계에 이음매를 냅니다. 로그처럼 부수적인 호출이라도, 확인하고 싶은 경로 위에 있으면 그 경로 전체를 막습니다. catch 블록 안의 로그가 딱 그 경우입니다.

확인 방법

① 실패 경로 테스트만 따로 돌려 본다. 성공 테스트가 통과하는 건 아무것도 증명하지 않습니다. 끊김·타임아웃·즉시 포기 같은 실패 테스트를 지정해 돌립니다.

./gradlew :app:testDebugUnitTest --tests "*RetryInterceptorTest*"

② 이음매를 잠깐 되돌려 빨간불을 본다. 테스트에서 logWarn을 넘기지 않으면 기본값인 Log.w가 쓰이니, 실패 경로 테스트가 다시 not mocked로 깨져야 합니다. 깨지지 않는다면 그 테스트는 애초에 catch 블록까지 가지 않고 있는 것입니다.

③ 프로젝트에 returnDefaultValues가 켜져 있지 않은지 본다. 누군가 예전에 켜 뒀다면 ②의 빨간불이 안 납니다.

grep -rn "ReturnDefaultValues" --include=*.gradle --include=*.kts .

저는 그 뒤 새로 만든 앱의 build.gradle.kts testOptions 블록에 "이 옵션은 켜지 않는다"는 주석을 남겨 두고 있습니다. 설정이 없으면 왜 없는지도 모르고 누군가 켜게 되기 때문입니다.

자주 묻는 것

테스트 폴더에 가짜 android.util.Log 클래스를 만드는 방법은 어떤가요?

src/test/java/android/util/Log.kt(또는 .java)를 만들어 표준 출력으로 찍게 하는 방법도 널리 쓰입니다. 테스트 클래스패스에서 이 클래스가 껍데기보다 먼저 잡히기 때문에 동작합니다. returnDefaultValues와 달리 Log 하나에만 영향이 있어 훨씬 안전합니다. 다만 테스트에서 "경고가 남았는지"를 검증하기 어렵고, 테스트 대상 클래스가 여전히 안드로이드 API에 묶여 있다는 사실은 그대로입니다. 저는 의존을 드러내는 쪽을 골랐습니다.

Robolectric이나 mockkStatic(Log::class)을 쓰면 되지 않나요?

둘 다 됩니다. Robolectric은 안드로이드 프레임워크를 JVM에서 흉내 내 주니 Log도 정상 동작하고, MockK의 mockkStatic은 정적 메서드를 가로챕니다. 다만 재시도 판정처럼 안드로이드와 무관한 로직을 테스트하려고 프레임워크 흉내 전체를 띄우는 건 과합니다. 로그 하나 때문에 테스트가 느려지고 설정이 늘어난다면, 로그를 빼는 쪽이 비용이 작습니다.

계측 테스트(androidTest)로 돌리면 이 문제가 없지 않나요?

없습니다. 기기나 에뮬레이터에서 도는 계측 테스트는 진짜 프레임워크를 쓰니 Log가 정상 동작합니다. 하지만 기기를 붙여야 하고 훨씬 느립니다. 재시도 규칙처럼 순수 로직은 로컬 단위 테스트로 빠르게 자주 돌리고, 기기에서만 확인할 수 있는 것(실제 네트워크 스택, 권한 등)만 계측 테스트로 보내는 게 맞습니다.

catch 블록에서 로그를 아예 빼면 안 되나요?

실기기에서 실패 원인을 logcat으로 보는 건 여전히 쓸모가 있습니다. 지울 필요는 없고, 부르는 방식만 바꾸면 됩니다. 기본값을 Log.w로 두면 앱에서는 이전과 똑같이 찍히고, 테스트에서만 다른 함수로 바꿔 끼웁니다.

728x90
반응형
LIST

+ Recent posts