Flow는 생명주기를 전혀 알지 못한다: repeatOnLifecycle과 LifecycleRegistry는 어떻게 수집을 안전하게 만드는가
Flow는 생명주기를 전혀 알지 못한다: repeatOnLifecycle과 LifecycleRegistry는 어떻게 수집을 안전하게 만드는가
최신 안드로이드 개발에서는 대체로 UI 코드가 ViewModel의 Flow를 구독하고 데이터를 받아옵니다. collect를 repeatOnLifecycle로 감싸거나 컴포저블 안에서 collectAsStateWithLifecycle을 호출하면, 화면이 백그라운드로 내려갈 때 데이터 흐름이 멈췄다가 다시 돌아올 때 재개됩니다. 대부분의 개발자는 이 방식이 권장된다는 사실은 알고 있지만, 한 걸음 더 들어가 보면 '대체 이게 왜 필요한가'라는 질문이 남습니다. 사실 Flow는 안드로이드와 아무런 관련이 없는 순수 코틀린 코루틴 타입입니다. 화면이라는 존재가 있다는 사실조차 알지 못하며, 그중 하나가 백그라운드로 내려갔다는 것은 더더욱 알 수 없습니다. 이 간극이 어떻게 메워지는지, 그리고 예전의 LiveData는 왜 이런 고민이 애초에 필요 없었는지를 이해하면, 오늘날의 UI 계층이 어떻게 맞물려 돌아가는지를 상당 부분 파악할 수 있습니다.
이 글에서는 생명주기를 인식하는 Flow 수집이 어떻게 동작하는지를 깊이 있게 파고듭니다. Flow가 왜 생명주기를 보지 못하는지, LifecycleRegistry가 어떻게 상태 변화를 정해진 순서의 이벤트로 옵저버들에게 전달하는지, repeatOnLifecycle이 그 기계 장치에 올라타 어떻게 수집을 시작하고 취소하는지, 그 재시작 동작이 여러분의 코드에 어떤 의미인지, launchWhenStarted는 왜 deprecated되었는지, collectAsStateWithLifecycle이 어떻게 같은 토대 위에 세워지는지, 그리고 LiveData는 어떻게 이 모든 것을 거저 얻었는지까지 차례대로 살펴봅니다.
근본적인 문제: Flow는 화면이 사라졌다는 사실을 알지 못한다
Flow는 전적으로 kotlinx.coroutines 안에서 정의됩니다. Flow를 수집하면 호출한 코루틴이 일시 중단되고, 그 코루틴이 취소될 때까지 실행될 뿐 그 외에는 아무 일도 일어나지 않습니다. '사용자가 홈 버튼을 눌렀다'에 대응하는 콜백 같은 것은 존재하지 않습니다. Flow에게는 사용자도, 화면도, 안드로이드 생명주기도 없는 개념이기 때문입니다. collect를 멈출 수 있는 것은 오직 하나, collect가 돌아가고 있는 코루틴을 취소하는 일뿐입니다.
이 방식은 화면이 보이는 시간보다 더 오래 살아남는 스코프에서 수집하기 전까지는 아무 문제가 없습니다. 가장 자연스러워 보이는 코드를 살펴보겠습니다.
lifecycleScope.launch {
locationFlow.collect { updateMap(it) }
}
lifecycleScope는 생명주기가 DESTROYED에 도달할 때에만 취소됩니다. 사용자가 다른 앱으로 전환하면 Activity는 멈추기는 하지만 소멸되지는 않으므로, 이 코루틴은 계속 돌아가고 locationFlow 역시 값을 계속 방출합니다. 만약 locationFlow가 GPS를 감싼 callbackFlow라면, 센서는 백그라운드에서도 켜진 채로 배터리를 갉아먹습니다. 폴링(polling) 방식의 Flow라면 쉼 없이 서버를 두드립니다. 뷰에 데이터를 공급하는 StateFlow라면, 방출이 일어날 때마다 사용자가 볼 수도 없는 화면을 갱신하게 됩니다. 이는 낭비되는 작업일 뿐만 아니라, 예전 뷰 시스템에서는 이미 멈춰 버린 뷰를 건드리다 크래시로 이어질 수도 있습니다.
정작 우리가 원하는 동작은 아주 좁습니다. 화면이 최소한 STARTED 상태일 때에만 수집하고, 그 아래로 떨어지면 수집을 취소하며, 다시 돌아오면 새로 시작하는 것입니다. Flow는 이 가운데 어느 것도 혼자서는 표현할 수 없습니다. STARTED가 무엇을 뜻하는지조차 알지 못하기 때문입니다. 결국 생명주기의 변화를 시작과 중단 신호로 번역해 줄 무언가가 필요한데, 그 역할을 맡는 것이 바로 Lifecycle 라이브러리입니다.
상태 머신으로서의 생명주기: 상태, 그리고 한 번에 한 걸음씩
무언가가 생명주기에 반응하려면, 그에 앞서 생명주기 자체가 먼저 모델링되어야 합니다. Lifecycle.State는 enum이며, 여기서는 선언된 순서 자체가 핵심입니다.
public enum class State {
DESTROYED,
INITIALIZED,
CREATED,
STARTED,
RESUMED;
public fun isAtLeast(state: State): Boolean {
return compareTo(state) >= 0
}
}
상태에 순서가 매겨져 있으므로 '최소한 STARTED'라는 조건은 단순한 비교 한 번으로 끝나고, DESTROYED는 의도적으로 가장 낮은 값에 놓여 있습니다. 각 상태를 노드로, Lifecycle.Event 값들을 인접한 노드 사이를 잇는 간선으로 떠올려 보면 이해하기 쉽습니다. 이 둘을 연결해 주는 헬퍼 함수들은 언제나 딱 한 걸음만을 설명합니다.
public fun upFrom(state: State): Event? = when (state) {
State.INITIALIZED -> ON_CREATE
State.CREATED -> ON_START
State.STARTED -> ON_RESUME
else -> null
}
public fun downFrom(state: State): Event? = when (state) {
State.CREATED -> ON_DESTROY
State.STARTED -> ON_STOP
State.RESUMED -> ON_PAUSE
else -> null
}
upFrom은 '이 상태에서 한 단계 위로 올라갈 때 떠나는 이벤트가 무엇인가'에 답하고, downFrom은 반대로 내려갈 때를 두고 같은 답을 줍니다. 두 노드를 한 번에 건너뛰는 간선은 존재하지 않습니다. 바로 이 점이, INITIALIZED에서 RESUMED로 이동하는 옵저버가 ON_CREATE, ON_START, ON_RESUME을 정확히 그 순서대로 하나도 빠짐없이 받게 된다는 사실을 나중에 보장해 줍니다. 이와 짝을 이루는 upTo(state)와 downFrom(state)는, 올라가는 길에 어떤 상태에 도착하게 하는 이벤트와 내려가는 길에 그 상태를 떠나는 이벤트를 각각 가리킵니다. repeatOnLifecycle이 나중에 작업을 시작시킬 이벤트와 취소시킬 이벤트를 골라낼 때 사용하는 것이 바로 이 한 쌍입니다.
LifecycleRegistry: 상태 변화를 순서대로 전달하기
LifecycleRegistry는 Activity, Fragment, 또는 NavBackStackEntry가 소유하는 구체적인 Lifecycle 구현체입니다. 현재 상태와 옵저버 집합을 들고 있으며, 모든 옵저버를 앞서 본 한 걸음짜리 이벤트들을 거쳐 현재 상태까지 끌어올리거나 끌어내리는 것이 그 역할입니다. 이 라이브러리는 코틀린으로 다시 작성되었기 때문에, 구현부는 이제 m 접두사가 붙은 필드가 없는 평범한 코틀린 코드입니다.
옵저버들은 순서가 있는 맵에 담기며, 소스 코드 자체에 명시된 한 가지 불변 조건을 따릅니다. 어떤 옵저버가 다른 옵저버보다 먼저 등록되었다면, 그 상태는 항상 최소한 같거나 더 앞서 있다는 것입니다. 먼저 등록된 옵저버가 앞서가고, 나중에 등록된 옵저버가 그 뒤를 따릅니다. 소유자의 상태가 바뀌면 handleLifecycleEvent가 그 이벤트를 목표 상태로 변환한 뒤 sync()를 호출하고, sync()는 모든 옵저버가 레지스트리와 일치할 때까지 반복합니다.