
검색창이 빠르게 반응할수록 결과는 더 늦게 도착합니다. 응답 순서가 꼬여서 한 박자 늦은 결과가 화면을 덮어버리지요.
작년에 사이드 프로젝트 검색 화면을 새로 짤 때 이 함정에 정통으로 박혔습니다. 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 안에 깔리는 파이프라인은 결국 네 개 연산자 조합으로 정리됩니다.
debounce(300)— 타이핑이 멈춘 시점만 잡기filter { it.length >= 2 }— 빈 문자열·한 글자는 차단distinctUntilChanged()—ab → abc → ab같은 왕복 입력 무시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 라고 외워두면 마이그레이션이 거의 끝나요. debounce 와 distinctUntilChanged 는 이름이 같고 동작도 같으니까 그대로 옮기시면 됩니다.
월요일 아침에 검색창 관련 슬랙 알림이 한 건도 안 와 있는 화면, 그게 이 파이프라인을 깔고 나서 제일 먼저 바뀌는 풍경이에요. 디버깅하느라 새벽에 깨던 사이클이 사라지고, "검색 결과가 이상하게 나와요" 라는 메시지 자체가 백로그에서 빠지는 날이 옵니다.

'Android 개발 > Kotlin' 카테고리의 다른 글
| @JvmField 어디 붙이지? Kotlin 2.4 use-site target 정리 (0) | 2026.07.16 |
|---|---|
| 코틀린 2.4에서 Swift 패키지를 의존성으로 바로 먹였더니 — 실측 기록 (0) | 2026.07.15 |
| 직렬화 라이브러리 3개를 kotlinx.serialization 한 곳으로 정리한 후기 (0) | 2026.07.14 |
| 코틀린 sealed Result 패턴으로 try/catch 좀비를 잡은 3주 (0) | 2026.07.07 |
| Kotlin 2.4 field 키워드로 ViewModel _state/state 보일러플레이트 한 줄로 줄이기 (0) | 2026.06.27 |