본문 바로가기

Android/개인 기록

Debounce와 Throttle, Event 핸들링

Photo by Usplash, Jason Dent

 

본인이 전담해서 운영 중인 어플리케이션은 로그인을 하거나 특정 API를 호출할 때, 서버에 로그를 남긴다.

앱 운영을 전담하기 이전부터 있던 고질적인 문제가 있었는데.. 사용자가 로그인을 시도 할 때, 1초도 안되는 시간동안 소수초 간격으로 Api가 연속 호출되는 문제였다.

다수의 이용자가 사용하는 서비스이고, 최근 사용자가 늘어가는 만큼, 이대로 방치하면 서버의 과부하가 유발되어 서비스 품질이 낮아질 수 있겠다는 생각이 들었다.

마침 최근에 Medium에서 Debounce와 Throttle에 관련된 칼럼을 읽었던 기억이 있어서 적용해 보았고, 이슈를 해결할 수 있었다.

 

비슷한 이슈를 겪는 사람들에게 기술적 인사이트를 제공할 수 있겠다는 생각에, 관련 내용을 기록해 둔다.


Debounce와 Throttle의 개념

Debounce와 throttle은 전자 공학에서 기계적 스위치나 유체 흐름을 제어하는 개념으로 출발했지만, 소프트웨어 개발에서는 주로 이벤트 핸들링과 관련된 개념으로 사용된다.

이 둘의 공통점은 이벤트 발생을 제어하여 과도한 호출을 방지하고 시스템 자원을 효율적으로 사용하는 데 목적이 있다는 점이다.

개발자는 이를 통해 성능을 최적화하고 불필요한 연산을 줄일 수 있다.

공통점

  • Debounce와 throttle 모두 특정 이벤트가 너무 자주 발생하는 것을 제어한다.
  • 예를 들어, 사용자가 입력 필드에 빠르게 타이핑하거나 무한 스크롤에서 스크롤 이벤트가 빠르게 발생하는 경우, 이벤트 콜백의 호출 빈도를 조절할 수 있다.
  • 결론은, 둘 다 불필요하거나 중복된 작업을 방지하여 시스템과 서비스의 안정성을 높이는 것이 주된 목적이다.

 

차이점

Debounce

  • 이벤트가 발생한 후 일정 시간이 지나기 전에 다시 이벤트가 발생하면 타이머를 리셋한다. 최종적으로 일정 시간 동안 이벤트가 발생하지 않았을 때 한 번만 실행된다.
  • 즉, 이벤트를 그룹핑하여 일정 시간동안 이벤트가 추가로 갱신되지 않으면 서버에 전송한다.
  • 주로 사용자의 마지막 입력이 끝난 후, 작업을 실행하고 싶을 때 사용한다.
  • 예: 검색 입력 시 실시간 자동완성

 

Throttle

  • 일정 시간 간격으로 이벤트를 실행한다.
  • 즉, 지정된 시간 동안에는 한 번만 이벤트가 발생하도록 한다.
  • 주로 일정 주기마다 작업을 실행하고 싶을 때 사용한다.
  • 예: 무한 스크롤, 윈도우 리사이즈 이벤트

각각의 구현 방법

Debounce

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> {
    // 예제 이벤트 흐름
    val flow = flow {
        emit("Event 1")
        delay(100) // 100ms 대기
        emit("Event 2")
        delay(300) // 300ms 대기
        emit("Event 3")
        delay(100) // 100ms 대기
        emit("Event 4")
        delay(500) // 500ms 대기
        emit("Event 5")
    }
    
    // debounce 연산자를 사용하여 이벤트 제어
    flow
        .debounce(200) // 200ms 동안 대기 후 마지막 이벤트 내보내기
        .collect { event ->
            println("Collected: $event")
        }
}

위 코드는 200ms 동안 새로운 이벤트가 발생하지 않으면 마지막으로 수집된 이벤트를 방출한다.

 

주로 검색과 같은 텍스트 입력에 사용되기 때문에, EditText를 확장해서 사용할 수 있다.

fun EditText.textChanges(): Flow<CharSequence?> = callbackFlow {
    val textWatcher = object : TextWatcher {
        override fun afterTextChanged(s: Editable?) {
            trySend(s)
        }

        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
    }
    addTextChangedListener(textWatcher)
    awaitClose { removeTextChangedListener(textWatcher) }
}

(TextWatcher 내용은 이 글 참조)

fun observeTextChanges(editText: EditText) {
    lifecycleScope.launch {
        editText.textChanges()
            .debounce(300) // 300ms 동안 대기 후 마지막 입력값 내보내기
            .collect { text ->
                // 텍스트 변경 이벤트 처리
                println("Text changed: $text")
            }
    }
}

이렇게 하면, 300ms 동안 대기한 후, 마지막 입력 값을 내보낼 수 있게 된다.

이를 통해 사용자가 빠르게 입력하는 도중에는 이벤트가 발생하지 않고, 입력이 멈추면 마지막 입력 값이 전달된다.

 

Throttle

class ThrottleManager(private val boundary: Long) {
    private val lastCallTimes = mutableMapOf<String, Long>()

    fun canProceed(key: String): Boolean {
        val currentTime = System.currentTimeMillis()
        val lastCallTime = lastCallTimes[key] ?: 0L
        return if (currentTime - lastCallTime >= boundary) {
            lastCallTimes[key] = currentTime
            true
        } else {
            false
        }
    }
}

본인은 이렇게 ThrottleManager 클래스를 생성해서 관리한다.

 

여러 곳에서 손쉽게 재사용을 할 수 있기 때문이다.

특히, ViewModel에서 API호출을 제어할 때 유용하다.

private val throttleManager = ThrottleManager(2000L) // 2000ms의 최소 호출 간격

fun login(username: String, password: String){
    if (throttleManager.canProceed("login")) {
        loginApi(username, password)
    }
}

위와 같이 사용하면 여러 메서드에서 동시에 Throttle을 제어할 수 있다.

 

만약, 비동기 데이터 슽트림을 처리하기 위해 Throttle을 구현해야 한다면, Flow를 사용해서도 구현할 수 있다.

Flow에서 기본으로 제공되는 Debounce와 달리, Throttle은 다음처럼 직접 작성해 주어야한다.

fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
    var lastEmissionTime = 0L
    collect { value ->
        val currentTime = System.currentTimeMillis()
        if (currentTime - lastEmissionTime >= windowDuration) {
            emit(value)
            lastEmissionTime = currentTime
        }
    }
}

 


Ref

https://medium.com/@sonikamaheshwari067/debounce-and-throttling-what-they-are-and-when-to-use-them-b1db3eb0280d