[Android] EditText 를 Reactive 하게!

H43RO·2021년 9월 13일
8

Android 와 친해지기

목록 보기
8/26
post-thumbnail
post-custom-banner

🤚🏻 Reactive X, RxJava반응형 프로그래밍에 대한 사전 지식이 필요한 포스팅입니다.

예제 코드는 모두 킹갓 언어 'Kotlin' 을 기준으로 합니다.

사용자의 입력을 받는 컴포넌트, EditText

안드로이드를 개발하는 우리는 사용자들에게 무언가를 검색하는 기능을 제공해주기 위해 EditText 라는 View 위젯을 사용한다. EditText하나의 입력 상자로, 사용자가 키보드로 두드린 텍스트 입력값을 갖고 있게 된다. 따라서 보통 폼 입력 혹은 검색 기능을 위해 사용된다.

그럼 만약 검색 기능을 구현하고 싶다 할 때, 크게 두 가지 방법을 떠올릴 수 있다.

  1. 검색 버튼을 통한 수동 검색
  2. 사용자가 입력한 키워드로 자동 검색

첫 번째 '검색 버튼을 통한 수동 검색' 은 흔히 볼 수 있는 구조다. 사용자가 검색어를 다 입력하고, 보통은 오른쪽에 있는 '검색' 버튼을 누름으로써 쿼리 동작이 발생하여 결과를 표시하게 된다.

두 번째 방법은 사용자가 키워드를 입력하면, 별도로 검색버튼을 누르지 않아도 자동으로 검색하여 결과를 보여주는 방법이다. 검색 버튼을 누르기 위해 손가락을 움직일 필요가 없으므로, UX 측면에서 이 방법이 더 효율적으로 느껴진다.

그럼 사용자의 입력이 바뀔 때 마다, 자동으로 쿼리 동작을 수행하면 쉽게 구현할 수 있을 것이다!
.
.
.
라고 생각한다면 오산이다. 한 번 예시를 살펴보자.

쇼핑몰에서 '맥북' 을 검색한다고 가정해보자. 이상적인 동작은 '맥북' 이라고 입력하면, 자동으로 이 텍스트를 기반으로 쿼리를 수행하여 맥북과 관련된 리스트를 보여주는 것이다.

그런데 실제로 동작되는 모양을 살펴보면?

  • 'ㅁ' → 쿼리 수행
  • '매' → 쿼리 수행
  • '맥' → 쿼리 수행
  • '맥ㅂ' → 쿼리 수행
  • '맥부' → 쿼리 수행
  • '맥북' → 쿼리 수행

🤦🏻‍♂️ 어허.. 한 키워드로 검색할 뿐인데 가설 그대로 구현했다가 쿼리를 총 6번이나 수행하고 말았다. 심지어 '맥북' 쿼리를 제외한 5번의 쿼리는 쓸모도 없다.

당연히 매우 비효율적이고, 리소스를 낭비하는 행위이다. 조금 과장하면 부하를 일으키는 원인이 될 수도 있다.

"그럼 어떻게 처리 해주어야 이상적인 동작을 구현해낼 수 있을까?"
라고 생각이 들 때 필요한 개념이 바로 '디바운스' 이다.


Debounce 개념

디바운스는 불안정하게 연속적으로 발생하는 이벤트들 중 '가장 마지막에 발생하는 이벤트'만 처리하는 기법을 의미한다.

쉬운 이해를 위해 하드웨어 공학적인 이야기를 해보겠다. 우리가 흔히들 아는, 형광등 킬 때 누르는 딸깍 딸깍 '스위치' 가 대표적으로 디바운싱이 적용되는 하드웨어 요소이다.


(이거 아니다.)

우리 눈에는 스위치를 꾹 누른다고 하지만, 내부적으로는 물리적으로 떨림이 발생하여 스위치를 누르는 찰나의 순간에 엄청난 속도로 접점에 붙었다 떼졌다를 반복하여 전기 신호가 불안정해지는 '바운스 (채터링) 현상'이 발생하게 된다.

[이미지 출처] https://juahnpop.tistory.com/39

따라서 신호 변화가 완전히 사라졌을스위치의 값을 읽어들여 정상동작하게 하는 것이다. 만약 디바운싱 처리하지 않은 형광등 스위치라고 했을 때, 스위치를 누르는 찰나 엄청난 속도로 형광등이 파파파팟 깜빡일 수 있는 것이다. TMI : 심지어는 우리가 쓰고있는 키보드의 키 하나하나에 모두 디바운싱 처리가 되어있다. (키보드 스펙표에도 나와있음)

확 와닿지 않는가? 이 개념을 그대로 옮겨보면, 사용자가 검색어를 완벽하게 다 입력했을 때, 이에 반응하여 쿼리를 수행하도록 하여 불필요한 연산을 방지하는 로직을 구현하려는 것이다. 간단하다!

💁🏻‍♂️ 그렇다면 다시 '쇼핑몰 검색' 예제로 돌아와보자.

안드로이드의 EditTextTextWatcher 라는 인터페이스를 제공해준다. 입력값이 달라지는 순간 순간을 콜백 형태로 감지할 수 있는 인터페이스이다. 이러한 인터페이스는 기본적으로 제공이 되니까, 일정 시간 동안 입력값의 변화가 없음을 감지하여 그 때 쿼리를 수행하는 동작만 구현하면 된다.

하지만 콜백을 일일히 구현하고, 일일히 시간을 재서 입력값 변화를 감시하다보면 코드가 길어져 보기 불편해질 것이다.
이럴 때 사용하기 딱 좋은 것이 있다. 바로 반응형 프로그래밍 기법 이다.


Reactive X 를 활용해보자!

어떻게 보면 EditText 는 Observable 의 적임자 그 자체다. 사용자에 의해 데이터 (텍스트) 가 계속하여 바뀌고, 개발자들은 이 데이터를 활용하여 특정 동작을 수행한다. 이를 미루어봤을 때 EditText 자체를, 사용자의 입력값을 데이터로써 발행하는 'Observable' 으로 이용하면 더욱 편리하지 않겠는가?

왜냐하면, TextWatcher 라는 콜백 형태의 이벤트 핸들러를 통해 텍스트의 변경을 감지해서 일정 시간동안 텍스트의 변화가 일어나지 않느니 마느니를 다 고려해주다보니 불필요한 보일러 플레이트 코드가 발생하게 되는데, 만약 Rx 를 사용한다면 콜백 지옥에서 벗어나고, 간결한 코드로 다양한 동작을 수행할 수 있기 때문이다.

심지어는 Observable 에는 이미, 핵심인 디바운싱 처리를 할 수 있는 연산자를 지원해준다. (필자가 이전 포스팅에서 한 번 다룬 적 있다.)

debounce() 라는 것을 이용하면 된다. 지정해준 특정 간격동안 더 이상 데이터의 변화가 없을 때, onNext() 이벤트를 통해 데이터를 발행하는 역할을 해준다. 이 연산자를 응용하여 우리가 구현하려는 동작을 쉽게 뚝딱 만들어 낼 수 있다. 아주 편리하다.

하지만, 알다시피 Observable 을 생성하는 다양한 연산자들 중에 EditText 를 Observable 로 만들어 주는 연산자는 당연하게도 없다. Iterable 도 Callable 도 아니기 때문이다.

따라서, 이러한 View 에서 발생하는 이벤트를 Observable 데이터 스트림으로 바꿔주는 라이브러리도 존재한다. 바로 RxBinding 이다.

RxBinding

GitHub Repo : https://github.com/JakeWharton/RxBinding

이 라이브러리는, 안드로이드에 존재하는 View 들의 이벤트를 리액티브하게 처리하기 위한 기본적인 준비 과정들을 생략해주는 라이브러리이다. EditText, RecyclerView, ViewPager2, SwipeRefreshLayout 등 다양한 View 들에서 발생하는 이벤트를 Observable 형태로 쉽게 변환해준다.

그럼 이제 RxBinding 을 활용하여, EditText쿼리 디바운싱 동작을 구현해보자.


백문이 불여일타 (백 마디 글자보다 한 번 쳐보는게 낫다)

1. 라이브러리 의존성 추가

우선 라이브러리를 추가해주자. 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'

2. 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 을 따오는 동작을 수행하게 된다. editTextChangeObservableObservable 을 받아두자.

val editTextChangeObservable = editText.textChanges()

3. Observable 구독하기

받아둔 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 : 키워드 형태로 로그캣이 찍히는 것을 확인할 수 있다. 그럼 이제 본격적으로 디바운싱을 적용해보자.

4. 디바운싱 구현하기

아래와 같이 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 의 확장함수로 분리하기

검색 기능을 여러 페이지에서 사용하는 경우, 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 로써 활용할 수 있는 점, 알아 가자!

만약 더 효율적인 방법이 있거나 코드의 개선사항이 보인다면, 언제든지 피드백해주시면 감사하겠습니다!

profile
어려울수록 기본에 미치고 열광하라
post-custom-banner

0개의 댓글