AX 노트 · 사고 기록

Android 7에서만 실행 즉시 꺼지던 크래시: java.time 한 줄과 NewApi 린트 게이트

공용 모듈의 java.time 한 줄이 minSdk 24 게임 두 개를 Android 7에서 죽였습니다. 테스트 기기가 모두 최신 OS라 2주 동안 몰랐습니다.

작성 SugarMount Android 개발 읽는 시간 13분 최종 업데이트

minSdk 24인 화살표 게임이 Android 7에서 첫 화면을 그리기 전에 꺼졌고(GA4에는 첫 실행의 app_exception만 남고 화면 기록은 없었습니다), 같은 공용 모듈을 쓰는 블록 퍼즐도 같은 경로를 지납니다. 2026년 10월 17일 오전 8시 50분에 화살표 앱의 GA4 이탈 분석을 맡긴 하위 에이전트가 8시 56분에 이 내용을 보고했습니다. 원인은 두 게임이 같이 쓰는 광고 모듈의 한 줄이었습니다. API 26부터 있는 java.time을 minSdk 24 앱에서 불렀고, 그 줄은 앱이 뜰 때마다 지나갑니다. 10월 3일 커밋으로 들어가 14일 동안 코드에 있던 줄입니다. 그날 9시 29분에 두 앱을 고쳐 제출했고, 오후 3시 4분에 부팅한 Android 7.0 에뮬레이터에서 3시 5분에 릴리스 빌드가 뜨는 것을 봤습니다.

근거는 게임 저장소의 git 기록(6bb016c, cc4e860, 44c6acd 등), 빌드 파일과 테스트 코드, 수정 당일의 작업 세션 기록, GA4 이탈 분석 문서입니다. 코드는 2026년 10월 25일 HEAD에서 다시 확인했습니다.

목차 (9)
  1. 실행할 때마다 지나가는 한 줄: Ads.start에서 java.time까지
  2. 2주 동안 어떤 검사도 이 줄을 Android 7에서 돌리지 않았습니다
  3. 하루 뒤 다른 두 파일은 java.time을 피했습니다
  4. 앱 모듈의 lint 설정으로는 공용 모듈이 막히지 않았습니다
  5. Android 7 에뮬레이터는 내려받기 시작 5분 뒤에 부팅됐습니다
  6. 게이트는 core-platform 하나와 NewApi 하나만 봅니다
  7. rg 두 줄에서 API 24 AVD까지
  8. 남은 일
  9. 자주 묻는 질문

실행할 때마다 지나가는 한 줄: Ads.start에서 java.time까지

두 앱(한판블록, 화살표 매듭풀기)은 첫 Activity의 onCreate에서 공용 모듈 core-platform의 Ads.start()를 부릅니다. 프로세스가 처음 뜬 경우라면 광고 간격을 정하는 Pacer의 onLaunch()가 불리고, 그 안의 rollDay()가 오늘이 1970년 1월 1일부터 며칠째인지 계산합니다. 하루 광고 횟수를 날짜별로 세는 데 씁니다. 그 계산의 기본값이 이 줄이었습니다.

private val day: (Long) -> Long = { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay() },

java.time은 Android 8.0(API 26)부터 기기에 들어 있습니다. 두 앱과 core-platform의 minSdk는 모두 24이고, 저장소 어디에도 core library desugaring 설정이 없습니다. Android 7 기기에는 이 클래스가 없으니 앱은 첫 화면을 그리기 전에 꺼집니다. 아래는 나중에 게이트를 걸고 옛 줄을 넣어 돌린 lint 출력입니다.

Pacer.kt:68: Error: Call requires API level 26, or core library desugaring (current min is 24): java.time.Instant#atZone [NewApi]

하위 에이전트는 화살표 앱 기록에서 크래시를 찾았고, 같은 보고에서 한판블록도 같은 core-platform을 쓰니 같은 방식으로 죽는다고 짚었습니다.

단계 그림: 10월 3일 공용 모듈 Pacer에 java.time 한 줄, 10월 4일 다른 두 파일은 java.time을 피함, 10월 17일 8시 56분 GA4 분석에서 발견, 9시 4분 공용 모듈 lintRelease 게이트, 15시 4분 Android 7.0 에뮬레이터 부팅.
줄이 들어가서 고칠 때까지 14일이었고, 그동안 쓰던 기기와 에뮬레이터는 모두 API 30 이상이었습니다.

2주 동안 어떤 검사도 이 줄을 Android 7에서 돌리지 않았습니다

이 줄이 들어간 10월 3일 커밋(6bb016c)은 에이전트와 함께 썼고, 메시지 끝에 검증 결과가 있습니다. "Verified on S21 … release (R8, test ads) monkey 3x1500 0 fatals." 릴리스 빌드에서 몽키 1,500회를 세 번 돌려 치명 오류 0이었는데, 이 S21은 Android 15였고 minSdk 24는 이 검증에서 한 번도 확인되지 않았습니다.

다른 실제 기기 Galaxy S20은 Android 13이었고, PC에 받아 둔 에뮬레이터 이미지도 API 30, 33, 34, 35뿐이었습니다.

단위 테스트는 그 줄을 아예 실행하지 않았습니다. Pacer는 Android 없이 모든 규칙을 단위 테스트하려고 만든 순수 클래스인데, 테스트 13개가 모두 한 도우미에서 Pacer(rules, state, now = { t }, day = { today })로 객체를 만듭니다. 시계와 날짜를 갈아 끼운 덕에 규칙 테스트는 쉬워졌는데, 갈아 끼운 자리가 바로 문제의 기본값이었습니다. JVM에는 java.time이 있으니 그 줄을 실행했더라도 통과했을 겁니다.

릴리스 빌드에서는 공용 모듈의 lint가 돌지 않았습니다. 10월 17일 :core-platform:lintDebug를 돌리자 옛 줄에 NewApi가 걸렸지만, 릴리스 빌드가 저절로 돌리는 lint는 앱 모듈 것이었습니다.

하루 뒤 다른 두 파일은 java.time을 피했습니다

10월 4일 새벽 한판블록 앱을 추가한 커밋(cd62087)의 MainActivity는 날짜 문자열을 SimpleDateFormat으로 만들고 옆에 "java.time needs API 26"이라고 적었습니다. 같은 날 밤 화살표 앱의 오늘의 퍼즐 커밋(89dc35a)은 날짜 계산을 Hinnant의 civil 알고리즘으로 직접 짜면서 "so the app needs no java.time (minSdk 24, no desugaring)"이라는 주석을 달았습니다. 하루 전 공용 모듈에 들어간 줄은 그대로 남았습니다.

10월 17일 rg -n "java\.time" --type kotlin으로 일곱 개 모듈을 검색하니 실행 코드에 남은 java.time은 Pacer의 그 한 줄뿐이었습니다.

고친 코드는 java.util.TimeZone만 씁니다.

fun localEpochDay(ms: Long, zone: java.util.TimeZone = java.util.TimeZone.getDefault()): Long =
    Math.floorDiv(ms + zone.getOffset(ms), 86_400_000L)

이번에는 기본값 자체를 테스트에 넣었습니다. LocalEpochDayTest는 Asia/Seoul, America/New_York, Pacific/Kiritimati, America/Adak를 포함한 7개 시간대에서 2025년 12월 31일부터 400일 동안 37분 간격으로 시각을 찍고, JVM의 java.time 결과와 하나씩 비교합니다. 37분씩 건너면 자정과 서머타임 전환을 모두 지나갑니다.

앱 모듈의 lint 설정으로는 공용 모듈이 막히지 않았습니다

재발 방지책의 첫 시도는 틀렸습니다. 에이전트는 두 앱의 build.gradle.kts에 lint { checkDependencies = true; fatal += "NewApi" }를 넣었습니다. 의존 모듈까지 검사하고 NewApi를 치명 오류로 올리면 릴리스 빌드가 막힐 거라는 판단이었습니다. 고친 줄을 옛 java.time 줄로 잠시 되돌리고 :app-block:lintVitalRelease를 돌렸더니 결과는 BUILD SUCCESSFUL in 29s였습니다.

두 번째 시도는 공용 모듈이 자기 lint를 돌리게 했습니다.

// core-platform/build.gradle.kts
lint {
    checkOnly += "NewApi"
    abortOnError = true
}

// app-block/build.gradle.kts, app-arrows/build.gradle.kts
tasks.matching { it.name == "preReleaseBuild" }.configureEach { dependsOn(":core-platform:lintRelease") }

9시 4분, 같은 방식으로 옛 줄을 넣고 릴리스 빌드를 돌리자 위의 NewApi 오류와 함께 BUILD FAILED in 28s, 종료 코드 1이 나왔고, 고친 줄로는 :core-platform:lintRelease가 종료 코드 0이었습니다. 빌드 파일 주석에는 이유를 "lintVitalRelease does not look into library modules"라고 적었고, 첫 번째 설정은 지웠습니다.

비교 그림: 옛 java.time 줄을 넣고 돌린 결과, 앱 모듈 lint 설정은 lintVitalRelease BUILD SUCCESSFUL 29초, 공용 모듈 lintRelease 의존은 BUILD FAILED 28초에 종료 코드 1.
같은 옛 줄을 넣었을 때 빌드를 멈춘 것은 공용 모듈 자기 lintRelease였습니다.

출시 전에는 릴리스 dex도 봤습니다. 두 앱의 dex를 풀어 Ljava/time/ 문자열을 찾으면 Instant, ZonedDateTime 같은 이름이 여전히 나옵니다. dexdump로 그 이름을 부르는 클래스를 찾고 R8 mapping.txt로 원래 이름을 맞춰 보니 Compose의 API 분기 클래스와 Firebase 쪽 클래스였고, 우리 코드는 0이었습니다. 그다음 cc4e860으로 화살표 매듭풀기 1.2.1(vc6)을 1.2.2로, 한판블록 1.1.2(vc5)를 1.1.3으로 올려 제출했습니다. 이 커밋에는 첫 장 퍼즐 순서 같은 다른 수정도 섞여 있어서 19개 파일, 241줄 추가입니다.

Android 7 에뮬레이터는 내려받기 시작 5분 뒤에 부팅됐습니다

10월 17일 오전 에이전트는 S20·S21로는 확인할 수 없다고 보고 Firebase Test Lab 기기 목록부터 조회했는데, API 26 이하를 지원하는 모델은 가상 기기 4종뿐이었고 가장 낮은 것이 API 26이었습니다.

오후 보고에서 에이전트가 권장한 것은 기다리기입니다. 새 버전의 Crashlytics 실사용자 데이터에서 Android 7 크래시가 0인지 며칠 지켜보자는 안이었고, Android 7 이미지(약 1GB)를 받아 에뮬레이터를 만드는 것은 승인을 받아야 하는 두 번째 안이었습니다. Galaxy S23을 새로 사도 Android 14 이상이라 소용없다는 말도 붙어 있더군요. 운영자가 내려받기를 승인했고(이 작업 환경에서는 설치 전에 동의를 받습니다), 14시 59분에 시작해 15시 4분에 Android 7.0 AVD가 부팅됐습니다.

같은 날 12시 44분 커밋(b2d305d)으로 두 앱의 다음 버전(한판블록 1.1.4, 화살표 매듭풀기 1.2.3)이 나왔고, 14시 51분 보고 때는 이미 공개돼 있었습니다. 이 버전과 같은 릴리스 APK를 깔고 am start -W로 띄우자 두 앱 모두 Status: ok, crash 버퍼는 빈 채였습니다. 10월 18일 분석 문서(44c6acd)에 정리한 결과입니다.

Android 7.0 에뮬레이터, 릴리스(R8) 빌드한판블록 1.1.4화살표 매듭풀기 1.2.3
실행 즉시 종료없음(FATAL 0)없음(FATAL 0)
첫 화면까지2.2초1.3초
몽키(시드 7·21·99, 600~800회)크래시 0, ANR 0크래시 0, ANR 0

한판블록 시드 7 첫 회차에는 에뮬레이터의 system_server가 DeadSystemException으로 죽었습니다. 같은 시드로 두 번 더 돌렸을 때는 나오지 않았고, 우리 앱 프로세스의 크래시 로그도 없었습니다. (몽키 중 화면에 보인 회색 앱 이름 줄은 Android 7 화면 분할의 캡션이었습니다.)

이 에뮬레이터는 지금 에이전트 메모리에 minSdk 24 회귀 확인용으로 올라 있습니다. minSdk를 24로 두었다면 API 24 에뮬레이터에서 릴리스 빌드를 띄우기 전에는 출시 검증을 마쳤다고 쓰지 마십시오.

게이트는 core-platform 하나와 NewApi 하나만 봅니다

공용 모듈이 하나 더 생기면 앱마다 같은 dependsOn 줄을 다시 넣어야 합니다. checkOnly로 NewApi 하나만 켰기 때문에 다른 lint 문제도 이 단계에서는 걸리지 않습니다. 릴리스 빌드 전체를 어떤 순서로 점검하는지는 AI가 만든 Android 앱, 출시 전에 무엇을 검사하나: 릴리스 게이트와 R8 크래시에 적었습니다.

에뮬레이터는 Google APIs x86_64 이미지였고, 확인한 빌드는 테스트 광고 ID를 넣은 릴리스였습니다.

원인의 근거는 GA4 이벤트, 코드 경로, lint 결과 셋입니다. 예외 이름(NoClassDefFoundError)은 코드 주석과 분석에 적힌 판단이고, 로컬 스택트레이스로 본 것은 아닙니다.

desugaring을 쓰는 프로젝트라면 사정이 다릅니다. lint 메시지도 "or core library desugaring"을 대안으로 듭니다. 이 저장소는 다른 두 파일이 이미 desugaring 없이 짜여 있었고, 고칠 실행 코드는 한 줄이었습니다. desugaring과 견줘 본 기록은 없습니다.

rg 두 줄에서 API 24 AVD까지

  1. 모든 모듈에서 rg -n "java\.time" --type kotlin과 rg -n "desugar|minSdk" */build.gradle.kts를 돌려 실행 코드의 호출과 desugaring 여부를 봅니다.
  2. 공용(라이브러리) 모듈마다 ./gradlew :모듈:lintDebug를 돌리고 lint-results XML에서 NewApi를 찾습니다.
  3. 공용 모듈에 lint { checkOnly += "NewApi"; abortOnError = true }를 넣고, 앱마다 preReleaseBuild가 그 모듈의 lintRelease에 의존하게 합니다.
  4. 문제 줄을 잠시 되돌려 릴리스 빌드가 실패하는지, 원래대로 두면 통과하는지 봅니다. 옛 줄로도 통과하면 그 설정은 게이트가 아닙니다.
  5. 시계·날짜를 주입받는 클래스는 기본값도 테스트합니다. JVM의 java.time을 정답으로 두고 여러 시간대와 서머타임 구간을 차례로 비교합니다.
  6. sdkmanager "system-images;android-24;google_apis;x86_64"로 이미지를 받아 AVD를 만들고, 릴리스 APK를 깔아 am start -W, logcat -b crash, 몽키 3시드를 돌립니다.
  7. 릴리스 dex에 남은 Ljava/time/ 참조는 mapping.txt로 클래스 출처를 확인합니다. 라이브러리의 API 분기 코드면 그대로 둡니다.

남은 일

제조사 롬이 올라간 실제 Android 7 기기에서는 아직 돌려 보지 못했습니다. 고치기 전 빌드를 이 에뮬레이터에 띄워 크래시와 예외 이름을 재현한 기록도 없어서, 그 재현이 다음 확인입니다. 영향받은 사용자 수는 확인하지 않았고, 확인하기 전에는 이 글에 적지 않습니다.

자주 묻는 질문

minSdk 24 앱에서 java.time을 쓰면 왜 Android 7에서만 크래시가 나나요?

java.time 클래스가 Android 8.0(API 26)부터 기기에 들어 있기 때문입니다. core library desugaring을 켜지 않으면 Android 7 기기에는 이 클래스가 없고, 그 줄을 실행하는 순간 앱이 꺼집니다. Android 8 이상 기기와 JVM 단위 테스트에서는 나타나지 않습니다.

라이브러리 모듈의 NewApi 오류가 릴리스 빌드에서 안 걸리는 이유는 무엇인가요?

이 저장소에서는 릴리스 빌드가 자동으로 돌리는 lintVitalRelease가 라이브러리 모듈을 보지 않았습니다. 앱에 checkDependencies = true와 fatal += "NewApi"를 넣고도 옛 코드가 통과했습니다. 라이브러리 모듈의 lintRelease를 앱의 preReleaseBuild 앞에 걸어서 막았습니다.

Android 7 실기기가 없으면 어떻게 확인하나요?

API 24 시스템 이미지로 에뮬레이터를 만들어 릴리스 빌드를 실행합니다. 2026년 10월 17일에 조회한 Firebase Test Lab 목록에는 API 24·25 기기가 없었습니다. 에뮬레이터에는 제조사 롬이 없다는 한계가 남습니다.

이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.

Android 개발 글 전체 보기 · AX 노트 전체 보기