
addOnCompleteListener(this)가 onStop에서 지운 리스너 때문에 스플래시에 멈춘 버그
addOnCompleteListener(this)로 건 리스너는 onStop에서 사라집니다. 조회 중 화면이 꺼지면 다음 화면으로 가는 코드가 불리지 않았습니다.
화면이 꺼진 상태에서 맞춤법 앱 한글 띄어쓰기를 실행하면, 잠금을 해제해도 검은 스플래시에서 넘어가지 않았습니다. 2026년 10월 11일 오후, Galaxy S21에서 R8 릴리스 APK로 출시 전 기기 확인을 하다가 vc95에서 먼저 스플래시에 멈춘 것을 봤고, vc96에서 화면을 끈 채 실행해 재현했습니다. 원인인 fetchAndActivate().addOnCompleteListener(this) 한 줄은 2020년 12월 16일 저장소 첫 커밋부터 있었고, 10월 11일 15시 43분 커밋(ab95e9a)에서 고쳤습니다.
근거: 앱 저장소의 git 기록(d5ac5e2, ab95e9a), CHANGELOG 1.6.029, 릴리스 문서의 기기 확인 기록, 당시 에이전트 세션 기록, 모니터링 기록(WATCH.md), Google Play services Task 참조 문서. 2026-10-30에 다시 확인했습니다.
목차 (7)
onStop이 지운 리스너는 돌아와도 불리지 않습니다
스플래시 화면 ActivityEmpty는 onCreate에서 Remote Config(이하 RC) 조회를 한 번 시작합니다. 조회가 끝나면 버전을 확인하고 checkMyPermission()을 불러 다음 화면으로 넘어갑니다. 다음 화면으로 가는 길이 이 완료 리스너 안에만 있었습니다.
// ActivityEmpty, ab95e9a 직전, 줄임 (2020-12-16 d5ac5e2부터 같은 줄)
// [START fetch_config_with_callback]
remoteConfig.fetchAndActivate().addOnCompleteListener(this) { task ->
...
checkMyPermission() // (설명) 성공·실패 갈래 모두 여기서 다음 화면으로
}
Task 참조 문서는 Activity를 받는 addOnCompleteListener를 두고 "The listener will be automatically removed during onStop."이라고 적습니다. 조회가 끝나기 전에 화면이 꺼지거나 사용자가 다른 앱으로 가면 onStop이 먼저 오고, 리스너가 지워집니다. 그날 화면이 꺼진 동안 onStop이 정확히 언제 왔는지 남긴 수명주기 로그는 없어, 이 단계는 문서와 수정 전후 재현으로 받친 설명입니다.
돌아왔을 때 다시 시도하는 코드도 없었습니다. 액티비티가 살아 있으니 onCreate는 다시 불리지 않고, onStart는 시스템 바 플래그만 설정합니다. 그래서 프로세스를 죽이기 전까지 스플래시에 남습니다. 그날 기기의 GA4 디버그 로그에는 first_open, session_start, screen_view와 앱 열기 광고 이벤트 두 개까지만 찍혔습니다.
주석의 [START fetch_config_with_callback] 표지는 Firebase quickstart-android의 Remote Config 예제에도 그대로 있고, 그 예제도 addOnCompleteListener(this)를 사용합니다. 표지와 줄이 같아 예제에서 온 코드로 보입니다. 같은 스플래시가 RC의 강제 업데이트 값 때문에 사용자를 막은 일은 "6.7" < "6.11"이 거짓이라 콘텐츠 갱신이 멈춘 기록에 따로 적었습니다.

처음 본 멈춤은 강제 종료로 넘겼습니다
운영자가 production 전에 심층 기기 확인을 요청했고, Claude Code 에이전트가 S21에서 광고 DNS를 막고 업데이트, 회전, 홈 왕복 15회, 몽키 5,000회 x 3을 돌렸습니다. 몽키에서 앱 크래시와 앱 ANR은 0건이었습니다.
14시 37분쯤 에이전트가 꺼져 있던 S21을 깨워 잠금을 풀자, 10여 분 전에 설치해 둔 vc95가 스플래시에 멈춰 있었습니다. 앱이 언제 어떻게 실행됐는지는 기록에 남아 있지 않아 모릅니다. 에이전트는 강제 종료하고 다시 실행해 메인 화면이 뜨는 것만 확인한 뒤, 자기 진행 메모에 95 버전 문제로 적고 96에서 따로 확인하기로 하고 넘어갔습니다. 강제 종료 뒤 다시 실행하면 onCreate부터 새로 시작해 화면이 켜진 채 조회가 끝나므로, 그 확인은 원인을 드러내지 못합니다.
40분 뒤 vc96을 일부러 화면을 끈 채 실행해 보자 다시 멈췄고, 그제야 코드를 열었습니다. 원인 줄은 두 버전에서 똑같은 한 줄입니다.

리스너는 풀고 화면 이동만 STARTED까지 미뤘습니다
ab95e9a는 리스너에서 this를 빼 액티비티와 떼어 놓고, 첫 줄에 isFinishing || isDestroyed 검사를 넣었습니다. 다음 화면으로 가는 호출을 옮겨 넣은 곳은 새로 만든 whenStarted입니다.
fun whenStarted(owner: LifecycleOwner, block: () -> Unit) {
owner.lifecycleScope.launch { owner.lifecycle.withStarted(block) }
}
화면이 보이는 상태(STARTED 이상)면 바로 실행하고, 멈춰 있으면 다시 보일 때 한 번 실행하고, 그 전에 액티비티가 없어지면 실행하지 않습니다. 이렇게 바꾼 호출은 5곳입니다. 스플래시의 checkMyPermission() 호출 3곳, 앱 열기 광고 대기가 끝난 콜백, 로고 화면(ActivityIntro)의 goIntent입니다. 에이전트는 whenStarted의 단위 테스트 3개를 먼저 쓰고, 빈 함수로 돌려 2개가 실패하는 것을 본 뒤 구현했습니다.
this만 빼는 수정은 쓰지 않았습니다. 그러면 리스너는 불리지만 멈춘 화면에서 다음 액티비티를 띄우게 되고, 커밋 주석에 적은 대로 그 startActivity는 막히거나 늦습니다. 스플래시 중 홈으로 나간 사용자 앞에 앱이 다시 튀어나올 수도 있습니다. 수정 뒤 기기 확인에는 홈 위로 튀어나오지 않는지가 들어 있습니다.
다른 길은 리스너를 onStart에서 다시 붙이는 것입니다. 문서상 이미 끝난 Task에 리스너를 붙이면 바로 호출이 예약됩니다. 하지만 이 방법으로 고쳐지는 곳은 RC Task 하나뿐입니다. 같은 커밋이 바꾼 5곳 가운데 광고 대기 콜백과 로고 화면의 Timer는 Task가 아니라서 이 방법이 닿지 않습니다. Task.await()로 코루틴에 넣으려면 kotlinx-coroutines-play-services를 새로 들여야 합니다. 지금 build.gradle에는 coroutines-android와 lifecycle만 있고, 그날 기록에 이 길을 따진 흔적은 없습니다.
화면을 넘기는 콜백은 addOnCompleteListener(this)에 걸지 마십시오. 결과는 액티비티와 상관없이 받고, 화면 이동만 수명주기에 맞춰 미루십시오.
whenStarted도 사용자가 돌아오지 않으면 다음 화면으로 가지 않습니다. 그사이 액티비티가 없어지면 블록을 버리고, 앱을 다시 열면 onCreate가 조회를 처음부터 시작합니다. 이 마지막 부분은 코드를 읽고 한 판단이고, 기기에서 시험하지는 않았습니다.
grep 한 줄과 화면을 끄고 실행하는 기기 시험
먼저 액티비티에 묶인 리스너를 찾습니다. 그날 에이전트가 같은 결함을 찾으며 쓴 검색은 아래 첫 줄이고, main 소스에서 0건이었습니다. 둘째 줄은 프래그먼트의 requireActivity()와 Canceled 리스너까지 넓힌 패턴으로, 그날 쓰지는 않았습니다. 리스너 안에 화면 이동이 있는 곳이 위험합니다.
git grep -nE "addOn(Complete|Success|Failure)Listener\(this"
git grep -nE "addOn(Complete|Success|Failure|Canceled)Listener\((this|requireActivity\(\)|activity)"
기기 시험은 R8 릴리스 빌드로 합니다. 아래 1~2는 수정 전 vc96과 수정 뒤 빌드에, 3~4는 수정 뒤 빌드에 돌린 순서입니다.
- 화면 끄기:
adb shell input keyevent SLEEP, 3초 뒤adb shell monkey -p <패키지> -c android.intent.category.LAUNCHER 1로 실행합니다. - 20초 기다린 뒤 깨우고 잠금을 해제합니다.
adb shell dumpsys window | grep mCurrentFocus가 메인 액티비티를 가리켜야 합니다. 수정 전에는 스플래시를, 수정 뒤에는 메인을 가리켰습니다. - 실행 직후 홈으로 나갔다가 12초 뒤 다시 실행합니다. 그사이 홈 화면에 머무는지, 돌아오면 메인인지 봅니다. 로고 화면에서도 같은 시험을 합니다.
- 스플래시 중에 뒤로 가기를 눌러 크래시가 없는지 봅니다.
whenStarted에는 Robolectric과 LifecycleRegistry.createUnsafe로 RESUMED, CREATED, STARTED 순서로 상태를 옮기는 단위 테스트 3개를 붙였습니다. 수정 뒤 단위 테스트 437개가 모두 통과했고, 수정본 몽키 2회도 크래시·ANR 0이었습니다. 이 수정이 든 1.6.029는 단계적 출시 없이 처음부터 100%로 나갔고, 그 경위는 20% 단계적 출시가 100%로 나간 기록에 따로 있습니다.
신규 17%가 메인에 못 온 것과의 관계는 모릅니다
수정 전날 만든 기준선 문서에 따르면 9월 12일~10월 9일 신규 739명 중 24시간 안에 메인 화면에 온 사람은 611명(82.7%)입니다. CHANGELOG는 이 버그가 "신규 17% 메인 미도달의 일부일 수 있다"고만 적었습니다.
10월 15일 점검에서 1.6.029 신규는 first_open 120명 중 107명(89%)이 메인에 왔습니다. 이 숫자로 버그의 몫을 셀 수는 없습니다. 기준선은 메인 화면 도달로, 1.6.029는 1.6.028에 새로 넣은 main_shown 이벤트로 재서 잣대가 다르고, 같은 1.6.029에 새 설치 절반의 첫 세션 앱 열기 광고를 빼는 A/B가 함께 들어갔고, 표본이 120명입니다. 멈춤을 따로 남기는 이벤트가 없어 몇 명이 멈췄는지도 세지 못합니다.
기기 확인은 S21 한 대, 광고를 DNS로 막은 상태에서만 했습니다. 실제 광고가 뜨는 경로와 다른 제조사의 잠금 화면, 백그라운드에서 프로세스가 죽은 뒤 돌아오는 경우는 시험하지 않았습니다.
남은 일: 5곳의 배선은 테스트가 지키지 않습니다
단위 테스트는 whenStarted 자체만 지킵니다. 릴리스 문서에도 "배선(ActivityEmpty·Intro가 whenStarted를 부르는지)은 단위 테스트로 못 막아 기기로 확인했다"고 적었습니다. 누가 스플래시에 새 화면 이동을 whenStarted 없이 넣으면 지금 테스트는 통과하고, 그 실수는 화면을 끄거나 스플래시 중 홈으로 나가는 기기 시험에서만 드러납니다.
자주 묻는 질문
addOnCompleteListener(this)는 언제 리스너를 지우나요?
액티비티의 onStop 때 제거합니다. Task 참조 문서에 그렇게 적혀 있고, 이 앱은 조회 중 화면이 꺼지면 결과를 놓쳤습니다.
액티비티에 묶지 않으면 메모리 누수가 생기지 않나요?
이 앱은 리스너 첫 줄에서 isFinishing이나 isDestroyed면 바로 끝내고, 화면 이동은 whenStarted가 액티비티가 없어지면 실행하지 않습니다. 누수를 따로 재 보지는 않았습니다.
몽키 테스트로는 왜 못 찾았나요?
그날 몽키는 화면을 켜 둔 채 홈 키 없이 돌렸기 때문입니다. 이 멈춤은 조회가 끝나기 전에 onStop이 와야 생겨서, 화면을 끄거나 조회 중에 홈으로 나가는 시험을 따로 넣어야 보입니다.
이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.
다른 AX 노트
- Android assets를 미리 gzip한 실측: APK 이득은 0, .pack.gz는 .pack이 되어 FileNotFoundException
- SKT 판매 삼성 폰에서 market://을 열면 스토어 선택 창이 뜬다: Play 패키지부터 지정하기
- "6.7" < "6.11"이 거짓이라 콘텐츠 갱신이 멈췄다: 6.2~6.9 기기만 막힌 Remote Config 버전 비교
- R8이 지우지 못한 테스트 광고 ID 5개: listOf().any 한 줄과 dex 건수 검사
- 화살표 퍼즐 1,100판을 만든 역구성 생성기와 G2 게이트
- 대개편 직후 DAU는 올랐고, 자연 유입 코호트 D1은 15.2%에서 11.2%로 내려갔다