🤚🏻 Reactive X, RxJava 등 반응형 프로그래밍에 대한 사전 지식이 필요한 포스팅입니다.
예제 코드는 모두 킹갓 언어 'Kotlin' 을 기준으로 합니다.
EditText
안드로이드를 개발하는 우리는 사용자들에게 무언가를 검색하는 기능을 제공해주기 위해 EditText
라는 View 위젯을 사용한다. EditText
는 하나의 입력 상자로, 사용자가 키보드로 두드린 텍스트 입력값을 갖고 있게 된다. 따라서 보통 폼 입력 혹은 검색 기능을 위해 사용된다.
그럼 만약 검색 기능을 구현하고 싶다 할 때, 크게 두 가지 방법을 떠올릴 수 있다.
- 검색 버튼을 통한 수동 검색
- 사용자가 입력한 키워드로 자동 검색
첫 번째 '검색 버튼을 통한 수동 검색' 은 흔히 볼 수 있는 구조다. 사용자가 검색어를 다 입력하고, 보통은 오른쪽에 있는 '검색' 버튼을 누름으로써 쿼리 동작이 발생하여 결과를 표시하게 된다.
두 번째 방법은 사용자가 키워드를 입력하면, 별도로 검색버튼을 누르지 않아도 자동으로 검색하여 결과를 보여주는 방법이다. 검색 버튼을 누르기 위해 손가락을 움직일 필요가 없으므로, UX 측면에서 이 방법이 더 효율적으로 느껴진다.
그럼 사용자의 입력이 바뀔 때 마다, 자동으로 쿼리 동작을 수행하면 쉽게 구현할 수 있을 것이다!
.
.
.
라고 생각한다면 오산이다. 한 번 예시를 살펴보자.
쇼핑몰에서 '맥북' 을 검색한다고 가정해보자. 이상적인 동작은 '맥북' 이라고 입력하면, 자동으로 이 텍스트를 기반으로 쿼리를 수행하여 맥북과 관련된 리스트를 보여주는 것이다.
그런데 실제로 동작되는 모양을 살펴보면?
🤦🏻♂️ 어허.. 한 키워드로 검색할 뿐인데 가설 그대로 구현했다가 쿼리를 총 6번이나 수행하고 말았다. 심지어 '맥북' 쿼리를 제외한 5번의 쿼리는 쓸모도 없다.
당연히 매우 비효율적이고, 리소스를 낭비하는 행위이다. 조금 과장하면 부하를 일으키는 원인이 될 수도 있다.
"그럼 어떻게 처리 해주어야 이상적인 동작을 구현해낼 수 있을까?"
라고 생각이 들 때 필요한 개념이 바로 '디바운스' 이다.
디바운스는 불안정하게 연속적으로 발생하는 이벤트들 중 '가장 마지막에 발생하는 이벤트'만 처리하는 기법을 의미한다.
쉬운 이해를 위해 하드웨어 공학적인 이야기를 해보겠다. 우리가 흔히들 아는, 형광등 킬 때 누르는 딸깍 딸깍 '스위치' 가 대표적으로 디바운싱이 적용되는 하드웨어 요소이다.
(이거 아니다.)
우리 눈에는 스위치를 꾹 누른다고 하지만, 내부적으로는 물리적으로 떨림이 발생하여 스위치를 누르는 찰나의 순간에 엄청난 속도로 접점에 붙었다 떼졌다를 반복하여 전기 신호가 불안정해지는 '바운스 (채터링) 현상'이 발생하게 된다.
[이미지 출처] https://juahnpop.tistory.com/39
따라서 신호 변화가 완전히 사라졌을 때 스위치의 값을 읽어들여 정상동작하게 하는 것이다. 만약 디바운싱 처리를 하지 않은 형광등 스위치라고 했을 때, 스위치를 누르는 찰나 엄청난 속도로 형광등이 파파파팟 깜빡일 수 있는 것이다. TMI : 심지어는 우리가 쓰고있는 키보드의 키 하나하나에 모두 디바운싱 처리가 되어있다. (키보드 스펙표에도 나와있음)
확 와닿지 않는가? 이 개념을 그대로 옮겨보면, 사용자가 검색어를 완벽하게 다 입력했을 때, 이에 반응하여 쿼리를 수행하도록 하여 불필요한 연산을 방지하는 로직을 구현하려는 것이다. 간단하다!
💁🏻♂️ 그렇다면 다시 '쇼핑몰 검색' 예제로 돌아와보자.
안드로이드의
EditText
는TextWatcher
라는 인터페이스를 제공해준다. 입력값이 달라지는 순간 순간을 콜백 형태로 감지할 수 있는 인터페이스이다. 이러한 인터페이스는 기본적으로 제공이 되니까, 일정 시간 동안 입력값의 변화가 없음을 감지하여 그 때 쿼리를 수행하는 동작만 구현하면 된다.
하지만 콜백을 일일히 구현하고, 일일히 시간을 재서 입력값 변화를 감시하다보면 코드가 길어져 보기 불편해질 것이다.
이럴 때 사용하기 딱 좋은 것이 있다. 바로 반응형 프로그래밍 기법 이다.
어떻게 보면 EditText
는 Observable 의 적임자 그 자체다. 사용자에 의해 데이터 (텍스트) 가 계속하여 바뀌고, 개발자들은 이 데이터를 활용하여 특정 동작을 수행한다. 이를 미루어봤을 때 EditText
자체를, 사용자의 입력값을 데이터로써 발행하는 'Observable' 으로 이용하면 더욱 편리하지 않겠는가?
왜냐하면, TextWatcher
라는 콜백 형태의 이벤트 핸들러를 통해 텍스트의 변경을 감지해서 일정 시간동안 텍스트의 변화가 일어나지 않느니 마느니를 다 고려해주다보니 불필요한 보일러 플레이트 코드가 발생하게 되는데, 만약 Rx 를 사용한다면 콜백 지옥에서 벗어나고, 간결한 코드로 다양한 동작을 수행할 수 있기 때문이다.
심지어는
Observable
에는 이미, 핵심인 디바운싱 처리를 할 수 있는 연산자를 지원해준다. (필자가 이전 포스팅에서 한 번 다룬 적 있다.)
debounce()
라는 것을 이용하면 된다. 지정해준 특정 간격동안 더 이상 데이터의 변화가 없을 때,onNext()
이벤트를 통해 데이터를 발행하는 역할을 해준다. 이 연산자를 응용하여 우리가 구현하려는 동작을 쉽게 뚝딱 만들어 낼 수 있다. 아주 편리하다.
하지만, 알다시피 Observable 을 생성하는 다양한 연산자들 중에 EditText
를 Observable 로 만들어 주는 연산자는 당연하게도 없다. Iterable 도 Callable 도 아니기 때문이다.
따라서, 이러한 View
에서 발생하는 이벤트를 Observable 데이터 스트림으로 바꿔주는 라이브러리도 존재한다. 바로 RxBinding
이다.
GitHub Repo : https://github.com/JakeWharton/RxBinding
이 라이브러리는, 안드로이드에 존재하는 View
들의 이벤트를 리액티브하게 처리하기 위한 기본적인 준비 과정들을 생략해주는 라이브러리이다. EditText
, RecyclerView
, ViewPager2
, SwipeRefreshLayout
등 다양한 View
들에서 발생하는 이벤트를 Observable 형태로 쉽게 변환해준다.
그럼 이제 RxBinding
을 활용하여, EditText
에 쿼리 디바운싱 동작을 구현해보자.
우선 라이브러리를 추가해주자. app
단위의 build.gradle
에 아래 구문을 추가해주자.
// Basic
implementation 'com.jakewharton.rxbinding4:rxbinding:4.0.0'
// AndroidX (Option)
implementation 'com.jakewharton.rxbinding4:rxbinding-core:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-appcompat:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-drawerlayout:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-leanback:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-recyclerview:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-slidingpanelayout:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-swiperefreshlayout:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-viewpager:4.0.0'
implementation 'com.jakewharton.rxbinding4:rxbinding-viewpager2:4.0.0'
textChanges()
메소드 사용하기RxBinding
에서 지원하는 기능이다. EditText
의 확장함수 형태로 구현이 되어있는데, 한 번 간략히 살펴보자. 아래는 textChanges()
의 내부 구현이다.
@CheckResult
fun TextView.textChanges(): InitialValueObservable<CharSequence> {
return TextViewTextChangesObservable(this)
}
private class TextViewTextChangesObservable(
private val view: TextView
) : InitialValueObservable<CharSequence>() {
override fun subscribeListener(observer: Observer<in CharSequence>) {
val listener = Listener(view, observer)
observer.onSubscribe(listener)
view.addTextChangedListener(listener)
}
override val initialValue get() = view.text
private class Listener(
private val view: TextView,
private val observer: Observer<in CharSequence>
) : MainThreadDisposable(), TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
if (!isDisposed) {
observer.onNext(s)
}
}
override fun afterTextChanged(s: Editable) {
}
override fun onDispose() {
view.removeTextChangedListener(this)
}
}
}
TextWatcher
인터페이스를 구현하여, onTextChanged()
콜백에서 onNext()
이벤트를 통해 사용자의 입력값을 발행하는 내부 동작을 확인해볼 수 있다. 이러한 보일러플레이트 코드를 줄여준다는 점에서 RxBinding
의 효용성은 뛰어나다고 할 수 있다.
textChanges()
는 아래와 같이 사용하면 된다. EditText
로부터 Observable 을 따오는 동작을 수행하게 된다. editTextChangeObservable
에 Observable 을 받아두자.
val editTextChangeObservable = editText.textChanges()
받아둔 Observable 을 구독함으로써, 사용자의 입력값 변화를 감지해보자. 여기서부턴 일반적인 Reactive X 개념이 사용된다. 아래와 같이 subscribeBy()
를 정의해두고, 앱을 실행한 뒤 EditText
에 글자를 입력해보고 출력을 확인해보자. (참고로 onNext()
의 it
키워드에는 CharSequence! 타입의 데이터가 담긴다)
val searchEditTextSubscription: Disposable =
editTextChangeObservable
.subscribeOn(Schedulers.io())
.subscribeBy(
onNext = {
Log.d(TAG, "onNext : $it")
},
onComplete = {
Log.d(TAG, "onComplete")
},
onError = {
Log.e(TAG, "onError : $it")
}
)
잘 따라왔다면, 키보드를 한 번 누를 때마다 onNext : 키워드
형태로 로그캣이 찍히는 것을 확인할 수 있다. 그럼 이제 본격적으로 디바운싱을 적용해보자.
아래와 같이 debounce()
메소드를 붙여주면, 간단하게 디바운싱 구현이 완료된다. 전달 인자로 timeout
간격과 그 단위를 넘겨주기만 하면, 이 간격동안 데이터 스트림의 변화가 없을 때 최종 데이터를 발행하는 동작을 수행하게 된다.
val searchEditTextSubscription: Disposable =
editTextChangeObservable
// 마지막 글자 입력 0.8초 후에 onNext 이벤트로 데이터 스트리밍
.debounce(800, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io())
.subscribeBy(
onNext = {
Log.d(TAG, "onNext : $it")
// getQueryList(it) 등등 쿼리 동작 수행
},
onComplete = {
Log.d(TAG, "onComplete")
},
onError = {
Log.e(TAG, "onError : $it")
}
)
이렇게 작성하고 실행을 한 뒤 텍스트를 입력해보면, 신세계가 펼쳐진다. 쿼리가 0.8초 동안 더이상 변화하지 않으면 onNext()
를 통해 데이터가 발행되어 출력되는 것을 확인할 수 있다.
물론 메모리 릭 방지를 위해 Disposable 관리는 꼭 해주어야 한다! (해당 포스팅에선 다루지 않겠다)
너무 깔끔하지 않은가? 필자는 이러한 코드를 볼 때마다, Rx 의 매력에 더더욱 빠지곤 했다. 메소드들의 이름이 너무나도 직관적이고, 술술 읽히는 코드가 예술과도 같다. (너무 주접같긴 하다)
아래는 필자가 개발하고 있는 앱의 일부분을 녹화한 장면이다. 이 페이지 역시 검색 기능을 수행하며, Rx 를 활용하여 디바운싱 처리를 하여 UX 를 고려함과 동시에 효율성까지 챙길 수 있었다. 쓸데 없는 검색어 (중간 입력 과정 등) 를 모두 무시하고, 가장 마지막 상태의 검색어만을 활용하여 쿼리를 날리게 된다.
(엥 이미지가 왜 깨지지..)
검색 기능을 여러 페이지에서 사용하는 경우, EditText
의 확장함수로 분리해두면 언제 어디서든 이를 사용할 수 있기 때문에 한층 더 편리해질 것이다. (필자도 이렇게 사용하는 편이다)
// Edit Text 에 쿼리 디바운싱 적용
fun EditText.setQueryDebounce(queryFunction: (String) -> Unit): Disposable {
val editTextChangeObservable = this.textChanges()
val searchEditTextSubscription: Disposable =
editTextChangeObservable
// 마지막 글자 입력 0.8초 후에 onNext 이벤트로 데이터 발행
.debounce(800, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io())
// 구독을 통해 이벤트 응답 처리
.subscribeBy(
onNext = {
Timber.d("onNext : $it")
queryFunction(it.toString())
},
onComplete = {
Timber.d("onComplete")
},
onError = {
Timber.i("onError : $it")
}
)
return searchEditTextSubscription // Disposable 반환
}
editText.setQueryDebounce {
getQueryResult(it)
}
우리는 이로써, Android 를 더욱 리액티브하게 접근하는 수많은 방법들 중 한 가지를 익혔다. RxBinding
을 활용하여 View
컴포넌트들도 자유롭게 Observable 로써 활용할 수 있는 점, 알아 가자!
만약 더 효율적인 방법이 있거나 코드의 개선사항이 보인다면, 언제든지 피드백해주시면 감사하겠습니다!