본 포스팅에서는 RxJS를 통해 반응형 프로그래밍의 원리에 대해서 조금 더 자세하게 알아보는 시간을 가지려고 한다.
나의 이전 여러 포스팅에서 객체지향 방식에 대해서 설명을 했었다. 객체지향 방식에서는 클래스가 주요 작업 단위가 된다. 이러한 클래스를 얻을 때 까지 컴포넌트는 세분화가 되며 클래스의 상태를 조작하면 어플리케이션의 로직이 개선된다.
하지만 반응형 프로그래밍은 조금 다르다. 기본적으로 반응형 프로그래밍에서 기본 작업 단위는 스트림(stream)이다. 반응형 프로그래밍을 하기 위해서는 스트림 관점에서 생각하고 데이터를 유지하는 대신 원하는 상태에 도달할 때까지 데이터를 흐르게 하고 그 과정에서 변환을 적용하도록 설계해야 한다. 클래스의 메서드로 조작된 상태들의 합이 아닌, 원하는 동작을 구현하는 연산자 구성을 통해 생산자에서 소비자까지 연속적으로 이동하는 데이터 시퀀스로서 접근해야 한다.
함수형 프로그래밍(Functional Programming, FP)
반응형 프로그래밍을 이해하기 위해서는 우선적으로 함수형 프로그래밍에 대해서 알아야 한다. ReactiveX 프로젝트 사이트에서는 다음과 같이 반응형 프로그래밍을 정의한다.
ReactiveX는 옵저버 패턴과 이터레이터 패턴, 그리고 함수형 프로그래밍에서 나온 최상의 아이디어를 조합한 것이다.
함수형 프로그래밍은 함수로 선언적이고 불변(immutable)하며 부작용(side effect)이 없는 프로그램 만들기를 강조하는 소프트웨어 패러다임이다. 기존에는 변수를 변경하고 전달해서 문제를 해결한 반면, FP는 상태를 바꾸지 않으면서 같은 동작을 수행하도록 구현한다. RxJS는 FP에서 함수 체인(function chain), 지연 평가(lazy evaluation), 데이터 흐름을 조정하기 위한 추상적 데이터 타입 사용 개념을 차용한다.
함수형 프로그래밍에는 다음과 같은 특징이 있다.
- 선언적(declarative) : 자바스크립트의 고차 함수를 활용함. SQL 구문이 대표적인 선언적 코드의 예
- 불변성(immutable) : 데이터를 생성한 후나 선언된 후 이를 변경하거나 수정하지 않음. String 타입이 대표적인 예
- 부작용 없음(side effect free) : 지역 범위 밖에 있는 데이터에 따라 결과가 달라지지 않는 함수를 의미
불변성이 지켜지지 않고 사이드이펙트가 발생하는 함수는 신뢰할 수 없고 예측할 수도 없다. 객체 지향 측면에서 생각할 수 있는 해결책은 시스템이 다른 컴포넌트에 직접 접근하지 못하게 캡슐화를 통해 보호하는 것이다. 하지만 함수형 프로그래밍에서는 불변성을 유지하고 사이드이펙트를 예방하여 상태를 관리하기에 함수를 신뢰하여 실행할 수가 있다.
예를 들면 다음과 같은 경우 입력 배열의 내용을 실제로 변경하기에 나중에 사용하게 될 경우 부작용이 발생할 수 있다.
const lowest = arr => arr.sort().shift();
let source = [3,1,9,8,3,7,4,6,5];
let result = lowest(source); // 1
console.log(source); // [3,3,4,5,6,7,8,9]
서로 다른 컴포넌트에서 데이터 구조를 공유하고 사용하는 동시성 비동기 프로세스가 있는 경우에도 문제가 발생할 수 있다. 자바스크립트는 싱글 스레드 언어이기 때문에 다른 쓰레드의 공유 상태를 걱정할 필요가 없다. 하지만 웹 워커(Web worker)를 사용하거나 HTTP 호출을 동시에 할 때 동시성 코드를 다루게 된다. 아래의 그림의 경우 doAsyncWork() 함수가 서버에서 데이터를 불러오는 시간은 항상 일정하지 않고 때로는 오래 걸릴 수도 있기 때문에, 만약에 그 데이터가 불러오지 않은 상태에서 doMoreWork()로 전달될 경우 이 함수는 올바르게 실행될 수 없다.
함수형 프로그래밍은 추상화 수준을 높이고 프로그램의 목적을 명확하게 기술하여 프로그램의 용도가 아닌 기능을 설명하는 선언적 코딩 스타일을 장려한다. 루프나 if문을 사용하지 않고 프로그래밍을 하며 이러면서 원래의 값은 그대로 지키는 불변성도 유지한다. 이와 같이 부작용이 없는 함수를 순수함수(pure function)라고 한다. 순수함수는 테스트하고 추론하기가 쉽다. 간단한 연산 예제를 보자.
const isEven = num => num%2 === 0;
const square = num => num*num;
const add = (a,b) => a+b;
const arr = [1,2,3,4,5];
const result = arr.filter(isEven).map(square).reduce(add);
console.log(result); // 20
console.log(arr); // [1,2,3,4,5]
Rx는 이러한 함수형 프로그래밍에서 영감을 받았다. 스트림을 구독하여 파이프라인에 선언된 일련의 단계들을 통해 계산한 후 결과적으로 원하는 값을 반환한다. 데이터는 스트림을 통해 흐르고 끊임없이 변하지만 불변 데이터 타입이다. Rx는 객체나 값의 동적인 동작은 선언적이고 불변하게 지정한다.
또한 비즈니스 로직은 순수하며, 생산된 데이터를 원하는 결과로 변환하고자 스트림에 매핑되는 부가 작용이 없는 함수를 활용한다. 모든 부가 작용이 격리되고 소비자에게 전달이 되며 이러한 관심사의 분리는 이상적이고 비즈니스 로직을 깨끗하고 순수하게 분리한다.
FP에서 차용한 스트림의 또 다른 디자인 원칙은 지연평가(lazy evaluation)이다. 지연 평가는 코드가 실제로 필요할 때까지 호출되지 않는 것을 말한다. 구독자가 구독을 시작하면 스트림은 생산자에서 소비자로 단방향 바인딩 및 단일 흐름으로 파이프라인을 통해 이벤트 다운스트림을 내보낸다. 이 방법은 파이프라인이 단방향으로 실행되어 함수 호출이 순서대로 실행되도록 돕기 때문에 함수에 사이드이펙트가 있는 경우 유용하다. 만약 지연 평가가 없으면, 스트림히 무한대로 데이터를 받아들여 프로그램이 멈추게 될 수가 있다.
이터레이터 패턴
RxJS 스트림의 핵심 디자인 원칙은 배열처럼 친숙한 탐색 메커니즘을 제공하는 것이다. 이터레이터는 배열, 트리, 맵 심지어 스트림 등 이러한 요소를 활용하는데 사용되는 기본적인 데이터 구조와 독립적으로, 그 구조와 상관없이 데이터의 컨테이너를 탐색하는데 사용된다. 그리고 이 패턴은 반복 자체에서 각 요소에 적용된 비즈니스 로직을 분리하는 데에도 효과적이다. 이터레이터 패턴을 사용하는 목적은 각 요소에 접근하고 다음 요소로 이동하는 단일 프로토콜을 제공하는 것이다.
숫자 배열을 탐색하고 일정량의 인접 요소를 버퍼링하는 이터레이터 예제를 한 번 보자.
이터레이터를 사용하면 자바스크립트 런타임을 쉽게 활용하여 사용자를 대신해 반복을 처리할 수 있다. 위에서 구현한 이터레이터로 데이터를 버퍼링하는 방법이다.
const arr = [1,2,3,4,5,6];
for(let i of new BufferIterator(arr, 2)) { // 두 요소를 한 번에 버퍼링한다.
console.log(i);
}
// [1,2] [3,4] [5,6]
for(let i of new BufferIterator(arr, 3)) { // 세 요소를 한 번에 버퍼링한다. 버퍼링 로직돠 반복 메커니즘이 어떻게 완전히 분리되는지 주목한다.
console.log(i);
}
// [1,2,3] [4,5,6]
Rx에서 스트림은 Iterator 인터페이스를 준수하며, 이 스트림의 구독자는 그 안에 포함된 모든 이벤트를 듣는다. 이터레이터는 반복 메커니즘과 반복되는 데이터를 비즈니스 로직에서 분리하는데 큰 도움이 된다.
스트림의 데이터 기반 접근
객체 지향 접근법에서는 데이터 자체보다 지원 구조에 중점을 둔다. 예를 들면 자바와 같은 객체 지향 언어는 Array, ArrayList, LinkedList, DoublyLinkedList 등 각각 다른 사용 사례를 다루는 요소들의 순차적 컬렉션을 저장하기 위해 다양한 구현을 갖게 된다.
하지만 데이터 중심 접근은 조금 다르다. 데이터를 전면에 내세워 시스템 동작과 분리하는 것이 데이터 기반/데이터 중심 디자인의 핵심이다. 그리고 데이터가 들어있는 객체에 함수를 느슨하게 연결하는 것은 FP 디자인의 원칙이며 여기서 확장된 개념이 RP이다. 데이터 기반이 되려면 데이터의 존재에 따라 작동하고 로직에 데이터를 주입해야 한다. 동작할 데이터가 없으면 아무 작동도 하지 않아야 한다.
Rx.Observable로 데이터 소스 감싸기
옵저버블 객체의 컨택스트로 여러 종류의 입력을 다룰 수 있다. 데이터 타입을 분류해 보면 크게 다음과 같이 나누어서 생각해 볼 수가 있다.
- 방출 데이터 (emitted data) : 방출 데이터는 시스템과의 상호 작용 결과로 생성되는 데이터이다. 예를 들면 마우스 클릭과 같은 사용자 상호 작용이나 파일 읽기 같은 시스템 이벤트가 있다. 데이터를 요청하고 미래의 어느 시점에 응답을 받는다.
- 정적 데이터 (static data) : 정적 데이터는 이미 존재하며 시스템(메모리)에 있는 데이터입니다. 배열이나 문자열이 여기에 속한다. 인공적인 단위 테스트 데이터도 여기에 속한다. 실무에서는 이러한 데이터보다 방출/생성 데이터에 더 초점을 맞춘다.
- 생성 데이터 (generated data) : 생성 데이터는 주기적 또는 최종으로 생성하는 데이터이다. 피보나치 시퀀스와 같이 절차적인 경우도 있다. 그때그때 상황에 맞게 필요에 따라 클라이언트에게 줄 수 있어야 한다.
Rx에서 옵저버는 옵저버블을 구독한다. 옵저버는 옵저버블에서 방출된 모든 이벤트에 비동기로 반응하므로 대규모 이벤트가 발생하면 흐름을 차단하는 대신 어플리케이션을 유연하게 유지할 수 있다. 옵저버블은 현재의 값이 아니라 미래에 발생할 값에 대한 개념을 나타낸다는 점을 이해하는 것이 중요하다.
옵저버블은 언제 사용하면 좋을까? 데이터 소스는 동기/비동기, 단일값/다중값에 따라 크게 네 가지로 구분해 줄 수 있다.
- 동기, 단일 값 : 숫자와 문자 같은 데이터가 하나인 경우이다. 이 경우는 옵저버블을 사용하는 것이 과하다.
- 동기, 다중 값 : 배열이나 객체로 값을 가지는 데이터 컬렉션으로 컬렉션이 전부 소모될 때 까지 하나씩 순서대로 처리한다.
Rx.Observable.from([1,2,3]).subscribe(console.log);
// 1 / 2 / 3
const map = new Map();
map.set('key1', 'value1');
map.set('key2', 'value2');
Rx.Observable.from(map).forEach(console.log);
// ['key1', 'value1'] ['key2', 'value2']
- 비동기, 단일 값 : 비동기의 경우 코드가 단지 미래의 어느 시점에 실행됨을 보증한다. 따라서 후속 블록은 이미 수행된 이전 블록의 실행에 의존할 수 없다. 자바스크립트에서는 일반적으로 Promise로 처리한다.
const fortyTwo = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(42);
}, 5000);
});
Rx.Observable.fromPromise(fortyTwo)
.map(increment)
.subscribe(console.log); // 43
- 비동기, 다중 값 : 이터레이터와 Promise 패턴의 의미가 혼합되어야 한다. 일반적으로 EventEmitter를 사용하는데, 클로저가 전달될 수 있는 훅 또는 콜백을 제공하기 때문이다.
소비자와 데이터의 위치에 따라 푸시 기반(ex. 옵저버블)인지 풀 기반(ex. 이터레이터)인지 나뉘어진다. 푸시 기반은 데이터 소스에서 소비자로 데이터를 전송하고, 풀 기반은 소비자가 데이터를 요청한다.
풀 기반 패러다임은 연산에서 값을 즉시 반환할 수 있음을 알고 있을 때 유용하다. 하지만 소비자가 다음 데이터를 언제 사용할지 알 수 없는 마우스 클릭 같은 시나리오에서는 취약하다. 이러한 이유로 푸시 기반인 비동기에 해당하는 타입이 필요하다. RxJS 옵저버블은 푸시 기반 알림을 사용한다. 데이터를 요청하지 않으며 데이터가 옵저버블에 전달되어 옵저버블이 반응하는 식이다.
옵저버로 데이터 사용하기
옵저버블을 통해 방출되고 처리되는 모든 데이터에는 목적지가 필요하다. 옵저버는 구독 컨텍스트 안에서 만들어지는데, 이는 옵저버블 소스에서 subscribe()를 호출한 결과가 Subscription 객체임을 의미한다. 옵저버블은 동기 또는 비동기로 작동하므로 옵저버블의 소비자는 콜백과 함께 발생되는 제어의 역전을 어떤 식으로든 지원해야 한다. 옵저버블은 옵저버의 next() 함수로 더 많은 데이터를 사용할 수 있음을 옵저버 구조에 신호를 보내거나 호출해야 한다.
옵저버의 메서드 호출 방식은 다음과 같이 next(), error(), complete() 세 가지로 이루어져 있다. subscribe()가 호출되면 다음 세 가지 메서드를 드러내는 API를 사용하여 옵저버가 암시적으로 생성되는 모습은 아래 그림과 같다.
const observer = {
next: function() {
// 다음 값을 처리함
},
error: function() {
// 사용자에게 알림
},
complete: function() {
}
}
이번 포스팅에서 RxJS의 세 가지 주요 부분인 생산자(옵저버블), 파이프라인(비즈니스 로직), 소비자(옵저버)를 알아보고 반응형(그리고 함수형)으로 생각하는 방법을 살펴보았다. 앞으로 반응형에 대한 더 깊이있는 예제를 다루면서 RxJS를 다음 포스팅부터 본격적으로 다루어 보고자 한다.
참고자료
- <RxJS 반응형 프로그래밍> 폴 대니얼스, 루이스 아텐시오 저
'Prog. Langs & Tools > RxJS' 카테고리의 다른 글
[RxJS] Ch06. 반응형 스트림 적용하기 - 상 (0) | 2021.04.12 |
---|---|
[RxJS] Ch05. RxJS에서의 시간 - 하 (0) | 2021.04.05 |
[RxJS] Ch04. RxJS에서의 시간 - 상 (0) | 2020.12.17 |
[RxJS] Ch03. 핵심 연산자 (0) | 2020.11.23 |
[RxJS] Ch01. 반응형으로 생각하기 (0) | 2020.10.03 |