안드로이드 시스템 상태를 Flow로 바꾸기
안드로이드 시스템 상태를 Flow로 바꾸기
안드로이드 앱은 언젠가는 자기 프로세스 바깥에 있는 상태에 반응해야 할 때가 옵니다. 사용자가 Wi-Fi 범위를 벗어나면 네트워크 연결이 끊기고, 국경을 넘거나 설정을 바꾸면 기기의 타임존이 달라집니다. 프레임워크는 이 둘을 모두 명령형 콜백으로 알려 줍니다. 연결 상태는 ConnectivityManager.NetworkCallback이, 타임존 변경은 BroadcastReceiver가 맡습니다. 리스너를 등록하면 시스템이 콜백을 호출하고, 다 쓰고 나면 등록을 해제합니다. 하지만 대부분의 Compose UI는 이 상태를 collect하고, 변환하고, 스냅샷 상태로 바꿀 수 있는 Flow로 다루고 싶어 합니다. 여기서 흥미로운 질문은, 리시버를 누수시키거나 생명주기 관리를 손수 짜지 않고 어떻게 그 간극을 잇느냐입니다. Now in Android 샘플은 이를 코루틴 빌더 하나로 답합니다. 바로
callbackFlow입니다.
이 글에서는 명령형 안드로이드 시스템 콜백을 콜드(cold) 반응형 Flow로 바꾸는 메커니즘을 깊이 있게 파고듭니다. callbackFlow 빌더와 그 ProducerScope, 논블로킹 전달 방식인 trySend, 콜백이 새지 않도록 정리해 주는 awaitClose, 백프레셔(back pressure) 연산자인 conflate와 distinctUntilChanged, 순수하게 콜드한 모니터와 공유 멀티캐스트 모니터의 차이, 그리고 그렇게 만들어진 Flow가 오프라인을 인식하는 스낵바를 움직이는 핫(hot) UI 상태로 어떻게 바뀌는지까지 차례대로 살펴봅니다.
근본적인 문제: 명령형 콜백은 조합되지 않는다
플랫폼에서 오는 연결 상태는 오버라이드 메서드를 가진 객체의 형태로 도착합니다. NetworkCallback을 상속해 등록해 두면, 네트워크가 나타나거나 사라질 때마다 시스템이 어떤 프레임워크 스레드에서 그 메서드들을 호출합니다. 이를 앱 코드로 곧이곧대로 옮기면 다음과 같습니다.
var isOnline = false
val callback = object : NetworkCallback() {
override fun onAvailable(network: Network) { isOnline = true }
override fun onLost(network: Network) { isOnline = false }
}
connectivityManager.registerNetworkCallback(request, callback)
// 다른 곳에서, 나중에, 잊지 말고 반드시 이걸 호출해야 한다:
connectivityManager.unregisterNetworkCallback(callback)
이 방법도 동작하지만, 반응형 UI에는 맞지 않습니다. 상태가 아무도 관찰하지 않는 가변 var에 담겨 있어서, map을 걸 수도, 다른 스트림과 합칠 수도, collectAsStateWithLifecycle에 흘려보낼 수도 없습니다. 등록과 해제가 코드상으로도 시간상으로도 떨어져 있는데, 리시버가 새는 것은 바로 이런 지점에서입니다. 게다가 콜백이 진짜 ConnectivityManager를 필요로 하기 때문에, 이 로직을 건드리는 테스트라면 무엇이든 안드로이드 프레임워크를 띄워야 합니다. 정작 우리가 원하는 것은, 누군가 수집을 시작하면 콜백을 등록하고 멈추면 해제하는 Flow<Boolean>입니다. 그러면 시스템 리소스의 생명주기가 관찰자의 생명주기를 자동으로 따라가게 됩니다.
좁은 인터페이스: Flow 뒤에 안드로이드 숨기기
브리지를 살펴보기 전에, 앱의 나머지 부분에 허락된 모습부터 눈여겨보세요. Now in Android는 연결 상태를 프로퍼티 하나짜리 인터페이스 하나로 노출합니다.
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}
타임존도 같은 패턴을 따르며, 그 KDoc은 구현이 반드시 지켜야 할 계약을 명시합니다. 항상 현재 기본값으로 최소 한 번 방출하고, 그 뒤로는 변경이 있을 때마다 다시 방출한다는 것입니다.
/**
* 기기에 설정된 현재 타임존을 알려 주는 유틸리티입니다.
* 항상 기본 설정으로 최소 한 번 방출하고, 이후 타임존이 바뀔 때마다 방출합니다.
*/
interface TimeZoneMonitor {
val currentTimeZone: Flow<TimeZone>
}
핵심은 이것입니다. 두 인터페이스 어디에도 ConnectivityManager나 NetworkCallback, BroadcastReceiver, Intent가 등장하지 않습니다. 도메인 계층과 UI 계층의 호출자들은 오직 Flow<Boolean>과 Flow<TimeZone>에만 의존합니다. 이 경계가 소스를 교체 가능하게, 소비자를 테스트 가능하게 만들어 줍니다. 안드로이드에 특화된 모든 것은 그 뒤편, 구체적인 구현 안에 자리합니다.
callbackFlow: 콜백을 Flow로 잇는 표준 방식
callbackFlow는 kotlinx.coroutines가 제공하는 flow 빌더로, 바로 이런 형태의 문제를 위해 존재합니다. 이 빌더는 블록을 ProducerScope 안에서 실행하는데, ProducerScope는 버퍼가 있는 Channel을 함께 감싼 CoroutineScope입니다. 평범한 flow { emit(value) } 빌더는 코루틴 안에서만 방출할 수 있게 해 주는데, emit이 suspend 함수라서 그 자체가 코루틴이 아닌 프레임워크 콜백에서는 호출할 수 없습니다. callbackFlow는 trySend를 노출해 이 제약을 없앱니다. trySend는 suspend가 아닌 함수라, onAvailable이나 onReceive를 전달하는 프레임워크 스레드를 포함해 어느 스레드에서든 호출할 수 있습니다. ProducerScope는 그 채널에 위임하므로 trySend(x)와 channel.trySend(x)는 같은 호출이며, 아래 코드 조각들은 두 형태를 모두 씁니다.
블록은 리스너를 등록하고, 각 값을 trySend로 흘려보낸 다음, 수집자가 사라질 때까지 awaitClose에서 멈춰 있도록 되어 있습니다. 뼈대만 추리면, 콜백을 Flow로 잇는 모든 브리지가 이런 모습입니다.
val updates: Flow<T> = callbackFlow {
val listener = Listener { value -> trySend(value) }
api.register(listener)
awaitClose { api.unregister(listener) }
}
등록하고, trySend로 흘려보내고, awaitClose에서 해제합니다. Now in Android의 두 모니터 모두 이 세 줄을 변주한 것이며, 초기값을 심고 하류에서 스트림을 다듬는 일이 조금 더 붙어 있습니다.
ConnectivityManager를 Flow로 잇기
연결 상태 구현은 이 뼈대를 그대로 따릅니다. 코드에 앞서 한 가지만 짚자면, 실제 소스는 Perfetto 프로파일링을 위해 블록의 일부를 androidx.tracing.trace { } 호출로 감싸 둡니다. 이는 계측용일 뿐 flow 동작을 바꾸지 않으므로, 아래 코드 조각에서는 생략했습니다.
블록은 먼저 ConnectivityManager를 찾는 것으로 시작합니다. 이 서비스가 없는 드문 기기라면 관찰할 것이 없으므로, false를 방출하고 채널을 닫습니다.