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

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
728x90
반응형
SMALL

앱이 데이터를 못 불러올 때 원인을 분석 이벤트로 보내 두었는데, GA4 보고서에 남은 값이 X 한 글자뿐이었습니다. 예외 클래스 이름을 그대로 실어 보냈고, 릴리스 빌드의 R8이 그 이름을 한 글자로 줄여 버린 것입니다. 같은 코드를 쓰는 다른 앱에서는 이름이 멀쩡히 읽혀서 더 늦게 알아챘습니다. 이름 대신 타입 검사로 고정 키를 만들어 보내는 방식으로 바꾼 과정을 정리합니다.

증상 — 보고서의 실패 원인이 reason=X

공공데이터를 받아 보여 주는 알리미 앱 두 개에, 목록을 못 불러오면 실패 이벤트를 남기도록 계측을 넣어 두었습니다. 원인 칸은 예외 클래스 이름으로 채웠습니다. 실제로 들어가 있던 코드는 이 한 줄입니다.

.onFailure { e ->
    Analytics.logDataLoadFailed("markets", e::class.simpleName ?: "unknown")
}

디버그 빌드는 난독화를 하지 않으니 이 한 줄은 개발 중에 언제나 정상으로 보입니다. 예외 이름이 그대로 찍히기 때문입니다.

2026-09-01에 GA4에 남은 실패 이벤트를 열어 보니 두 앱의 값이 이렇게 갈려 있었습니다.

앱GA4에 남은 reason읽을 수 있나
알리미 앱 AUnknownHostException읽힌다 — 기기가 망에 없었다
알리미 앱 BX못 읽는다

B 앱이 그날 왜 실패했는지는 지금도 모릅니다. 이벤트 자체는 정상으로 들어왔는데, 원인 칸 하나가 아무 뜻도 없는 글자였습니다.

원인 — R8은 앱 안에 들어 있는 클래스 이름만 줄인다

릴리스 빌드는 isMinifyEnabled = true라 R8이 클래스 이름을 짧게 바꿉니다. e::class.simpleName은 런타임에 실제 클래스 이름을 읽어 오므로, 바뀐 뒤의 이름을 돌려줍니다.

그런데 모든 예외가 바뀌는 건 아닙니다. R8이 손댈 수 있는 건 APK 안에 함께 들어가는 코드뿐이고, java.net·javax.net.ssl 같은 JDK 예외는 기기의 런타임이 제공하는 클래스라 이름이 그대로 남습니다.

예외가 어디서 왔나예릴리스에서 simpleName
JDK·안드로이드 런타임UnknownHostException, SocketTimeoutException, SSLException그대로 읽힌다
앱에 함께 들어간 라이브러리
(keep 규칙이 없는 것)
파싱 라이브러리의 예외 등한 글자로 줄어든다
내가 만든 예외class ApiFormatException : Exception()한 글자로 줄어든다

이 표가 곧 두 앱의 결과가 갈린 이유입니다. A 앱의 실패는 마침 JDK 예외였고, B 앱의 실패는 앱 안에 들어간 클래스였습니다. 코드는 똑같은데 어떤 예외가 났느냐에 따라 읽히기도 하고 안 읽히기도 합니다. 한쪽이 멀쩡하니 "계측은 잘 돌고 있다"고 믿게 됩니다.

이름을 알아도 집계가 안 되는 이유

"mapping.txt로 되돌리면 되지 않나" 싶지만, 집계용으로는 성립하지 않습니다.

  • 빌드마다 바뀝니다. 코드가 조금만 달라져도 R8이 매기는 이름이 달라집니다. 어제 빌드의 X와 오늘 빌드의 X가 같은 예외라는 보장이 없어서, 여러 버전이 섞여 쌓이는 GA4에서는 같은 줄로 묶을 수가 없습니다.
  • 패키지가 빠집니다. simpleName은 패키지를 떼고 이름만 주므로, 같은 빌드 안에서도 서로 다른 패키지의 클래스가 똑같이 X나 a로 찍힐 수 있습니다. mapping.txt를 펼쳐도 어느 쪽이었는지 가릴 근거가 남지 않습니다.

R8 때문에 릴리스에서만 앱이 죽는 경우는 릴리스 빌드에서만 죽는 앱(R8 난독화) 글에서 다뤘습니다. 이번 건은 앱은 멀쩡하고 밖으로 나가는 값만 망가지는 쪽이라 크래시 리포트에도 아무것도 안 남습니다.

해결 — 이름이 아니라 타입으로 고정 키를 만든다

is 검사는 R8이 이름을 바꿔도 같은 클래스를 가리키도록 함께 고쳐지므로 난독화와 상관이 없습니다. 그리고 결과가 내가 정한 문자열이라 빌드가 바뀌어도 값이 같습니다. 실패 원인을 분류하는 파일을 하나 두고, 이벤트를 보내는 곳은 전부 이걸 거치게 했습니다. 실제로 쓰는 코드입니다.

// analytics/FailureReason.kt
object FailureReason {

    /** 예외가 아니라 빈 결과로 실패한 경우. 예외가 안 나는 사고라 별도 키를 둔다. */
    const val EMPTY_RESULT = "empty_result"

    fun of(e: Throwable?): String = when (e) {
        null -> UNKNOWN
        // 순서가 의미를 갖는다. SocketTimeoutException 은 SocketException 이
        //   아니라 InterruptedIOException 이지만, ConnectException 은
        //   SocketException 의 자식이다 — 넓은 타입을 먼저 두면 자식이 가려진다.
        is UnknownHostException -> "no_network"      // DNS 실패 = 기기가 망에 없음
        is SocketTimeoutException -> "timeout"        // 서버가 느린 것일 수 있다
        is SSLException -> "ssl"                      // 공공데이터 서버의 단골
        is ConnectException -> "connect_failed"
        is SocketException -> "conn_reset"            // 전송 중 끊김
        is JsonParseException -> "parse_error"        // 상대 API 가 형식을 바꿨다
        is IOException -> "io_error"
        else -> UNKNOWN
    }

    fun safeMessage(e: Throwable?): String {
        val raw = e?.message ?: return ""
        return SECRET_PARAM.replace(raw) { "${it.groupValues[1]}=***" }.take(80)
    }

    const val UNKNOWN = "unknown"

    // serviceKey=... / apiKey=... / key=... 의 값만 지운다.
    private val SECRET_PARAM = Regex("(?i)\\b(serviceKey|apiKey|auth|token|key)=[^&\\s]*")
}

보내는 쪽은 원인 키와 원문을 다른 파라미터로 나눴습니다.

fun logDataLoadFailed(stage: String, reason: String, detail: String = "") {
    log(
        EV_DATA_LOAD_FAILED,
        params("stage" to stage, "reason" to reason, "detail" to detail)
    )
}

fun logDataLoadFailed(stage: String, e: Throwable) {
    if (e is CancellationException) return   // 취소는 실패가 아니다
    logDataLoadFailed(stage, FailureReason.of(e), FailureReason.safeMessage(e))
}

reason은 빈 결과까지 아홉 개의 고정 값만 들어가니 GA4에서 그대로 묶여 셀 수 있고, 분류하지 못한 예외는 unknown으로 떨어지되 사람이 읽을 단서는 detail에 남습니다.

when 절의 순서를 지키세요.
ConnectException은 SocketException의 하위 클래스입니다. is SocketException을 먼저 두면 연결 실패가 전부 conn_reset으로 뭉쳐 버립니다. 마찬가지로 IOException은 맨 아래에 둬야 합니다. 컴파일러는 이 순서를 경고하지 않습니다.

예외 원문을 보낼 때 인증키를 지우는 이유

원인 키만으로는 부족할 때가 있어서 예외 메시지도 같이 보내는데, 이걸 그대로 보내면 안 됩니다. 공공데이터 API는 인증키를 serviceKey라는 URL 쿼리 파라미터로 받습니다. 연결 실패 예외의 메시지에는 요청 URL이 통째로 실려 오는 경우가 있어서, 그대로 보내면 인증키가 GA4에 평문으로 남습니다.

// 이런 메시지가 올 수 있다
Failed to connect to apis.data.go.kr/notices?serviceKey=abcd1234SECRET&page=1

// safeMessage 를 거친 뒤
Failed to connect to apis.data.go.kr/notices?serviceKey=***&page=1

정규식은 serviceKey= 뒤의 값만 지우고 호스트 이름은 남깁니다. 어느 서버에서 실패했는지가 원인을 좁힐 거의 유일한 단서라서입니다. 길이를 80자로 자르는 건 Analytics 문자열 파라미터가 100자에서 잘리기 때문인데, 잘린다고 안전해지는 건 아닙니다. 잘린 자리가 키 한가운데라면 앞부분은 그대로 새어 나갑니다. 자르기 전에 먼저 지워야 합니다.

같은 키가 logcat으로도 샐 수 있습니다.
이걸 고치던 날 B 앱에서 하나를 더 찾았습니다. OkHttp의 HttpLoggingInterceptor가 빌드 타입 구분 없이 붙어 있어서, 요청 URL에 실린 serviceKey가 배포 빌드의 logcat에 평문으로 찍히고 있었습니다. A 앱은 같은 자리에 처음부터 BuildConfig.DEBUG 가드가 있었는데, 베껴 쓴 파일의 한쪽만 고쳐져 있었던 겁니다.

확인 방법

1. 유닛테스트로 "모르는 예외도 값이 고정되는가"를 지킨다

테스트에서 확인할 것은 "이벤트가 나가는가"가 아니라 "나간 값이 밖에서 읽히는 형태인가"입니다. 앱이 직접 정의한 예외를 넣어도 unknown으로 고정되는지, 인증키가 지워지는지를 봅니다.

@Test
fun `모르는 예외도 키가 고정된다`() {
    // 분류를 못 해도 값은 매번 같아야 한다. 난독화된 클래스명을 쓰면
    //   빌드마다 달라져 집계가 성립하지 않는다.
    class 앱이_던진_예외 : RuntimeException("무언가")
    assertEquals(FailureReason.UNKNOWN, FailureReason.of(앱이_던진_예외()))
    assertEquals(FailureReason.UNKNOWN, FailureReason.of(null))
}

@Test
fun `SocketException 의 자식인 ConnectException 이 가려지지 않는다`() {
    assertEquals("connect_failed", FailureReason.of(ConnectException("failed to connect")))
    assertEquals("conn_reset", FailureReason.of(SocketException("Connection reset")))
}

@Test
fun `인증키는 원문에서 지운다`() {
    val e = IOException(
        "Failed to connect to apis.data.go.kr/notices?serviceKey=abcd1234SECRET&page=1")
    val msg = FailureReason.safeMessage(e)
    assertFalse(msg.contains("abcd1234SECRET"))
    assertTrue(msg.contains("serviceKey=***"))
    assertTrue(msg.contains("apis.data.go.kr"))
}

릴리스 설정으로 돌리려면 ./gradlew testReleaseUnitTest를 씁니다. 다만 JVM 유닛테스트는 R8을 거치지 않으므로 이것만으로 "릴리스에서 이름이 뭉개지는가"까지 증명되지는 않습니다. 그건 다음 단계에서 봅니다.

2. 난독화된 릴리스 빌드에서 실제로 나가는 값을 본다

이 결함은 디버그 빌드에서는 절대 재현되지 않습니다. 반드시 isMinifyEnabled = true로 만든 빌드를 실기기에 깔고 Firebase Analytics 디버그 로그를 켭니다.

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 | findstr "Logging event"

비행기 모드로 실패를 일으켜 보고, reason 자리에 no_network처럼 내가 정한 키가 찍히는지 확인합니다. 한 글자 이름이 보이면 아직 simpleName을 쓰는 경로가 남아 있는 겁니다. 숫자 파라미터가 GA4에서 (not set)으로 비는 문제도 이 로그로 같이 잡히는데, 그쪽은 GA4 커스텀 측정기준이 전부 (not set)에 따로 정리했습니다.

3. 코드 전체에서 남은 자리를 찾는다

grep -rn "simpleName" app/src/main

로그용 Log.e 안에 있는 건 괜찮습니다. 내 기기에서 볼 로그라면 디버그 빌드로 읽을 테니까요. 문제는 분석 이벤트·서버 전송·파일 기록처럼 기기 밖으로 나가는 값에 들어간 경우만입니다.

자주 묻는 것

proguard-rules.pro에 예외 클래스 이름을 keep 하면 되지 않나요?

-keepnames class * extends java.lang.Throwable로 이름을 살릴 수는 있습니다. 그래도 고정 키 쪽을 권합니다. 이름을 살려도 값의 종류가 예외 클래스 수만큼 늘어나 GA4에서 묶기 어렵고, 클래스 이름을 바꾸는 리팩터링 한 번에 과거 집계와 끊깁니다. 무엇보다 "이 값은 집계용"이라는 약속이 코드에 남지 않아서, 다음에 누군가 다른 곳에서 또 simpleName을 보냅니다.

Crashlytics에는 원래 이름이 나오던데요?

Crashlytics는 빌드 때 올라간 매핑 파일로 스택 트레이스를 되돌려 보여 주기 때문입니다. 분석 이벤트의 문자열 파라미터는 그냥 문자열이라 아무도 되돌려 주지 않습니다. 같은 예외라도 크래시 화면에서는 읽히고 이벤트 보고서에서는 안 읽힙니다.

코루틴 취소도 실패로 잡히던데 괜찮나요?

걸러야 합니다. 사용자가 로딩 중에 화면을 벗어나면 CancellationException이 올라오는데, 이걸 실패로 세면 고칠 수 없는 줄이 매일 리포트에 쌓입니다. 제 경우 다른 앱에서 사흘간 53건이 그렇게 올라와 진짜 실패를 덮었습니다. 위 코드의 if (e is CancellationException) return이 그 처리입니다. 계측에서만 거르고, 취소를 위로 다시 던지는 건 호출부가 할 일입니다.

빈 목록처럼 예외가 안 나는 실패는요?

예외가 없으니 of(e)로는 못 잡습니다. 그래서 EMPTY_RESULT를 따로 두고, 응답이 성공했는데 목록이 비었을 때 직접 보냅니다. API가 200을 주면서 응답 형식만 바뀌면 앱은 멀쩡히 돌고 화면만 빕니다. 이런 실패는 따로 키를 만들어 두지 않으면 어디에도 안 남습니다.

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

큰 글꼴이나 작은 화면에서 앱이 어떻게 보이는지 확인하려고 adb로 화면 밀도를 바꾸는 경우가 있습니다. 끝나고 wm density reset으로 되돌리면 될 것 같지만, 삼성 기기에서는 원래 값으로 안 돌아옵니다. 제조사가 패널 기본값보다 낮은 값을 강제해 두는데 reset이 그 강제값을 지워 버리기 때문입니다. 저는 이걸 모르고 사용자 폰을 바꿔 놓은 채 한참 작업했습니다.

앱이 큰 글꼴 설정에서 깨지지 않는지 확인하려고 밀도를 올려 봤습니다.

adb shell wm density 480

확인을 마치고 되돌렸습니다.

adb shell wm density reset

그런데 그 뒤로 찍은 스크린샷의 좌표가 계속 이상했습니다. 버튼이 있어야 할 자리에 없고, 같은 화면인데 y좌표가 100픽셀 넘게 밀려 있었습니다. 앱을 의심하다가 기기를 확인했습니다.

reset은 "기본값으로"가 아니라 "설정을 지움"이다

제 기기는 갤럭시 노트9입니다. 패널 자체는 1440×2960에 560dpi인데, 삼성은 기본적으로 1080×2220 / 420dpi로 낮춰서 씁니다. 설정의 '화면 해상도'가 FHD+로 되어 있는 그 상태입니다.

이 낮춘 값은 안드로이드 입장에서 재정의(override)입니다. 확인해 보면 이렇게 나옵니다.

adb shell settings get secure display_density_forced
420

wm density reset은 이 재정의를 지웁니다. 그러면 패널 원래 값인 560으로 돌아갑니다. 즉 "원래대로"가 아니라 삼성이 낮춰 둔 설정을 없애서 화면이 더 커진 상태가 됩니다. settings delete secure display_density_forced도 결과가 같습니다.

명령노트9에서 일어나는 일
wm density 480재정의 값을 480으로 바꾼다
wm density reset재정의를 삭제 → 패널 값 560이 된다 (원래 420이었는데)
wm density 420✅ 원래 상태로 복귀

wm size도 똑같습니다. wm size reset을 하면 1080×2220이 아니라 1440×2960이 됩니다.

해결 — 건드리기 전에 값을 적어 둔다

reset을 믿지 말고 원래 값을 먼저 읽어서 기록한 다음, 끝나면 그 값을 직접 넣어 되돌립니다.

# 1. 시작 전에 반드시 읽어 둔다
adb shell wm size
  Physical size: 1440x2960
  Override size: 1080x2220      ← 이 값이 원래 상태다

adb shell wm density
  Physical density: 560
  Override density: 420         ← 이 값이 원래 상태다

# 2. 테스트
adb shell wm density 480

# 3. 되돌리기 — reset 이 아니라 값으로
adb shell wm density 420
adb shell wm size 1080x2220
Override 줄이 없는 기기도 있습니다.
제조사가 패널 값을 그대로 쓰는 기기라면 Override 줄이 아예 없습니다. 그럴 때는 reset이 정말로 원래대로 돌려놓습니다. 그래서 기기에 따라 이 문제를 겪기도 하고 안 겪기도 합니다 — 남의 글에서 "reset하면 된다"를 읽고 그대로 따라 하다 당하는 이유입니다. 자기 기기에서 먼저 출력을 확인하세요.

왜 늦게 알아챘나

이 글을 쓰는 이유입니다. 밀도가 바뀌면 아무 오류도 안 납니다. 앱은 정상으로 뜨고 스크린샷도 멀쩡히 찍힙니다. 다만 모든 좌표가 조용히 달라집니다.

저는 되돌렸다고 믿고 그 뒤 여러 화면을 캡처해 확인했는데, 그게 전부 잘못된 밀도에서 찍힌 것이었습니다. 자동화 스크립트가 좌표로 버튼을 누르고 있었으니 그 클릭들도 제자리가 아니었습니다. 버튼의 y좌표가 189에서 290으로 밀린 걸 보고 나서야 기기를 의심했습니다.

더 곤란한 건 그 기기가 제가 실제로 쓰는 폰이라는 점입니다. 모르고 넘어갔다면 사용자(저 자신)의 폰 화면이 바뀐 채로 남았을 것입니다. 남의 테스트 기기를 빌려 쓰는 상황이라면 더 나쁩니다.

같이 조심할 것 — 회전과 자동 회전

가로 화면을 확인할 때도 비슷한 일이 납니다. 자동 회전을 끄고 강제로 돌리는데, 원래 자동 회전이 켜져 있었는지 꺼져 있었는지를 먼저 적어 두지 않으면 되돌릴 수 없습니다.

# 원래 값 확인
adb shell settings get system accelerometer_rotation

# 앱을 띄운 뒤에 돌린다 (런처가 떠 있으면 안 먹는다)
adb shell settings put system accelerometer_rotation 0
adb shell settings put system user_rotation 1

# 끝나면 둘 다 원래 값으로

정리

  • 삼성 기기는 패널 기본값보다 낮은 값을 재정의로 써 둡니다(노트9: 560 → 420).
  • wm density reset은 그 재정의를 지웁니다. 결과는 원래보다 큰 화면입니다.
  • wm size reset도 같습니다(1080×2220 → 1440×2960).
  • 되돌리기는 wm density 420처럼 값을 직접 넣어서 합니다.
  • 건드리기 전에 wm size·wm density 출력을 적어 두세요.
  • 밀도가 바뀌어도 오류가 안 나고 좌표만 조용히 달라집니다. 자동화가 있으면 결과가 전부 어긋납니다.

자주 묻는 것

재부팅하면 돌아오지 않나요?

안 돌아옵니다. display_density_forced는 settings에 저장되는 값이라 재부팅해도 유지됩니다. 오히려 그래서 위험합니다 — 눈치채지 못하면 계속 그 상태로 씁니다. 설정 앱의 '화면 해상도'에서 다시 고르면 복구되긴 하지만, 그러려면 먼저 바뀌었다는 걸 알아야 합니다.

큰 글꼴 테스트는 밀도 말고 다른 방법이 없나요?

글꼴 크기만 볼 거라면 settings put system font_scale 1.3이 더 좁은 변경이고 되돌리기도 쉽습니다(원래 값은 보통 1.0). 밀도는 화면 전체 배율이라 영향 범위가 훨씬 넓습니다. 확인하려는 것이 글꼴이면 글꼴만 건드리세요.

에뮬레이터에서 하면 안전하지 않나요?

안전합니다. 내 기기 설정을 건드릴 일이 없고 AVD를 지우면 끝입니다. 다만 제조사 UI에서만 나타나는 문제(이 글의 재정의 같은 것)는 순정 에뮬레이터에서 재현되지 않습니다. 레이아웃 확인은 에뮬, 제조사 특성 확인은 실기기로 나누는 게 맞습니다.

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

계측 테스트를 돌리는데 앱이 프로세스째 죽었습니다. 포그라운드 서비스를 띄워 놓고 5초 안에 알림을 올리지 않으면 시스템이 강제로 죽이는데, 이 예외는 권한이 없을 때도 나고 코드가 호출을 빠뜨렸을 때도 납니다. 문구가 같아서 저는 권한 문제로 닫았다가, 나중에 진짜 원인이 따로 있었다는 걸 알았습니다.

등산 기록 앱의 계측 테스트를 돌렸더니 26개 중 여러 개가 무더기로 깨졌습니다. 테스트가 실패한 게 아니라 앱 프로세스 자체가 사라졌습니다.

android.app.RemoteServiceException: Context.startForegroundService()
did not then call Service.startForeground()

규칙은 단순하다

startForegroundService()로 서비스를 시작하면, 그 서비스는 약 5초 안에 startForeground()를 불러 알림을 올려야 합니다. 안 부르면 시스템이 "백그라운드에서 몰래 도는 서비스"로 보고 프로세스를 죽입니다.

여기서 중요한 건 ANR이나 일반 예외가 아니라는 점입니다. 앱이 통째로 사라지기 때문에 테스트는 연결이 끊겼다는 식으로 실패하고, 정작 무엇이 문제였는지는 로그캣을 따로 봐야 나옵니다.

첫 번째 원인 — 권한이 없어서 승격에 실패한다

제 경우 위치를 쓰는 서비스였습니다. 위치 권한이 없으면 서비스가 자기 일을 시작하지 못하고, 그 흐름에서 startForeground()까지 도달하지 못했습니다.

계측 테스트는 설치하면서 권한을 지웁니다.
미리 pm grant를 해 둬도 소용없습니다. connectedAndroidTest가 앱을 다시 설치하면서 초기화하기 때문입니다. 그래서 설치와 실행을 분리해야 합니다.
gradlew installDebug installDebugAndroidTest

adb shell pm grant com.example.app android.permission.ACCESS_FINE_LOCATION
adb shell appops set com.example.app android:mock_location allow   # 위치를 흉내 내는 테스트라면

adb shell am instrument -w -r \
  com.example.app.test/androidx.test.runner.AndroidJUnitRunner

이렇게 하니 26개가 전부 통과했습니다. 저는 여기서 "환경 문제였다"로 적고 닫았습니다.

두 번째 원인 — 권한을 다 줘도 난다

나중에 같은 예외가 다시 나왔습니다. 이번에는 권한이 전부 부여된 상태였습니다. 테스트 하나가 정리 단계(tearDown)에서 서비스를 멈출 때 터졌습니다.

코드를 열어 보니 onStartCommand가 명령마다 갈라지는데, 네 갈래 중 세 곳에서 startForeground()를 부르지 않았습니다.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    when (intent?.action) {
        ACTION_BEGIN  -> begin()      // 여기서만 startForeground() 를 불렀다
        ACTION_PAUSE  -> pause()      // ❌
        ACTION_RESUME -> resume()     // ❌
        ACTION_STOP   -> stop()       // ❌
    }
    return START_STICKY
}

게다가 begin()도 이미 진행 중인 기록이 있으면 그냥 돌아가도록 돼 있어서, 그 경우에는 유일한 호출 지점마저 건너뛰었습니다.

이 서비스는 어느 명령이든 startForegroundService()로 시작됩니다. 그러니 명령이 무엇이든 5초 규칙이 적용됩니다. "멈추라는 명령인데 왜 알림을 올려야 하지?"가 직관적이지만, 시스템은 그렇게 보지 않습니다.

고침 — 진입 즉시 부른다

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    startForeground(NOTI_ID, buildNotification())   // 분기 전에 무조건

    when (intent?.action) {
        ACTION_BEGIN  -> begin()
        ACTION_PAUSE  -> pause()
        ACTION_RESUME -> resume()
        ACTION_STOP   -> stop()
    }
    return START_STICKY
}

멈추는 명령이면 stop() 안에서 stopSelf()를 부르니 알림은 곧 사라집니다. 잠깐 떴다가 없어지는 것이 프로세스가 죽는 것보다 낫습니다.

이건 테스트만의 문제가 아니었다

여기가 핵심입니다. 저는 이 결함을 계측 테스트에서 만났지만, 실사용자도 똑같이 겪을 수 있는 경로였습니다.

배터리 절약 기능이 서비스를 죽인 뒤에 사용자가 알림에 남아 있는 "종료" 버튼을 누르면, ACTION_STOP이 startForegroundService()로 날아옵니다. 그 경로에는 startForeground()가 없었습니다. 그러면 앱이 죽습니다.

재현이 까다로워서 크래시 보고에 잘 안 잡히는 종류입니다. 테스트가 아니었다면 사용자 몇 명이 "가끔 앱이 꺼진다"고 느끼는 채로 오래 갔을 것입니다.

같은 문구, 다른 원인

제가 놓친 지점
권한을 주고 26개가 전부 통과했을 때, 저는 그것으로 원인이 규명됐다고 봤습니다. 초록불이 났으니까요. 하지만 통과는 "이 조건에서는 안 난다"를 보여 줄 뿐이지 "원인이 그것뿐이다"를 보여 주지 않습니다.

같은 예외 문구가 원인 둘을 덮고 있었고, 그중 하나는 사용자에게도 일어나는 코드 결함이었습니다.

이 예외를 만나면 순서를 이렇게 잡으세요.

  1. 권한을 전부 부여한 상태로 다시 돌린다
  2. 그래도 나면 코드다. onStartCommand의 모든 분기에서 startForeground()가 불리는지 본다
  3. 통과했더라도, 서비스를 시작하는 모든 인텐트를 세어 보고 각 경로를 확인한다

정리

  • startForegroundService()로 시작한 서비스는 5초 안에 startForeground()를 불러야 합니다. 안 부르면 프로세스가 죽습니다.
  • 계측 테스트는 재설치하면서 권한을 지웁니다. installDebug → pm grant → am instrument 순서로 나누세요.
  • 권한을 다 줘도 같은 예외가 나면 코드에서 호출을 빠뜨린 것입니다.
  • onStartCommand의 일부 분기에만 호출이 있으면 나머지 경로에서 죽습니다.
  • 분기 전에 무조건 부르고, 멈추는 명령은 그 뒤에 stopSelf()로 처리합니다.
  • 이 경로는 실사용자에게도 일어납니다(서비스가 죽은 뒤 알림 버튼을 누를 때).

자주 묻는 것

알림을 잠깐 띄웠다 지우면 사용자에게 깜빡여 보이지 않나요?

멈추는 명령이라면 거의 눈에 안 띕니다. 어차피 그 직전까지 같은 알림이 떠 있던 상황이라 이어지는 것처럼 보이고, stopSelf()가 곧 정리합니다. 깜빡임이 신경 쓰이더라도 프로세스가 죽는 쪽과 비교할 문제는 아닙니다.

bindService로 바꾸면 안 나나요?

바인드만 하는 서비스는 이 규칙의 대상이 아닙니다. 다만 화면이 꺼진 뒤에도 계속 돌아야 하는 작업이라면 포그라운드 서비스가 맞는 도구입니다. 규칙을 피하려고 구조를 바꾸면 대개 다른 제약에 걸립니다.

어떤 인텐트가 서비스를 깨우는지 어떻게 전부 찾나요?

startForegroundService(를 프로젝트 전체에서 검색하세요. 액티비티, 알림의 PendingIntent, 부팅 리시버, 알람 리시버까지 포함해서 봐야 합니다. 저는 알림 버튼이 만드는 인텐트를 늦게 확인했는데, 그게 실사용자 경로였습니다.

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

광고를 한 줄도 넣지 않은 앱인데 APK를 뜯어 보면 광고 ID 권한이 들어 있습니다. Firebase Analytics가 딸려 오는 라이브러리를 통해 매니페스트에 합쳐 넣기 때문입니다. 내 소스 매니페스트에는 그런 줄이 없어서 눈으로는 찾을 수 없고, 그대로 두면 Play 데이터 보안 신고와 개인정보처리방침이 사실과 어긋나게 됩니다.

광고 없는 앱을 하나 만들었습니다. 수익화는 나중 문제로 미루고 기능만 먼저 내보기로 한 앱이라, 광고 SDK를 아예 넣지 않았습니다. 출시 전 APK를 확인하다가 권한 목록에서 이걸 봤습니다.

com.google.android.gms.permission.AD_ID

AndroidManifest.xml을 다시 열어 봤지만 그런 줄은 없었습니다. 프로젝트 전체를 AD_ID로 검색해도 안 나왔습니다.

소스에는 없고 결과물에는 있다

안드로이드 빌드는 내 매니페스트와 라이브러리들의 매니페스트를 하나로 합칩니다. 라이브러리가 선언한 권한은 내가 쓰지 않아도 결과물에 들어갑니다. 그래서 소스를 아무리 봐도 안 보입니다.

합쳐진 결과는 빌드 산출물에 있습니다.

app/build/intermediates/merged_manifest/release/AndroidManifest.xml

여기를 열면 AD_ID 권한이 있고, 어느 라이브러리에서 왔는지도 주석으로 붙어 있습니다. APK에서 직접 보려면 이렇게도 됩니다.

aapt dump permissions app-release.apk

누가 넣었나

firebase-analytics입니다. 정확히는 그것이 끌고 오는 play-services-ads-identifier가 이 권한을 선언합니다. 광고 ID를 읽어 분석 데이터에 붙이기 위한 것으로, Analytics만 넣어도 자동으로 들어옵니다.

./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep ads-identifier

왜 그냥 두면 안 되나

권한 하나 더 있는 게 뭐가 문제냐 싶지만, 이건 기능이 아니라 신고 대상입니다.

걸리는 곳내용
Play 데이터 보안광고 ID를 수집한다고 신고해야 한다. 안 하면 정책 위반이다
개인정보처리방침"광고 ID를 읽지 않습니다"라고 써 뒀다면 그 문장이 거짓이 된다
사용자에게 보이는 권한광고 없는 앱인데 광고 관련 권한이 보인다

저는 방침 문서에 광고 ID를 쓰지 않는다고 적어 둔 상태였습니다. 문서가 앱의 실제 동작과 어긋나 있었던 것이고, 그건 문서를 고칠 일이 아니라 앱을 고칠 일이었습니다.

해결 — 매니페스트에서 걷어낸다

내 매니페스트에서 그 권한을 제거하겠다고 선언하면 됩니다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission
        android:name="com.google.android.gms.permission.AD_ID"
        tools:node="remove" />

    ...
</manifest>

tools: 네임스페이스 선언이 <manifest> 태그에 있어야 합니다. 없으면 조용히 무시됩니다.

Analytics는 그대로 동작합니다.
광고 ID를 못 읽게 될 뿐이고 이벤트 수집·화면 추적·사용자 수 집계는 그대로입니다. 광고 ID는 주로 광고 성과를 사용자 단위로 잇는 데 쓰이는 값이라, 광고를 안 붙이는 앱에서는 잃는 것이 없습니다. 다만 Google Ads 캠페인으로 설치를 유도하고 그 성과를 앱 안 행동과 연결하려는 계획이 있다면 그때는 필요합니다.

확인은 반드시 결과물로

고친 뒤에도 소스를 보면 안 됩니다. 다시 빌드해서 합쳐진 매니페스트를 봐야 합니다.

# 빌드 후
grep -c "AD_ID" app/build/intermediates/merged_manifest/release/AndroidManifest.xml
0

# 또는 결과물에서 직접
aapt dump permissions app-release.apk | grep AD_ID

0이 나와야 끝입니다. tools:node="remove"를 넣었다는 사실만으로 판단하면 네임스페이스 누락 같은 것을 놓칩니다.

광고 없는 다른 앱도 확인하세요

이 글을 쓰는 이유입니다. 이건 그 앱만의 문제가 아닙니다. Firebase Analytics를 넣은 앱이면 전부 같은 상태일 수 있고, 광고가 있는 앱은 어차피 광고 ID를 쓰니 상관없지만 광고 없는 앱은 전부 확인 대상입니다.

저는 이걸 새 앱 하나에서 발견했는데, 그때 먼저 든 생각이 "그럼 광고 없는 나머지 앱들은?"이었습니다. 라이브러리가 조용히 넣는 것이라 앱마다 따로 알아챌 방법이 없고, Play 콘솔이 알려 줄 때는 이미 신고 내용이 틀린 상태입니다.

비슷한 일이 한 번 더 있었습니다. 구글 플레이 서비스 계열이 오래된 androidx.fragment를 끌고 와서 Play Console이 SDK 경고를 띄운 건인데, 그것도 내가 선언하지 않은 것이 결과물에 들어가 있던 경우였습니다. 전이 의존성이 넣는 것은 소스에서 안 보인다는 점이 같습니다.

정리

  • firebase-analytics가 play-services-ads-identifier를 통해 AD_ID 권한을 끌고 옵니다.
  • 소스 매니페스트에는 안 보입니다. merged_manifest나 aapt dump permissions로 봐야 합니다.
  • 그대로 두면 Play 데이터 보안에 광고 ID 수집을 신고해야 하고, 방침 문구와 어긋납니다.
  • tools:node="remove"로 제거합니다. xmlns:tools 선언을 빠뜨리지 마세요.
  • Analytics 기능은 그대로 동작합니다.
  • 확인은 다시 빌드한 결과물로 합니다. 광고 없는 앱은 전부 점검하세요.

자주 묻는 것

이미 광고 ID 수집으로 신고해 뒀으면 그냥 둬도 되나요?

정책 위반은 아닙니다. 신고와 실제가 맞으니까요. 다만 개인정보처리방침에도 같은 내용이 적혀 있어야 합니다. 광고가 없는 앱이라면 권한을 빼고 신고도 "수집 안 함"으로 맞추는 쪽이 사용자에게도 정직하고 관리하기도 쉽습니다.

권한을 빼면 Analytics 데이터가 줄어들지 않나요?

이벤트 수, 사용자 수, 화면 조회 같은 기본 지표는 그대로입니다. 광고 ID는 기기를 광고 네트워크 차원에서 식별하는 값이라, 앱 안에서 일어나는 일을 세는 것과는 별개입니다. 광고 캠페인 성과 분석을 안 한다면 차이를 느낄 일이 없습니다.

targetSdk 33 이상이면 자동으로 빠지지 않나요?

반대입니다. targetSdk 33부터는 광고 ID를 쓰려면 이 권한을 명시적으로 선언해야 해서, 라이브러리들이 자기 매니페스트에 넣어 두기 시작했습니다. 그래서 최근 라이브러리를 쓸수록 더 잘 들어옵니다. 자동으로 빠지길 기다릴 것이 아니라 직접 빼야 합니다.

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

디버그 빌드를 실기기에 따로 깔려고 applicationIdSuffix ".debug"를 넣었더니 빌드가 깨집니다. google-services.json에 그 패키지 이름이 없기 때문입니다. Firebase 콘솔에서 앱을 하나 더 만들어 넣으면 빌드는 통과하지만 더 큰 것을 잃습니다. 목적이 통계 분리라면 방법이 따로 있습니다.

같은 폰에 릴리스와 디버그를 나란히 깔고 싶어서 흔히 쓰는 설정을 넣었습니다.

buildTypes {
    debug {
        applicationIdSuffix = ".debug"
    }
}

빌드하면 이렇게 멈춥니다.

No matching client found for package name 'com.example.app.debug'

왜 나는가

google-services.json은 패키지 이름별로 클라이언트 정보를 담고 있습니다. Gradle 플러그인이 빌드할 때 현재 applicationId와 일치하는 항목을 찾는데, .debug가 붙은 이름은 그 파일에 없습니다. 파일을 못 읽은 게 아니라 파일 안에 그 이름이 없는 것입니다.

특히 앱 여러 개를 한 Firebase 프로젝트에 묶어 쓰고 있다면, 그 통합 json에는 실제 출시 패키지들만 들어 있습니다. 거기에 디버그용 이름이 있을 리가 없습니다.

흔한 처방과 그 대가

검색하면 대개 "Firebase 콘솔에서 .debug 패키지로 앱을 추가하고 json을 다시 받으라"고 합니다. 빌드는 통과합니다. 그런데 이렇게 됩니다.

생기는 것결과
Firebase 앱이 하나 더앱 10개인데 콘솔에는 20개가 보인다
데이터 스트림이 하나 더Analytics 속성에 디버그용 스트림이 섞인다
Crashlytics 대상이 하나 더내 테스트 크래시가 별도 앱으로 쌓인다

앱이 한두 개면 견딜 만하지만, 여러 개를 운영하면 콘솔에서 앱 수가 안 맞는 상태가 됩니다. 어느 것이 진짜인지 매번 확인해야 하고, 통계를 볼 때마다 디버그 스트림을 빼는 걸 기억해야 합니다. 저는 두 앱에 이렇게 해 뒀다가 나중에 걷어냈습니다.

애초에 무엇을 하려던 것이었나
.debug 접미사를 붙이는 진짜 이유는 대개 둘 중 하나입니다. ① 같은 폰에 두 벌을 동시에 깔고 싶다 ② 내 테스트가 실사용자 통계에 섞이는 걸 막고 싶다. ②가 목적이라면 패키지를 나눌 필요가 없습니다.

해결 — versionNameSuffix로 표시만 한다

패키지는 그대로 두고 버전 이름에만 표시를 답니다.

buildTypes {
    debug {
        versionNameSuffix = "-debug"
    }
}

이러면 빌드가 깨지지 않습니다. 패키지 이름이 안 바뀌니 google-services.json도 그대로 맞습니다. 그리고 Analytics에 올라가는 앱 버전이 1.0.5-debug가 됩니다.

이게 왜 중요하냐면, 통계에서 디버그 빌드를 걸러낼 때 기준으로 쓸 수 있는 값이 생기기 때문입니다. GA4에서 appVersion에 debug가 들어간 것을 제외하면 내 테스트가 빠집니다.

이걸 안 넣으면 실제로 어떻게 되나

제 경우가 그랬습니다. 앱 여러 개를 실기기로 점검한 날, 그 세션들이 전부 실사용자 통계로 잡혔습니다. 하루 테스트만으로 열 개 앱의 수치가 올라갔고, 앱 상태를 자동으로 감시하는 쪽은 그걸 실사용자 이용으로 읽었습니다. 사용자가 늘었다고 좋아할 뻔했고, 실패 건수도 함께 올라가서 헛경보가 났습니다.

더 곤란한 건 사후에 걸러낼 방법이 없다는 점입니다. 이미 올라간 이벤트에는 디버그 표시가 없으니, 그 날짜 데이터는 그냥 오염된 채로 남습니다.

같은 폰에 두 벌을 정말 동시에 깔아야 한다면
그때는 applicationIdSuffix가 맞는 도구입니다. 다만 Firebase를 쓰는 앱이라면 디버그 전용 google-services.json을 소스 세트로 분리하는 편이 낫습니다. app/src/debug/google-services.json에 디버그용을, app/에 운영용을 두면 빌드 타입별로 다른 파일이 쓰입니다. 그래도 Firebase 콘솔에 앱이 하나 더 생기는 건 마찬가지라, 통계 분리가 목적이라면 여전히 versionNameSuffix가 낫습니다.

확인

설정이 실제로 먹었는지는 빌드 결과물로 봅니다.

adb shell dumpsys package com.example.app | grep versionName
    versionName=1.0.5-debug

Analytics에 실제로 그 값이 실려 나가는지까지 보려면 로그로 확인합니다.

adb shell setprop log.tag.FA VERBOSE
adb shell setprop log.tag.FA-SVC VERBOSE
adb shell setprop debug.firebase.analytics.app com.example.app
adb logcat -d -s FA FA-SVC

정리

  • applicationIdSuffix ".debug"는 google-services.json에 그 패키지가 없어서 빌드를 깹니다.
  • 콘솔에 .debug 앱을 추가하면 Firebase 앱·데이터 스트림이 두 배가 됩니다.
  • 목적이 통계 분리라면 versionNameSuffix = "-debug"로 충분합니다.
  • 그러면 앱 버전이 1.0.5-debug가 되어 통계에서 걸러낼 수 있습니다.
  • 이 표시가 없으면 내 테스트가 실사용자 수치로 섞이고, 사후에 못 걷어냅니다.
  • 두 벌 동시 설치가 정말 필요하면 src/debug/google-services.json으로 분리합니다.

자주 묻는 것

디버그 빌드는 Analytics를 아예 끄면 되지 않나요?

그것도 방법이지만, 그러면 계측이 제대로 나가는지를 디버그에서 확인할 수 없습니다. 이벤트가 안 나가는 결함은 코드를 읽어서는 안 잡히고 실제로 보내 봐야 보입니다. 보내되 표시를 달아 나중에 거르는 쪽이 낫습니다.

versionNameSuffix를 붙이면 스토어 업로드에 문제가 없나요?

디버그 빌드 타입에만 붙는 설정이라 릴리스 빌드의 버전 이름은 그대로입니다. 업로드하는 것은 릴리스 빌드이니 영향이 없습니다. 혹시 불안하면 릴리스 AAB를 만든 뒤 위의 dumpsys나 번들 매니페스트에서 버전 이름을 한 번 확인하세요.

이미 오염된 통계는 어떻게 하나요?

날짜로 거르는 수밖에 없습니다. 테스트한 날이 언제였는지 기록해 두고 그 구간을 판단에서 빼세요. 그래서 테스트로 무엇을 오염시켰는지 그때그때 적어 두는 습관이 필요합니다. 릴리스 빌드로 실기기 점검을 하는 경우에는 versionNameSuffix가 안 붙으니 특히 그렇습니다.

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

MockK을 쓰는 프로젝트를 여러 개 연달아 빌드하면 몇 개가 알 수 없는 이유로 실패합니다. 그런데 실패한 것만 따로 다시 돌리면 통과합니다. JDK 21이 실행 중인 자기 JVM에 에이전트를 붙이는 것을 막기 때문에 생기는 일이고, 테스트 태스크에 JVM 옵션 두 줄을 주면 끝납니다. 코드 문제로 오해하기 쉬워서 남겨 둡니다.

앱을 여러 개 운영하고 있어서 배포 전에 전부 한 번에 빌드하고 테스트를 돌립니다. 어느 날 그 배치에서 몇 개가 BUILD FAILED로 떨어졌습니다. 로그를 보니 이런 것이 있었습니다.

Could not self-attach to current VM using external process

당황스러운 건 그다음이었습니다. 실패한 프로젝트를 하나만 다시 돌리니 그냥 통과했습니다. 코드는 한 글자도 안 바꿨습니다.

왜 나는가

MockK은 클래스의 동작을 실행 중에 바꿔치기합니다. 그러려면 JVM에 에이전트를 붙여야 하는데, 이 일을 ByteBuddy가 대신합니다.

문제는 JDK 9 이후로 JVM이 자기 자신에게 에이전트를 붙이는 것을 기본적으로 막는다는 점입니다. JDK 21에서는 이게 더 엄격해졌습니다. 그러면 ByteBuddy는 포기하지 않고 우회로를 씁니다 — 바깥에 별도 프로세스를 띄워서 그 프로세스가 대신 붙여 주게 합니다.

평소에는 이 우회로가 조용히 성공합니다. 그런데 프로젝트를 연달아 빌드하면 Gradle 데몬과 테스트용 JVM이 여러 개 떠 있는 상태가 되고, 그 상황에서 외부 프로세스를 띄우는 경로가 간헐적으로 실패합니다. 그래서 "배치로 돌리면 몇 개가 죽고, 혼자 돌리면 통과하는" 모양이 됩니다.

이게 왜 위험한 진단인가
실패가 간헐적이고 혼자 돌리면 재현되지 않습니다. 그래서 "테스트가 불안정하다", "이 앱만 뭔가 이상하다"로 정리하고 넘어가기 쉽습니다. 실제로 저는 처음에 그 프로젝트의 테스트 코드를 들여다봤습니다. 거기에는 아무 문제가 없었습니다.

해결 — 자기 자신에게 붙는 것을 허용한다

우회로를 안 타게 하면 됩니다. 테스트 태스크에 두 가지를 열어 줍니다.

// app/build.gradle.kts
tasks.withType<Test>().configureEach {
    jvmArgs(
        "-XX:+EnableDynamicAgentLoading",
        "-Djdk.attach.allowAttachSelf=true"
    )
}
옵션하는 일
-XX:+EnableDynamicAgentLoading실행 중 에이전트 로딩을 허용한다(안 주면 경고나 거부가 난다)
-Djdk.attach.allowAttachSelf=true자기 자신에게 붙는 것을 허용한다 — 외부 프로세스 우회로 자체가 필요 없어진다

Groovy DSL이면 이렇게 씁니다.

tasks.withType(Test).configureEach {
    jvmArgs '-XX:+EnableDynamicAgentLoading', '-Djdk.attach.allowAttachSelf=true'
}
MockK을 쓰기 시작할 때 같이 넣으세요.
이 설정은 문제가 터진 뒤에 넣으면 그 문제를 찾는 시간이 통째로 비용입니다. testImplementation에 MockK을 추가하는 그 자리에서 함께 넣어 두는 편이 낫습니다. 프로젝트 하나만 돌릴 때는 겉으로 아무 차이가 없어서, 나중에 배치로 돌리기 시작하는 날 처음 터집니다.

같은 계열 — 연달아 빌드하면 나는 다른 실패

배치 빌드에서 실패한 것이 전부 MockK 때문은 아니었습니다. 다른 프로젝트들은 이유 없이 BUILD FAILED였고, 역시 단독 재실행은 통과했습니다.

원인은 Gradle 데몬이 쌓이는 것이었습니다. 프로젝트마다 설정이 조금씩 달라 데몬이 재사용되지 않고 계속 새로 뜨는데, 메모리를 나눠 쓰다 보면 뒤쪽 빌드가 밀립니다. 각 프로젝트 빌드가 끝날 때 데몬을 정리하면 사라집니다.

gradlew --stop

배포 스크립트에서 앱 하나를 끝낼 때마다 이 줄을 넣었습니다. 빌드가 조금 느려지지만(데몬을 매번 새로 띄우니까) 이유 없는 실패를 뒤쫓는 시간보다는 쌉니다.

실패를 코드 탓으로 돌리기 전에

이 두 가지에는 공통점이 있습니다. 혼자 돌리면 통과하고, 여럿을 돌리면 실패합니다. 그리고 실패 메시지가 테스트 내용과 아무 상관이 없습니다.

이런 모양이면 코드보다 환경을 먼저 보는 편이 빠릅니다. 구분하는 방법은 단순합니다 — 실패한 것만 단독으로 다시 돌려 보세요. 통과하면 코드 문제가 아닙니다. 통과한다고 넘어가지 말고 그때 원인을 찾아야, 다음번 배치에서 다른 앱이 걸릴 때 같은 자리를 또 파지 않습니다.

정리

  • MockK은 ByteBuddy로 실행 중 JVM에 에이전트를 붙입니다.
  • JDK는 자기 자신에게 붙는 것을 막으므로 ByteBuddy가 외부 프로세스로 우회합니다.
  • 여러 프로젝트를 연달아 빌드하면 그 우회로가 간헐적으로 실패합니다.
  • -XX:+EnableDynamicAgentLoading과 -Djdk.attach.allowAttachSelf=true를 테스트 태스크에 주면 해결됩니다.
  • 배치 빌드의 이유 없는 실패는 gradlew --stop으로 데몬을 정리하면 줄어듭니다.
  • 단독 재실행으로 통과하는 실패는 코드 문제가 아닙니다. 환경을 보세요.

자주 묻는 것

JDK 버전을 낮추면 해결되나요?

낮은 JDK에서는 제약이 덜해서 덜 터질 수 있지만, 안드로이드 빌드 도구가 요구하는 JDK 버전이 계속 올라가기 때문에 되돌리는 방향은 오래 못 갑니다. 옵션 두 줄이 더 싸고 확실합니다.

Mockito를 써도 같은 일이 나나요?

Mockito도 인라인 목 방식을 쓰면 같은 자기 부착 경로를 탑니다. 증상이 같다면 같은 옵션을 걸어 보세요. 에이전트를 실행 중에 붙이는 라이브러리는 대체로 이 제약의 영향을 받습니다.

CI에서만 나는데 로컬에서는 안 납니다.

자연스러운 일입니다. CI는 보통 여러 모듈이나 프로젝트를 한 번에 돌리고 메모리도 빠듯합니다. 배치로 돌릴수록 잘 터지는 종류라 CI에서 먼저 보이는 게 정상입니다. 설정은 프로젝트에 넣는 것이라 로컬·CI 양쪽에 같이 적용됩니다.

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

용량을 줄이려고 JSON을 gzip으로 압축해 assets에 넣었는데, 앱에서 열면 FileNotFoundException이 납니다. 안드로이드 빌드 도구가 빌드 중에 gzip을 풀어서 넣고 .gz 확장자까지 떼어 버리기 때문입니다. 더 나쁜 건 앱이 멀쩡히 켜진다는 점입니다 — 그 파일을 여는 화면으로 들어가야만 죽습니다.

지역별 데이터를 들고 다니는 앱을 만들면서 지역마다 JSON 하나씩을 assets에 넣었습니다. 합치면 꽤 커서 gzip으로 압축했습니다.

app/src/main/assets/
  r0000.json.gz
  r0001.json.gz
  ...

코드는 평범합니다.

val stream = context.assets.open("r0000.json.gz")
val json = GZIPInputStream(stream).bufferedReader().readText()

설치하고 실행했더니 앱은 잘 떴습니다. 로그캣에도 이상이 없었습니다.

ActivityTaskManager: Displayed com.example/.MainActivity: +2s800ms

그런데 지역을 하나 탭하는 순간 앱이 죽었습니다.

java.io.FileNotFoundException: r0000.json.gz

APK 안을 열어 보면 파일 이름이 다르다

분명히 넣었는데 없다고 합니다. APK 안을 직접 봤습니다.

unzip -l app-release.apk | grep r0000

들어 있는 것은 이것이었습니다.

assets/r0000.json      # .gz 가 없다

빌드 도구가 gzip을 풀어서 평문 JSON으로 넣고, 확장자에서 .gz까지 떼어 버린 것입니다. 파일이 사라진 게 아니라 이름이 바뀐 겁니다. 그래서 r0000.json.gz로 여는 코드는 당연히 못 찾습니다.

noCompress로는 안 막힙니다.
가장 먼저 시도한 게 이것이었습니다.
androidResources {
    noCompress += "gz"
}
이 설정은 "APK를 만들 때 이 확장자는 더 압축하지 마라"는 뜻이지, "내가 넣은 gzip을 건드리지 마라"가 아닙니다. 방향이 반대라 원하는 일이 일어나지 않습니다.

해결 — 평문으로 넣는다

결론부터 말하면 assets에 gzip을 넣을 이유가 거의 없습니다. APK 자체가 zip이고 그 안에서 deflate로 압축되기 때문입니다. 제 경우 평문 JSON을 넣었더니 APK 안에서 약 4분의 1로 줄었습니다. 직접 압축해서 얻는 것이 사실상 없었습니다.

app/src/main/assets/
  r0000.json          # 그냥 평문

// 코드도 단순해진다
val json = context.assets.open("r0000.json").bufferedReader().readText()

정말 gzip인 채로 들고 가야 한다면

서버에서 받은 gzip을 그대로 캐시해 두는 것처럼, 압축된 상태를 유지해야 하는 경우가 있습니다. 그럴 때는 빌드 도구가 내용을 들여다보지 않는 확장자를 쓰면 됩니다. r0000.json.bin처럼 바꿔서 넣고, 코드에서는 여전히 GZIPInputStream으로 읽으면 됩니다. 확장자는 이름일 뿐이라 내용과 무관합니다.

다만 이렇게까지 할 값어치가 있는지는 먼저 재 보세요. 평문으로 넣고 APK 크기를 비교하는 데 10분이면 됩니다.

진짜 문제는 이게 늦게 발견된다는 것

이 글을 쓰는 이유입니다. 이 결함은 앱이 정상으로 켜지는 동안 숨어 있습니다.

  • 로그캣에 Displayed가 정상으로 찍힙니다
  • 프로세스도 살아 있습니다
  • 첫 화면이 멀쩡히 그려집니다

스크린샷 한 장으로 "실행 확인"을 끝냈다면 이 결함을 안고 배포했을 것입니다. 실제로 저는 첫 화면을 보고 넘어갈 뻔했고, 목록에서 항목을 하나 탭해 보고 나서야 알았습니다.

실기기 확인은 한 칸 더 들어가서
assets든 네트워크든, 데이터를 처음 여는 지점은 대개 첫 화면이 아니라 그다음 화면입니다. 목록에서 하나 눌러 상세까지 들어가 보는 것까지가 "실행 확인"입니다. 확인은 이렇게 합니다.
adb shell screencap -p /sdcard/s.png
adb pull /sdcard/s.png
윈도우 PowerShell에서 > s.png로 받으면 PNG가 깨집니다(인코딩이 끼어듭니다). 기기에 파일로 찍고 pull 하세요.

정리

  • assets의 .gz는 빌드 중에 풀리고 확장자까지 떨어집니다. APK 안에는 .json으로 들어 있습니다.
  • noCompress는 "더 압축하지 마라"라서 이 문제와 방향이 다릅니다.
  • 확인은 unzip -l app-release.apk로 APK 안의 실제 이름을 보면 1초입니다.
  • APK가 어차피 압축하므로 평문으로 넣는 것이 정답입니다. 제 경우 약 4분의 1이 됐습니다.
  • 압축 상태를 유지해야 하면 .bin 같은 확장자로 위장합니다.
  • 앱은 정상 실행되다가 그 파일을 여는 순간에만 죽습니다. 첫 화면만 보면 못 잡습니다.

자주 묻는 것

res/raw에 넣으면 어떤가요?

res/raw도 빌드 도구가 관리하는 영역이라 같은 처리를 기대하는 편이 안전합니다. 확실히 하고 싶으면 넣어서 빌드한 뒤 unzip -l로 결과물을 확인하세요. 어느 쪽이든 결과물을 보고 판단하는 습관이 답입니다. 설정 문서를 읽고 추측하는 것보다 빠릅니다.

디버그 빌드에서는 잘 되던데요?

빌드 타입에 따라 리소스 처리 설정이 다르게 걸려 있으면 그럴 수 있습니다. 그래서 더 위험합니다 — 개발 중에는 멀쩡하다가 릴리스에서만 죽으면 원인을 찾는 데 시간이 훨씬 오래 걸립니다. 릴리스 APK를 풀어서 확인하세요.

데이터가 정말 크면 어떻게 하나요?

APK에 넣지 않고 실행 중에 내려받는 쪽을 고려해 보세요. 저는 60MB짜리 지역 데이터를 별도 저장소에 두고 사용자가 자기 지역 하나만 받도록 바꿔서, 릴리스 APK를 21MB에서 1.1MB로 줄였습니다. 받은 파일은 filesDir에 캐시해 두면 오프라인에서도 열립니다. 전송 구간 압축은 서버가 알아서 해 줍니다.

728x90
반응형
LIST

+ Recent posts