Published on

Kotlin Flow에서 mapLatest vs flatMapLatest 선택 기준

Authors
Binance registration banner

서로 비슷해 보이지만 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 호출에도 응용할 수 있습니다.