콜드 플로우 효율적으로 사용하기
Effective cold flow
Korean [English]
(목차로 돌아가기)
우리는 애플리케이션을 개발하면서 단일 데이터가 아닌 서로 연관된 일련의 데이터들, 즉 데이터 스트림을 다루어야 할 경우가 있습니다. 지속적으로 변화하는 센서의 측정 데이터나 큰 파일을 이루는 각 청크 데이터 등을 그 예로 들 수 있습니다. 이러한 데이터들을 다룰 때 언어 레벨에서 지원하는 Stream API 를 사용하거나 ReactiveX 구현체 같은 라이브러리를 이용할 수도 있지만, 코틀린 코루틴에서는 Flow 를 사용할 수 있습니다.
이 글은 Manuel Vivo 님의 A Safer way to collect flows from Android UIs 를 기반으로 작성되었습니다.
Flow(Stream)는 크게 Cold Flow 와 Hot Flow 로 나눌 수 있는데 이번 글에서는 Cold Flow 를 안드로이드 앱에서 이용할 때 발생할 수 있는 비효율과 이를 효율적으로 이용하는 새로운 방법, 그리고 약간의 내부에 대한 이야기 입니다.
Cold Flow❄️ 는 Flow 를 수집하는 각각의 Collector 들이 데이터를 수집 할 때마다 새로운 데이터 스트림을 생성하므로 Collector 들은 각각의 개별적인 데이터 스트림에서 데이터를 수집합니다.
Hot Flow🔥 는 Flow 를 수집하는 각각의 Collector 들이 데이터 스트림을 공유하여 동일한 데이터를 수집하며 구현에 따라 Collector 유/무에 따라 시작되기도 하지만 기본적으로 Collector 가 없어도 데이터 제공자(Provider)는 스트림 데이터를 제공합니다.
기본적인 콜드 플로우 내부에 대해 다룬 이전에 작성 된 플로우 내부 살펴보기 1 에서 flowOn 을 사용할 경우 내부적으로 버퍼가 생성되는 부분을 참고하시면 이번 이야기가 더 잘 이해됩니다. 👍
우리는 애플리케이션 개발을 하면서 UI 와 비지니스 로직 및 데이터 간의 의존성을 낮추기 위해서 다양한 아키텍쳐 패턴을 차용하거나 새로 구성합니다. 그에 따라 애플리케이션 구조는 UI 영역부터 데이터 영역에 이르기까지 각각의 역할을 수행하는 레이어들이 정의 되고, 레이어를 따라 일련의 데이터 흐름이 생기게 됩니다.
이 때, 특정 레이어는 하부 레이어의 게이트웨이 역할을 수행하며 인증 확인 및 스레드 컨트롤 등을 수행하기도 합니다 (이번 글과는 큰 관련이 없습니다 😅).
위 그림은 UI 로부터 시작된 데이터 요청이 데이터 레이어까지 전달되어 결과 데이터가 다시 UI 로 반환 되는 흐름을 나타냅니다. 앞서 이야기 한 것 처럼 이런 구조를 가져갈 때 함께 일하는 구성원들과의 내부 규칙으로 스레드 컨트롤을 하는 구간, 인증 체크를 하는 구간 등을 설정하기도 합니다.
Manuel Vivo 님이 작성한 원글에서도 이야기 했지만 이러한 앱 아키텍쳐의 데이터 레이어에서 일련의 데이터 스트림을 UI 레이어로 제공하기 위해서 Flow 를 생성, 전달하는 것은 어색하지 않은 자연스러운 구현 패턴이며 실제로 안드로이드 JetPack 의 많은 구성 요소들도 데이터 스트림을 전달해야 할 경우 이렇게 구현 되고 있습니다.
위 그림에서 UI 레이어의 데이터 요청에 데이터 레이어에서는 Flow 가 전달 되고, 최종적으로 UI 레이어에서 collect (수집) 를 시작하면 데이터 레이어의 데이터가 전달되기 시작합니다.
지금까지 살펴본 데이터 흐름을 기반으로 이제 본격적으로 Flow, 더 자세히는 Cold Flow 가 비효율적으로 사용될 수 있는 상황을 알아보고, 어떻게 개선할 수 있는지 알아보도록 하겠습니다.
Flow 는 크게 데이터 스트림을 제공하는 Provider
와 데이터 스트림에서 데이터를 수집하는 Collector
로 나누어 생각해 볼 수 있습니다. 기본적으로 Cold Flow 는 버퍼 없이 Collector 가 데이터를 수집함에 따라서 Provider 가 데이터를 제공하기(Rendezvous) 때문에 별도의 윈도우 컨트롤도 필요 없습니다. 즉, Collector 가 리소스가 불필요한 시점에 적절히 수집을 지연한다면 리소스 낭비는 발생하지 않습니다.
안드로이드에서는 launchWhenStarted {} 와 같은 launchWhenXXX 코루틴 빌더를 이용하여 생성한 코루틴에서 Flow 를 수집하면 지정된 Lifecycle 의 시작/종료 시점에 맞추어 수집을 재개/일시정지 하기 때문에 이를 적절히 사용하여 UI 가 유효하지 않은 때에 UI 업데이트를 시도하는 것을 발생하는 것을 방지할 수 있습니다…만 특정 상황에서는 이것만으로 충분하지 않은 경우가 있습니다 😭.
callbackFlow { } 와 같은 Flow Builder 나 Cold Flow 에 buffer, conflated, flowOn, shareIn 같은 오퍼레이터를 사용하는 경우 내부적으로 ❗️버퍼❗️를 생성하게 되는데, 이 경우에는 UI 컴포넌트 라이프 사이클에 대응하지 못하고 Buffer 수용량 최대치에 도달하기 전까지는 Provider 가 데이터를 제공 가능해 짐에 따라 불필요한 리소스를 낭비하게 됩니다.
callbackFlow
{ } 빌더의 경우 빌더 생성자는 다음과 같이 생겼는데,
버퍼의 수용량(capacity)도 전달 받거니와 ChannelFlowBuilder 를 상속하고 있으므로 ChannelFlow 즉, 내부적으로 채널(버퍼)을 이용함을 알 수 있습니다. flowOn
연산자는 플로우의 업 스트림의 실행 컨텍스트를 변경하기 위해 사용되는데 (주로 디스패처(스레드) 전환), 이 연산자를 사용하면 내부적으로 채널을 생성하여 버퍼를 이용하는 것을 플로우 내부 살펴보기 1 에서 살펴 보았었습니다.
다음의 두 가지 그림은 버퍼의 유무에 따른 플로우 동작 모습입니다.
Provider 가 데이터를 제공하면(Emit) Collector 는 데이터 수집 블록에서 수집된 데이터를 처리하며 이 역시 중단 함수로 수행됩니다. 그런데 이 수집이 지연되거나 일시 정지된다면 Provider 는 더이상 데이터를 전달하지 못하고 대기하게 됩니다.
(회색으로 표시 된 emit / collect 는 실제 발생하지 않습니다.)
하지만 앞서 이야기한 내부적으로 버퍼를 생성하는 Flow Builder 나 Operator 를 사용하면 위 모습처럼 Provider 는 Collector 가 중단 되어도 데이터를 Emit 할 수 있습니다. Provider 는 데이터를 중간에 위치한 Buffer 로 전달하기 때문입니다.
이 말은 결국 안드로이드에서는 데이터 수집을 시작한 UI 컴포넌트 (Activity, Fragment, View, …) 가 일시 정지 상태에 들어가도 Provider 는 데이터를 생산해 내고 있다는 것을 의미합니다.
이를 확인해보기 위한 예제를 작성하였습니다.
예제는 미세먼지 측정 센서에서 데이터를 수신하는 것을 묘사 합니다.
예제의 MainFragment 를 살펴보면
onStart / onStop 라이프사이클에 로그를 남기고,
onViewCreated 시점에 이 라이프사이클에 맞추어 데이터 수집을 시작하고 있습니다. observeAirQuality1 과 observeAirQuality2 의 차이는 observeAirQuality2 의 경우 flowOn 연산자를 이용하여 업스트림 스레드 전환을 한다는 것 외에는 없습니다.
(📌 flowOn 을 사용하면 내부적으로 버퍼를 사용합니다)
observeAirQuality1 으로 수행한 결과는 다음과 같습니다.
내부 버퍼가 없으므로 Provider 와 Collector 는 서로 제공과 수집 시점을 맞추어 측정 데이터 전달되고, 라이프사이클이 멈춘 약 30초간은 Collector(MainFragment) 로 데이터 전달도 없으며, Provider(AQSensorDataSource) 도 데이터를 생산해 내고 있지 않음을 알 수 있습니다.
observeAirQuality2 으로 수행한 결과는 다음과 같습니다.
결과를 보면 MainFragment 가 Stop 된 이후로는 Provider 만 동작하고 있음을 알 수 있으며, MainFragment 가 Start (재개) 되어서는 밀려있던 이벤트를 한번에 수신하면서 다시 정상적으로 이후 데이터를 수신함을 알 수 있습니다.
(observeAirQuality2 는 flowOn 연산자를 사용했으므로 버퍼가 존재하기 때문입니다.)
이렇게 View Lifecycle 이 Paused 나 Stopped 상태인데도 내부적으로 버퍼를 이용하는 콜드 플로우가 리소스를 낭비하는 상황을 방지하기 위해서 Lifecycle Ktx 를 통해서 헬퍼 함수가 제공될 예정입니다.
(현재 alpha — lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01)
콜드 플로우를 수집할 때 다음과 같이 repeatOnLifecycle
확장함수를 이용하면 원하는 Lifecycle Pair 에 맞추어서 수집을 시작하고 취소하게 됩니다 (일시 정지가 아님).
결과는 다음과 같이 정상 동작함을 볼 수 있습니다.
repeatOnLifecycle 의 주요 코드는 다음과 같습니다.
2 라인을 보면 suspendCancellableCouroutine { } 으로 시작하고 있습니다. 이것은 결국 repeateOnLifecycle 의 호출부는 이 시점에 중단(suspend) 상태로 전환됨을 의미합니다.
3~4 라인를 보면 repeatOnLifecycle(STATE) { block } 에서 전달 받은 라이프사이클 상태(STATE)에 따라 언제 block 을 실행하고 언제 block 을 취소할 지를 변수로 정의하고 있습니다.
그리고 라이프사이클 이벤트를 구독하여 이벤트 발생 시 block 의 실행 및 취소를 수행합니다. 추가로 onDestroy 라이프사이클에서는 2 라인에서 중단했던 현재 코루틴을 재개합니다.
이것은
2번 라인에 정지되었던 코드가 재개되어 9번 라인(Some clean-up tasks)이 실행되고 함수 블록이 종료됨을 의미 합니다.
Flow 자체에 다음과 같이 flowWithLifecycle
연산자를 사용하여 라이프사이클에 맞춰 수집이 시작/취소 되게 만들 수도 있습니다.
결과는 repeatOnLifecycle
을 사용했을 때와 동일하며 내부 코드는 다음과 같습니다.
코드를 살펴보면 flowWithLifecycle
은 내부적으로 callbackFlow 로 wrapping 하여 결국repeatOnLifecycle
을 사용하고 있음을 알 수 있습니다.
repeatOnLifecycle
을 외부에서 호출하고 Flow.collect() 를 사용할 때에는 Lifecycle 에 따른 collect() 호출을 외부에서 제어하지만, flowWithLifecycle
의 경우에는 Lifecycle 에 따른 업스트림 플로우의 collect() 제어가 Flow 내부로 들어오게 되었으므로 callback flow 가 사용 되었습니다.
또한 앞서 언급한 것처럼 repeatOnLifecycle
은 onDestroy 시점에 5번라인 중단지점에서 재개될 것이고 10번 라인의 close() 로 리소스를 정리하며 종료하게 됩니다.
그런데 위에서 이야기한 repeatOnLifecycle
과 flowWithLifecycle
은 종료 라이프사이클 — onPause, onStop — 에서 데이터 수집의 일시 정지가 아닌 취소를 하고 있습니다. 이것은 라이프사이클이 재개될 경우 다시 스트림을 생성하여 처음부터 데이터를 수신함을 의미합니다. 그래서 실시간으로 위치, 밝기 등의 센서 데이터를 읽는 경우에는 수집이 일시 정지 된 이후 재개되기 전까지 데이터는 필요 없기 때문에 이 방식이 적합하지만 수집의 시작부터 끝까지 데이터가 모두 필요한 경우에 이 방법은 적합하지 않습니다. 또한 해당 경우는 모든 데이터가 필요한 것이므로 리소스 낭비인 부분이라고 보기도 어렵습니다.
샘플 애플리케이션을 실행해보면 시스템 업데이트 이미지를 다운로드 하는 것을 묘사하는 프로그레스 바가 있습니다. 이것은 flowWithLifecycle
을 이용하여 다운로드 이벤트를 수집하고 있는데 홈으로 빠져나갔다가 다시 앱으로 돌아오게되면 다시 다운로드를 요청하고 0% 부터 시작하는 모습을 볼 수 있습니다. 우리가 원하는 모습은 아니지요.
(물론 다운로드는 ForegroundService 나 WorkManager 를 이용하겠지만 여기서는 예제를 위해 이렇게 구성해 보았습니다.)
지금까지 우리는 어떤 경우에 Cold Flow 의 리소스가 낭비될 수 있는지 알아보았습니다. 결론적으로 이제 launchWhenXXX 대신 항상 repeatOnLifecycle 이나 flowWithLifecycle 을 사용해야 한다는 것은 아니며 앞서 살펴보았던 것 처럼 경우에 따라 적절하게 선택하여 사용하면 되겠습니다.
새로 나올
repeatOnLifecycle
,flowWithLifecycle
은 Flow 수집을 일시 정지 하는 것이 아닌, 취소하고 재수집 한다는 것만 기억하신다면 선택 시 편리할 것입니다.
끝.