
프래그먼트는 액티비티에 비해 더 복잡한 생명주기를 가지고 있습니다. Fragment 자체의 생명주기뿐만 아니라, 프래그먼트에 호스팅되는 뷰의 생명주기까지 함께 관리해야 하기 때문입니다.
이를 고려하지 않고 코드를 작성하면 예상치 못한 crash가 발생할 수 있습니다. 특히 최근 많은 앱에서 Jetpack Compose를 사용하면서, 프래그먼트뿐만 아니라 Composable 간의 생명주기 차이까지 함께 고려해야 하는 상황이 생기고 있습니다.
이번 글에서는 생명주기의 잘못된 관리로 인해 발생할 수 있는 crash 사례를 살펴보고, 이를 해결하는 방법을 알아봅니다.
이 글을 이해하기 위해서는 다음 개념에 대한 기본적인 이해가 필요합니다.
특정 화면 위에 커스텀 다이얼로그를 띄워야 하는 상황을 가정해 봅시다. 아키텍처상 Fragment-ViewModel 단위로 다이얼로그를 관리해야 한다면, 안드로이드에서 제공하는 DialogFragment를 활용할 수 있습니다.
다이얼로그 창을 액티비티 화면의 전면에 띄우는 프래그먼트입니다.
다이얼로그를 제어(표시, 숨김, 종료)할 때는 이 API를 통해 관리해야 하며, 다이얼로그 객체를 직접 호출하여 제어하는 방식은 권장되지 않습니다.
— DialogFragment 공식 문서
DialogFragment를 사용하면 액티비티 화면 위에 독립적인 플로팅 UI를 띄울 수 있을 뿐 아니라, 다이얼로그의 표시, 숨김, 종료를 일관되게 제어할 수 있습니다. 예를 들어 다이얼로그를 종료하고 싶다면 dismiss() 메서드를 호출하기만 하면 됩니다.
이 방법으로 아래와 같이 DialogFragment와 Compose를 활용해 간단한 화면을 구현해 볼 수 있을 것입니다.
class PermissionFragment : DialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
// Compose API의 Dialog 컴포저블 사용
Dialog(
onDismissRequest = {
dismiss() // DialogFragment의 dismiss() 호출
}
) {
// 다이얼로그 UI 구성
Button(
onClick = {
dismiss() // DialogFragment의 dismiss() 호출
}
) {
Text("확인")
}
}
}
}
}
}
이 코드는 onCreateView에서 ComposeView를 생성하고, Compose에서 제공하는 Dialog 컴포저블을 활용했습니다. 사용자가 버튼을 클릭하거나, 뒤로가기를 누르거나, 외부 영역을 터치하면 DialogFragment의 dismiss()를 호출하도록 구현했습니다.
겉보기에는 전혀 문제가 없어 보여, 처음에는 이 방식을 활용하여 회사 프로젝트를 구현했습니다. 하지만 코드 리뷰를 통해 이 구현은 심각한 문제를 가지고 있음을 깨닫게 되었습니다. 생명주기 측면에서 위험 요소가 있어 실제 기기 환경에서 크래시가 발생할 수 있기 때문입니다.
dismiss()는 '언제든지' 호출해도 안전한 것이 아니다.
프래그먼트에는 두 가지 생명주기가 존재합니다.
onCreateView ~ onDestroyView)
위 다이어그램을 보면 프래그먼트 자체의 생명주기가 뷰의 생명주기를 포함하고 있음을 알 수 있습니다. 즉, 프래그먼트는 살아있지만(CREATED) 뷰는 이미 파괴된(DESTROYED) 상황이 존재할 수 있다는 의미입니다.
Compose를 함께 사용한다면 다음 세 가지 생명주기를 모두 고려해야 합니다.
이 세 가지 생명주기가 서로 다르게 움직인다는 특성을 고려하지 않고 잘못된 시점에 dismiss()를 호출하면 크래시가 발생합니다.
ComposeView를 프래그먼트에 호스팅할 때, 언제 해당 뷰를 처리(dispose)할 것인지 결정하는 전략을 ViewCompositionStrategy라고 합니다. 선택할 수 있는 세 가지 전략은 다음과 같습니다.
DisposeOnDetachedFromWindow(OrReleasedFromPool): ComposeView가 윈도우에서 detach될 때 처리DisposeOnViewTreeLifecycleDestroyed: View 트리를 거슬러 올라가 찾은 가장 가까운 LifecycleOwner가 소멸될 때 처리DisposeOnLifecycleDestroyed: 명시적으로 지정한 특정 생명주기가 소멸될 때 처리문제는 기본 전략에 있습니다. 앞선 코드에서는 특별한 설정을 하지 않았기 때문에 DisposeOnDetachedFromWindow 기본 전략이 적용됩니다. 이 경우 View가 윈도우에서 detach되기 전까지 Composition이 유지됩니다.
생명주기 흐름을 정리하면 다음과 같습니다.
onCreateView → View 생성
↓
onViewCreated
↓
onStart, onResume, onPause, onStop
↓
onSaveInstanceState → Fragment Transaction 금지 시점
↓
onDestroyView → View 소멸
↓
윈도우에서 뷰 detach → Composition 소멸
핵심은 onDestroyView가 호출된 시점에 프래그먼트의 View는 파괴되었지만, Composition은 여전히 살아있다는 점입니다.
이로 인해 다음과 같은 크래시 시나리오가 발생할 수 있습니다.(특히 IllegalStateException)
1. 사용자가 다이얼로그를 연 상태
↓
2. 홈 버튼을 누르거나 다른 앱으로 전환
↓
3. onSaveInstanceState() 호출 (Fragment Transaction 금지)
↓
4. Compose의 이벤트 리스너(onClick, onDismissRequest)는 아직 살아있는 상태
↓
5. 비동기 작업 완료 등으로 뒤늦게 dismiss() 호출
↓
6. Fragment Transaction 시도, 크래시 발생
다음 방법들을 통해 안정적인 구현을 할 수 있습니다.
가장 먼저 할 수 있는 것은 ComposeView의 dispose 전략을 변경하는 것입니다. DisposeOnViewTreeLifecycleDestroyed를 사용하면 View의 생명주기와 Composition의 생명주기를 동기화할 수 있습니다.
class PermissionFragment : DialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
// 전략 변경
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
Dialog(
onDismissRequest = { dismiss() }
) {
Button(onClick = { dismiss() }) {
Text("확인")
}
}
}
}
}
}
더 확실한 해결책은 단방향 데이터 플로우(UDF)를 적용하는 것입니다.
프래그먼트에서 직접 dismiss()를 호출하는 대신, ViewModel을 통해 이벤트를 전달하고 Fragment에서 안전하게 처리합니다. viewLifecycleOwner를 사용하여 View의 생명주기에 안전하게 바인딩된 경우에만 이벤트를 관찰하고 dismiss()를 호출하기 때문에 안전합니다.
class PermissionViewModel : ViewModel() {
private val _dismissEvent = MutableLiveData<Event<Unit>>()
val dismissEvent: LiveData<Event<Unit>> = _dismissEvent
fun onDismissRequested() {
_dismissEvent.value = Event(Unit)
}
}
class PermissionFragment : DialogFragment() {
private val viewModel: PermissionViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
Dialog(
onDismissRequest = { viewModel.onDismissRequested() }
) {
Button(onClick = { viewModel.onDismissRequested() }) {
Text("확인")
}
}
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// viewLifecycleOwner로 관찰하여 안전하게 처리
viewModel.dismissEvent.observe(viewLifecycleOwner) { event ->
event.getContentIfNotHandled()?.let {
dismiss()
}
}
}
}
"LiveData? 그거 레거시 아닌가?"
생명주기를 인식하는 이벤트 전달이라는 관점에서 보면, LiveData는 여전히 강력한 도구입니다. LiveData는 observer가 STARTED 또는 RESUMED 상태일 때만 활성화되며, viewLifecycleOwner로 관찰할 경우 View의 생명주기에 안전하게 바인딩됩니다.
이를 활용한 EventHandler를 만들어보겠습니다. LiveData를 래핑하여 이벤트를 안전하게 전달하는 역할을 합니다.
class EventHandler<S>(
private val state: MutableLiveData<Event<S>> = MutableLiveData()
) {
fun update(value: S) {
if (Looper.myLooper() == Looper.getMainLooper()) {
state.value = Event(value)
} else {
state.postValue(Event(value))
}
}
fun post(value: S) {
state.postValue(Event(value))
}
fun observe(lifecycleOwner: LifecycleOwner, observer: EventObserver<S>) {
state.observe(lifecycleOwner, observer)
}
fun removeObserver(observer: EventObserver<S>) {
state.removeObserver(observer)
}
}
이어서 Composition이 dispose될 때 observer를 자동으로 제거하도록 DisposableEffect를 활용한 EventHandlingEffect를 구현합니다. eventHandler나 lifecycleOwner가 변경될 때만 재구독하므로 생명주기 안정성을 보장합니다.
@Composable
fun <S> EventHandlingEffect(
eventHandler: EventHandler<S>,
observer: (S) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(eventHandler, lifecycleOwner) {
val eventObserver = EventObserver(observer)
eventHandler.observe(lifecycleOwner, eventObserver)
onDispose {
eventHandler.removeObserver(eventObserver)
}
}
}
최종적으로 다음과 같이 사용할 수 있습니다.
class PermissionFragment : DialogFragment() {
private val viewModel: PermissionViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
PermissionDialogScreen(
viewModel = viewModel,
onDismiss = { dismiss() }
)
}
}
}
}
@Composable
fun PermissionDialogScreen(
viewModel: PermissionViewModel,
onDismiss: () -> Unit
) {
// 생명주기를 고려한 안전한 이벤트 처리
EventHandlingEffect(
eventHandler = viewModel.dismissEvent,
observer = { onDismiss() }
)
Dialog(
onDismissRequest = { viewModel.onDismissRequested() }
) {
Button(onClick = { viewModel.onDismissRequested() }) {
Text("확인")
}
}
}
Fragment에서 Compose를 사용할 때는 생명주기 관련 사항들에 유의하여 구현을 진행해야 합니다.