Android 개발/Kotlin

타이핑마다 API 때리던 검색창, Kotlin Flow 연산자 4개로 살린 이야기

stackD 2026. 7. 29. 18:00
반응형

 

검색창이 빠르게 반응할수록 결과는 더 늦게 도착합니다. 응답 순서가 꼬여서 한 박자 늦은 결과가 화면을 덮어버리지요.

 

작년에 사이드 프로젝트 검색 화면을 새로 짤 때 이 함정에 정통으로 박혔습니다. addTextChangedListener 안에서 바로 searchApi.query(text) 를 호출했는데요. "abc" 를 빠르게 치면 a, ab, abc 세 번이 동시에 날아가고, 가장 느린 "a" 응답이 마지막에 도착해서 abc 결과를 덮어쓰는 황당한 화면이 만들어지더라고요.

 

이걸 Handler.postDelayed 로 막아보려다 코드가 점점 누더기가 됐어요. 결국 클로드 코드(Claude Code) 한테 "이 흐름 Flow 로 다시 짜줘" 라고 던졌더니 나온 파이프라인이 지금 제가 표준으로 쓰는 형태입니다.

 

자동완성에서 늘 터지는 Stale Result 문제

debounce 한 줄로 해결되지 않는 게 이 버그의 핵심이에요. 디바운스를 걸어도 사용자가 잠깐 멈춘 사이 요청 두세 개는 무조건 빠져나갑니다. 와이파이 신호가 들쑥날쑥하거나 백엔드 응답 시간이 일정하지 않은 환경에선 이전 요청 응답이 더 늦게 돌아오는 일이 자주 생깁니다.

 

Stale Result(이전 응답이 늦게 도착해 새 결과를 덮어쓰는 현상)라고 부르는 이 문제는 Manuel Vivo 같은 Android Developer Relations 출신이 쓴 글들에서도 자동완성 시나리오 해법으로 flatMapLatest 가 자주 언급되는 패턴이에요. 해결책 자체는 단순합니다. 새 쿼리가 들어오면 진행 중이던 이전 요청을 코루틴 수준에서 취소해버리는 거지요.

 

 

Kotlin Flow 자동완성 파이프라인 — 연산자 4개 조합

ViewModel 안에 깔리는 파이프라인은 결국 네 개 연산자 조합으로 정리됩니다.

 

  1. debounce(300) — 타이핑이 멈춘 시점만 잡기
  2. filter { it.length >= 2 } — 빈 문자열·한 글자는 차단
  3. distinctUntilChanged()ab → abc → ab 같은 왕복 입력 무시
  4. flatMapLatest { ... } — 새 쿼리 들어오면 이전 API 호출 자동 취소

 

이 중 진짜 무게중심은 4번이에요. 앞의 셋은 호출 횟수를 줄여주는 최적화고, 4번이 Stale Result 를 근본에서 막아주는 자물쇠입니다.

 

private val _query = MutableStateFlow("")

val searchResults: StateFlow<UiState> = _query
    .debounce(300)
    .filter { it.length >= 2 }
    .distinctUntilChanged()
    .flatMapLatest { query ->
        flow {
            emit(UiState.Loading)
            runCatching { searchRepository.getResults(query) }
                .onSuccess { emit(UiState.Success(it)) }
                .onFailure { emit(UiState.Error(it.message ?: "검색 실패")) }
        }
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Idle
    )

fun onQueryChanged(text: String) {
    _query.value = text
}

 

flow { ... } 블록 안에서 emit(UiState.Loading) 부터 흘려보내면 로딩 스피너 처리까지 같은 스트림에서 끝납니다. 성공·실패는 runCatching 으로 묶어 둘 다 UiState 로 환원되고요. UI 입장에선 StateFlow 하나만 보고 있으면 되니까 화면 코드가 깔끔해져요.

 

 

한글 IME 환경에서 debounce 와 distinctUntilChanged 함정

300ms 라는 숫자는 사실 영문권 샘플 코드에서 굳어진 기본값에 가깝습니다. 영문 위주면 충분한데, 한글 자모 조합 중간 단계가 다 emit 되는 환경에선 살짝 짧게 느껴질 수 있어요. 제 체감으론 한국어 검색은 400~500ms 가 더 자연스럽게 잡힙니다. "안" → "안ㄴ" → "안녕" 식으로 조합 중간 글자가 한 번씩 튀어나오는데, 디바운스가 짧으면 그 중간 조합으로 요청이 빠져나가버리지요.

 

distinctUntilChanged() 도 함정이 하나 있는데요, 비교 대상이 단순 String 이면 문제가 없지만 equals 가 제대로 구현 안 된 데이터 객체를 흘릴 경우 참조 비교로 떨어져서 의도와 다르게 매번 통과돼버리는 일이 생깁니다. 커스텀 객체로 비교할 일이 있으면 distinctUntilChangedBy { it.key } 처럼 키를 명시하는 게 안전합니다.

 

Jetpack Compose 에서 snapshotFlow 로 자연스럽게 연결하기

젯팩 컴포즈(Jetpack Compose) 환경이면 TextField 의 실시간 입력 상태와 ViewModel 에 흘릴 "확정된 쿼리" 를 분리하는 게 권장 방식이에요. 입력 한 글자마다 StateFlow 를 두드리면 불필요한 리컴포지션이 따라붙습니다.

 

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    var text by rememberSaveable { mutableStateOf("") }
    val results by viewModel.searchResults.collectAsStateWithLifecycle()

    TextField(
        value = text,
        onValueChange = {
            text = it
            viewModel.onQueryChanged(it)
        }
    )
    // results 로 UI 렌더링
}

 

onValueChange 콜백에서 text 갱신과 viewModel.onQueryChanged(it) 호출을 같이 처리하는 구조예요. 디바운스 책임은 ViewModel Flow 파이프라인 한 곳에서만 집니다. 컴포저블 쪽에서 한 번, ViewModel 쪽에서 또 한 번 거는 이중 디바운스를 가끔 보는데 그건 추천하지 않아요. 둘 중 한 곳에만 두는 게 디버깅하기도 쉽고, 디바운스 값을 바꿀 때 한 군데만 손대면 되니까 유지보수도 편합니다.

 

flatMapLatest 와 형제들 — 자동완성에 맞는 선택

flatMap 계열은 이름이 비슷해서 잘못 끼워 넣으면 조용히 다른 버그를 만들어요. 자동완성 글이라면 이 세 가지 구분만 머리에 박혀 있으면 됩니다.

 

  • flatMapLatest — 새 값 오면 이전 작업 취소. 자동완성·검색에 정답.
  • flatMapConcat — 이전 작업이 끝날 때까지 대기. 순서 보장이 중요한 결제·트랜잭션용.
  • flatMapMerge동시 실행 후 도착 순서대로 emit. 백그라운드 동시 다운로드 같은 자리용.

 

자동완성에 flatMapConcat 을 끼우면 "a" 응답이 끝나야 "ab" 가 나가서 체감 지연이 누적되고, flatMapMerge 를 쓰면 Stale Result 가 그대로 살아나요. 마지막 입력만 의미 있는 시나리오엔 flatMapLatest 하나로 정리하시는 게 깔끔합니다.

 

RxJava 에서 넘어오신 분들은 switchMap 자리가 flatMapLatest 라고 외워두면 마이그레이션이 거의 끝나요. debouncedistinctUntilChanged 는 이름이 같고 동작도 같으니까 그대로 옮기시면 됩니다.

 


월요일 아침에 검색창 관련 슬랙 알림이 한 건도 안 와 있는 화면, 그게 이 파이프라인을 깔고 나서 제일 먼저 바뀌는 풍경이에요. 디버깅하느라 새벽에 깨던 사이클이 사라지고, "검색 결과가 이상하게 나와요" 라는 메시지 자체가 백로그에서 빠지는 날이 옵니다.

 

 

반응형
LIST