본문 바로가기

Prog. Langs & Tools/RxJS

[RxJS] Ch05. RxJS에서의 시간 - 하

RxJS 목차

이전 포스팅의 내용에 이어서 RxJS에서 다루는 시간에 대해서 다뤄 보고자 한다.

 

사용자 입력 처리하기

interval()과 timer() 정적 메서드는 모두 옵저버블을 만들고 설정된 오프셋 시간 후 작업을 시작하는데 사용된다. 이들은 delay()와 함께 설정된 간격 또는 설정된 시간 후에 한 번 실행되는 미래의 작업을 스케줄링 할 때 가장 많이 사용하는 조합이다. 이 연산자는 수행할 작업을 알고 있으며 이를 나중에 실행되도록 스케줄링하려는 명시적 이벤트에 사용하기 적합하다.

그런데 만약 마우스 움직임이나 키 입력처럼 동적 이벤트 이미터에 일련의 이벤트가 생성되어 짧은 시간에 많은 이벤트를 방출한다면 어떤 일이 일어나게 될까? 이 과정에서 유용한 옵저버블 메커니즘인 디바운싱과 쓰로틀링을 살펴보도록 하자. 이 둘은 서로 비슷한 기능을 수행한다. 

 

디바운싱 (debouncing)

소프트웨어 용어에서 디바운싱이란 '호출 없이 일정 기간이 경과한 경우에만 함수 또는 일부 동작을 실행함'을 뜻하는데, 여기서는 다른 값을 방출하지 않고 설정된 시간 범위가 지나면 옵저버블 시퀀스에서 이벤트가 방출됨을 의미한다. 마블 다이어그램으로 표현하면 아래와 같이 표현할 수 있다.

아래 예제는 빠른 연속 클릭 후 가장 최근 클릭을 방출하는 디바운싱 연산자를 보여주는 간단한 예제 코드이다. 사용자는 클릭 이벤트를 생성할 수 있지만, 비활성 상태로 1초가 지나면 마지막 이벤트만 방출이 된다.

이러한 연산자는 검색 프로그램 등에서 유용하게 사용될 수 있다. 사용자가 키워드를 입력할 때, 각 문자를 입력할 때 마다 해당 웹 요청을 하는 것은 낭비가 된다. 비용이 비싼 라운드 트립 요청을 하기 전, 사용자가 먼저 입력한 뒤 일정 시간을 기다리게 하는 것이 좋다. 이렇게 하면 사용자가 입력하는 동안 서버에서 수행한 웹 요청 수를 제한한다는 이점이 있으며 전반적으로 리소스 사용률이 더 낫다.

RxJS를 사용하지 않고 이러한 로직을 작성하려면 setTimeout()과 같은 콜백함수를 사용하여 수동으로 직접 구현을 해 주어야 한다. 명령형으로 작성한 예제는 아래와 같이 짤 수 있다.

사용자가 키를 입력할 때마다 해당 키 입력 이벤트가 이벤트 리스너를 작동시킨다. 이벤트 리스너 내부에서 clearTimeout()으로 기존에 보류한 타임아웃을 지운다. 그리고 타이머를 재설정 한다. 이 모든 결과는 입력이 끝나거나 입력 사이에 지연이 있을 때 까지 작업이 실행되지 않음을 의미한다.

이 코드에는 몇 가지 바람직하지 않은 부분이 있다. 첫 번째로 콜백의 클로저 내에 접근할 수 있는 외부 timeoutId 변수를 작성해야 하며, 이 변수는 이벤트 핸들러의 범위 밖에 존재해야 한다는 것이다. 이로 인해 전역 상태의 오염이 더 심각해지는 부작용이 있다. 두 번째로 관심사의 분리가 존재하지 않는다. 작업 자체가 의도한 바를 거의 나타내지 않으며, 모든 비즈니스 로직과 함께 타임아웃 값이 디바운싱 로직과 얽히게 된다.

따라서 이 작업을 정리해보자. 함수형을 적용하며, 디바운싱 메커니즘을 캡슐화 하여 나머지 비즈니스 로직과 분리하고 메서드를 클로저 밖으로 꺼내는 setTimeout()과 유사한 메서드를 만든다.

이렇게 하면 동작을 여러 함수로 나누어서 유지보수가 쉬워진다. 하지만 이렇게 작업을 수행하게 되면 작업 완료 결과를 더 이상 쉽게 사용할 수 없다는 또 다른 문제가 발생한다. 결과를 클로저 외부로 전달할 방법이 없으므로 이벤트 처리 로직을 모두 sendRequest()로 전달해야 한다. 따라서 자체 디바운싱 로직을 구현하기가 어려워지고 디자인에 제약이 많아진다.

RxJS를 사용하면 이 작업을 매우 간단하게 만들 수 있다. 모든 DOM의 상호작용을 옵저버에 전달하면 비즈니스로직이 앞에서 설명한 복잡한 흐름도에서 아래와 같은 추상화 모델로 매우 단순해 진다. 여기서 추상화 모델은 데이터가 항상 생산자에서 소비자로 움직이는 전형적인 단방향 양식이다.

반응형으로 생각할 때 디바운싱 연산은 간단히 처리 파이프라인 안에 내장된 필터라고 볼 수 있다. 처리 파이프라인은 옵저버에서 특정 이벤트를 제거할 때 시간을 사용한다. 그러므로 옵저버블을 사용하여 스트림에 일급 객체로서 시간을 명시적으로 주입할 수 있다. 이에 대한 함수형 버전에서는 sendRequest()를 보다 간결하고 순수하게 만들고 debounceTime()으로 모든 디바운싱 로직을 구현한다. 아래 예제는 함수형-반응형 버전이다.

이 방식의 장범은 다른 연산자들과 마찬가지로 debounceTime()은 단순히 스트림에 삽입된다. RxJS의 시간 추상화를 통해 다른 모든 연산자와 함께 시간을 완벽하고 투명하게 도입할 수 있다. 아래의 그림은 디바운싱이 스트림으로 전달되는 사용자 입력에 미치는 영향을 보여준다.

 

스로틀링(throttling)

스로틀링은 일정 시간 뒤에 다른 값이 따라오는 옵저버블 시퀀스의 값을 무시한다. 다시 말하면 아래의 그림처럼 매 회 한 번만 함수를 실행한다.

예를 들면 뱅킹 사이트에서 돈을 출금하는 것과 같은 액션 버튼 제어나 쇼핑 사이트의 원 클릭 구매 버튼 주변의 로직들을 추가할 때 효과적으로 사용할 수 있다. 아래와 같은 코드는 사용자가 마우스를 빠르게 움직이더라도 중간에 수백 개의 이벤트가 발생하는 대신 2초 동안 한 번만 실행이 된다.

delay()와 같은 시간 기반 연산자는 데이터 손실 없이 방출 이벤트를 일시적으로 지연하고자 버퍼링 또는 캐싱 로직을 포함하고 있다. 이를 통해 RxJS가 옵저버블 시퀀스의 이벤트에서 시간을 제어하거나 조작할 수 있다. 그리고 더 나아가 RxJS는 버퍼링 연산자들을 드러내어 일정량 또는 일정 시간 동안 데이터를 임시로 저장하는데 직접 사용할 수 있다. 스트림이 흐르기 전에 결정을 내리고 잠재적으로 스트림을 변환한다. 아래에서 더 자세하게 알아보도록 하자.

 

RxJS에서의 버퍼링

RxJS는 이벤트 홍수를 한 번에 처리하지 않고 마우스 움직임과 클릭, 키 입력 같은 이벤트를 임시로 캐싱하고 구독자에게 브로드캐스팅 하기 전에 비즈니스 로직을 이벤트에 적용하는데 유용하다. 버퍼링 연산자는 과거 데이터를 일시적으로 저장하는 기본 데이터 구조를 제공하므로 아래 그림과 같이 한꺼번에 작업을 할 수 있다.

이러한 버퍼링은 아이템 처리의 오버헤드가 큰 작업에 유용하며, 여러 아이템을 한꺼번에 처리하는 것이 좋다. 대표적인 예시가 마우스를 움직이거나 웹 페이지를 스크롤하는 사용자에게 반응하는 경우이다. 마우스의 움직임은 수백 개의 이벤트가 동시에 발생하기 때문에 마우스나 페이지의 위치에 따라 일정량을 버퍼링한 다음 옵저버블을 방출할 수 있다.

대표적인 버퍼 연산자 API는 다음과 같다.

  • buffer(observable) : 제공된 옵저버블이 값을 방출할 때 까지 들어오는 옵저버블의 값을 버퍼링 하는데, 이 때 반환된 옵저버블에서 버퍼를 방출하고 다음에 옵저버블이 방출할 때를 기다리며 내부적으로 새로운 버퍼를 생성한다.
  • bufferCount(number) : 소스 옵저버블으로부터 여러 값을 버퍼링 한 다음, 버퍼 전체를 방출하고 지운다. 이 시점에서 새로운 버퍼가 내부적으로 초기화된다.
  • bufferWhen(selector) : 버퍼를 즉시 열고 selector가 호출하여 반환된 옵저버블이 값을 방출했을 때 버퍼를 닫는다. 이 때 즉시 새 버퍼가 열리며 이 프로세스가 반복된다.
  • bufferTime(time) : 일정 기간 소스의 이벤트를 버퍼링한다. 시간이 지나면 데이터가 방출되고 새 버퍼가 내부적으로 초기화된다.

buffer()는 닫는 옵저버블이라고 하는 전달된 옵저버블이 이벤트를 방출할 때까지 버퍼에서 소스 옵저버블이 방출한 이벤트를 수집한다. 이 시점에서 buffer()는 버퍼링된 데이터를 비우고 내부적으로 새 버퍼를 시작한다. 아래의 예제는 50ms 마다 타이머가 후속 값을 방출한다. 그리고 500ms의 옵저버블 닫기 타이머로 이벤트를 버퍼링한다. 따라서 500/50 = 10개의 이벤트가 한 번에 방출이 된다.

bufferCount()는 한 번에 일정량의 데이터를 보유하며 그 크기는 전달된 데이터의 크기에 의해 정의된다. 데이터의 크기가 특정 수에 도달하면 데이터가 방출되고 새 버퍼가 시작된다. bufferCount()로 크기가 큰 숫자 입력에 대한 경고 메시지를 표시할 수 있다. 금액 필드의 변경 사항을 수신하여 금액의 값이 5자리면 옆에 경고 표시를 할 수 있다.

bufferWhen()은 다른 옵저버블이 값을 방출할 때 까지 이벤트를 캐싱할 때 유용하다. 그리고 버퍼가 방출해야 할 때를 알리기 위해 옵저버블을 생성하는 셀렉터 함수를 사용한다는 점이 buffer()와 약간 다르다. 이 연산자는 종속된 옵저버블의 속도에 맞춰 버퍼링하지 않는다. 팩토리 함수를 호출하여 언제 새로운 버퍼를 생성하고 방출 및 재설정해야 할지 지정하는 옵저버블을 만든다.

위의 프로그램의 목표는 값을 폼 필드에 입력할 때 버퍼에 이를 보관하여 원하면 언제든지 값으로 되돌리는 것이다. 키 입력을 수신하는 스트림은 0.5초가 조금 넘는 시간에 이벤트를 캡쳐하기 위해 디바운싱한다. 입력된 단어는 간단한 유효성 검사를 거치며, 데이터는 버퍼를 채우고 기록을 요청할 때만 비워지고, 이 옵저버블은 버퍼를 닫고 기록을 출력한다.

bufferTime()은 일정 기간 옵저버블 시퀀스의 데이터를 유지했다가 옵저버블 배열로 방출한다. 

출처 : rxjs 공식 문서

 

이번 포스팅에서는 RxJS에서의 디바운싱과 쓰로틀링, 버퍼링에 대해서 알아보았다. 다음 포스팅에서는 RxJS의 조금 더 현실적인 문제들을 다루어 보고자 한다.

 

참고자료

  • <RxJS 반응형 프로그래밍> 폴 대니얼스, 루이스 아텐시오 저
  • RxJS 공식 문서