[Android] Fragment 생명주기를 화면 가시성으로 판단하면 안 되는 이유

Kame·2026년 5월 9일

Android

목록 보기
14/14
post-thumbnail

들어가며

이 글을 이해하기 위해 아래 개념들을 먼저 알고 오면 좋습니다.

  • Android Fragment의 기본 생명주기 (onStart, onResume, onPause, onStop 등)
  • FragmentManager / FragmentTransaction
  • DialogFragment

생각해보기

Fragment(A)가 있고, 그 위에 다른 Fragment(B)가 올라올 수 있는 구조가 있다고 가정해봅시다. 아래 코드와 같이, 두 Fragment 모두 같은 KEY로 FragmentResultListener를 등록했습니다.

같은 FragmentManager를 사용하는지의 여부는 상관없습니다.

// Fragment A: onResume마다 재등록
LifecycleResumeEffect(Unit) {
    fm.setFragmentResultListener(KEY, owner) { _, _ -> /* 이벤트 처리 */ }
    onPauseOrDispose { fm.clearFragmentResultListener(KEY) }
}

// Fragment B — 올라올 때 같은 KEY로 덮어씌움
fm.setFragmentResultListener(KEY, owner) { _, _ -> /* 다른 처리 */ }

의도한 흐름은 이렇습니다.

Fragment B 열림  
→  Fragment B가 KEY로 리스너 등록
→ 리스너 덮어씌움

Fragment B 닫힘  
→  Fragment A의 onResume 호출
→  Fragment A가 KEY로 리스너 재등록(다시 덮어씌움)
→  이후 Result를 Fragment A가 다시 수신 가능

하지만 어떻게 구현했느냐에 따라, 실제 흐름은 예상과 다르게 나타날 수 있습니다.

Fragment B 열림  
→  Fragment B가 KEY로 리스너 등록(덮어씌움)

Fragment B 닫힘  
→  Fragment A의 onResume 호출 안 됨
→  Fragment B의 리스너 동작
→  Result가 Fragment A에 전달되지 않음

위에 사용된 LifecycleResumeEffect를 사용해서 리스너를 등록하고자 할 때, 해당 프래그먼트에 ON_RESUME 이벤트가 발생해야 리스너가 재등록됩니다. 그 이벤트가 발생하지 않는다면, 프래그먼트는 Result를 받아올 수 없게 됩니다.

어떤 상황에서 문제가 발생할 수 있는지, 이어질 내용에서 자세히 살펴보겠습니다.


핵심 개념

Fragment 생명주기 상태 결정 방식

Activity의 생명주기 콜백은(onResume, onPause 등)는 시스템(ActivityTaskManager)이 직접 호출합니다.

반면, Fragment의 생명주기 콜백은 시스템이 직접 호출하지 않습니다. FragmentManager가 아래 두 가지 요인의 변화를 감지하여 적절한 생명주기 콜백을 호출합니다.

1. 호스트 Activity의 lifecycle 변화

Activity가 STARTED, RESUMED 혹은 STOPPED 상태로 변경되면 그 상태 정보는 FragmentManager로 전달됩니다.

이때 FragmentManager는 각 Fragment가 Activity 상태 기준으로 어디까지 올라갈 수 있는지를 판단하는 역할을 수행합니다. 판단 예시는 아래와 같습니다.

Activity = STARTED → Fragment RESUMED 불가
Activity = RESUMED → Fragment RESUMED 가능

즉 호스트 Activity의 생명주기가 Fragment 생명주기의 상한선을 결정하는 데 사용되도록 하는 것이라 볼 수 있습니다.

2. FragmentTransaction 커밋

FragmentTransaction은 FragmentManager에 의해 실행되는 일종의 명령으로, Fragment를 추가(add), 제거(remove), 교체(replace), 표시/숨김(show/hide)하는 일련의 변경 작업을 하나의 단위로 묶은 것입니다.

트랜잭션이 커밋되면 Activity lifecycle 변화가 없어도 FragmentManager는 expected state를 재계산합니다. 그리고 그 계산 결과는 어떤 종류의 트랜잭션이 커밋되었느냐에 따라 달라집니다.

🤔 expected state?
FragmentStateManager.computeExpectedState()가 계산하는 값입니다.

해당 Fragment가 있어야 할 상태를 CREATED, STARTED, RESUMED 같은 lifecycle 상수로 나타낸 것입니다. FragmentManager는 이 값과 Fragment의 현재 상태를 비교해서, 올라가야 하면 올리고 내려가야 하면 내립니다.

알아두면 좋은 점은, expected state는 '호스트가 허용하는 상한'과 '트랜잭션이 결정하는 상한' 중 낮은 쪽으로 결정된다는 것입니다.

FragmentManager는 어떤 트랜잭션에 반응하는가?

어떤 트랜잭션이 Fragment의 생명주기를 바꾸는가?

위의 내용으로부터 Fragment의 생명주기 콜백은 FragmentManager가 트랜잭션을 커밋하거나 Activity의 상태가 전환될 때 내부적으로 재계산됨을 알 수 있었습니다. 아래 표는 트랜잭션의 종류별로 expected state가 구체적으로 어떻게 달라지는지 보여주고 있습니다.

트랜잭션기존 Fragment 생명주기에 미치는 영향
add없음
show / hide없음. (onHiddenChanged() 콜백만 호출, onPause 등 생명주기 콜백 호출 없음)
detachonDestroyView까지 호출. 인스턴스는 유지
removeonDestroy까지 호출
replace기본적으로 remove 적용. addToBackStack이 있으면 detach처럼 동작

add와 같은 트랜잭션은 기존 프래그먼트의 생명주기에 아무런 영향도 주지 않음을 확인할 수 있습니다.

또한 프래그먼트 위에 다이얼로그를 표출하는데 주로 사용되는 DialogFragment.show()는 내부적으로 add와 동일하게 동작하기 때문에, 이 경우 역시 기존 Fragment에 아무런 생명주기 콜백이 전달되지 않습니다.


실험 환경 셋업

아래 코드를 그대로 새 프로젝트에 붙여넣으면 바로 확인할 수 있습니다.

실험에서 사용하는 모든 오버레이(OverlayFragment, OverlayDialogFragment)는 화면 전체를 불투명하게 덮도록 만들었습니다. 아래 깔리는 Fragment가 전혀 보이지 않도록 하는 목적입니다.

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            supportFragmentManager.commit {
                add(R.id.container, HostFragment(), "host")
            }
        }
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        Log.d("ActivityFocus", "onWindowFocusChanged hasFocus=$hasFocus")
    }
}

res/layout/activity_main.xml

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>

HostFragment.kt

class HostFragment : Fragment(R.layout.fragment_host) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val fm = requireActivity().supportFragmentManager

        view.findViewById<Button>(R.id.btn_dialog).setOnClickListener {
            OverlayDialogFragment().show(fm, "overlay-dialog")
        }
        view.findViewById<Button>(R.id.btn_add).setOnClickListener {
            fm.commit { add(R.id.container, OverlayFragment(), "overlay-add") }
        }
        view.findViewById<Button>(R.id.btn_replace).setOnClickListener {
            fm.commit {
                replace(R.id.container, OverlayFragment(), "overlay-replace")
                addToBackStack(null)
            }
        }
    }

    override fun onStart()       { super.onStart();   log("onStart") }
    override fun onResume()      { super.onResume();  log("onResume") }
    override fun onPause()       { super.onPause();   log("onPause") }
    override fun onStop()        { super.onStop();    log("onStop") }
    override fun onDestroyView() { super.onDestroyView(); log("onDestroyView") }

    private fun log(name: String) = Log.d("HostLifecycle", name)
}

OverlayDialogFragment.kt

화면 전체를 덮는 fullscreen Dialog입니다.

class OverlayDialogFragment : DialogFragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setStyle(STYLE_NORMAL, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
    }

    override fun onCreateView(i: LayoutInflater, c: ViewGroup?, s: Bundle?): View =
        View(requireContext()).apply {
            setBackgroundColor(0xFF1A1A2E.toInt()) // 화면 전체를 덮는 불투명 View
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        }
}

OverlayFragment.kt

화면 전체를 덮는 불투명 Fragment입니다.

class OverlayFragment : Fragment() {
    override fun onCreateView(i: LayoutInflater, c: ViewGroup?, s: Bundle?): View =
        View(requireContext()).apply {
            setBackgroundColor(0xFF1A1A2E.toInt()) // 화면 전체를 덮는 불투명 View
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        }
}

케이스별 분석

Case 1 : FragmentTransaction.add(): 일반 Fragment

OverlayFragment는 화면 전체를 불투명하게 덮습니다. 아래 Fragment는 완전히 가려집니다.

supportFragmentManager.commit {
    add(R.id.container, OverlayFragment(), "overlay-add")
}

이 때, 실제 Logcat에는 아무것도 찍히지 않습니다.

화면이 완전히 가려진다고 해도 onPause도, onStop도 호출되지 않는다는 것을 확인할 수 있습니다.

Process
└── Activity (RESUMED)
    └── FragmentManager
        ├── HostFragment    (RESUMED — 그대로)
        └── OverlayFragment (RESUMED — 새로 올라옴)

앞서 언급했듯, add 트랜잭션은 기존 Fragment에 아무런 영향을 주지 않습니다. FragmentManager는 View가 다른 View에 의해 시각적으로 가려졌는지 알지도 않고 알 필요도 없습니다. 두 Fragment는 나란히 RESUMED 상태에 놓이게 되므로 생명주기 관련 콜백 사용 시 각별한 주의가 필요합니다.


Case 2 : DialogFragment.show()

이번에는 일반 Fragment 대신 DialogFragment를 올려봅니다. 마찬가지로 화면 전체를 불투명하게 덮습니다.

OverlayDialogFragment().show(supportFragmentManager, "overlay-dialog")

실제 Logcat

ActivityFocus  onWindowFocusChanged hasFocus=false
(HostLifecycle 아무것도 찍히지 않음)

HostLifecycle은 Case 1과 똑같이 침묵합니다. 화면이 완전히 가려졌는데도 onPause도, onStop도 호출되지 않습니다.

구조적으로 무슨 일이 일어났나?

Process
└── Activity (여전히 RESUMED 상태)
    └── FragmentManager
        ├── HostFragment           (RESUMED — 그대로)
        └── OverlayDialogFragment  (RESUMED — 새로 올라옴)

FragmentManager 관점에서 보면, DialogFragment.show(fm, tag)는 내부적으로 add 트랜잭션과 동일합니다. Case 1과 구조적으로 차이가 없습니다. HostFragment에게는 어떤 콜백도 전달되지 않습니다.

그런데 Window 포커스는 왜 바뀌나?

Case 1과 달리 ActivityFocus 로그가 찍혔습니다. Fragment 생명주기는 그대로인데 왜 포커스만 바뀔까요?

Dialog.show()가 호출되면 내부적으로 WindowManager.addView()가 실행되어 Dialog 전용 Window가 새로 생성됩니다. 입력 포커스는 이 새 Window로 넘어가기 때문에 onWindowFocusChanged(false)가 발생합니다. 그러나 이것은 Activity가 Foreground를 잃은 것이 아닙니다. 시스템 입장에서 Activity는 여전히 RESUMED 상태이고, FragmentManager가 관리하는 Fragment 목록에도 변화가 없습니다.

⚠️ Window 포커스 ≠ 생명주기 상태

포커스는 "지금 어떤 Window가 입력 이벤트를 받는가"의 문제이고, 생명주기는 "Activity(또는 Fragment)가 어떤 상태인가"의 문제입니다. 서로 다른 서브시스템이 만들어내는 서로 다른 신호입니다. Dialog가 뜨면 포커스는 바뀌지만, FragmentManager가 커밋한 트랜잭션의 종류(add)는 바뀌지 않습니다.

⚠️ Dialog의 크기와 Fragment 생명주기는 무관하다
또 다른 착각의 한 예시로, "Dialog를 만들 때 windowIsFloating=false로 설정하면 Activity의 onPause, onStop이 호출되지 않나?" 라고 생각할 수 있습니다. 하지만 android:windowIsFloating은 Dialog의 시각적 크기와 레이아웃에만 영향을 미칩니다.

결국 DialogFragment.show()가 내부적으로 add 트랜잭션을 사용한다는 사실은 변하지 않습니다.


Case 3 : FragmentTransaction.replace() + addToBackStack()

supportFragmentManager.commit {
    replace(R.id.container, OverlayFragment(), "overlay-replace")
    addToBackStack(null)
}

실제 Logcat

HostLifecycle  onPause
HostLifecycle  onStop
HostLifecycle  onDestroyView
... 뒤로가기 ...
HostLifecycle  onCreateView
HostLifecycle  onViewCreated
HostLifecycle  onStart
HostLifecycle  onResume

구조적으로 무슨 일이 일어났나?

Process
└── Activity (RESUMED)
    └── FragmentManager
        ├── HostFragment    (인스턴스는 살아있지만 View는 제거됨)
        └── OverlayFragment (RESUMED)

앞선 두 케이스와의 결정적인 차이는 트랜잭션의 종류입니다. replace는 기존 Fragment에 remove를 적용합니다. 단, addToBackStack()이 함께 사용되면 인스턴스를 보존한 채 detach처럼 동작하여 onPause → onStop → onDestroyView가 순서대로 호출됩니다. View는 파괴되지만 인스턴스는 백 스택에 남아 뒤로가기 시 재사용됩니다(onCreateView 재호출).


Case 4 : childFragmentManager를 통해 띄우는 경우

앞선 케이스들은 모두 requireActivity().supportFragmentManager, 즉 Activity 소유의 FragmentManager를 사용했습니다. 이번에는 HostFragment 자신이 소유한 childFragmentManager를 사용하는 경우를 살펴보겠습니다. 오버레이는 여전히 화면 전체를 덮습니다.

// DialogFragment를 자식으로 띄우는 경우
OverlayDialogFragment().show(childFragmentManager, "overlay-dialog")

// 일반 Fragment를 자식으로 add하는 경우
childFragmentManager.commit {
    add(R.id.child_container, OverlayFragment(), "overlay-add")
}

실제 Logcat :

(HostLifecycle 아무것도 찍히지 않음)
(ActivityFocus — DialogFragment의 경우 onWindowFocusChanged hasFocus=false)

Case 1, 2와 마찬가지로, 화면이 완전히 가려졌는데도 onPause도, onStop도 호출되지 않습니다.

구조적으로 무슨 일이 일어났나?

Process
└── Activity (RESUMED)
    └── Activity의 FragmentManager
        └── HostFragment (RESUMED)
              └── HostFragment의 childFragmentManager
                   └── OverlayDialogFragment 또는 OverlayFragment (RESUMED)

childFragmentManager를 사용하면 오버레이가 HostFragment자식이 됩니다. Activity의 FragmentManager 입장에서 HostFragment에는 아무런 변화가 없습니다. 자식을 추가하는 트랜잭션은 여전히 add이고, HostFragment 자신에게는 어떤 트랜잭션도 적용되지 않았으므로 onPause는 여기서도 불리지 않습니다.


교훈

프래그먼트에서 onPause와 onStop이 호출되는지는 화면의 포커스 여부와는 직접적인 관련이 없습니다.

이 콜백들은 Activity의 생명주기 변화와 FragmentTransaction의 결과로 FragmentManager가 상태를 재계산한 결과에 의해 결정됩니다.

이 구조를 이해하지 못한 상태에서 생명주기 기반 작업들을 설계하면, 코드 자체는 정상적으로 보이지만 정작 기대했던 생명주기 이벤트가 발생하지 않아 아무 동작도 실행되지 않는 상황이 발생할 수 있습니다.

따라서 Fragment 생명주기를 설계할 때는, 현재 Fragment가 어떤 트랜잭션과 Activity 상태 위에 놓여 있는가를 기준으로 판단해야 할 것입니다.

방식Host의 onPause/onStopWindow 포커스 변화이유
FragmentTransaction.add(...) (supportFragmentManager)없음없음add 트랜잭션; Host Fragment에 아무런 트랜잭션도 적용되지 않음
DialogFragment.show(supportFragmentManager, tag)없음있음add 트랜잭션; Host Fragment에 아무런 트랜잭션도 적용되지 않음
childFragmentManager.commit { add(...) }없음없음자식으로 추가될 뿐, Host 자신에게는 아무런 트랜잭션도 적용되지 않음
DialogFragment.show(childFragmentManager, tag)없음있음Host가 부모가 되지만, Host 자신에게는 아무런 트랜잭션도 적용되지 않음
FragmentTransaction.replace(...).addToBackStack(...)있음없음replace → Host에 detach 적용 → onPause/onStop/onDestroyView 호출
NavController.navigate(...) (기본)있음없음내부적으로 replace 동작
startActivity(...)있음있음진짜 Activity 전환

참고 자료

profile
Software Engineer

0개의 댓글