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

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

실기기 테스트를 자동화하다 보면 검색창에 한글을 넣어야 할 때가 옵니다. 그런데 adb shell input text는 한글을 받으면 NullPointerException으로 죽습니다. ASCII 문자만 키 매핑이 있기 때문입니다. 저는 이 한 가지 때문에 앱 하나를 통째로 "자동 검증 불가"로 분류했다가, 나중에 키보드 자판을 좌표로 직접 탭하는 방법으로 뚫었습니다.

안드로이드 앱을 여러 개 운영하면서 배포 전 실기기 점검을 스크립트로 돌립니다. 그중 한 앱은 음식 이름을 검색해 칼로리를 찾는 앱이라, 검색 기능을 확인하려면 반드시 한글을 입력해야 했습니다.

adb shell input text "김치"

결과는 이렇습니다.

java.lang.NullPointerException
	at com.android.commands.input.Input...

왜 ASCII만 되나

input text는 문자열을 그대로 앱에 꽂아 주는 명령이 아닙니다. 문자를 키 이벤트로 바꿔서 보냅니다. 그 변환표가 KeyCharacterMap인데, 이건 물리 키보드의 자판 배열을 표현한 것이라 알파벳·숫자·기호까지만 들어 있습니다.

한글에 해당하는 키는 표에 없습니다. 찾지 못한 자리가 null로 남고, 그걸 그대로 쓰려다 NullPointerException이 납니다. 버그라기보다 애초에 지원 범위 밖입니다. 그래서 문자열을 바꿔 넣는 우회(공백을 %s로 바꾸는 식)로는 해결되지 않습니다. 그건 ASCII 안에서의 이야기입니다.

여기서 포기하기 쉽습니다. 저도 그랬습니다. "한글 입력이 필요한 화면은 자동화가 안 된다"로 적어 두고 그 앱만 수동 점검 대상으로 뺐습니다. 그런데 막힌 것은 문자열을 보내는 경로 하나였을 뿐이고, 화면을 누르는 경로는 그대로 열려 있었습니다.

뚫는 방법 — 자판을 좌표로 누른다

사람이 폰에서 한글을 칠 때 무엇을 합니까. 키보드를 띄우고 자판을 누릅니다. adb shell input tap은 좌표를 누르는 명령이고, 이건 한글과 아무 상관이 없습니다. 키보드 자판의 좌표를 알아내서 순서대로 누르면 됩니다.

1. 먼저 입력칸을 탭해서 키보드를 올린다

adb shell input tap 540 300     # 검색 입력칸
# 키보드가 올라올 시간을 준다

이게 빠지면 뒤의 탭이 전부 허공을 누릅니다.

2. 자판 좌표는 스크린샷을 눈으로 읽어서 구한다

키보드는 uiautomator dump에 안 잡힙니다. IME는 앱과 별도의 윈도우라서 uiautomator dump로 받은 XML에 자판 키가 하나도 나오지 않습니다. 평소처럼 노드를 찾아 좌표를 얻는 방법이 여기서는 통하지 않습니다.

그래서 스크린샷을 찍어 눈으로 읽습니다.

adb shell screencap -p /sdcard/kb.png
adb pull /sdcard/kb.png

받은 그림에서 각 키의 중심 좌표를 재면 됩니다. 제 기기(삼성 SM-N960N, 1080×2220, 천지인 자판) 기준으로는 이렇게 나왔습니다.

행키좌표 (x, y)
모음ㅣ145, 1451
모음·409, 1451
모음ㅡ671, 1451
자음 1행ㄱ … ㅌ80 … 736, y=1628
자음 2행ㅂ … ㅊ80 … 736, y=1805
자음 3행ㅇ343, 1983
자음 3행ㅁ474, 1983

같은 행의 키들은 등간격이라 양 끝만 재면 가운데는 계산으로 나옵니다. 이 숫자를 그대로 쓰지는 마세요. 기기 해상도와 자판 종류(천지인이냐 쿼티냐)에 따라 전부 달라집니다. 방법이 요점이고 숫자는 예시입니다.

3. 자모 순서대로 탭한다

천지인은 모음을 ㅣ · ㅡ 조합으로 만들기 때문에, 칠 순서를 자모 단위로 풀어야 합니다. "김치"는 이렇게 됩니다.

# 김 = ㄱ ㅣ ㅁ
adb shell input tap 80 1628      # ㄱ
adb shell input tap 145 1451     # ㅣ
adb shell input tap 474 1983     # ㅁ
# 치 = ㅊ ㅣ
adb shell input tap 736 1805     # ㅊ
adb shell input tap 145 1451     # ㅣ

실제로는 파이썬 같은 걸로 자모를 분해해 좌표 목록으로 바꿔 두고 한 번에 보냅니다. 한 글자씩 adb를 새로 띄우면 느리니 adb shell 한 세션에서 이어 보내는 편이 낫습니다.

왜 이게 되는가
input text가 막힌 이유는 문자→키 변환표에 한글이 없어서입니다. 탭은 그 변환을 거치지 않고 화면 좌표만 보냅니다. 조합은 IME가 알아서 합니다 — 우리는 사람이 누르는 것과 똑같은 신호를 보낼 뿐입니다.

확인

입력이 실제로 들어갔는지는 화면으로 봅니다. 자동화 스크립트라면 스크린샷을 찍어 두거나, 검색 결과 목록이 떴는지를 uiautomator dump로 확인하면 됩니다. 입력칸 자체는 앱 윈도우라 덤프에 나옵니다 — 안 나오는 건 키보드뿐입니다.

adb shell uiautomator dump /sdcard/ui.xml
adb pull /sdcard/ui.xml

이 한 가지가 판단을 바꿨다

이 글을 쓰는 진짜 이유입니다. 저는 "한글 입력 자동화 불가"라는 결론을 근거로 앱 하나의 검증 계획을 통째로 축소했습니다. 검색이 핵심 기능인 앱이었는데 그 경로를 자동 점검에서 뺀 것입니다.

그런데 막혔던 건 input text라는 도구 하나였지 "한글 입력"이라는 목적이 아니었습니다. 한 층 아래로 내려가니 길이 있었습니다. 도구가 막히면 그 도구가 무엇을 대신해 주고 있었는지를 보고, 그걸 직접 하는 방법을 찾는 편이 빠릅니다.

덧붙이면 한글 입력을 자동화할 수 있게 되면서 한글로만 재현되는 결함도 볼 수 있게 됐습니다. 예를 들어 Compose의 TextField는 값을 비동기 저장소에 왕복시키면 한글 자모가 흩어지는데, 영어로 테스트하면 100% 통과합니다. 이런 건 한글을 실제로 쳐 봐야 걸립니다.

정리

  • input text는 문자를 키 이벤트로 바꿔 보냅니다. 그 변환표에 한글이 없어 NullPointerException이 납니다.
  • 문자열을 치환하는 우회로는 해결되지 않습니다. 지원 범위 밖입니다.
  • input tap으로 자판 좌표를 직접 누르면 됩니다. 조합은 IME가 합니다.
  • 키보드는 별도 윈도우라 uiautomator dump에 안 잡힙니다. 좌표는 스크린샷으로 읽습니다.
  • 먼저 입력칸을 탭해 키보드를 올려야 합니다.
  • 좌표는 기기·자판마다 다릅니다. 숫자가 아니라 방법을 가져가세요.

자주 묻는 것

ADB 키보드 같은 걸 설치하는 방법도 있지 않나요?

테스트용 IME를 설치해 기본 키보드로 바꾸고, 브로드캐스트로 문자열을 넣는 방식이 있습니다. 문자열을 통째로 넣을 수 있어 편하지만 기본 키보드를 바꿔야 하고, 끝나고 되돌리지 않으면 사용자의 폰 설정이 바뀐 채로 남습니다. 저는 남의 기기 설정을 건드리지 않는 쪽을 택했습니다. 조합 과정 자체를 확인하고 싶을 때도 실제 자판을 누르는 편이 실제에 가깝습니다.

쿼티 자판에서도 되나요?

됩니다. 좌표만 다시 재면 됩니다. 다만 쿼티는 키가 촘촘해서 좌표 오차에 민감하고, 자모를 키 단위로 분해해야 하는 것은 같습니다. 천지인은 키가 크고 수가 적어 좌표를 재기는 더 쉬웠습니다.

에뮬레이터에서는요?

같은 방법이 통합니다. 다만 에뮬레이터 기본 로케일은 영어라 한글 키보드가 아예 안 올라올 수 있습니다. setprop persist.sys.locale ko-KR 후 재시작하거나, 설정에서 한국어 키보드를 추가해 두고 좌표를 재세요.

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

Fragment를 한 줄도 쓰지 않는 Compose 앱인데 Play Console이 androidx.fragment:fragment 1.1.0을 오래된 SDK로 지목한다면, 범인은 구글 자신의 라이브러리입니다. 광고·Firebase·인앱 리뷰가 공통으로 거치는 play-services-basement가 2019년판 fragment 1.1.0을 끌고 옵니다. 최신 basement로 올려도 그대로라서 앱에서 버전을 승격하는 것이 유일한 방법입니다. 앱 18개를 전수로 확인해 보니 12개가 이 상태였는데, 콘솔 경고는 3개에만 떠 있었습니다.

안드로이드 앱 여러 개를 운영하고 있습니다. 어느 날 한 앱의 Play Console에 SDK 문제로 이 라이브러리가 떴습니다.

androidx.fragment:fragment  1.1.0

당황스러웠습니다. 그 앱은 순수 Compose라 Fragment 클래스를 한 번도 import 한 적이 없고, build.gradle에도 fragment라는 글자가 없었습니다. 차단은 아니고 권고 수준의 경고였지만, 내가 선언하지 않은 것을 고치라니 어디서부터 봐야 할지 몰랐습니다.

누가 끌고 오는지 — dependencyInsight

Gradle에 물어보면 바로 나옵니다.

./gradlew :app:dependencyInsight \
    --configuration releaseRuntimeClasspath \
    --dependency androidx.fragment:fragment

경로를 따라 올라가면 끝에 이것이 있습니다(출력을 줄여 옮겼습니다).

androidx.fragment:fragment:1.1.0
\--- com.google.android.gms:play-services-basement:18.x
     +--- com.google.android.gms:play-services-ads-...
     +--- com.google.android.gms:play-services-measurement-...   (firebase-analytics)
     \--- com.google.android.play:review ...

play-services-basement는 구글 플레이 서비스 계열의 바닥 라이브러리라 애드몹, Firebase Analytics, 인앱 리뷰가 전부 이걸 거칩니다. 광고나 분석을 넣은 앱이면 거의 자동으로 생긴다고 보면 됩니다.

라이브러리를 최신으로 올리면 되지 않나 — 안 된다

제일 먼저 시도한 게 이것이었습니다. 그런데 Google Maven의 POM을 직접 열어 보면 답이 나옵니다.

play-services-basement 의존하는 androidx.fragment
18.4.0 1.1.0
18.11.0 (확인 시점 최신) 1.1.0
curl -s https://dl.google.com/android/maven2/com/google/android/gms/play-services-basement/18.11.0/play-services-basement-18.11.0.pom

최신판도 fragment 1.1.0을 그대로 선언하고 있습니다. 기다려도, SDK를 올려도 안 사라집니다. 앱 쪽에서 더 높은 버전을 요구해 Gradle의 충돌 해소가 높은 쪽을 고르게 하는 수밖에 없습니다.

해결 — 앱에서 버전을 승격한다

방법 A: constraints로 바닥값만 올린다 (권장)

# gradle/libs.versions.toml
[versions]
fragment = "1.8.9"

[libraries]
androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" }
// app/build.gradle.kts
dependencies {
    constraints {
        implementation(libs.androidx.fragment) {
            because("play-services-basement 가 끌어오는 fragment 1.1.0 을 Play 가 경고한다")
        }
    }
    ...
}

constraints는 의존성을 추가하지 않습니다. 누군가 fragment를 끌고 올 때만 "최소 이 버전"을 걸어 줍니다. 안 쓰는 Fragment를 내가 선언해 들고 있는 모양이 안 되고, 나중에 basement가 알아서 최신을 끌고 오게 되면 이 줄은 아무 일도 안 합니다.

방법 B: implementation으로 직접 선언한다

// Fragment 버전 강제 업그레이드 (play-services-basement 가 1.1.0 을 끌고 온다)
implementation(libs.androidx.fragment)

더 단순하고 확실합니다. 다른 앱들은 이렇게 고쳤습니다. 다만 아래 두 가지를 조심하세요.

① Java 앱에는 fragment-ktx 말고 fragment를 쓰세요.
fragment-ktx를 선언하면 activity-ktx·collection-ktx·lifecycle 계열 ktx·savedstate-ktx 등 여섯 개가 딸려 들어왔습니다. Kotlin/Compose 앱은 그것들을 이미 갖고 있어서 차이가 없지만, Java 앱에는 전부 새 짐입니다.

② 최신 버전이 Kotlin 버전을 끌어올릴 수 있습니다.
fragment 1.9.0은 kotlin-stdlib 2.1.20을 요구해서, Kotlin 2.0.21인 앱에서는 못 썼습니다. 1.8.9는 stdlib 1.8.22·activity 1.8.1을 요구해 기존 그래프보다 전부 낮아 아무것도 안 밀었습니다. 올리기 전에 dependencyInsight로 함께 올라가는 것을 확인하세요.

확인 — 소스가 아니라 빌드 결과물로

의존성 트리는 "그렇게 될 것"이지 실제로 들어간 것이 아닙니다. Play가 읽는 것은 결과물이니 결과물에서 확인합니다.

# APK 안에 들어간 버전 파일 — 한 줄이면 끝난다
unzip -p app-release.apk META-INF/androidx.fragment_fragment.version
1.8.9

AAB로 올린다면 번들 안의 의존성 메타데이터(BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb)가 Play가 SDK 색인에 쓰는 자료입니다. 바이너리라 눈으로 읽기는 어렵고, 같은 빌드로 만든 APK에서 버전 파일을 보는 편이 빠릅니다.

경고가 뜬 앱만 아픈 게 아니다

이 글을 쓰게 된 진짜 이유입니다. 경고는 최근에 업로드한 앱에만 뜹니다. 저는 처음에 경고가 뜬 앱 셋만 고쳤는데, 나중에 18개 앱을 전부 APK로 확인해 보니 12개가 여전히 1.1.0이었습니다.

더 뼈아팠던 건 따로 있었습니다. 이미 앱 네 개를 주석까지 달아 고쳐 두었는데, 그 뒤에 새로 만든 앱 다섯 개에 그 한 줄이 옮겨지지 않았습니다. 새 앱을 만들 때 기존 앱의 build.gradle을 참고하긴 했지만, "이 줄은 왜 있지?"로 읽히는 줄은 빠지기 쉽습니다. 특히 이 줄은 앱의 기능과 아무 관계가 없어 보이는 줄이라 더 그렇습니다.

여러 앱을 운영한다면 한 번에 확인하는 법
각 앱의 릴리스 APK에서 META-INF/androidx.fragment_fragment.version을 뽑아 목록으로 보면 됩니다. 콘솔 알림을 기다리면 업로드한 순서대로 하나씩 알게 됩니다.

정리

  • Fragment를 안 써도 광고·Firebase·인앱 리뷰가 있으면 play-services-basement를 통해 fragment 1.1.0이 들어옵니다.
  • 최신 basement(18.11.0)도 1.1.0을 선언합니다. SDK를 올려서는 안 사라집니다.
  • 앱에서 constraints(권장) 또는 implementation으로 버전을 승격합니다.
  • Java 앱은 fragment(non-ktx)를, 버전은 Kotlin 요구 수준을 확인하고 고릅니다.
  • 확인은 APK의 META-INF/androidx.fragment_fragment.version으로 합니다.
  • 경고는 최근 업로드한 앱에만 뜹니다. 여러 앱이면 전부 확인하고, 새 앱 템플릿에도 넣으세요.

자주 묻는 것

이 경고를 무시하면 앱이 내려가나요?

제가 받은 것은 차단이 아닌 권고 수준이었고, 업로드와 출시는 그대로 됐습니다. 다만 Play의 SDK 색인 경고는 SDK 제공자가 등급을 올리면 나중에 업로드를 막는 쪽으로 바뀔 수 있습니다. 한 줄로 끝나는 일이라 미뤄 둘 이유가 없습니다.

fragment를 올리면 광고나 Firebase가 깨지지 않나요?

AndroidX는 같은 메이저 버전(1.x) 안에서 하위 호환을 지키는 것이 원칙이라, 1.1.0에서 1.8.x로 올리는 것은 그 범위 안입니다. 저는 광고·인앱 리뷰·Firebase Analytics가 든 앱들에 적용해 배포했고 빌드와 실행에 문제가 없었습니다. 그래도 불안하면 광고가 뜨는 화면과 동의(UMP) 팝업을 실기기에서 한 번씩 띄워 보세요.

constraints를 넣었는데 버전이 안 바뀌어요.

constraints는 같은 설정(configuration)에 걸려야 합니다. implementation 블록 안에 넣었는지, 다른 곳에서 strictly나 force로 낮은 버전을 박아 두지 않았는지 dependencyInsight로 확인하세요. 출력에 "By constraint"라는 사유가 붙어 있으면 제대로 걸린 것입니다.

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

Firebase Remote Config에서 값을 바꿨는데 앱이 반응하지 않을 때, fetchAndActivate가 성공했다는 로그만 보고 넘어가면 원인을 못 찾습니다. 조회는 템플릿이 비어 있어도 성공하기 때문에, 성공 여부만으로는 원격값을 받았는지 인앱 기본값으로 돌고 있는지가 구분되지 않습니다. 하필 기본값과 원격값이 같은 숫자라면 구분이 원리적으로 불가능해집니다. 앱 16개에 원격 설정을 붙이면서 겪은 것을 바탕으로, 값이 어디서 왔는지 확인하는 법과 반영이 늦는 세 가지 이유를 정리합니다.

광고 클릭 차단 시간 같은 값을 앱 재배포 없이 바꾸고 싶어서 Remote Config를 붙였습니다. 코드는 문서대로 짰습니다.

val rc = FirebaseRemoteConfig.getInstance()
rc.setDefaultsAsync(mapOf("bok_config" to """{"click_block_expiry_min":1440}"""))
rc.fetchAndActivate().addOnCompleteListener { task ->
    Log.i(TAG, "fetch ok=${task.isSuccessful}")
    apply(rc.getString("bok_config"))
}

실기기에서 돌리니 fetch ok=true가 찍혔고, 차단 시간도 1440분(24시간)으로 적용돼 있었습니다. 성공으로 보였습니다.

그런데 콘솔에는 아무 값도 올린 적이 없었다

그 시점에 Remote Config 템플릿은 비어 있었습니다. 원격에 값을 올리는 스크립트를 아직 안 돌렸기 때문입니다. 즉 앱이 쓰던 1440은 원격값이 아니라 setDefaultsAsync로 심어 둔 인앱 기본값이었습니다.

빈 템플릿에서도 fetch는 성공합니다.
조회 자체는 서버와 정상적으로 통신했으니 task.isSuccessful은 true입니다. "받을 값이 없었다"는 실패가 아닙니다. 그래서 성공 로그만 보면 "원격값을 받았다"와 "기본값으로 돈다"가 똑같이 생겼습니다.

제 경우는 더 나빴습니다. 첫 원격값을 일부러 인앱 기본값과 같은 1440으로 올릴 계획이었거든요. 동작은 안 바꾸고 연결만 확인하려는 의도였는데, 그러면 적용됐는지를 값으로는 영영 알 수 없습니다. 대조군이 없는 초록불입니다.

해결 — 값의 출처(source)를 같이 찍는다

getValue(key)가 돌려주는 FirebaseRemoteConfigValue에는 그 값이 어디서 왔는지가 들어 있습니다.

val source = when (rc.getValue("bok_config").source) {
    FirebaseRemoteConfig.VALUE_SOURCE_REMOTE  -> "remote"   // 2
    FirebaseRemoteConfig.VALUE_SOURCE_DEFAULT -> "default"  // 1
    FirebaseRemoteConfig.VALUE_SOURCE_STATIC  -> "static"   // 0
    else -> "unknown"
}
Log.i(TAG, "fetch ok=${task.isSuccessful} source=$source value=...")
source 뜻
remote 원격 템플릿에서 받아 활성화된 값
default setDefaultsAsync로 심은 인앱 기본값 — 템플릿에 키가 없거나 아직 활성화 전
static 기본값조차 없어 SDK가 주는 빈 값(문자열 "", 숫자 0, 불리언 false)

로그를 바꾸고 다시 봤습니다.

원격 템플릿 비어 있음  →  fetch ok=true source=default applied=expiryMs=86400000
bok_config 발행 후     →  fetch ok=true source=remote  applied=expiryMs=86400000

값은 똑같이 86400000인데 출처만 바뀌었습니다. 이게 보이고 나서야 "원격에서 앱까지 사슬이 이어졌다"를 확정할 수 있었습니다.

static이 찍히면 의심할 것
키 이름 오타이거나 setDefaultsAsync가 호출되기 전에 값을 읽은 경우입니다. 앱이 빈 문자열이나 0으로 조용히 돌고 있다는 뜻이라, 기본값을 "안전한 쪽"으로 잡아 둔 의미가 사라집니다.

값을 바꿨는데 반영이 늦는 세 가지 이유

① 최소 조회 간격 기본값이 12시간이다

minimumFetchIntervalInSeconds의 기본값은 43200초(12시간)입니다. 그 사이에 fetch를 불러도 SDK가 캐시된 값을 돌려주고 서버에 가지 않습니다. 오류도 아니라서 조용히 옛 값이 유지됩니다.

rc.setConfigSettingsAsync(
    FirebaseRemoteConfigSettings.Builder()
        .setMinimumFetchIntervalInSeconds(3600)   // 1시간
        .build()
)

저는 1시간으로 줄였습니다. 사고가 났을 때 켜는 스위치(광고 끄기)가 반나절 뒤에 닿으면 뜻이 없어서입니다. 다만 개발 중처럼 0으로 두고 배포하면 안 됩니다. 기기당 요청이 스로틀에 걸리면 FirebaseRemoteConfigFetchThrottledException이 나면서 오히려 더 늦게 반영됩니다.

테스트할 때 캐시를 바로 버리려면 앱 데이터를 지웁니다.

adb shell pm clear 내.패키지.이름

② fetchAndActivate는 비동기다 — 첫 화면에 늦는다

완료까지 1초 안팎 걸립니다. 그 사이에 첫 화면은 이미 그려집니다. 실기기에서 시각을 찍어 보니 이랬습니다.

13.565  배너를 띄울지 판정 (아직 기본값)
14.291  배너 생성
14.381  원격값 도착         ← 이미 늦었다

Compose에서 배너를 remember로 한 번만 만들면 나중에 다시 판정하지 않으므로, 늦게 온 값이 그 배너에는 영영 닿지 않습니다. 대처는 두 가지입니다.

  • 직전에 활성화된 값을 먼저 읽는다. fetchAndActivate를 부르기 전에 getString으로 한 번 적용합니다. 디스크에 남은 지난번 값이 동기로 읽히므로 두 번째 실행부터는 화면보다 먼저 적용됩니다. 설치 직후 첫 실행은 원리적으로 못 막습니다.
  • 값이 꼭 필요한 판단은 도착한 뒤에 한다. 업데이트 안내처럼 원격값을 보고 사용자에게 뭔가를 띄우는 코드를 앱 시작 직후에 두면, 첫 실행에서는 거의 항상 "값 없음"으로 읽혀 영영 안 뜹니다. 코드를 읽으면 정상으로 보이는 종류입니다.

③ 의존성이 없으면 조용히 기본값으로 돈다

공통 라이브러리에서 Remote Config를 쓰면서 SDK를 compileOnly로 두었는데, 앱 쪽에 firebase-config 의존성을 안 넣으면 빌드는 되고 런타임에 클래스가 없습니다. 앱이 죽지 않게 감싸 두면, 그 앱만 원격 설정이 통째로 꺼진 채 기본값으로 돕니다. "원격으로 껐는데 그 앱만 안 꺼진다"를 겪고 나서야 알게 되는 구조입니다.

runCatching {
    FirebaseRemoteConfig.getInstance()
    ...
}.onFailure { e ->
    // NoClassDefFoundError 까지 여기서 걸린다 — 조용히 넘기지 말고 기록한다
    Log.w(TAG, "원격 설정을 쓸 수 없다(firebase-config 의존성 누락?): ${e.javaClass.simpleName}")
}

firebase-analytics는 모든 앱에 이미 있어서 베끼는 과정에서 안 빠지는데, firebase-config는 0개에서 시작한 의존성이라 새 앱을 만들 때 빠지기 쉽습니다.

기본값은 "안전한 쪽"으로

원격값은 늦게 오거나 안 올 수 있으니, 앱은 상당 시간 기본값으로 돈다고 생각해야 합니다. 그래서 기본값을 정할 때 "평소에 쓸 값"이 아니라 "조회가 실패한 사용자에게 무슨 일이 생기나"로 정합니다.

값 기본값 반대로 두면
광고 끄기 스위치 꺼짐 통신이 불안한 사용자 전원이 광고를 잃는다
최소 지원 버전 0 (제한 없음) 오프라인 사용자에게 업데이트를 요구한다
차단 유지 시간 실측 기반 24시간 조회 실패 시 방어가 꺼진다

정리

  • fetchAndActivate 성공은 원격값을 받았다는 뜻이 아닙니다. 빈 템플릿에서도 성공합니다.
  • getValue(key).source로 remote / default / static을 같이 찍으세요. 값이 같아도 출처는 갈립니다.
  • 최소 조회 간격 기본값은 12시간입니다. 너무 짧게 두면 스로틀에 걸립니다.
  • 원격값은 첫 화면에 늦습니다. 직전 값을 먼저 적용하고, 값이 필요한 판단은 도착 뒤에 합니다.
  • SDK를 compileOnly로 쓰는 구조라면 의존성 누락이 조용히 기본값이 됩니다. 실패를 기록하세요.

자주 묻는 것

fetchAndActivate가 false를 돌려주는데 실패인가요?

아닙니다. Task<Boolean>의 결과값 false는 이번 호출에서 새로 활성화한 값이 없다는 뜻입니다. 이미 활성화된 값과 같거나 캐시에서 돌려준 경우입니다. 실패 여부는 task.isSuccessful로 봅니다. 결과값과 성공 여부를 섞어 읽으면 정상 동작을 실패로 오판합니다.

콘솔에서 게시했는데 source가 계속 default예요.

세 가지를 확인하세요. 키 이름이 코드와 정확히 같은지(대소문자 포함), 조건(앱 버전·국가 등)에 걸려 이 기기에 값이 안 내려오는지, 그리고 최소 조회 간격 때문에 아직 서버에 안 간 것인지입니다. 마지막은 adb shell pm clear로 캐시를 지우고 다시 켜면 바로 갈립니다.

값을 바꾸면 앱이 켜진 채로 바로 반영되게 할 수 있나요?

실시간 업데이트 리스너(addOnConfigUpdateListener)를 쓰면 게시 직후 켜져 있는 앱에 변경이 전달됩니다. 다만 받은 뒤 activate()를 불러야 적용되고, 이미 그려진 화면이 다시 판정하지 않는 문제는 그대로 남습니다. 저는 사고 대응용 스위치라 1시간 간격 조회로 충분하다고 보고 붙이지 않았습니다.

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

OkHttp에 재시도 인터셉터를 달아 뒀는데 네트워크 실패가 전혀 줄지 않는다면, 재시도 조건이 실제 예외를 못 잡고 있을 가능성이 큽니다. 연결이 응답 도중 끊기면 OkHttp는 SocketException이 아니라 평범한 IOException에 unexpected end of stream이라는 문구를 실어 던지고, 읽기 타임아웃의 문구는 timeout이 아니라 Read timed out입니다. 제 앱은 두 구멍에 차례로 빠져 있었고, 실사용자 실패 9건의 재시도 기록이 9건 전부 "한 번에 포기"였습니다. 재시도 코드는 멀쩡히 붙어 있었는데 한 번도 돈 적이 없었던 것입니다.

공공데이터 API를 쓰는 앱이었습니다. 정부 서버가 가끔 연결을 끊어서, 몇 년 전에 재시도를 붙여 둔 코드가 있었습니다.

fun isRetryable(e: IOException): Boolean =
    e is SSLException ||
    e is SocketException ||
    e.message?.contains("Connection reset") == true ||
    e.message?.contains("timeout") == true

읽어 보면 그럴듯합니다. SSL 오류, 소켓 오류, 연결 리셋, 타임아웃. 흔한 네트워크 실패는 다 들어 있는 것처럼 보입니다. 파일 맨 위 주석에도 "서버의 Connection reset에 대비한 재시도"라고 적혀 있었습니다.

첫 번째 구멍 — unexpected end of stream

이 람다를 별도 클래스로 꺼내서 테스트를 붙였더니 첫 실행에서 바로 빨간불이 났습니다. 테스트는 MockWebServer로 서버가 연결을 진짜로 끊게 만든 것이었습니다.

server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
server.enqueue(MockResponse().setBody("ok"))

client.newCall(request).execute().use { assertEquals(200, it.code) }

첫 요청은 끊기고 두 번째는 성공하니, 재시도가 돌면 통과해야 합니다. 그런데 첫 요청에서 예외가 그대로 밖으로 나왔습니다. 찍힌 예외는 이랬습니다.

java.io.IOException: unexpected end of stream on http://127.0.0.1:2114/...

SocketException이 아닙니다. 그냥 IOException이고, 문구에 "Connection reset"도 "timeout"도 없습니다. 네 조건 어디에도 걸리지 않으니 재시도 없이 즉시 포기했습니다. "서버가 연결을 끊는 상황"에 대비한 코드가 정작 그 상황의 상당 부분을 놓치고 있었습니다.

손으로 만든 예외로 테스트했다면 영영 몰랐습니다.
isRetryable(SocketException("Connection reset"))처럼 예외를 직접 만들어 넣는 테스트는 내가 짐작한 예외를 내가 분류하는지만 봅니다. OkHttp가 실제로 무엇을 던지는지는 소켓을 진짜로 끊어 봐야 나옵니다.

같이 터진 것 — android.util.Log는 JVM 테스트에서 예외를 던진다

처음 돌린 테스트 네 개가 전부 다른 이유로 깨지기도 했습니다. 재시도 경로의 catch 블록 안에 Log.w(...)가 있었는데, 로컬 유닛테스트에서 android.util.Log는 호출만 해도 RuntimeException: Method w in android.util.Log not mocked를 던집니다. 재시도 경로가 통째로 폭발한 것입니다.

unitTests.isReturnDefaultValues = true를 켜면 사라지긴 합니다. 저는 켜지 않았습니다. 그 옵션은 안드로이드 API 전부를 조용한 기본값으로 바꿔서, 언젠가 다른 곳의 진짜 실패까지 초록불로 덮기 때문입니다. 대신 로그를 생성자 파라미터로 뺐습니다.

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) },   // 테스트에서는 {} 를 넘긴다
) : Interceptor

두 번째 구멍 — "timeout"과 "timed out"은 다른 문자열이다

unexpected end of stream을 조건에 추가해서 배포했습니다. 닷새 뒤, 실사용자 기기 세 종류에서 첫 실행 데이터 로딩 실패가 6건 올라왔습니다. 실패 사유는 timeout으로 정확히 분류돼 있었습니다. 그런데 같은 시간대의 재시도 결말 기록을 보니 이상했습니다.

재시도 결말건수뜻
recovered0재시도해서 살아났다
exhausted0끝까지 재시도했는데 실패
skipped9재시도할 실패가 아니라 한 번에 포기

실패 9건, 포기 9건. 모든 실패에서 재시도가 0회였습니다. 원문을 열어 보니 이렇게 찍혀 있었습니다.

retry_skipped  detail=1/3 timeout Read timed out

조건은 contains("timeout")인데 메시지는 Read timed out입니다. "timeout"과 "timed out"은 다른 문자열이라 걸리지 않습니다. 타입 조건에도 안 걸렸습니다. SocketTimeoutException은 이름과 달리 SocketException의 자식이 아니라 InterruptedIOException의 자식입니다.

java.io.IOException
 ├─ java.net.SocketException            ← 조건이 본 것
 │    └─ ConnectException ...
 └─ java.io.InterruptedIOException
      └─ java.net.SocketTimeoutException ← 실제로 온 것

더 허무했던 건, 같은 앱에서 실패 사유를 분류하는 코드는 진작 is SocketTimeoutException으로 정확히 분류하고 있었다는 점입니다. 그래서 보고서에는 reason=timeout이 제대로 찍혔습니다. 분류는 타입으로, 재시도 판정만 문자열로 한 불일치가 원인이었습니다.

이 옛 테스트가 결함을 덮고 있었다

타임아웃 테스트가 없었던 게 아닙니다. 있었고, 초록불이었습니다.

assertTrue(isRetryable(IOException("read timeout")))   // ← 손으로 만든 예외

첫 번째 구멍에서 "손으로 만든 예외로 테스트하면 안 된다"를 배우고도, 바로 그 파일에 그 함정이 한 줄 남아 있었습니다. SocketPolicy.NO_RESPONSE로 서버가 응답을 영영 안 주게 만들어 진짜 읽기 타임아웃을 내는 테스트로 바꿨습니다.

server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
server.enqueue(MockResponse().setBody("ok"))
// client 는 readTimeout 을 짧게(예: 200ms) 잡아 둔다

해결 — 판정을 문자열이 아니라 타입으로

fun isRetryable(e: IOException): Boolean =
    e is SocketTimeoutException ||      // "Read timed out" / "timeout" 둘 다
    e is SSLException ||
    e is SocketException ||
    e.message?.contains("unexpected end of stream", ignoreCase = true) == true

unexpected end of stream은 전용 예외 타입이 없어서 문구로 남길 수밖에 없습니다. 나머지는 전부 타입입니다. 실제로 실기기에서 타임아웃을 재현해 보니 같은 SocketTimeoutException인데 메시지가 Read timed out이 아니라 timeout으로 나왔습니다. 어느 계층이 먼저 터지느냐로 문구가 갈립니다(Okio의 타임아웃이냐, 소켓의 SO_TIMEOUT이냐). 문자열 판정은 기기와 경로에 따라 되고 안 되고가 갈린다는 뜻입니다.

다른 앱에서 그대로 쓰기 싫다면 — IOException 전부 재시도
나중에 만든 앱에서는 아예 종류로 거르지 않고 IOException이면 다 재시도하고, HTTP 4xx만 바로 던지게 했습니다. 거르는 조건이 없으면 조건이 좁아서 새는 일도 없습니다. 다만 비행기 모드처럼 UnknownHostException이 나는 상황에서도 기다리게 되니, 시도 횟수를 작게 잡아야 합니다.

함께 막아야 하는 두 가지

① 취소는 실패가 아니다. 코루틴이 취소되면 Retrofit이 Call.cancel()을 부르고, OkHttp는 IOException("Canceled")를 던집니다. 실패처럼 생겨서 옵니다. 그대로 두면 사용자가 화면을 벗어난 횟수만큼 실패 통계가 쌓입니다. 문구가 아니라 call의 상태로 가릅니다.

} catch (e: IOException) {
    if (chain.call().isCanceled()) throw e   // 재시도도, 실패 기록도 하지 않는다
    ...

② 타임아웃은 시도 상한을 따로 낮춘다. 끊긴 연결은 즉시 예외가 오지만, 타임아웃은 한 번마다 readTimeout을 꼬박 태웁니다. 20초 타임아웃에 3회면 최악 60초인데, 제 경우 그 실패가 나는 자리가 하필 첫 실행 스플래시였습니다. 타임아웃만 2회로 줄였고, 실기기에서 43초에 끝나는 것을 확인했습니다.

private fun attemptLimit(e: IOException) =
    if (e is SocketTimeoutException) minOf(maxAttempts, 2) else maxAttempts

재시도가 실제로 도는지 밖에서 보는 법

이번 일의 진짜 교훈은 여기 있습니다. 재시도는 잘 돌아도 조용하고, 안 돌아도 조용합니다. 실패 건수만 보면 "네트워크가 원래 불안정하구나"로 읽힙니다. 그래서 재시도의 결말을 세 갈래로 나눠 기록합니다.

결말이게 많으면 볼 곳
recovered재시도가 일을 하고 있다는 유일한 증거. 0이면 의심할 것
exhausted서버가 정말 죽었는지, 상한이 너무 낮은지
skipped재시도 조건이 너무 좁은지 — 제 경우가 이것

첫 시도 성공은 기록하지 않습니다. 재시도가 있었을 때만 남겨야 "살려낸 요청"이 잡음에 묻히지 않습니다. 실패 건수와 skipped 건수가 똑같이 움직이면, 그게 재시도가 한 번도 안 돌고 있다는 지문입니다.

정리

  • 연결이 응답 도중 끊기면 OkHttp는 평범한 IOException("unexpected end of stream on …")을 던집니다. SocketException 조건에 안 걸립니다.
  • 읽기 타임아웃의 문구는 Read timed out이라 contains("timeout")에 안 걸립니다. SocketTimeoutException은 SocketException의 자식도 아닙니다.
  • 판정은 타입으로 합니다. 같은 예외라도 계층에 따라 문구가 달라집니다.
  • 테스트는 MockWebServer의 SocketPolicy로 진짜 소켓을 끊어서 합니다. 손으로 만든 예외는 아무것도 검증하지 않습니다.
  • 수정을 잠깐 되돌려서 새 테스트가 빨간불이 되는지 확인하세요. 초록불이 "검증됐다"인지 "조건에 못 닿았다"인지는 그렇게만 갈립니다.
  • 재시도 결말(recovered·exhausted·skipped)을 기록하세요. 안 도는 재시도는 로그 없이는 안 보입니다.

자주 묻는 것

OkHttp의 retryOnConnectionFailure로는 안 되나요?

기본값이 true이고 일부 연결 실패는 알아서 다시 시도합니다. 하지만 대상이 좁습니다. 이미 요청을 보낸 뒤 응답 도중 끊긴 경우나 읽기 타임아웃은 다시 보내지 않는 경우가 많습니다. 서버 쪽에서 중복 처리돼도 문제없는 GET 요청이라면 인터셉터로 직접 재시도하는 편이 확실합니다.

POST도 이렇게 재시도해도 되나요?

조심해야 합니다. unexpected end of stream은 서버가 요청을 이미 처리한 뒤 응답만 못 보낸 경우에도 납니다. 결제·등록 같은 요청을 다시 보내면 두 번 처리될 수 있습니다. 저는 조회용 GET에만 이 인터셉터를 붙였습니다.

unexpected end of stream이 자꾸 나는 원인 자체는 뭔가요?

대개 서버나 중간 장비(로드밸런서·프록시)가 연결을 먼저 닫은 것입니다. 오래 놀던 keep-alive 연결을 재사용하려는 순간 서버는 이미 닫아 둔 경우가 흔합니다. 서버를 고칠 수 없는 공공 API라면 클라이언트에서 재시도로 받아내는 것이 현실적인 답입니다.

728x90
반응형
LIST

+ Recent posts