Corouintes Flow의 debounce, throttleLast, throttleFirst

앱을 개발하다 보면 “이벤트가 너무 많이 들어오는” 순간이 종종 발생한다. 검색창에 글자 하나 입력할 때마다 네트워크 요청이 발생하면 서버 비용과 지연이 커지고, 버튼을 연타했는데 화면이 두 번 전환되면 버그가 된다.
이때 이벤트의 유량을 제어하는 대표적인 기법이 debounce(디바운스)와 throttle(스로틀)이다.
이 글을 통해 Kotlin Coroutines Flow 기준으로 debounce와 throttleFirst/throttleLast 개념을 분리해서 설명하고, 안드로이드 개발 시 쓸 수 있는 패턴과 코드를 정리하려 한다.
TL;DR
- debounce: 입력이 멈춘 뒤 일정 시간이 지나면 마지막 값을 방출한다. → 검색 자동완성, 필터 변경, 슬라이더 조절에 적합하다.
- throttleFirst: 주기(window) 시작 시점의 첫 이벤트만 즉시 처리하고 나머지는 무시한다. → 버튼 따닥 방지, 중복 네비게이션 방지에 적합하다.
- throttleLast(throttleLatest): 주기(window) 동안 들어온 값 중 마지막(최신) 값을 주기적으로 방출한다. → 진행 상태/스크롤/센서 등 “최신 상태만 필요”할 때 유용하다.
- Flow 표준 연산자에는 debounce는 존재하지만, throttleFirst는 기본 제공이 아니다. 반면 sample은 throttleLast와 유사한 역할을 한다.
1) 왜 필요한가: 이벤트 폭주와 UI/서버 비용
사용자 입력은 불규칙하고, 때로는 시스템 처리 속도보다 훨씬 빠르다. 모든 이벤트를 1:1로 처리하면 다음 문제가 발생한다.
- 서버 비용 증가: “안드로이드” 검색에서
ㅇ → 아 → 안 → 안드…매번 API 호출이 발생하는 상황이다. - UI 정합성 붕괴: 네비게이션/다이얼로그 호출이 중복되어 화면이 겹치거나 크래시가 발생한다.
- 성능 저하: 불필요한 리렌더링과 연산이 늘어난다.
결국 핵심은 “모든 이벤트를 처리할 필요가 없다”는 점이다. 이벤트 스트림에서 버릴 건 버리고, 최신/대표값만 선택하는 전략이 필요하다.
2) debounce: “입력이 멈추면 처리한다”
개념
debounce는 이벤트가 연속으로 들어오다가 끊긴 후, 지정한 시간 동안 추가 이벤트가 없을 때 마지막 값 하나만 방출한다.
즉 “사용자가 입력을 멈췄다”를 감지하는 연산자이다.
동작 흐름 (Mermaid Sequence)
아래는 debounce(300ms)에서 A(0ms) → B(100ms) → C(200ms)가 들어오는 예시이다.

실제 활용 1: 검색 자동완성(네트워크 폭주 방지)
검색은 debounce의 대표 적용처이다. 보통은 아래 조합이 가장 안정적이다.
debounce()로 입력 멈춤 감지filter로 빈 문자열 제거distinctUntilChanged()로 같은 값 반복 방지flatMapLatest()로 최신 요청만 유지(이전 요청 취소)
private val query = MutableStateFlow("")
val searchResult = query
.debounce(500L)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinctUntilChanged()
.flatMapLatest { q ->
repository.searchFlow(q) // 최신 요청만 유지, 이전 요청은 취소
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun onQueryChange(newQuery: String) {
query.value = newQuery
}
flatMapLatest가 중요한 이유
debounce만 쓰면 “요청 수”는 줄지만, 네트워크 지연이 길 때 이전 요청 결과가 뒤늦게 도착해 UI를 덮어쓰는 문제가 남는다.
flatMapLatest는 이전 Flow를 취소하므로, 검색 UX의 정합성을 지키는 핵심이다.
3) throttle: “주기(window)당 몇 번만 처리한다”
throttle은 “정해진 시간 창(window)에서 이벤트를 제한한다”는 개념이다. 다만 throttle은 구현/정의가 여러 갈래라서 반드시 throttleFirst vs throttleLast를 분리해서 이해해야 한다.
3-1) throttleFirst: “첫 이벤트만 즉시 처리하고 차단한다”
개념
- *throttleFirst(window)**는 window가 시작될 때 들어온 첫 이벤트를 즉시 처리하고, window가 끝날 때까지 들어오는 이벤트는 무시한다.
버튼 연타 방지의 정석이다.
동작 흐름 (Mermaid Sequence)
아래는 throttleFirst(1000ms)에서 클릭이 연속으로 들어오는 예시이다.

Flow에 throttleFirst가 없는 이유
kotlinx.coroutines Flow에는 throttleFirst가 기본 제공 연산자로 들어있지 않다. 그래서 보통 확장 함수를 만들어 쓴다.
중요 팁: 시간 측정은 System.currentTimeMillis()보다 단조 증가 시간인 SystemClock.elapsedRealtime()을 쓰는 편이 안전하다. (사용자가 시스템 시간을 변경해도 영향이 적다)
import android.os.SystemClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun <T> Flow<T>.throttleFirst(windowDurationMs: Long): Flow<T> = flow {
var lastEmissionTime = 0L
collect { value ->
val now = SystemClock.elapsedRealtime()
if (now - lastEmissionTime >= windowDurationMs) {
lastEmissionTime = now
emit(value)
}
}
}
실제 활용: 버튼 중복 클릭(따닥) + 중복 네비게이션 방지
viewClicks(binding.btnSubmit) // 아래에서 callbackFlow 예시 제공
.throttleFirst(1_000L)
.onEach { viewModel.submit() }
.launchIn(lifecycleScope)
3-2) throttleLast(throttleLatest): “주기마다 마지막(최신) 값만 뽑는다”
개념
- *throttleLast(window)**는 window 동안 들어온 값 중 마지막(최신) 값을 window가 끝날 때 방출하는 방식이다.
“이벤트는 자주 오지만, 나는 최신 상태만 주기적으로 반영하면 된다”는 상황에 적합하다.
sample은 throttleLast와 유사하다
Flow에는 sample(window)가 존재한다. sample은 일정 주기마다 가장 최근 값을 방출하는 동작을 하므로, 실무에서는 흔히 throttleLast/throttleLatest 대용으로 사용한다.
val latestPosition = locationFlow
.sample(1_000L) // 1초마다 최신 위치만 반영
다만 sample은 “주기적으로 시점을 찍어서 최신값을 가져오는” 형태이므로, 구현/정의 관점에서 throttleLast와 1:1로 완전히 동일하다고 단정 짓기보다는 “유사한 효과”로 이해하는 편이 안전하다.
동작 흐름 (Mermaid)
아래는 “1초 창 동안 들어온 값의 최신만 반영”하는 그림이다.

4) View 클릭을 Flow로 변환하는 callbackFlow 예시
clicks() 같은 확장 함수는 내부적으로 callbackFlow로 구현하는 경우가 많다. 최소 형태는 아래 정도가 자주 쓰인다.
import android.view.View
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
fun viewClicks(view: View): Flow<Unit> = callbackFlow {
val listener = View.OnClickListener { trySend(Unit).isSuccess }
view.setOnClickListener(listener)
awaitClose { view.setOnClickListener(null) }
}
라이프사이클과 함께 쓰기
UI에서는 flowWithLifecycle 또는 repeatOnLifecycle 패턴이 안정적이다.
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
lifecycleScope.launch {
viewClicks(binding.btnSubmit)
.throttleFirst(1_000L)
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { viewModel.submit() }
.collect()
}
5) 언제 debounce이고, 언제 throttle인가
둘 다 이벤트를 버리는 기법이지만 기준점이 다르다.
| 구분 | debounce | throttleFirst | throttleLast(throttleLatest) |
|---|---|---|---|
| 핵심 | “멈춘 뒤” 마지막 1회 | “처음 1회” 즉시 처리 | “주기마다” 최신 1회 |
| 반응 시점 | 지연 발생 | 즉시 반응 | 주기 경계에서 반영 |
| 대표 사례 | 검색어 입력, 슬라이더/필터 | 버튼 따닥 방지, 네비 중복 방지 | 스크롤/센서/상태 업데이트 |
| Flow 기본 제공 | debounce() | 없음(확장 구현) | sample()로 유사 구현 가능 |
6) 업무시 사람들이 자주 하는 실수와 체크리스트
- debounce만 걸고 요청 취소를 안 하는 실수가 흔하다. 검색은
flatMapLatest조합이 사실상 필수이다. - 시간 소스는
currentTimeMillis보다elapsedRealtime이 안전한 편이다. - 클릭 Flow는 상황에 따라
buffer()/conflate()를 섞어 “백프레셔로 UI 스레드가 밀리는 문제”를 줄이는 것도 방법이다. - 중복 네비게이션은 throttleFirst로 줄일 수 있지만, **Navigation Component 자체의 중복 방지(현재 destination 체크 등)**와 같이 쓰면 더 안전하다.
마무리
UI/UX의 품질은 “이벤트가 폭주할 때도 의도대로 동작하는가”에서 갈린다.
검색창에는 debounce(+flatMapLatest)를 적용하고, 버튼에는 throttleFirst를 적용하는 것만으로도 서버 비용과 중복 동작 버그를 크게 줄일 수 있다. Flow 기반으로 구성하면 시간 제어 로직이 짧고 읽기 쉬운 코드로 정리되는 장점도 크다.
FAQ
Q. Flow에서 throttleFirst가 기본 제공이 아닌 이유는 무엇인가?
A. Flow 표준 연산자는 비교적 최소한의 스트림 연산자 위주로 구성되어 있고, throttleFirst는 UI 이벤트 성격이 강해 프로젝트별 요구가 달라 커스텀 구현으로 두는 경우가 많다.
Q. Flow의 sample은 throttleLast와 같은가?
A. sample은 일정 주기마다 최신 값을 방출하므로 throttleLast/throttleLatest와 유사한 효과를 낸다. 다만 정의 관점에서 동일하다고 단정하기보다는 “목적이 같고 실무에서 대용으로 충분히 쓸 수 있다” 정도로 이해하는 편이 안전하다.
Q. 검색 자동완성에서는 debounce만 쓰면 충분한가?
A. 보통 충분하지 않다. 네트워크 지연이 있을 때 이전 요청 결과가 늦게 도착해 UI를 덮어쓰는 문제가 생길 수 있으므로 flatMapLatest 조합이 권장된다.