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

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

+ Recent posts