UI를 구현하다 보면 사용자 입력이나 상태 변경에 따라 이벤트가 아주 자주 발생하는 경우가 많습니다.
예를 들어 검색 입력, 스크롤 이벤트, 버튼 연타, 상태 변화 감지 등은 그대로 처리하면 불필요한 연산이나 네트워크 호출로 이어지기 쉽습니다.
이런 문제를 해결하기 위해 Kotlin Flow에서는 Debounce, DistinctUntilChanged와 같은 연산자를 제공합니다.
이번 글에서는 Flow에서 자주 사용되는 Debounce와 DistinctUntilChanged가 각각 어떤 역할을 하는지, 그리고 실제 UI 구현에서 어떻게 활용할 수 있는지 정리해보려고 합니다.
debounce는 연속적으로 발생하는 이벤트 중 마지막 이벤트만 일정 시간 후에 처리하도록 도와주는 연산자입니다. 즉, 이벤트가 계속 들어오고 있다면 처리를 미루고, 일정 시간 동안 추가 이벤트가 없을 때만 값을 방출합니다.
flow
.debounce(300)
.collect { value ->
// 처리 로직
}
debounce를 사용하는 가장 대표적인 예시는 검색 입력입니다. 사용자가 키보드를 입력할 때마다 API를 호출하면 비효율적이기 때문에, 입력이 멈춘 뒤 일정 시간이 지난 후에만 검색 요청을 보내는 것이 일반적입니다.
searchQuery
.debounce(500)
.collect { query ->
search(query)
}
이렇게 하면 사용자가 "compose"를 입력할 때
c → co → com → comp ... 마다 요청을 보내는 것이 아니라,
입력이 끝난 뒤 한 번만 요청하게 됩니다.
distinctUntilChanged는 이전 값과 동일한 값이 연속으로 들어오면 무시하는 연산자입니다.
flow
.distinctUntilChanged()
.collect { value ->
// 처리 로직
}
distinctUnitlChanged를 사용하여 같은 값이 반복해서 emit되는 경우를 방지할 수 있습니다. 같은 검색어가 다시 설정되는 경우, 같은 UI상태가 재전달 되는 경우, recomposition 과정에서 동일한 값이 다시 흘러오는 경우의 상황에서 distinctUntilChanged를 사용하면 실제로 값이 변경된 경우에만 처리할 수 있습니다. 불필요한 UI 업데이트나 사이드 이펙트를 줄이는 데 매우 유용합니다.
실제 UI에서는 이 두 연산자를 함께 사용하는 경우가 가장 많습니다. 예를 들어 검색 기능을 구현할 때는 같은 검색어는 다시 처리하지 않고 입력이 멈췄을 때만 요청을 보내고 싶습니다.
searchQuery
.debounce(500)
.distinctUntilChanged()
.collect { query ->
search(query)
}
이 조합을 사용하면
결과적으로 네트워크 요청 수 감소 + UX 개선이라는 두 가지 효과를 동시에 얻을 수 있습니다.
debounce와 distinctUntilChanged는 단순한 Flow 연산자이지만, UI 성능과 사용자 경험에 큰 영향을 미치는 도구입니다.
UI에서 “얼마나 많은 이벤트가 발생했는가”보다는 “정말로 처리해야 할 이벤트인가”에 초점을 맞춘다면, 이 두 연산자는 매우 강력한 무기가 될 수 있습니다.
특히 Jetpack Compose처럼 상태 중심 UI에서는 불필요한 이벤트를 줄이는 것이 곧 성능 최적화로 이어지기 때문에 적절한 위치에 debounce와 distinctUntilChanged를 사용하는 습관을 들여두면 많은 도움이 됩니다.