- Published on
Kotlin Flow debounce가 안 될 때 7가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 박스 자동완성, 입력값 검증, 스크롤 이벤트 처리처럼 "짧은 시간에 연속으로 발생하는 이벤트"를 다룰 때 debounce()는 거의 필수입니다. 그런데 Flow 체인에 debounce()를 넣었는데도 호출이 줄지 않거나, 반대로 아예 이벤트가 안 나오거나, 특정 상황에서만 무시되는 경우가 있습니다.
이 글은 "왜 debounce()가 안 먹는 것처럼 보이는가"를 원인별로 쪼개서 설명합니다. 핵심은 debounce() 자체보다도 Flow가 만들어지는 방식(콜드/핫), 업스트림의 방출 패턴, 버퍼/컨플레이트, 스코프/취소, 그리고 어디서 collect 하느냐에 의해 결과가 크게 달라진다는 점입니다.
비슷한 형태의 "겉보기 증상은 단순하지만 원인이 여러 갈래인" 트러블슈팅 글로는 Jenkins 빌드가 갑자기 느려질 때 원인 7가지도 참고가 됩니다. 접근 방식이 유사합니다.
먼저 확인: 내가 기대하는 debounce의 의미
debounce(window)는 "마지막 값이 방출된 뒤 window 동안 추가 이벤트가 없으면 그 마지막 값을 내보낸다"에 가깝습니다.
- 연속 입력 중에는 계속 타이머가 리셋됩니다.
- 입력이 멈춘 뒤 마지막 값 1개만 downstream으로 나갑니다.
- 단, 업스트림이 완전히 끝나거나(complete) 스코프가 취소되면 타이머가 끝까지 못 가서 값이 안 나갈 수 있습니다.
간단 예시:
val flow = flow {
emit("a")
delay(50)
emit("ab")
delay(50)
emit("abc")
}
flow
.debounce(200)
.collect { println(it) } // "abc"만 출력
이제부터는 "왜 위처럼 안 되는지"를 7가지로 나눠 봅니다.
1) 업스트림이 delay 없이 동기적으로 한 번에 방출한다
가장 흔한 착시입니다. 업스트림이 사실상 같은 시점에 여러 값을 "즉시" 방출하면, debounce()는 마지막 값만 내보내는 게 맞습니다. 그런데 사용자는 "debounce가 안 됐다"고 느낄 수 있습니다. 이유는 대개 다음 중 하나입니다.
- 이미 마지막 값만 나가고 있는데, 로그 위치가 업스트림에 찍혀 있다.
onEach { log }를debounce()앞에 달아놓았다.
예시(잘못된 로깅 위치):
textChangesFlow
.onEach { println("upstream=$it") } // 여긴 debounce 전
.debounce(300)
.onEach { println("downstream=$it") }
.launchIn(scope)
해결 체크:
- "API 호출"이나 "무거운 작업"이 실제로
debounce()뒤에 있는지 확인하세요. - 디버깅 로그를
debounce()뒤에도 반드시 찍어 downstream 이벤트 수를 확인하세요.
2) StateFlow의 초기 값이 즉시 방출되어 바로 호출된다
StateFlow는 구독 즉시 현재 값을 방출합니다. 그래서 화면 진입 시점에 collect를 시작하면, 사용자가 입력하지 않았는데도 초기 값이 debounce()를 통과해 호출되는 것처럼 보일 수 있습니다.
예시:
val query: StateFlow<String> = _query
query
.debounce(300)
.onEach { search(it) }
.launchIn(viewModelScope)
위 코드에서 _query의 초기 값이 ""라면, 300ms 후 search("")가 호출될 수 있습니다.
해결 패턴:
query
.drop(1) // 첫 방출(초기 값) 제거
.debounce(300)
.filter { it.isNotBlank() }
.distinctUntilChanged()
.onEach { search(it) }
.launchIn(viewModelScope)
대안으로는 초기 값과 실제 입력 이벤트를 분리하는 것도 좋습니다.
3) debounce() 뒤가 아니라 앞에서 이미 작업이 실행되고 있다
debounce()는 downstream으로 흘러가는 이벤트를 늦출 뿐, 업스트림에서 이미 실행된 부작용을 되돌릴 수 없습니다.
나쁜 예(업스트림에서 이미 네트워크 호출):
val results = query
.map { q -> api.search(q) } // 여기서 이미 호출 발생
.debounce(300)
이 경우 debounce()는 "검색 결과"를 늦출 뿐, "검색 호출"은 그대로 폭주합니다.
올바른 예:
query
.debounce(300)
.distinctUntilChanged()
.mapLatest { q -> api.search(q) } // debounce 이후 호출
.collect { render(it) }
포인트:
- 네트워크/DB/무거운 계산은
debounce()뒤로 보내세요. - 최신 요청만 유지하려면
mapLatest또는flatMapLatest를 고려하세요.
4) flatMapMerge나 높은 동시성으로 인해 "debounce 효과"가 상쇄된다
debounce()는 이벤트 수를 줄이지만, downstream에서 병렬로 작업을 늘리면 결과적으로 "호출이 줄지 않는" 것처럼 보일 수 있습니다. 특히 flatMapMerge(concurrency = N)는 이벤트를 병렬로 처리합니다.
예시:
query
.debounce(300)
.flatMapMerge(concurrency = 8) { q ->
flow { emit(api.search(q)) }
}
.collect { render(it) }
이 패턴은 입력이 뜸해도(300ms 단위로) 여전히 동시 요청이 쌓일 수 있습니다.
해결:
- 검색/자동완성은 대부분
flatMapLatest가 맞습니다.
query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q ->
flow { emit(api.search(q)) }
}
.collect { render(it) }
5) buffer() 또는 conflate()로 인해 기대한 타이밍이 달라진다
버퍼링은 "이벤트 처리량"을 바꾸고, 그 결과 debounce()의 체감 동작이 달라질 수 있습니다.
conflate()는 중간 값을 버리고 최신 값만 유지합니다.buffer()는 생산자와 소비자를 분리해 생산이 빨라질 수 있습니다.
대표적인 함정은 debounce() 앞에 conflate()가 있어 입력 패턴이 바뀌는 경우입니다.
query
.conflate()
.debounce(300)
.onEach { search(it) }
.launchIn(scope)
이러면 "사용자가 빠르게 입력한 중간 값"이 이미 사라져서, debounce로 조절한다기보다 "최신 값만 대충 나오는" 느낌이 됩니다.
권장 접근:
- 입력 이벤트(사용자 타이핑)는 보통
conflate()를 먼저 쓰지 않습니다. - 느린 downstream 때문에 UI 이벤트가 막히는 게 문제라면,
debounce()뒤에서buffer()를 검토하세요.
query
.debounce(300)
.distinctUntilChanged()
.buffer(capacity = 64)
.mapLatest { api.search(it) }
.collect { render(it) }
6) collect 스코프가 너무 빨리 취소되어 타이머가 끝나기 전에 종료된다
debounce()는 내부적으로 타이머를 기다립니다. 그런데 collect가 걸린 스코프가 300ms를 못 버티고 취소되면, 마지막 값이 downstream으로 나오지 않습니다. 그래서 "debounce가 안 된다"가 아니라 "아무 값도 안 나온다"가 됩니다.
안드로이드에서 흔한 패턴:
lifecycleScope.launch { repeatOnLifecycle(...) { ... } }내부에서 화면 상태 변화로 블록이 자주 취소된다.- Composable 재구성으로 collect가 여러 번 만들어졌다가 사라진다.
디버깅 예시:
query
.onStart { println("collect start") }
.onCompletion { cause -> println("collect end cause=$cause") }
.debounce(300)
.onEach { println("debounced=$it") }
.launchIn(scope)
해결 체크:
- collect가 안정적으로 유지되는 스코프에서 실행되는지 확인하세요.
- UI 수명주기에 묶는다면,
repeatOnLifecycle의 최소 상태를 너무 높게 잡지 않았는지 확인하세요.
7) 테스트 환경에서 가상 시간/디스패처 설정이 맞지 않아 debounce가 "안 기다린다"
단위 테스트에서 debounce()가 기대대로 동작하지 않는 사례가 많습니다. 이유는 delay 기반 오퍼레이터가 테스트 디스패처/스케줄러의 영향을 받기 때문입니다.
문제 증상:
debounce(300)인데 테스트가 즉시 끝나서 아무 값도 못 받는다.- 반대로 실제 시간으로 300ms를 기다리며 테스트가 느려진다.
권장 테스트 패턴은 runTest와 가상 시간 진행을 쓰는 것입니다.
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun debounce_emits_last_value_after_window() = runTest {
val upstream = MutableSharedFlow<String>()
val out = mutableListOf<String>()
val job = launch {
upstream.debounce(300).toList(out)
}
upstream.emit("a")
advanceTimeBy(100)
upstream.emit("ab")
advanceTimeBy(100)
upstream.emit("abc")
// 아직 300ms가 안 지나서 미방출
advanceTimeBy(299)
assert(out.isEmpty())
// 1ms 더 진행하면 방출
advanceTimeBy(1)
assert(out == listOf("abc"))
job.cancel()
}
테스트가 갑자기 "렌더링/스케줄링" 때문에 폭증하거나 예측 불가해지는 문제는 프론트엔드에서도 자주 보이는데, 비슷한 진단 관점으로는 Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo 오용도 참고할 만합니다.
실전 체크리스트: debounce가 안 될 때 이렇게 좁혀라
아래 순서대로 보면 대부분 빠르게 원인이 좁혀집니다.
- 부작용(네트워크 호출/DB 조회)이
debounce()뒤에 있는가 - 로그를
debounce()뒤에 찍었는가(업스트림 로그만 보고 착각하지 않는가) - 입력 소스가
StateFlow라면 초기 값 방출을 처리했는가(drop(1),filter, 초기 상태 분리) distinctUntilChanged()가 필요한데 빠져서 같은 값으로도 계속 호출되는 건 아닌가flatMapMerge등 병렬 처리로 체감상 호출이 줄지 않는 건 아닌가(대개flatMapLatest가 정답)- collect 스코프가 너무 빨리 취소되지 않는가(
onCompletion으로 확인) - 테스트라면
runTest가상 시간으로advanceTimeBy를 사용하고 있는가
추천 조합: 검색 입력 Flow의 “안전한 기본 템플릿”
마지막으로, 현업에서 가장 많이 쓰는 형태를 하나로 정리해 두면 재사용하기 좋습니다.
fun Flow<String>.asSearchQueryFlow(): Flow<String> =
this
.map { it.trim() }
.dropWhile { it.isEmpty() } // 화면 진입 시 빈 값은 무시하고 싶을 때
.debounce(300)
.distinctUntilChanged()
// 사용 예
queryFlow
.asSearchQueryFlow()
.mapLatest { q -> api.search(q) }
.catch { e -> emit(emptyList()) }
.collect { results -> render(results) }
dropWhile { it.isEmpty() }는 상황에 따라 filter { it.isNotBlank() }로 바꾸거나, 아예 제거하고 "빈 검색"을 허용할 수도 있습니다.
debounce()는 단독으로 보면 단순하지만, Flow 파이프라인 전체의 시간/동시성/수명주기와 결합되면 쉽게 "안 되는 것처럼" 보입니다. 위 7가지를 기준으로 파이프라인을 분해해서 확인하면, 대부분은 debounce() 자체가 아니라 "어디에서 부작용이 실행되는지"와 "스코프가 유지되는지"에서 답이 나옵니다.