EEltaCrew
홈 › 블로그

dispatchDetachedFromWindow NullPointerException: 스택에 내 코드가 없는 안드로이드 크래시 추적기

게시 2026-10-01실측 기준 2026-09-30읽는 시간 약 5분elta · EltaCrew

스택 트레이스에 프레임워크 코드만 찍히는 NullPointerException 을 MdViewer 에서 만났습니다. onDetachedFromWindow 안에서 형제 뷰를 뗀 한 줄이 원인이었고, 부모 ViewGroup 이 자식 배열을 도는 방식 때문에 생긴 문제였습니다. 재현, 원인, 고친 방법을 정리합니다.

크래시 메일 두 통

9월 30일, Crashlytics 에 MdViewer 1.0.37 크래시 이슈가 두 건 올라왔습니다. 기기는 둘 다 갤럭시 S23(Android 16)이었고 메시지는 같았습니다.

java.lang.NullPointerException: Attempt to invoke virtual method
'void android.view.View.dispatchDetachedFromWindow()' on a null object reference
    at android.view.ViewGroup.dispatchDetachedFromWindow(ViewGroup.java)
    at android.view.ViewGroup.dispatchDetachedFromWindow(ViewGroup.java)
    ...

곤란한 건 스택에 제 코드가 한 줄도 없다는 점이었습니다. 프레임워크 프레임만 이어지다가, R8 인라인 때문에 엉뚱하게 MainActivity.onCreate 가 하나 끼어 있을 뿐이었습니다. 「어떤 뷰가 null 이냐」를 스택으로는 알 수 없었습니다.

어디서 찾았나

dispatchDetachedFromWindow 가 null 자식을 만났다는 건, 떼는 도중에 누군가 뷰 트리를 바꿨다는 뜻입니다. 그래서 스택 대신 코드에서 「떼어질 때 실행되는 곳」을 전부 찾았습니다.

onDetachedFromWindow
OnAttachStateChangeListener
doOnDetach

걸린 곳은 1.0.36 에서 문서 화면을 RecyclerView 기반으로 바꾸며 새로 만든 SegmentsView 였습니다.

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    dropMermaid()   // 형제인 mermaidHost 를 부모에서 removeView
}

MdViewer 는 mermaid 다이어그램을 WebView 로 그립니다. 이 WebView 를 담은 mermaidHost 를 문서 뷰의 형제로, 부모 CoordinatorLayout 의 0번 자리에 끼워 두었습니다. 문서 뷰가 떨어질 때 같이 정리하려고 onDetachedFromWindow 에서 떼게 한 것이 원인이었습니다.

왜 null 이 되나

부모 ViewGroup 은 자식들을 뗄 때 이렇게 돕니다(AOSP 요약).

final int count = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < count; i++) {
    children[i].dispatchDetachedFromWindow();
}

개수 count 와 배열 children 을 루프 전에 잡아 둡니다. 그런데 루프 안에서 불린 제 onDetachedFromWindow 가 형제를 removeView 하면, 부모의 자식 배열이 한 칸씩 앞으로 당겨지고 마지막 칸은 null 이 됩니다. 루프는 이미 잡아 둔 count 대로 끝까지 가므로 마지막 칸에서 null 을 만나 NPE 가 납니다.

그래서 조건이 좁았습니다. mermaid 다이어그램이 있는 문서를 연 상태에서 뷰어를 나가거나(프래그먼트가 뷰를 뗌), 다크 모드 전환처럼 화면이 다시 만들어질 때만 터졌습니다. 다이어그램이 없는 문서에서는 mermaidHost 자체가 없어서 아무 일도 없었습니다. 그리고 조건을 맞추면 100% 재현됐습니다.

고친 방법

떼는 일을 부모 순회가 끝난 뒤로 미뤘습니다.

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    val m = mermaid
    val h = mermaidHost
    mermaid = null
    mermaidHost = null
    if (m != null || h != null) Handler(Looper.getMainLooper()).post {
        m?.destroy()
        h?.let { (it.parent as? ViewGroup)?.removeView(it) }
    }
}

View.post 가 아니라 Handler(Looper.getMainLooper()).post 를 쓴 데는 이유가 있습니다. 이미 떨어진 뷰에서 View.post 를 부르면, 그 작업은 다음에 다시 붙을 때까지 뷰 안의 대기열에 머뭅니다. 뷰어를 완전히 나가는 경우엔 다시 붙을 일이 없으니 WebView 가 정리되지 않고 남습니다. 메인 핸들러에 올리면 현재 순회가 끝나자마자 실행됩니다.

검증

순서를 지켰습니다. 먼저 수정 전 빌드로 크래시를 재현하고, 같은 절차로 수정 후 빌드를 확인했습니다. 갤럭시 노트10+ 에서 mermaid 가 들어 있는 문서를 연 뒤 다음을 해 봤습니다.

수정 전 빌드에서는 크래시가 그대로 재현됐고, 수정 후에는 네 가지 모두 크래시가 없었습니다. 이 수정은 MdViewer 1.0.38 에 들어갔습니다.

정리

이 글에 나온 앱은 안드로이드 마크다운 뷰어 MdViewer(Google Play) 입니다.

Android크래시ViewMdViewer

함께 읽기

안드로이드에서 .md 파일 여는 법: AI 가 준 마크다운이 기호 범벅으로 보일 때ChatGPT·Claude·Codex 가 만들어 준 .md 파일, 옵시디언 노트, 깃허브 README 를 폰에서 열면 #·*·| 가 그대로 보입니다. 마크다운을 폰에서 제대로 읽는 방법과, 표·체크리스트·수식이 깨지는 이유, 직접 뷰어를 만들며 확인한 점을 씁니다.R8 을 켜고 빌드는 통과했는데 앱이 깨졌다: 15개 앱에서 실제로 걸린 런타임 함정 5가지Play 의 2027년 코드 최적화 요건에 맞춰 앱 15개에 R8 을 켰습니다. 빌드는 전부 통과했지만 7개 중 2개가 실행 중에 깨졌습니다. Gson 역직렬화, getIdentifier 리소스 조회, Room _Impl, 디버그 빌드의 무효화 등 크래시 로그에 안 잡히는 유형을 정리합니다.앱 안에서 언어를 바꿨는데 가끔 안 바뀌는 이유: setApplicationLocales 는 떠 있는 액티비티만 다시 만든다"한국어에서 English 로 바꿔도 그대로다, 껐다 켜야 바뀐다" 는 제보의 원인은 AppCompat 이 그 순간 화면에 떠 있는 액티비티만 재생성하기 때문이었습니다. 뒤에 멈춰 있던 화면은 옛 언어로 돌아옵니다. 앱 8개에 적용한 해결책과, 같이 걸린 함정 5가지를 씁니다.