
Flow를 모르고 살다 공식 문서를 슬슬 찾아보며 Flow를 발견하게 되었습니다. Compose는 사용이 필연적이고 아직 미숙하여 XML로 작업하고 있지만 그래도 미래에 Compose로 작업하게 되었을 때 사용할 줄 알면 좋지 않을까? 싶어 공식 문서를 조금 읽고 적용을 해봤습니다.
Flow를 제대로 공부한 것이 아니기 때문에 지식에 오류가 있을 수 있으니 살짝만 참고하시거나 많은 비판 해주시면 감사하겠습니다!
Flow는 코루틴을 기반으로 비동기적 데이터 스트림을 다루기 위해 사용합니다. 데이터 스트림이란 뭘까요?
stream 흐름, 즉 어떤 데이터의 흐름입니다. 비동기적 데이터 스트림이란 시간에 따라 연속적으로 발생하는 데이터의 흐름을 다루는 개념입니다.
이러한 데이터 흐름을 다루기 위해 Flow를 사용하며, 라이브러리를 제공해 데이터를 생산, 소비, 제어하는 데 도움을 줍니다.
아래는 Flow를 사용한 kotlin 예제 코드입니다.
fun simpleFlow(): Flow<Int> = flow {
for (i in 1..3) {
delay(100) // 비동기적으로 데이터를 생성하는 상황을 가정
emit(i) // 데이터를 emit하여 Flow로 내보냄
}
}
fun main() {
// Flow를 collect하여 데이터를 비동기적으로 처리
runBlocking {
simpleFlow()
.collect { value ->
println("Received: $value")
}
}
}
저또한 그러하듯 처음 보았을 때는 생소한 코드였습니다. emit? collect?
emit은 데이터를 생산합니다. 즉 데이터를 내보내는 작업을 합니다.
Flow 블록 내에서 호출되며, 데이터를 생산한 후 Flow에게 데이터를 전달합니다. Flow는 데이터를 구도학고 있는 모든 Subscriber에게 데이터를 전달합니다.
collec는 데이터를 수신하는 데 사용합니다. Flow를 구독하고 있는 Subscriber가 데이터를 받을 때 호출됩니다. collect 함수는 blocking 되지 않으며 데이터가 도착할 때마다 호출됩니다.
두 함수 모두 suspend 함수 내에서 호출된다는 특징이 있습니다.
이 코드는 1부터 3까지의 숫자를 100밀리초 간격으로 emit하는 간단한 Flow를 만들고 collect 함수를 사용해 비동기적으로 데이터를 처리하고 있습니다.
LiveData를 사용해서 데이터를 다루다 Flow를 하나 하나 적용해 보았던 경험을 공유하고자 합니다.
User의 정보를 조회하는 API를 예시로 설명하겠습니다.
// Service
@GET("/my-page")
suspend fun getUserInfo(): UserInfoResponse
// Repository
suspend fun getUserInfo(): UserInfoResponse
// RepositoryImpl
override suspend fun getUserInfo(): UserInfoResponse = dataSource.getUserInfo()
// DataSource
suspend fun getUserInfo(): UserInfoResponse {
val response = UserInfoResponse(...)
withContext(Dispatchers.IO) {
runCatching {
coHausService.getUserInfo()
}.onSuccess {
response = it
}.onFailure {
Log.e(...)
}
}
}
기존 User의 정보 조회 API를 다루기 위해 작성했던 코드입니다. Hilt를 통해 의존성을 주입했으며, DataSource 메서드 내 코드 라인이 response와 예외 처리까지 합쳐져 코드 라인이 불필요하게 늘어나는 느낌을 받았습니다.
이번엔 Flow를 사용한 코드를 보겠습니다.
@GET("/my-page")
suspend fun getUserInfo(): UserInfoResponse
// Repository
suspend fun getUserInfo(): Flow<UserInfoResponse>
// RepositoryImpl
override suspend fun getUserInfo(): Flow<UserInfoResponse> = dataSource.getUserInfo()
// DataSource
suspend fun getUserInfo(): Flow<UserInfoResponse> = flow {
val response = coHousService.getUserInfo()
emit(response)
// 예외 처리 또한 try-catch 문을 사용하지 않고 바로 처리 가능
}.catch {
Log.e(TAG, "Get User Info Failure: ${it.message.toString()}")
}
주요 특징을 살펴보겠습니다. 먼저 Repository, RepositoryImpl, DataSource까지의 반환 타입을 Flow 타입으로 명시하였습니다.
DataSource 코드를 보면 flow 블록으로 함수 본문을 감싸주었습니다. 이후 네트워크 통신을 통한 Response를 emit합니다.
예외 처리 또한 아래 코드를 통해 Flow의 확장 함수로 선언되어 있는 것을 알 수 있습니다.
public fun <T> kotlinx.coroutines.flow.Flow<T>.catch(action: suspend kotlinx.coroutines.flow.FlowCollector<T>.(kotlin.Throwable) -> kotlin.Unit): kotlinx.coroutines.flow.Flow<T> { /* compiled code */ }
이제 ViewModel 코드를 살펴보겠습니다. LiveData를 사용하였을 때와 마찬가지로 MutableState, State 타입의 변수를 선언합니다.
Android에선 LiveData를 대체하기 위해 StateFlow를 제공합니다.
// ViewModel
// Flow는 인스턴스 생성 시 초깃값이 필요함.
private val _userInfo = MutableStateFlow(UserInfoDto())
val userInfo: StateFlow<UserInfoDto> = _userInfo
// LiveData
private val _savedRecords = MutableStateFlow<List<RecordItem>>(emptyList())
val savedRecords: StateFlow<List<RecordItem>> = _savedRecords
StateFlow는 현재 상태와 새로운 상태 업데이트를 subscriber에 내보내는 관찰 가능한 상태 홀더 Flow입니다. LveData와 마찬가지로 value 속성을 통해 현재 상태 값을 읽을 수 있습니다.
상태를 업데이트하고 Flow에 전송하려면 MutableStateFlow 클래스의 value 속성에 새 값을 할당합니다.
StateFlow는 LiveData와 비슷한데 둘 다 관찰 가능한 데이터 홀더 클래스이며, 아키텍처에서 사용할 때 비슷한 패턴을 따릅니다. 그러나 LiveDatad와 다르게 작동합니다.
// ViewModel
fun getUserInfo() {
viewModelScope.launch {
try {
userMyPageRepository.getUserInfo().collect {
_userInfo.value = it.data
}
} catch (e: Exception) {
Log.e("Get User Info Error", "Error: ${e.message.toString()}")
}
}
}
getUserInfo()는 Flow를 반환하기에 collect 함수를 통해 MutableStateFlow의 value에 값을 할당합니다.
// Fragment
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
viewModel.userInfo.collectLatest {
withContext(Dispatchers.Main) {
binding.userDto = it
binding.isLoading = false
}
}
}
}
// LiveData
viewModel.userInfo.observe(viewLifecycleOwner) {
binding.userInfo = it
}
Fragment의 코드입니다. LiveData는 observe가 데이터의 변경을 관찰하고 처리합니다. 생명 주기를 인식하여 데이터 변경을 관리하고, Fragment, Activity가 활성 상태일 때만 데이터 변경을 전달합니다.
Flow는 이와 다릅니다.
먼저 lifecycleScople.launch { ... } 를 통해 비동기 작업을 수행할 수 있는 코루틴을 시작합니다.
repeatOnLifeCycle은 지정된 생명 주기 상태에서 작업을 반복하도록 도와줍니다. 위 코드에선 Lifecycle.State.CREATED에 의해 생명 주기 상태가 Created 상태에서만 코드 블록이 실행되도록 설정되어 있습니다.
withContext 블럭은 UI 업데이트는 메인 스레드에서 이루어져야 하기에 따로 작성하였습니다.
마지막으로 observe 블록 대신 collectLatest 블록으로 감싸 값을 수집합니다. Fragment XML의 userDto 바인딩 객체에 할당해 주었습니다.
LiveData와 비교하여 Flow를 사용하였을 때 느낀 저의 생각을 서술하겠습니다.
Fragment의 코드가 상당히 복잡한 느낌을 받았습니다. 그러나 코드가 순차적이며 가독성이 높다는 생각이 들었습니다.
이 코드에선 사용하지 않았지만 Flow는 여러 operators를 제공합니다. map, filter, combin 등 다양한 operator를 사용하여 데이터를 처리할 수 있습니다.
또한 Flow는 특정 생명 주기에 대응하는 데 유용했습니다.
LiveData와 달리 생명 주기를 인식하지 못한다는 단점이 있었지만 큰 불편은 아니었습니다. LiveData와 함께 사용하는 방식도 있다고 하나 Flow 만을 사용하여 개발했습니다.
이것으로 저의 Flow 적용기를 마무리하겠습니다!
참고 문서