👩💻 오늘의 할 일
오늘은 Room의 검색 기능에서 사용되는 Like, StateFlow에서의 Debounce와 Throttle를 실제 프로젝트에 적용하기 위해 사전 공부를 해보겠습니다. Room과 StateFlow의 새로운 기술이라니 벌써부터 두근두근 잼있을거 같네요!
📖 SQL LIKE
SQL Query에서의 Like는 특정 문자가 포함되어 있는지 검색할 때 사용됩니다. Like는 ' % ' 로 표현되는데, 그 위치에 따라 다르게 동작하게 됩니다.
1. 특정 문자로 시작하는 데이터 검색
SELECT * FROM STORE_INFO WHERE STORE_NAME LIKE '%짱구';
2. 특정 문자로 끝나는 데이터 검색
SELECT * FROM STORE_INFO WHERE STORE_NAME LIKE '짱구%';
3. 특정 문자가 포함된 데이터 검색
SELECT * FROM STORE_INFO WHERE STORE_NAME LIKE '%짱구%';
이를 Room에선 다음과 같이 Dao Query를 생성할 수 있습니다.
@Query(
"""
SELECT * FROM store_table WHERE
store_name LIKE '%' || :query || '%'
OR address LIKE '%' || :query || '%'
"""
)
fun readAutoCompleteSearchKeyword(query: String): Flow<List<StoreEntity>>
📖 Debounce와 Throttle
Debounce와 Throttle은 반복 되는 Api 호출, 함수 실행 등으로 인한 인한 리소스 낭비를 줄이기 위해 사용합니다. 이벤트가 발생하는 대로 Api를 호출하게 되면 기기와 서버 양측에 리소스 낭비와 부하를 일으키게 되겠죠? 그래서 이런 이벤트들을 적절히 핸들링하기 위해 사용되는 기법이 바로 Debounce와 Throttle입니다.
Api를 반복 호출하는 경우는 어떤 경우가 있을까요 ? 🤔
예를 들어, 검색어 자동 완성을 하면 DataSource에 있는 데이터를 기준으로 자동 완성을 시켜줘야 하기 때문에 검색어를 입력할 때마다 매번 DataSource에 접근하는 것은 매번 불필요한 리소스를 사용하게 되는 것이죠!
📜 Debouncing
Debounce는 이벤트를 그룹화하고 일정 시간동안 이벤트가 발생하지 않으면 가장 마지막 이벤트를 전달합니다. 아래 예시는 "아빠" 를 입력하고 일정 주기 동안 입력을 하지 않으면 "아빠가 방에 들어가신다"를 자동 완성 검색어로 출력합니다.
아빠가 방에 들어가신다
ㅇ
아
아ㅃ
아빠 <---- 정해진 구간 중 마지막에 들어간 데이터만 소모
Debounce를 그림으로 표현하면 위 사진처럼 동작합니다. 1 ~ 5번의 이벤트를 수행하던 1 ~ 2의 이벤트, 1 ~ 7 등 몇 번의 이벤트를 수행하던 이를 그룹화하여 debounce에 지정된 시간 동안 이벤트가 발생하지 않을 경우 가장 마지막 이벤트를 전달합니다.
debounce 함수에 마우스를 올려보면 sample Code를 확인할 수 있습니다.
fun debounce(): Flow<Int> = flow {
emit(1)
delay(90)
emit(2)
delay(90)
emit(3)
delay(1010)
emit(4)
delay(1010)
emit(5)
}.debounce(1000)
fun main() = runBlocking{
val format = SimpleDateFormat("HH:mm:ss")
debounce().collect{
println("${format.format(System.currentTimeMillis())} $it")
}
}
// 03:52:56 3
// 03:52:57 4
// 03:52:57 5
이 코드는 1초 동안 이벤트가 동작하지 않을 때 디바운싱 시키므로 아래와 같이 동작합니다.
📑 debounce 내부 구조
- require(timeoutMillis >= 0L) { "Debounce timeout should not be negative" }
- timeoutMillis 값이 음수가 아닌지 확인합니다. 음수가 전달되면 예외를 throw 합니다. 따라서 debounce의 시간 간격은 0 이상이어야 합니다.
- if (timeoutMillis == 0L) return this
- 만약 timeoutMillis가 0이라면, debounce를 적용하지 않고 원래의 Flow를 그대로 반환합니다.
- return debounceInternal { timeoutMillis }
- debounce 연산을 수행하는 내부 함수 debounceInternal을 호출하여 새로운 Flow를 반환합니다. 내부 함수에는 debounce의 시간 간격을 인자로 전달합니다.
📜 Throttling
Debounce는 일정 주기동안 이벤트를 수행하지 않을 때 동작한다면, Throttling은 이벤트에 관계없이 일정 주기 마다 이벤트를 전달합니다. 구현 방식에 따라 첫 번째 이벤트 이벤트를 전달( throttleFirst )할지 마지막 이벤트를 전달( throttleLast, sample )할지 지정할 수 있습니다.
Throttling은 버튼 중복 클릭 방지 및 활성화에 사용할 수 있습니다.
예를 들어, 찜 버튼을 반복적으로 클릭할 경우, 버튼이 활성화, 비활성화될 때마다 API 호출이 일어나 서버에 과부하를 일으킵니다. ThrottleFirst를 사용해 첫 번째 이벤트만 전달하면 아무리 많은 클릭 이벤트가 발생하더라도 가장 처음 이벤트만을 받아와서 처리하면 이벤트가 한 번만 전달되기 때문에 부하를 줄일 수 있습니다.
또한 버튼을 누르면 게시물이 업로드 되는 로직이 있다고 했을 때 사용자가 버튼을 빠르게 2번 누른다면 의도치 않게 업로드가 2번 되는 상황이 발생할 수 있습니다.
ThrottleFirst
ThrottleFirst는 일정 시간동안 발생한 이벤트 중 첫 번째 이벤트를 전달합니다. 위 그림을 보면 1 ~ 7 동안 발생하는 이벤트 그룹과 1 ~ 4, 1~5까지 발생하는 이벤트 그룹 모두 이벤트 발생 시점과는 상관 없이 일정 주기에 맞춰 이벤트를 전달합니다.
ThrottleLast(sample)
ThrottleLast는 일정 시간 동안 발생한 이벤트 중 마지막 이벤트를 전달합니다. 위 그림을 보면 1~2를 수행하는 이벤트 그룹은 전달되지 않고 일정 시간이 지난 후 1 ~ 7 이벤트 그룹의 이벤트 중 3번째 이벤트를 전달하는 것을 볼 수 있습니다. Flow에는 Throttle을 구현한 메서드가 없어 개발자가 직접 확장함수 형태로 구현해야 합니다.
👩🏫 throttleFirst 구현하기
💪 Flow 확장 함수 만들기
// FlowExtensions.kt
// 클릭 이벤트를 flow로 변환
fun View.clicks(): Flow<Unit> = callbackFlow {
setOnClickListener {
this.trySend(Unit)
}
awaitClose { setOnClickListener(null) }
}
// 마지막 발행 시간과 현재 시간 비교해서 이벤트 발행, 나머지는 무시.
fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
var lastEmissionTime = 0L
collect { upstream ->
val currentTime = System.currentTimeMillis()
if (currentTime - lastEmissionTime > windowDuration) {
lastEmissionTime = currentTime
emit(upstream)
}
}
}
- View.clicks() : Flow<T>.clicks() 확장함수를 만들어서 클릭 이벤트를 flow로 변환
- <T> Flow<T>.throttleFirst : 마지막 발행 시간과 현재 시간을 비교하여 데이터를 발행하고 나머지 데이터는 무시합니다.
💪 중복 클릭을 막아보기
fun View.setClickEvent(
uiScope: CoroutineScope,
windowDuration: Long = THROTTLE_DURATION,
onClick: () -> Unit,
) {
clicks()
.throttleFirst(windowDuration)
.onEach { onClick.invoke() }
.launchIn(uiScope)
}
button.setClickEvent(lifecycleScope) {
Log.i("[TAG]", "click - ${System.currentTimeMillis()}")
}
👩🏫 throttleLast 구현하기
throttleLast는 Flow의 sample 메서드를 이용하여 구현할 수 있습니다.
@FlowPreview
public fun <T> Flow<T>.sample(periodMillis: Long): Flow<T> {
require(periodMillis > 0) { "Sample period should be positive" }
return scopedFlow { downstream ->
val values = produce(capacity = Channel.CONFLATED) {
collect { value -> send(value ?: NULL) }
}
var lastValue: Any? = null
val ticker = fixedPeriodTicker(periodMillis)
while (lastValue !== DONE) {
select<Unit> {
values.onReceiveCatching { result ->
result
.onSuccess { lastValue = it }
.onFailure {
it?.let { throw it }
ticker.cancel(ChildCancelledException())
lastValue = DONE
}
}
// todo: shall be start sampling only when an element arrives or sample aways as here?
ticker.onReceive {
val value = lastValue ?: return@onReceive
lastValue = null // Consume the value
downstream.emit(NULL.unbox(value))
}
}
}
}
}
📕 후기
우선 Debounce와 Throttle의 기본적인 개념은 정리가 된 것 같습니다. 한 번도 생각 못했던 버튼 중복 클릭에 대한 문제도 알 수 있었고 무엇보다 새로운 기술을 알게 돼서 너무 잼있었던 것 같습니다. 이제 다음 글에선 실제로 프로젝트에서 사용해 보겠습니다.
📑 Reference
'Android' 카테고리의 다른 글
버튼 중복 클릭을 막아보자 (Android ThrottleFirst) (1) | 2024.06.24 |
---|---|
ViewLifeCycleOwner 제대로 알고 사용해보자 (1) | 2024.04.28 |
[Android] 프로퍼티의 초기화 시점이 중요한 이유! (0) | 2024.01.28 |
[Android] Room Database Migration (0) | 2024.01.21 |
[Android] Room TypeConverter (0) | 2024.01.21 |