프로젝트를 진행하면서 Compose의 생명주기를 다루는 방식에 대해 고민하게 되는건 당연한 것 같아요.
특히 안드로이드의 생명주기와 컴포저블의 생명주기가 만나는 지점에서 어떤 선택을 해야 하는지는 항상 고민이 됩니다..
최근 presentation:home 모듈을 리팩터링하면서 이 문제를 깊이 있게 들여다볼 기회가 있었는데요. 그 과정에서 배운 것들을 공유하고자 합니다😊
문제를 보자면
앱이 백그라운드에서 다시 활성화될 때마다 알림 권한 상태를 확인해야 하는 요구사항이 있었어요. 자연스럽게 DisposableEffect를 사용했습니다.
@Composable
fun HomeRoute(viewModel: HomeViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.handleNotificationPermission()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
코드는 잘 동작했어요. 하지만 뭔가 찝찝했습니다.
왜 찝찝했을까요?
- 코드의 의도를 파악하기 위해 구현 세부사항을 먼저 읽어야 했어요
- 옵저버를 등록하고 해제하는 보일러플레이트가 반복됐습니다
removeObserver를 빼먹으면 메모리 누수가 발생할 수 있었어요
무엇보다, "ON_RESUME일 때 권한을 체크한다"는 간단한 의도를 표현하기에 코드가 너무 장황했습니다. 가독성도 떨어지는건 덤이네요.
그냥 쓰긴 싫은데..
"더 나은 방법이 있지 않을까?" 하는 생각에 Compose의 공식 문서를 다시 살펴봤어요. 그러다 lifecycle-runtime-compose:2.7.0에 추가된 새로운 API들을 발견했습니다.
@Composable
fun HomeRoute(viewModel: HomeViewModel) {
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.handleNotificationPermission()
}
}
같은 동작을 하는 코드인데, 훨씬 읽기 쉬워졌어요. 옵저버 관리 로직이 사라지고 "무엇을 하려는지"가 명확히 드러났습니다.
하지만 단순히 코드가 짧아졌다는 것보다 더 중요한 변화가 있었어요.
명령형에서 선언형으로
기존 DisposableEffect 코드는 명령형으로 작성됐습니다.
"옵저버를 만들어라"
"생명주기에 등록해라"
"이벤트가 ON_RESUME이면 함수를 실행해라"
"정리할 때는 옵저버를 제거해라"
반면 LifecycleEventEffect는 선언형으로 작성돼요.
"ON_RESUME 이벤트가 발생하면 이 작업을 수행한다"
이 차이가 왜 중요할까요? Compose는 선언형 UI 프레임워크예요. "어떻게" 보다 "무엇을" 에 집중하는 것이 Compose의 철학이라고 생각합니다.
내부를 들여다봅시다
LifecycleEventEffect가 어떻게 동작하는지 궁금해서 소스 코드를 열어봤어요.
// 간단하게 가져온 버전입니다 :)
@Composable
fun LifecycleEventEffect(
event: Lifecycle.Event,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onEvent: () -> Unit
) {
val currentOnEvent by rememberUpdatedState(onEvent)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, e ->
if (e == event) {
currentOnEvent()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
놀랍게도 내부적으로는 DisposableEffect를 사용하고 있었어요. 하지만 몇 가지 중요한 개선이 있었습니다.
rememberUpdatedState의 역할
rememberUpdatedState를 사용해서 onEvent 람다를 관리해요. 이게 왜 필요할까요?
만약 onEvent 내부에서 상태 변수를 참조하고 있다면 상태가 바뀔 때마다 Effect가 재실행돼야 할까요? 아니에요. Effect의 생명주기는 유지하면서 람다만 최신 버전으로 교체하면 됩니다.
rememberUpdatedState가 바로 이 역할을 합니다. 불필요한 재실행을 방지하면서도 최신 상태를 참조할 수 있게 해줘요.
또한 removeObserver 로직이 내부에 캡슐화되어 있어요. 개발자가 실수로 빼먹을 가능성이 원천적으로 차단됩니다.
다른 API들도 살펴보자
LifecycleEventEffect 외에도 LifecycleStartEffect와 LifecycleResumeEffect라는 API가 있었어요. 조금 다른 상황에서 유용합니다.
LifecycleStartEffect
@Composable
fun LocationTracker(locationManager: LocationManager) {
LifecycleStartEffect(locationManager) {
locationManager.startTracking()
onStopOrDispose {
locationManager.stopTracking()
}
}
}
ON_START부터 ON_STOP까지 유지되어야 하는 작업에 사용해요. 화면이 완전히 보이지 않아도 앱이 활성화된 상태라면 계속 실행돼야 하는 경우가 되겠네요.
LifecycleResumeEffect
@Composable
fun VideoPlayer(player: Player) {
LifecycleResumeEffect(player) {
player.play()
onPauseOrDispose {
player.pause()
}
}
}
ON_RESUME부터 ON_PAUSE까지 유지되어야 하는 작업에 사용합니다. 사용자와 직접 상호작용할 때만 실행되어야 하는 경우예요.
두 API 모두 정리 로직이 두 가지 경우에 실행되는 것을 발견했어요.
- 안드로이드 생명주기가 종료될 때:
ON_STOP또는ON_PAUSE이벤트 - 컴포지션이 종료될 때: 컴포저블이 제거될 때
한 곳에만 정리 로직을 작성하면 두 가지 상황을 모두 처리할 수 있어요. 이것도 실수를 방지하는 설계라는게 느껴졌습니다.
언제 뭘 써야 되는데?
여러 API를 보면서 "언제 뭘 써야 하지?"라는 고민이 생겼어요. 제가 정리한 기준은 이렇습니다.
LifecycleEventEffect
단발성 작업이 필요할 때 사용해요.
- 로깅 이벤트 전송
- 데이터 새로고침 트리거
- 권한 재요청
- 분석 이벤트 기록
정리 로직이 필요 없는 작업이라면 이 API가 적합해요
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
analytics.logScreenView("home")
}
LifecycleStartEffect
화면이 보이지 않아도 계속되어야 하는 작업에 사용해요.
- GPS 위치 추적
- 센서 데이터 수집
- 백그라운드 데이터 동기화
앱이 활성화된 동안에는 계속 실행되어야 하지만 앱이 백그라운드로 가면 중단되어야 하는 경우예요.
LifecycleResumeEffect
사용자와 직접 상호작용할 때만 실행되어야 하는 작업에 사용합니다.
- 동영상 재생
- 카메라 프리뷰
- 복잡한 애니메이션
- 음성 녹음
화면이 조금이라도 가려지면 즉시 중단되어야 하는 경우예요.
아직도 감이 안오는데..
카메라 프리뷰를 구현하면서 LifecycleResumeEffect를 적용해봤어요.
@Composable
fun CameraPreview(
camera: Camera,
modifier: Modifier = Modifier
) {
LifecycleResumeEffect(camera) {
camera.startPreview()
onPauseOrDispose {
camera.stopPreview()
}
}
AndroidView(
factory = { context ->
PreviewView(context).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
},
modifier = modifier
)
}
화면이 일시정지되거나 컴포저블이 제거되면 자동으로 카메라 프리뷰가 중단돼요. 두 가지 경우를 하나의 블록으로 처리할 수 있어서 확실하게 쓰기 편하고 읽기 편했어요.
정리해보면
DisposableEffect는 범용적이지만 저수준 API예요. 특정 사용 사례에 맞는 고수준 API(LifecycleEventEffect 등)를 사용하면 코드의 의도가 명확해집니다.
Compose는 선언형 프레임워크예요. "어떻게"보다 "무엇을"에 집중하는 코드가 Compose의 철학과 잘 맞는다고 생각해요.
새로운 API들이 계속 추가되고 있어요. 정기적으로 공식 문서와 릴리스 노트를 확인하는 습관이 필요하다는 걸 느꼈습니다.
마치며
이 글을 쓰면서 "정답"을 제시하려는 건 아니에요. 다만 제가 고민했던 과정과 그 과정에서 배운 것들을 공유하고 싶었습니다.
여러분의 프로젝트에는 어떤 방식이 적합할까요? 상황에 따라 다를 수 있어요. 중요한 건 "왜 이 방식을 선택했는지" 설명할 수 있는 것이라고 생각합니다.
프로젝트에 DisposableEffect로 생명주기를 관리하는 코드가 있다면, 한번 다시 살펴보는 것도 좋을 것 같아요. 더 나은 방법이 있을지도 모르니까요🤔
'Android > Compose' 카테고리의 다른 글
| [Android Compose] 골치덩어리 EdgeToEdge를 잘 써보자. (1) | 2025.09.23 |
|---|---|
| [Android Compose] AboutLibraries로 오픈소스 라이선스 화면을 만들기 (0) | 2025.09.23 |
| [Android Compose] @Composable 종속성을 StateFlow로 바꿔보자 (1) | 2025.08.09 |
| [Android Compose] flowWithLifecycle은 언제 쓰면 좋을까? (0) | 2025.08.08 |
| [Android Compose] Effect Handlers 딥다이브 (0) | 2025.06.20 |