Published on

Kotlin Flow에서 mapLatest vs flatMapLatest 선택 기준

Authors

서로 비슷해 보이지만 mapLatestflatMapLatest"무엇을 반환하느냐"(값 vs Flow), 그리고 "취소되는 범위"(변환 블록 vs 내부 Flow 전체) 에서 결정적으로 다릅니다. 이 차이를 이해하면 검색 자동완성, 필터 변경에 따른 API 재호출, DB 스트림 전환 같은 케이스에서 코드가 훨씬 단단해집니다.

아래에서는 개념을 짧게 정리한 다음, 실전에서 흔히 겪는 함정과 선택 기준을 코드로 설명합니다.

한 줄 정의

  • mapLatest: 업스트림에서 새 값이 오면 이전 변환 블록을 취소하고, 최신 값에 대한 변환 결과(단일 값) 만 downstream 으로 보냄
  • flatMapLatest: 업스트림에서 새 값이 오면 이전 내부 Flow 전체를 취소하고, 최신 값이 만든 Flow 를 구독해서 그 안에서 나오는 값을 downstream 으로 보냄

즉,

  • 변환 결과가 R 이면 mapLatest
  • 변환 결과가 Flow<R> 이면 flatMapLatest

이게 기본 원칙입니다. 다만 실전에서는 네트워크, 캐시, 디바운스, 로딩 상태, 예외 처리 때문에 더 섬세한 판단이 필요합니다.

타입 시그니처로 보는 차이

public fun <T, R> Flow<T>.mapLatest(
    transform: suspend (value: T) -> R
): Flow<R>

public fun <T, R> Flow<T>.flatMapLatest(
    transform: suspend (value: T) -> Flow<R>
): Flow<R>

핵심은 transform 의 반환 타입입니다.

  • mapLatestsuspend 변환을 수행해 값 하나를 만든다
  • flatMapLatest새로운 Flow 를 만들어 구독한다

여기서 취소 의미가 달라집니다.

취소 의미: 무엇이 취소되는가

mapLatest 의 취소 범위

mapLatest변환 블록 실행 중이면 취소됩니다.

예를 들어 변환 블록 안에서 delay 나 네트워크 호출 같은 suspend 작업을 하면, 새 값이 들어오는 순간 이전 블록이 취소됩니다.

val queryFlow: Flow<String> = ...

val resultFlow: Flow<List<String>> = queryFlow
    .mapLatest { q ->
        delay(300) // 타이핑 중엔 이전 변환이 취소됨
        api.search(q) // suspend 함수라고 가정
    }

이 코드는 "최신 쿼리만 검색"이라는 의도에는 맞지만, 검색 결과를 스트리밍으로 받거나 페이지네이션으로 여러 값을 흘려보내야 하는 경우에는 한계가 있습니다. 왜냐하면 mapLatest 는 결과를 "한 번"만 내보내는 구조이기 때문입니다.

flatMapLatest 의 취소 범위

flatMapLatest 는 이전에 만들어진 내부 Flow 구독 자체를 취소합니다.

val queryFlow: Flow<String> = ...

val resultFlow: Flow<SearchEvent> = queryFlow
    .flatMapLatest { q ->
        api.searchAsFlow(q) // Flow<SearchEvent>
    }

api.searchAsFlow(q) 가 서버 스트리밍, DB observe, 혹은 페이지네이션 이벤트를 계속 내보내는 Flow 라면, 쿼리가 바뀌는 순간 이전 스트림을 끊고 최신 스트림으로 갈아탑니다.

선택 기준 1: 결과가 단발 값이면 mapLatest

다음 조건이면 mapLatest 가 더 단순하고 명확합니다.

  • 변환 결과가 단일 값이다
  • 변환 과정은 suspend 일 수 있다
  • 중간에 여러 번 emit 할 필요가 없다

예: 입력값에 대한 단일 API 호출 결과

val uiState: StateFlow<UiState> = queryFlow
    .debounce(250)
    .distinctUntilChanged()
    .mapLatest { q ->
        if (q.isBlank()) return@mapLatest UiState.Empty
        UiState.Success(api.search(q))
    }
    .catch { e -> emit(UiState.Error(e)) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Empty)

여기서 중요한 포인트는 catch 위치입니다.

  • mapLatest 뒤에 catch 를 두면 변환 블록에서 발생한 예외를 잡아낼 수 있습니다.

선택 기준 2: 결과가 스트림이면 flatMapLatest

다음 조건이면 flatMapLatest 가 정답인 경우가 많습니다.

  • 변환 결과가 Flow
  • 한 입력에 대해 여러 이벤트를 emit 해야 한다
  • 입력이 바뀌면 이전 스트림을 끊고 최신 스트림만 유지해야 한다

예: 서버에서 진행률 이벤트를 스트리밍으로 받는 다운로드

val urlFlow: Flow<String> = ...

val downloadEvents: Flow<DownloadEvent> = urlFlow
    .distinctUntilChanged()
    .flatMapLatest { url ->
        downloader.download(url) // Flow<DownloadEvent>
            .onStart { emit(DownloadEvent.Started(url)) }
            .catch { e -> emit(DownloadEvent.Failed(url, e)) }
    }

여기서 내부 Flow 에 catch 를 두는 이유는, flatMapLatest 바깥의 catch 는 "업스트림" 예외만 잡고, 내부 Flow 안에서 난 예외는 그 Flow 안에서 처리하는 편이 의도가 선명하기 때문입니다.

자주 하는 실수: mapLatestFlow 를 만들고 flattenLatest 를 까먹기

가끔 이런 코드가 나옵니다.

val flowOfFlow: Flow<Flow<Item>> = queryFlow
    .mapLatest { q -> repository.observeItems(q) } // Flow<Item> 를 반환

이 상태는 downstream 이 Flow<Item> 자체를 받게 되어 원하는 동작이 아닙니다. 이럴 땐 아래 둘 중 하나를 택해야 합니다.

  • 그냥 flatMapLatest 로 바꾸기
  • 혹은 mapLatest { ... }.flattenLatest() 사용

정답은 보통 flatMapLatest 입니다.

val items: Flow<Item> = queryFlow
    .flatMapLatest { q -> repository.observeItems(q) }

로딩 상태 표현: mapLatestflatMapLatest 모두 가능하지만 방식이 다름

UI 에서 흔히 필요한 패턴이 "로딩-성공-실패" 상태입니다.

mapLatest 로 로딩 상태 만들기

mapLatest 는 단일 값을 내보내므로, 로딩 상태를 내보내려면 mapLatest 자체로는 부족하고 transformLatest 같은 연산이 더 자연스럽습니다.

하지만 mapLatest 만으로 구현하고 싶다면, 보통은 onStart 를 조합하거나 flow { emit(...) } 형태로 감쌉니다.

val state: Flow<UiState> = queryFlow
    .debounce(250)
    .distinctUntilChanged()
    .flatMapLatest { q ->
        flow {
            emit(UiState.Loading)
            val data = api.search(q)
            emit(UiState.Success(data))
        }.catch { e -> emit(UiState.Error(e)) }
    }

여기서는 결과적으로 flatMapLatest 를 쓰게 되는데, 이유는 "로딩도 하나의 이벤트"로 emit 해야 해서 단일 값 변환보다 스트림이 더 편하기 때문입니다.

flatMapLatest 로 로딩 상태 만들기

위 예제처럼 내부에서 flow { ... } 를 만들어 Loading 을 먼저 emit 하고 실제 작업 후 Success 를 emit 하면 됩니다.

이 패턴은 검색, 필터 변경, 탭 전환 등 "입력이 바뀔 때마다 이전 작업을 취소"해야 하는 UI 에서 특히 강력합니다.

성능과 리소스 관점: 취소가 곧 비용 절감은 아니다

mapLatestflatMapLatest 는 모두 취소를 사용하지만, 취소가 실제로 서버 비용을 줄이는지는 별개입니다.

  • 클라이언트 코루틴 취소가 됐다고 해서 이미 날아간 HTTP 요청이 서버에서 중단되는 것은 아닐 수 있습니다.
  • OkHttp 같은 클라이언트는 코루틴 취소를 요청 취소로 연결할 수 있지만, 구현에 따라 다릅니다.

따라서 "타이핑 중 API 폭주"를 막고 싶다면 취소만 믿지 말고 다음을 같이 적용하세요.

  • debounce
  • distinctUntilChanged
  • 필요하면 서버 측 rate limit 또는 캐싱

이런 관점은 외부 API 호출 안정화와도 연결됩니다. 대규모 호출에서 재시도와 백오프 전략이 필요하다면 OpenAI 429/RateLimitError 재시도·백오프·큐 설계 같은 글의 설계 포인트가 그대로 참고됩니다.

실전 패턴 1: 검색 자동완성은 보통 mapLatest 또는 flatMapLatest

단발 응답이면 mapLatest

val suggestions: Flow<List<String>> = queryFlow
    .debounce(200)
    .mapLatest { q ->
        if (q.length < 2) emptyList() else api.suggest(q)
    }

스트리밍 응답이면 flatMapLatest

서버가 SSE, gRPC streaming 등으로 토큰 단위 이벤트를 주는 구조라면 flatMapLatest 가 맞습니다.

val suggestions: Flow<SuggestEvent> = queryFlow
    .debounce(200)
    .flatMapLatest { q ->
        if (q.length < 2) flowOf(SuggestEvent.Empty)
        else api.suggestStream(q) // Flow<SuggestEvent>
    }

실전 패턴 2: DB observe 전환은 거의 항상 flatMapLatest

Room 이나 DataStore 처럼 observeXxx()Flow 를 반환하는 경우가 많습니다.

예: 선택된 사용자 id 가 바뀌면 해당 사용자 스트림으로 전환

val selectedUserId: StateFlow<Long> = ...

val user: Flow<User> = selectedUserId
    .filter { it > 0 }
    .distinctUntilChanged()
    .flatMapLatest { id -> userDao.observeUser(id) }

이 케이스를 mapLatest 로 하면 Flow<User> 를 값으로 받게 되어 downstream 에서 한 번 더 펼쳐야 합니다.

실전 패턴 3: flatMapLatest 내부에서 병렬 작업을 섞을 때

flatMapLatest 는 "전환"에 강하지만, 내부에서 여러 작업을 섞다 보면 구조가 복잡해질 수 있습니다. 이때는 내부 Flow 를 작은 함수로 분리하면 유지보수가 쉬워집니다.

fun searchStateFlow(query: String): Flow<UiState> = flow {
    emit(UiState.Loading)
    val local = cache.search(query)
    if (local.isNotEmpty()) emit(UiState.Success(local))

    val remote = api.search(query)
    emit(UiState.Success(remote))
}

val state: Flow<UiState> = queryFlow
    .debounce(250)
    .distinctUntilChanged()
    .flatMapLatest { q ->
        if (q.isBlank()) flowOf(UiState.Empty) else searchStateFlow(q)
    }
    .catch { e -> emit(UiState.Error(e)) }

디버깅 팁: onEach 로 취소 여부를 관찰하기

취소가 제대로 되는지 확인하려면 로그를 다음처럼 넣어보면 좋습니다.

val flow = queryFlow
    .onEach { q -> println("upstream: $q") }
    .mapLatest { q ->
        try {
            println("start transform: $q")
            delay(500)
            println("end transform: $q")
            q.uppercase()
        } finally {
            println("finally (cancelled or completed): $q")
        }
    }

새 값이 빨리 들어오면 이전 변환의 finally 가 찍히면서 취소가 발생했음을 확인할 수 있습니다.

선택 체크리스트

아래 질문에 답하면 대부분 결정됩니다.

  1. 변환 결과가 Flow 인가
    • 그렇다면 flatMapLatest
  2. 한 입력에 대해 여러 번 emit 해야 하는가
    • 그렇다면 flatMapLatest
  3. 단일 결과만 필요하고, 새 입력이 오면 이전 계산을 버리면 되는가
    • 그렇다면 mapLatest
  4. 로딩 상태 같은 중간 이벤트가 필요한가
    • flatMapLatest + flow { emit(...) } 패턴이 보통 더 깔끔
  5. 취소가 서버 비용 절감으로 이어지는가
    • debounce, distinctUntilChanged, 캐시, 재시도 정책을 같이 고려

결론

mapLatest 는 "최신 값에 대한 단일 결과"를 만들 때 가장 단순하고, flatMapLatest 는 "최신 값이 만들어내는 스트림"으로 전환할 때 필수입니다. 특히 DB observe, 스트리밍 API, 로딩 상태를 이벤트로 내보내는 UI 상태 머신에서는 flatMapLatest 가 구조적으로 유리합니다.

마지막으로, 최신만 유지하는 패턴은 네트워크 안정화와도 연결됩니다. 요청이 잦은 기능을 운영 환경에 올릴 때는 재시도 및 백오프 같은 호출 제어도 함께 설계하는 것이 좋습니다. 관련해서는 OpenAI 429/RateLimitError 재시도·백오프·큐 설계 글의 패턴을 일반 API 호출에도 응용할 수 있습니다.