앱이 데이터를 못 불러올 때 원인을 분석 이벤트로 보내 두었는데, GA4 보고서에 남은 값이 X 한 글자뿐이었습니다. 예외 클래스 이름을 그대로 실어 보냈고, 릴리스 빌드의 R8이 그 이름을 한 글자로 줄여 버린 것입니다. 같은 코드를 쓰는 다른 앱에서는 이름이 멀쩡히 읽혀서 더 늦게 알아챘습니다. 이름 대신 타입 검사로 고정 키를 만들어 보내는 방식으로 바꾼 과정을 정리합니다.
증상 — 보고서의 실패 원인이 reason=X
공공데이터를 받아 보여 주는 알리미 앱 두 개에, 목록을 못 불러오면 실패 이벤트를 남기도록 계측을 넣어 두었습니다. 원인 칸은 예외 클래스 이름으로 채웠습니다. 실제로 들어가 있던 코드는 이 한 줄입니다.
.onFailure { e ->
Analytics.logDataLoadFailed("markets", e::class.simpleName ?: "unknown")
}
디버그 빌드는 난독화를 하지 않으니 이 한 줄은 개발 중에 언제나 정상으로 보입니다. 예외 이름이 그대로 찍히기 때문입니다.
2026-09-01에 GA4에 남은 실패 이벤트를 열어 보니 두 앱의 값이 이렇게 갈려 있었습니다.
| 앱 | GA4에 남은 reason | 읽을 수 있나 |
|---|---|---|
| 알리미 앱 A | UnknownHostException | 읽힌다 — 기기가 망에 없었다 |
| 알리미 앱 B | X | 못 읽는다 |
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에 남습니다.
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자에서 잘리기 때문인데, 잘린다고 안전해지는 건 아닙니다. 잘린 자리가 키 한가운데라면 앞부분은 그대로 새어 나갑니다. 자르기 전에 먼저 지워야 합니다.
이걸 고치던 날 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을 주면서 응답 형식만 바뀌면 앱은 멀쩡히 돌고 화면만 빕니다. 이런 실패는 따로 키를 만들어 두지 않으면 어디에도 안 남습니다.
