|

collectLatest vs collect: 언제 취소할까

collectLatest와 collect 차이와 안드로이드 Flow 취소 기준을 설명하는 대표 이미지
collectLatest와 collect를 언제 나눠 써야 하는지 실무 기준으로 정리한다

collectLatest vs collect는 안드로이드 Flow를 쓰다 보면 꼭 한 번 헷갈리는 주제입니다. 결론부터 말하면, 새 값이 오면 이전 처리 결과가 더 이상 의미 없을 때는 collectLatest가 잘 맞고, 모든 값을 끝까지 처리해야 할 때는 collect가 더 안전합니다.


collectLatest는 무엇을 취소할까

Kotlin 공식 문서가 설명하는 핵심은 단순합니다. 새 값이 emit되면, 이전 값을 처리하던 action 블록을 취소한다. 그래서 collect와 collectLatest는 같은 수집처럼 보여도 실행 흐름이 다릅니다.

flow {
    emit(1)
    delay(50)
    emit(2)
}.collect { value ->
    println("start $value")
    delay(100)
    println("done $value")
}

이 코드는 보통 1을 끝까지 처리한 뒤 2를 처리합니다. 즉, 순서는 1 처리 완료 → 2 처리입니다.

flow {
    emit(1)
    delay(50)
    emit(2)
}.collectLatest { value ->
    println("start $value")
    delay(100)
    println("done $value")
}

반면 collectLatest는 1을 처리하던 중 2가 오면 1 처리 블록을 취소합니다. 그래서 결과는 보통 start 1 → start 2 → done 2처럼 흘러갑니다. collectLatest는 이전 값을 무시하는 연산자가 아니라, 이전 값을 처리하던 작업을 취소하는 연산자에 가깝습니다.


언제 collectLatest를 쓸까

기준은 생각보다 명확합니다. 이전 결과를 끝까지 계산해도 사용자에게 보여줄 가치가 거의 없을 때입니다.

  • 검색창에 글자를 계속 입력하는 동안 이전 검색 결과는 금방 낡아지는 경우
  • 빠르게 바뀌는 상태를 기준으로 화면을 다시 그리는 경우
  • 같은 대상의 이전 애니메이션이나 렌더링을 끝까지 볼 이유가 없는 경우

이런 상황에서는 최신 값만 남기고 이전 처리를 끊는 것이 오히려 자연스럽습니다.


검색 UI에서는 왜 잘 맞을까

검색 화면이 대표적인 이유는 아주 단순합니다. 사용자가 a를 입력한 직후 ab, abc를 계속 입력하면, 대부분의 경우 화면에서 중요한 것은 마지막 검색어입니다. 이때 예전 결과를 순서대로 다 반영하면 화면이 늦게 흔들리거나, 늦게 끝난 과거 결과가 최신 결과를 덮어쓰는 문제가 생길 수 있습니다.

class SearchViewModel(
    private val repository: SearchRepository
) : ViewModel() {

    private val query = MutableStateFlow("")
    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState

    init {
        viewModelScope.launch {
            query
                .debounce(300)
                .distinctUntilChanged()
                .collectLatest { latestQuery ->
                    if (latestQuery.isBlank()) {
                        _uiState.value = SearchUiState()
                        return@collectLatest
                    }

                    _uiState.value = _uiState.value.copy(
                        query = latestQuery,
                        isLoading = true,
                        errorMessage = null
                    )

                    val items = repository.search(latestQuery)

                    _uiState.value = _uiState.value.copy(
                        isLoading = false,
                        items = items
                    )
                }
        }
    }

    fun onQueryChanged(value: String) {
        query.value = value
    }
}

이 코드는 새 검색어가 들어오면 이전 검색 처리를 끊고 최신 검색어 기준으로 다시 시작합니다. 검색처럼 이전 결과가 금방 쓸모없어지는 화면에서는 아주 잘 맞습니다.


가장 많이 헷갈리는 지점

많은 경우 collectLatest를 쓰면 검색 요청 자체가 언제나 내가 원하는 지점에서 취소된다고 생각하기 쉽습니다. 하지만 조금 더 정확하게 말하면, collectLatest가 직접 취소하는 것은 collector action 블록입니다. 즉, 취소가 어디에 걸리는지는 어떤 작업을 그 블록 안에 넣었는가에 따라 달라집니다.

예를 들어 위 코드처럼 repository.search()가 collectLatest 블록 안에 있으면 새 검색어가 왔을 때 이전 검색 suspend 작업도 함께 취소되기 쉽습니다. 반대로 업스트림에서 이미 비싼 연산을 끝내고 값을 emit한 뒤 아래에서만 collectLatest를 쓰면, 취소되는 것은 주로 마지막 소비 단계입니다.

  • 검색 요청 자체를 취소하고 싶은가
  • 이미 나온 결과를 화면에 반영하는 일만 취소하고 싶은가

이 차이를 놓치면 collectLatest를 붙였는데도 기대만큼 가벼워지지 않았다고 느끼게 됩니다.


UI 렌더링에서는 어떻게 볼까

이번에는 검색 요청이 아니라 화면 반영 쪽으로 생각해보겠습니다. 큰 리스트 diff 계산, 복잡한 이미지 전처리, 긴 애니메이션처럼 emission마다 무거운 후처리가 있다면 새 상태가 들어왔는데도 이전 렌더링을 끝까지 고집하는 것이 오히려 비효율적일 수 있습니다.

그럴 때는 collectLatest가 꽤 자연스럽습니다. 핵심은 렌더링도 비용이 드는 작업이라는 점입니다. 최신 UI state만 중요하다면 이전 렌더링을 중단하고 새 상태를 먼저 반영하는 쪽이 사용자 입장에서 더 낫습니다.


Compose에서도 항상 필요할까

여기서는 오히려 아니라고 보는 편이 안전합니다. Compose에서 화면 상태를 그냥 읽어 그리는 경우라면, 보통은 collectAsStateWithLifecycle() 같은 방식으로 충분한 경우가 많습니다.

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    SearchContent(
        query = uiState.query,
        isLoading = uiState.isLoading,
        items = uiState.items,
        errorMessage = uiState.errorMessage
    )
}

이런 코드는 상태를 화면에 반영한다는 목적이 분명합니다. 즉, 상태 수집 자체를 위해 굳이 collectLatest를 직접 꺼내지 않아도 되는 경우가 많습니다. 반대로 Compose에서도 emission마다 side effect를 수행할 때는 collectLatest를 의식해야 할 수 있습니다. 예를 들어 새 이벤트가 오면 이전 스낵바 표시 흐름을 중단하고 싶을 때가 그렇습니다.


오히려 위험한 경우

모든 emission이 의미 있는 작업에는 collectLatest가 위험할 수 있습니다. 로그를 서버에 순서대로 보내야 하는 경우, 결제 이벤트를 하나도 빠뜨리면 안 되는 경우, 로컬 DB 저장을 각 값마다 끝까지 해야 하는 경우가 대표적입니다.

eventsFlow.collectLatest { event ->
    analytics.log(event)
    localCache.save(event)
}

이 코드는 새 이벤트가 빨리 들어오면 앞 이벤트 저장이 중간에 끊길 수 있습니다. 이런 경우는 오히려 아래처럼 collect가 더 맞습니다.

eventsFlow.collect { event ->
    analytics.log(event)
    localCache.save(event)
}

이전 작업이 낡았는지, 아니면 아직도 반드시 완료돼야 하는지 먼저 판단해야 합니다. 이 질문이 collect와 collectLatest를 나누는 진짜 기준입니다.


같이 봐야 할 두 가지

첫째는 생명주기입니다. collectLatest를 쓴다고 해서 생명주기 문제가 사라지지는 않습니다. 화면 수집이라면 여전히 repeatOnLifecycle 같은 lifecycle-aware 수집 패턴이 중요합니다.

둘째는 연산 위치입니다. 취소하고 싶은 것이 무엇인지에 따라 collectLatest만으로 충분할 수도 있고, 더 앞단에서 취소가 일어나도록 흐름을 다시 설계해야 할 수도 있습니다. 이번 글에서는 collectLatest 자체에 집중하지만, 실무에서는 어디를 취소할 것인가가 더 본질적입니다.


빠른 선택 기준

  • 새 값이 오면 이전 결과는 거의 의미가 없나 → collectLatest 쪽이 자연스럽다
  • 이전 처리를 끝까지 해도 사용자에게 이득이 거의 없나 → collectLatest 쪽이 자연스럽다
  • 모든 emission이 기록돼야 하나 → collect를 먼저 떠올리는 편이 안전하다
  • 중간 작업이 빠지면 데이터 정합성이 깨지나 → collect가 더 안전하다
  • 최신 값 하나만 남기면 오히려 버그가 되나 → collect가 더 안전하다

정리

collect는 들어온 값을 끝까지 처리하고, collectLatest는 새 값이 오면 이전 처리 작업을 취소하고 최신 값으로 넘어갑니다. 그래서 검색 UI, 최신 화면 렌더링, 이전 결과가 빠르게 낡아지는 작업에는 collectLatest가 잘 맞고, 로그 저장, 이벤트 누락 방지, 순서 보장이 중요한 처리에는 collect가 더 안전합니다.

관련 흐름으로는 repeatOnLifecycle vs launchWhenStarted, StateFlow와 SharedFlow 차이, 안드로이드 코루틴 기초 정리를 같이 보면 이해가 더 잘 이어집니다.

외부 기준으로는 Kotlin 공식 collectLatest API 문서Kotlin Flow 문서, Android architecture recommendations를 함께 보면 좋습니다.

마지막으로 한 문장만 기억하면 충분합니다. collectLatest는 최신 값만 사랑하는 연산자가 아니라, 이전 작업을 버려도 되는 상황에서 쓰는 취소 정책이다.

함께보면 좋은 글