flatMapLatest를 어떻게 써먹을까요?

wonseok·2023년 4월 12일
0
post-thumbnail

flatMapLatest는 업스트림 플로우에서 발행된 데이터를 처리하는 도중 새로운 데이터가 발생될 경우, 변환하는 로직을 취소하고 최신의 데이터를 사용하여 변환을 수행합니다.

flatMapLatest의 정의만 글로 봐서는 이해가 잘 안갑니다.
말로 하는 것보다 코드를 보는 것이 더 이해가 편할 것입니다.

공식문서의 샘플 코드를 살펴보죠.

flow {
    emit("a")
    delay(100)
    emit("b")
}.flatMapLatest { value ->
    flow {
        emit(value)
        delay(200)
        emit(value + "_last")
    }
}

이 코드는 a b b_last 순서로 데이터를 뱉어냅니다.

a_last는 어디갔냐구요?

처음에 a 데이터가 발행되고 이를 새로운 플로우로 변환하는 과정을 진행하는 동안,
delay(200)이 발생합니다.
그 사이에 b라는 데이터가 새롭게! 발행됩니다.
그렇기 때문에 a_last가 발행되지 않고 취소되며, 최신의 데이터 변환 로직이 시작됩니다.

오케이. 이제야 flatMapLatest가 무슨 역할을 하는지는 이제 얼추 감이 잡혔는데, 그럼 안드로이드 프로젝트에서는 어떻게 써먹지?

예시를 한 번 들어봅시다.

저는 무한도전을 굉장히 좋아하기 때문에 무도 멤버들의 이름 검색을 통해 결과를 반환하는 간단한 앱을 만들어본다고 해봅시다.

간단하게 MemberRepository 클래스를 만들어줍니다.

class MemberRepository {
    private val allMembers = listOf("재석", "명수", "준하", "홍철", "형돈")

    suspend fun searchMembers(searchTerm: String): List<String> {
        delay(500) // 네트워크 대기 시간이라고 가정
        return allMembers.filter { it.contains(searchTerm, ignoreCase = true) }
    }
}

그다음 핵심부분인 MemberSearchViewModel 클래스를 만들어서 flatMapLatest를 잘 활용해봅시다.

class MemberSearchViewModel(private val memberRepository: MemberRepository) : ViewModel() {

    private val searchQueryFlow = MutableSharedFlow<String>(replay = 0)

    val searchResult = searchQueryFlow
        .debounce(300) // 과도한 네트워크 요청을 막기 위해 추가
        .flatMapLatest { searchTerm ->
            flow {
                if (searchTerm.isNotBlank()) {
                    val members = memberRepository.searchMembers(searchTerm)
                    emit(members)
                } else {
                    emit(emptyList<String>())
                }
            }
        }
        .catch { throwable ->
            // 예외처리하는 부분
            emit(emptyList<String>())
            Timber.e(throwable, "검색을 하는 과정에서 에러가 발생하였습니다.")
        }
        .asLiveData()

    fun onSearchQueryChanged(query: String) {
        searchQueryFlow.tryEmit(query)
    }
}

코드를 천천히 살펴봅시다.
먼저, 위에서 만들어준 MemberRepository 주입받습니다.
그 다음, MutableSharedFlowreplay=0을 사용하였는데, 이것은 새로운 구독자에게 이전의 이벤트를 전달하지 않음을 의미합니다.
또한 StateFlow를 사용하지 않고 MutableSharedFlow를 사용한 이유는 상태를 저장할 필요 없는 상황, 검색어를 다루는 이벤트-주도 시나리오에서는 더 적절하다고 판단했기 때문입니다.

다시말해, 검색어 자체는 상태를 나타내지 않고, 그들은 단지 검색 동작을 트리거하는 이벤트일 뿐이라고 생각했습니다.

그 다음 중요한 부분이 나옵니다.
사용자가 UI에서 무도 멤버 이름 검색어를 실시간으로 입력한다면?
아래의 onSearchQueryChanged 메서드를 통해 검색어 데이터가 String 타입으로 발행될 것입니다.

debounce 메서드는 디바운싱을 적용하기 위한 함수인데, 사용자가 재석이라는 검색어를 입력하기 위해 0.3초동안 가령 재ㅅ이라는 검색어를 입력했다고 가정해봅시다.

만약 디바운싱이 적용되지 않았더라면, 키입력 이벤트가 발생될 때마다onSearchQueryChanged 메서드를 통해 실시간으로 수많은 데이터들이 발행될 것이고, 이는 네트워크 리소스 낭비를 야기합니다.

그러나, 디바운싱 개념을 적용시킨다면 0.3초라는 타이머를 두고 그 안에 새로운 키입력이 들어오면 이전의 데이터 발행은 무시되고 0.3초가 지난 시점에서 마지막 데이터만이 발행이 됩니다.

쉽게 말하면

디바운싱 X -> 'ㅈ', 'ㅐ', 'ㅅ' (3번)
디바운싱 O -> '재ㅅ' (1번)

그 다음, 드디어 flatMapLatest를 통해 한 번 더 최적화를 진행해줍니다.

val members = memberRepository.searchMembers(searchTerm)

해당 코드는 네트워크 호출을 통해 검색 결과를 가져오는 것을 전제로 하고 있기 때문에 어느정도 딜레이되는 시간이 있을 수 있으며, 0.3초 이내에 이전의 검색결과를 가져오지 못했는데, 만약 새로운 데이터가 발행되었다?

그러면 이전의 검색 작업은 취소가 되고, 새로운 데이터로 검색 작업이 진행됩니다.

참고로, 검색 작업이 취소가 될 수 있는 이유는 searchMembers() 메서드가 코루틴 스코프 내에서 동작해야할 suspend fun이기 때문이겠죠.

Single Source Of Truth + 오프라인 우선 앱 아키텍처를 기반으로 설계했다고 해도 결국 캐싱된 데이터를 가져오는 데에 네트워크로부터 가져오는 것보다는 빠를지언정 어느정도 딜레이되는 시간은 분명히 존재하기에 제가 작성한 예시에서도 어느정도 유사하게 적용될 수 있다고 생각합니다.

마치며

저도 아직 부족한 점이 많습니다.
혹시라도 틀린 점이나 지적사항이 있다면 언제든지 댓글 남겨주시면 감사하겠습니다!

0개의 댓글