[RxJS] Ch01. 반응형으로 생각하기
Prog. Langs & Tools/RxJS

[RxJS] Ch01. 반응형으로 생각하기

개인적인 목적으로 RxJS를 공부하기 시작했다. 공부한 내용들을 여기에서 정리하고 공유하고자 한다.

 

동기 연산 vs 비동기 연산

동기 코드와 비동기 코드의 차이는 지연시간(latency)이 있는지 없는지의 차이가 가장 큰 차이점이 아닐까 싶다. 일반적으로 비동기적인 코드보다 동기적인 코드가 이해하기가 더 수월하다. 하지만 우리가 개발하는 어플리케이션에서는 메시지를 보내고 응답이 올 때 까지 시간이 걸리고 이 시간들이 쌓이는 동안 아무것도 하지 않으면 어플리케이션이 정상적으로 돌아가지 못하는 수준까지 가는 경우도 있다. 기존의 이러한 방식에서 벗어나 복잡성이 증가한 어플리케이션에서 유휴 상태가 없이 사용자의 시간을 아낄 수 있게 하기 위해서는 비동기적인 처리가 불가피하다.

동기 코드를 구현하는 가장 쉽고 단순한 방법은 각 블록의 코드가 실행되기 전 이전 코드가 실행이 완료되었는지를 확인하고 실행하는 것이다. 안전하고 구현이 쉬운 방법이지만 API 호출 등 시간이 오래 걸리는 작업을 이렇게 하게 되면 블로킹 되는 시간이 너무 길어져서 어플리케이션을 정상적으로 사용할 수 없다.

let items = blockingHttpCall('/data');
items.forEach(item => {
  // 각 항목 처리
});

이를 해결하기 위해 블로킹 호출을 한 후 응답을 기다리는 동안 다른 작업을 수행하게 할 수도 있다. 하지만 아주 짧은 시간동안 일어나는 이벤트 처리등의 작업이 무수히 쌓이게 되면 동기적으로 처리하는 데에는 한계가 나타난다. 이를 위해 자바스크립트는 콜백 함수를 제공하여 논블로킹(non-blocking) 방식으로 해결할 수 있게 지원한다.

 

자바스크립트는 싱글 스레드 언어이기에 위와 같은 구조가 필요하다. 데이터를 사용할 준비가 되면 자바스크립트 런타임에 호출할 처리 함수를 제공함으로써 장기 실행 작업 및 블로킹 문제를 해결하고자 콜백 함수가 쓰이게 되었다. 그 동안 어플리케이션은 계속해서 다른 작업을 수행할 수 있다. 이 때 제어의 역전이 발생하는데, 제어의 역전이란 코드의 특정 부분이 런타임 시스템에서 제어의 흐름을 되돌려 받는 방식을 말한다. 콜백 함수가 데이터를 처리할 준비가 되면 런타임이 함수 처리기를 통해 어플리케이션을 다시 호출하거나 제어권을 돌려준다.

ajax('/data', items => {
  items.forEach(item => {
    // 각 항목 처리
  });
});

beginUiRendering();

위 예제 코드에서 HTTP 함수는 백그라운드에서 실행되고 호출자(어플리케이션)에게 즉시 제어권을 반환하여 UI 렌더링을 시작한다. UI 렌더링은 완전히 로드된 후 items의 내용을 처리한다.

 

비동기 코드가 이와 같이 이상적이긴 하지만 좋은 점만 가지고 있는 것은 아니다. 동기 코드의 경우 특정 시점에 저장된 정보를 스냅샷으로 이해할 수 있다. 반면, 비동기 코드의 경우 작업의 대기 시간과 완료 시간이 다 다르기 때문에 예측할 수 없는 시간에 종료되는 함수를 적절하게 처리해주기가 어렵고, 전역 상태를 공유하는 함수의 경우 사이트 이펙트가 생길 수도 있다. 비동기 코드에서는 특정 시점에서 저장된 정보를 예측할 수 없는 것이다.

이렇게 사이드 이펙트가 발생하지 않게 하기 위해서 함수형 프로그래밍(FP)이나 반응형 프로그래밍(RP)에서는 순수 함수(pure function)를 사용하여 불안정성을 최소화 한다. 순수함수란 외부의 상태에 영향을 받지 않고 동일한 인자에 대해 동일한 리턴값을 가지는 함수를 말한다. 사이드 이펙트를 해결하고 나서도 시간 이슈가 있다. 따라서 콜백함수가 단계적으로 실행되도록 중첩해서 작성한다. 이렇게 코드를 작성하면 시간 의존성(time dependency)이 발생하게 된다. 이렇게 콜백을 중첩해서 사용하는 상황을 콜백 지옥(callback hell)이라고 부르며, 유지보수가 쉽고 합리적인 프로그램을 만들고 싶다면 피하는 것이 좋다. 여기에 반복문까지 사용하면 진짜... 힘들어 질 수도 있다. (참고로 RxJS에서는 반복문 되신 순수 함수를 활용하여 비동기 요청 시퀀스를 생성할 수 있다.)

콜백을 사용하는 사례로 이벤트 이미터(Event Emitter)를 생각해 볼 수 있다. 이벤트 이미터는 비동기 이벤트 기반 아키텍처에서 널리 사용되는 기술 방식이다. Node.js 같은 서버에서는 특정 종류의 객체가 주기적으로 함수를 호출하는 이벤트를 생성한다. Node.js에서 EventEmitter 클래스는 웹소켓 I/O 또는 파일 읽기/쓰기와 같은 API를 구현하여 디렉터리를 순회하는 중에 관심있는 파일을 발견하면 객체가 해당 파일을 참조하는 이벤트를 발생시켜 추가 코드를 실행하게 한다.

<RxJS 반응형 프로그래밍> p31

예제에서 이벤트 이미터는 addListener()를 통해 구독한다. 이 메서드를 사용하면 관심 이벤트가 발생했을 때 호출될 콜백을 등록할 수 있다. 그럼에도 불구하고 중첩된 비동기 흐름을 구성하는 일은 어렵다. 이를 해결하기 위해 Promise를 대안으로 사용한다.

 

Promise

사실 개인적으로 Promise에 대한 내용은 이전에 블로그 포스팅에서 정리해 둔 것들이 있어서 중복되는 내용은 빼고 정리하고자 한다.

프로미스의 가장 큰 장점은 일련의 작업을 미래 값과 연결하여 연속(continuation)을 구현할 수 있다는 점이다. 프로미스를 사용하면 코드를 지속적으로 일급 객체(first class citizen)로 만들어 나가는 과정을 할 수 있다. 일급 객체란 객체를 일급 시민으로서 취급한다는 뜻이며, 일급 시민은 변수에 담을 수 있고, 인자로 전달할 수 있으며, 변환값으로 전달할 수 있는 값을 말한다. 프로미스 함수들을 선언적(달성하려는 방법을 표현하는 것이 아닌 달성하려는 을 표현하는 방식)으로 적용하면 부가 작용을 순수 함수 방식으로 표현할 수 있다.

물론 프로미스에도 단점이 있다. 프로미스의 단점은 마우스 움직임이나 파일 스트림의 바이트 시퀀스처럼 둘 이상의 값을 생성하는 데이터 소스를 처리할 수 없다는 점이다. 또한 프로미스는 불변이라 취소할 수 없다는 점도 크리티컬한 단점으로 꼽힌다. 프로미스로 원격 HTTP를 호출 값을 래핑할 때 해당 작업을 취소할 수 있는 메커니즘이 없다.

앞서 언급한 이벤트 이미터와 지금 살펴본 프로미스는 본질적으로 같은 문제를 다른 방식으로 처리한다. 이렇게 두 가지 방법을 모두 사용해서 코드를 작성하다 보면 읽기도 어렵고 이해하기도 어려운 코드가 나타나게 된다.

 

다른 패러다임의 필요성

RxJS는 다음과 같은 문제들을 해결하는데 도움을 줄 수 있다.

깊게 중첩된 함수는 가독성과 복잡성 측면에서 문제가 되는 다른 변수들과 함께 얽히게 된다. 따라서 독립적으로 유지 보수와 단위 테스트를 할 수 있는, 느슨하게 결합된 비즈니스 로직을 얻으려면 재사용 가능한 모듈형 컴포넌트를 생성하는 것이 좋다.

이벤트 또는 장기 실행 작업이 멋대로 작동하거나 취소해야 할 상황임을 감지하기는 어렵다. 미리 정한 시간이 지나면 이벤트를 깨끗이 취소할 수 있는 메커니즘을 두는 것이 좋다.

제대로 만든 반응형 디자인은 항상 사용자와 UI 컴포넌트의 상호 작용을 적절히 조절하여 시스템이 불필요하게 과부화 되지 않는다. 수동을 제한을 가하는 방식은 일반적으로 작동이 제대로 이루어지지 않고, 지역 범위를 벗어나는 데이터에 접근하는 함수들을 포함하고 있어 전반적으로 프로그래밍의 안정성이 저하된다.

UI가 커지고 복잡해짐에 따라 남아 있는 이벤트리스너들이 메모리 누수를 일으키고 브라우저 프로세스의 크기가 커지게 된다. 브라우저가 코드에서 메모리 관리를 걱정하지 않아도 될 만큼 저수준의 세부 사항 대부분을 처리하지만 오늘날의 자바스크립트 어플리케이션의 복잡성은 이전과 비교가 되지 않을 만큼 복잡하다.

우리에게 필요한 방법은 코드에서 지연 시간이라는 개념을 추상화 하는 동시에 시간이 지남에 따라 데이터가 흐를 수 있는 일련의 선형 단계를 사용하여 해결책을 모델링하는 방법이다. 

 

RxJS 이해하기

RxJS(Reactive Extensions for JavaScript)는 파일 읽기, HTTP 호출, 키 입력 또는 마우스 움직임 등 흔한 이벤트의 소스를 처리하는 단일 프로그래밍을 사용하여 콜백 또는 프로미스 기반 라이브러리를 정확히 같은 방식으로 대체한다. 이러한 데이터 소스를 지금부터는 데이터 스트림(data stream)이라고 부를 것이다. 

// 예제
let a = 20;
let b = 22;
let c = a + b; // 42

a = 100;
c = ?

// 의사 코드
A$ = [20];
B$ = [22];
C$ = A$.concat(B$).reduce(adder); // 42

A$.push(100);
C$ = ?

스트림은 배열과 매우 유사한 데이터의 컨테이너(또는 래퍼)이다. 따라서 배열의 리터럴 표기법인 []를 사용하고 스트림을 가리키는 변수를 한정하는 데는 접미사 $를 사용하는 것이 일반적이다. 두 스트림을 연결할 때도 reduce와 같은 연산자 메서드를 사용해야 한다.

명령형 프로그래밍에서는 c의 값이 정해지고 a의 값이 바뀌더라도 c의 값이 변하지 않는다. 하지만 스트림에서는 A$가 새로운 값을 받으면 이 상태는 A$가 속한 모든 스트림을 통해 주입되고, C$에도 영향을 미쳐서 결국 122라는 값을 가지게 된다. RP는 데이터의 흐름과 전파를 중심으로 한다. 

RxJS는 스트림을 구독하고 관리하기 위해 일급 객체로 데이터를 전달하고 다른 스트림들과 결합할 수 있는 경량 데이터 타입을 제공한다. 이 때 스트림은 스트림을 듣는 구독자(또는 옵저버)가 있을 때 까지 실제로 아무 일도 하지 않고 유휴 상태로 있다. 이 부분이 프로미스와 다르다. 스트림은 구독자가 연결된 후 실행하므로 지연(lazy) 데이터 타입이라 볼 수 있다.

Stream(42).subscribe(
  val => { // 스트림에서 각 이벤트와 함께 호출될 간단한 함수를 사용한다.
    console.log(42);
  }
);
옵저버 패턴

옵저버 패턴은 View 계층이 Model 변경 사항을 지속적으로 듣는 MVC 아키텍처의 필수 요소로, 많은 어플리케이션에서 사용한다. 제대로 구축하지 못할 경우 옵저버의 부적절한 제거와 관련된 메모리와 누수가 발생한다. RxJS는 이 패턴에서 영감을 얻었으나 스트림 완료시 신호, 지연 초기화, 취소, 리소스 관리 및 폐기 등 추가적인 기능을 더 가지고 있다.

 

스트림 생산자와 소비자 사이에 일어나는 작업을 파이프라인이라고 한다. 간단한 예제를 살펴보자. 지금까지는 정적 데이터 소스로 스트림을 만들었지만 앞으로는 동적 데이터 소스로 확장해서 사용할 수 있다.

Stream([1, 2, 3, 4, 5])
  .filter(num => (num % 2) === 0)
  .map(num => num * num)
  .subscribe(val => {
    console.log(val);
  }
);

Rx 스트림의 컴포넌트를 본격적으로 살펴보면 다음과 같은 것들로 이루어져 있다.

  • 생산자
    • 데이터의 소스이다. RxJS에서 수행할 모든 로직의 시작점이 된다. 이러한 생산자를 옵저버 패턴에서 서브젝트(subject)라고 정의하며, RxJS에서는 옵저버블(Observable)이라고 부른다.
    • 옵저버블은 알림을 푸시하는 역할만 해서 이 동작을 실행 후 잊기라 부른다. 생산자는 이벤트 방출에만 관여하고 이벤트 처리에는 관여하지 않는다.
  • 소비자
    • 소비자는 소비할 이벤트에 대해 생산자를 듣기 시작하며 스트림을 생성한다. 이 시점에 스트림은 이벤트를 푸시한다. 소비자를 옵저버(Observer)라고 부르기도 한다.
    • 이벤트는 항상 옵저버블(생산자)에서 옵저버(소비자)로 흐른다.
  • 데이터 파이프라인
    • 데이터를 조작하고 편집하는 곳이다. 두 개체 간 관심사의 분리가 촉진되고 코드의 모듈성에도 큰 도움이 된다.
  • 시간
    • 시간 개념은 스트림을 조작하는데 사용할 수 있다.
    • 요구사항보다 더 빠르게 혹은 더 느리게 실행되는 스트림을 생성할 수 있다.

 

참고자료

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