[번역] 반응형 프로그래밍과 RxJS 이해하기

요약: RxJS는 이벤트 스트림과 데이터를 쉽게 만들고 다룰 수 있도록 도우는 라이브러리다. 복잡하지만 가독성이 좋은 비동기적 코드를 더 쉽게 작성할 수 있도록 도운다.


큰 규모의 비동기 어플리케이션을 작성하는 것은 쉬운 일이 아니다. 모두 콜백 헬 이슈를 한 번 쯤은 겪어보았을 것이다. 이런 걱정이 점점 커지면서, 사람들은 스스로 비동기적 JavaScript 세계를 개선했다. 모두 Promise, Generatorasync/await에 대해서 들어보았을 것이다. 또 다른 솔루션이 있다. 바로 RxJS다.

RxJS의 GitHub README에는, RxJS는 “Observable 시퀀스와 표현력있는 쿼리 연산자를 사용하는 비동기적, 이벤트 기반의 프로그램을 구성하기 위한 라이브러리의 집합”이라고 정의되어있다. 이것은 이벤트나 데이터로부터 스트림을 만들 수 있다는 것을 뜻한다. 이 데이터를 가지고 병합(Merge)하거나, 으깨(Mash)거나, 쪼개(Split)는 등의 작업을 할 수 있다. 원하는 데이터가 있으면, 데이터를 순회하면서 무언가를 할 수 있다.

“Observable”이나 “스트림”의 개념은 처음에 이해하기가 어려울 수도 있다. 나는 그것들을 한 번에 한 가지만 사용하는 단일 이벤트 혹은 데이터라고 생각하기 보다는, 어떤 기간동안 다루게 되는 이벤트나 데이터의 컬렉션이라고 생각한다.

RxJS가 어떻게 동작하는지 보기 위해서, 간단한 날씨 어플리케이션을 작성하게 될 것이다. 우편번호를 입력하기 위한 텍스트 input이 있을 것이고, 버튼을 클릭해서 Submit하면 우편번호 위치의 현재 온도 데이터를 얻기 위한 요청이 전송될 것이다. 온도를 얻고나면, 우편번호와 온도를 함께 페이지에 표시할 것이다. 또한 페이지에 많은 온도를 넣어 계속 Watch 하도록 만들 것이다. 그리고 지정한 시간 후에 온도를 새로고침하는 타이머를 만들게 될 것이다. 이제 시작해보자!

설정

첫 번째로 해야할 일은 RxJS 라이브러리를 로드하고 사용할 수 있는 기본적인 HTML 페이지를 만드는 것이다. 또한 약간의 CSS도 포함할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Weather Monitoring in RxJS</title>
<style>
#form {
margin-bottom: 20px;
}
.location {
float: left;
padding: 10px;
margin-right: 20px;
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.location p {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.zip { font-size: 2em; }
.temp { font-size: 4em; }
</style>
</head>
<body>
<div id="app-container">
<div id="form">
<label>Zip Code:</label>
<input type="text" id="zipcode-input">
<button id="add-location">Add Location</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/4.1.0/rx.all.min.js"></script>
<script>
// our code will go here
console.log('RxJS included?', !!Rx);
</script>
</body>
</html>

이 파일을 선호하는 브라우저에서 열고, 개발자 콘솔창을 열어보자. RxJS included? true 라는 문장이 나타났는가? 그렇다면 이제 반응형 JavaScript를 작성할 준비가 끝난 것이다! 우편번호를 넣는 <input><button>을 포함하는 간단한 “form”이 있다는 것을 확인하라. 첫 번째 JavaScript 코드는 이 element들의 이벤트로부터 스트림을 만드는 게 될 것이다. 또한 나중에 element들을 추가할 수 있도록 app-container의 참조도 얻을 것이다.

1
2
3
4
// Grab HTML elements
const appContainer = document.getElementById('app-container');
const zipcodeInput = document.getElementById('zipcode-input');
const addLocationBtn = document.getElementById('add-location');

평범한 JavaScript 코드다. 별로 특별한 것은 없다.

1
2
3
4
5
6
// Get stream of button clicks
const btnClickStream =
Rx.Observable
.fromEvent(addLocationBtn, 'click')
.map(() => true)
.forEach(val => console.log('btnClickStream val', val));

드디어 첫 번째로 RxJS 코드를 작성한다! Rx.Observable 객체의 fromEvent 메소드를 사용하여 addLocationBtn으로부터 발생하는 클릭 이벤트로부터 스트림을 만들었다. 이것은 언제든 버튼이 클릭될 떄마다, 이벤트 객체가 btnClickStream으로 전송된다는 것을 의미한다. 반환된 스트림의 각 값을 true로 매핑하기 위해서 map 메소드를 사용했다. 나는 내 머릿속에 있는 로직을 단순화하는 것을 좋아한다. 이벤트가 일어났는지만 확인하면 되기 때문에 값을 간단한 boolean 값으로 매핑했다. 이것은 그냥 내가 좋아하는 방식이다. 만약 당신의 취향과 맞지 않는다면 지워져도 상관없는 코드다. 마지막으로, 이벤트가 동작하는 지 확인하기 위해서 스트림의 구독자(subscriber)를 추가하는 forEach를 사용한다. 이것이 간단하게 값을 로깅(logging)하는 코드다.

버튼 클릭 스트림

페이지를 새로고침하고, 버튼을 몇 번 클릭해보면 개발자 콘솔에 출력 결과가 보일 것이다. 정상적으로 동작한다! 이제 버튼 스트림에서 forEach를 제거하자. 필요하지 않은 코드다. 이제 우편번호를 얻을 차례다. 우편번호 input에 입력된 문자열의 길이가 5일 때만 이벤트를 수신하고 싶다.

1
2
3
4
5
6
7
// Get stream of zip codes
const zipInputStream =
Rx.Observable
.fromEvent(zipcodeInput, 'input')
.map(e => e.target.value)
.filter(zip => zip.length === 5)
.forEach(val => console.log('zipInputStream val', val));

input 요소로부터 발생한 이벤트로 스트림을 만들었다. input 이벤트로부터 값을 추출하기 위해서 map을 사용한다. 그리고 문자열의 길이가 5가 아닌 값들을 제거하기 위해서 filter를 사용한다. filter는 지정된 조건식이 true를 반환하는 경우 해당 값을 반환되는 스트림에 포함시킨다. 마지막으로, 다시 각 값을 순회하면서 콘솔에 로그를 남긴다.

우편번호 입력 스트림

페이지를 새로고침하고 뭔가를 입력해보면, 입력된 문자열의 길이가 5일 때만 값을 확인할 수 있다는 사실을 알 수 있다. 잘하고 있다. forEach를 제거하자. 이제 유저가 버튼을 클릭했을 때만 input에 있는 값을 전달하도록 만들 것이다. 이를 위한 새로운 스트림을 만들어보자!

1
2
3
4
5
6
// Get zipcode after button clicked
const zipcodeStream =
btnClickStream
.withLatestFrom(zipInputStream, (click, zip) => zip)
.distinct()
.forEach(val => console.log('zipcodeStream val', val));

btnClickStreamwithLatestFrom을 호출하여 zipInputStream을 넘겼다. 이것은 btnClickStream의 값이 보일때마다 zipInputStream의 가장 최근 값을 얻고 map처럼 동작하는 함수인 “result selector”로 전달하는 것을 의미한다. 이 함수에서 반환된 값은 withLatestFrom으로부터 반환된 스트림에 전달된다. 다음으로, 새로운 값만을 얻기 위해 distinct 메소드를 사용한다. 중복된 값이 존재한다면, 그 값은 distinct로 인해 반환된 스트림에는 전달되지 않는다. 그리고 당연하지만, 제대로 동작하는지 확인하기 위해서 로그를 forEach를 사용해 출력하였다.

우편번호 스트림

페이지를 새로고침하고, 우편번호를 입력한 후에 버튼을 클릭해보자. 개발자 콘솔을 보면, 버튼이 클릭될 때 우편번호가 스트림으로 submit 되는 것을 확인할 수 있다. 이제, 아까 했던 것처럼, forEach 문을 제거한다. 우편번호가 추가되거나 타이머의 시간이 지날 때마다 호출할 재사용성 높은 날씨 API가 필요하다.

1
2
3
4
5
6
7
8
9
// Create reusable temperature fetching stream
const getTemperature = zip =>
fetch(`http://api.openweathermap.org/data/2.5/weather?q=${zip},us&units=imperial&appid=APPID`)
.then(res => res.json());

const zipTemperatureStreamFactory = zip =>
Rx.Observable
.fromPromise(getTemperature(zip))
.map(({ main: { temp } }) => ({ temp, zip }));

두 개의 함수를 제작하였다. 첫 번째 함수, getTemperature는 우편번호를 받아 날씨 API에서 온도를 받아오는 요청을 만든다. fetch가 Promise를 반환하므로, Promise에 있는 메소드인 then을 호출할 수 있다. 이 Promise는 응답을 받아 더욱 쉽게 활용할 수 있는 JSON 포맷을 리턴한다. 이것은 이 함수가 Promise를 반환한다는 것을 의미한다. 이건 중요하다. 또한, APPIDOpenWeatherMap 관리자 페이지에서 받을 수 있는 무료 API 키로 변경하는 걸 잊지마시기 바란다.

두 번째 함수는 우편번호를 받는다. Rx.ObservablefromPromise 메소드를 사용하여 getTemperature 함수에서 반환된 Promise로 스트림을 생성한다. 이것은 스트림을 반환하기 때문에, Rx.Observable 인스턴스가 가지는 메소드를 모두 사용할 수 있다. 현재 우편번호와 온도에만 관심이 있기 때문에, 해당 정보만 보유하는 객체를 반환하도록 하자. Promise 스트림에 map을 사용하여 입력을 destructuring하여 온도를 출력하고, 원하는 데이터를 포함하는 객체를 반환한다. ES2015의 새로운 destructuring 문법에 대한 더 많은 정보는 MDN을 참고하시기 바란다.

이제 날씨 API로부터 데이터를 얻는 함수가 완성되었다. 마지막으로 페이지에 element를 추가하도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Get new zip at each button click, get its
// temperature, and paint it to the screen
zipcodeStream
.flatMap(zipTemperatureStreamFactory)
.forEach(({ zip, temp }) => {
const locationEle = document.createElement('div');
locationEle.id = `zip-${zip}`;
locationEle.classList.add('location');

const zipEle = document.createElement('p');
zipEle.classList.add('zip');
zipEle.innerText = zip;

const tempEle = document.createElement('p');
tempEle.classList.add('temp');
tempEle.innerHTML = `${temp}&deg;F`;

locationEle.appendChild(zipEle);
locationEle.appendChild(tempEle);
appContainer.appendChild(locationEle);

zipcodeInput.value = '';
});

이제 우편번호 스트림의 flatMap 메소드를 호출한다. flatMap은 스트림으로 만들어진 스트림을 인자로 받아 각 스트림으로부터 값을 얻고 스트림으로 반환하여 사용가능하게 한다는 점만 제외한다면 일반 map처럼 동작한다. 즉, 날씨 API로의 요청으로부터 만들어진 스트림들을 납작하게(flatten) 만들고 스트림으로부터 얻어지는 값인 우편번호와 온도를 가지는 객체를 제공하는 메소드이다.

마지막 파트에서는 forEach를 사용하여 각 데이터를 얻고, 페이지에 추가한다. 콜백 함수 내에 있는 모든 코드는 순수한 JavaScript로 이루어져 있다. element를 구성하고 페이지에 추가한다. 그리고 zipcodeInput의 값을 비워준다.

페이지에 요소가 추가된 모습

페이지를 새로고침하고 우편번호를 몇 개 입력해보자. 페이지에 우편번호와 온도가 포함된 새로운 element가 추가된 것을 볼 수 있을 것이다.

이제 페이지에 온도가 표시되기 시작했으니, 이제 그것들을 업데이트 할 수 있는지 확인해보자. 지정한 시간마다 값을 출력하는 스트림은 만들 수 있다. 그러나, 입력한 모든 우편번호를 얻을 수 있는지 확인해 봐야한다. 이를 위해, ReplaySubject를 사용할 수 있다. ReplaySubject는 스트림을 구독하고 스트림으로부터 받은 모든 값을 기억한다. 그리고 그것을 원할 때마다 반복할 수 있다.

1
2
3
// Create stream that can replay all zips at will
const replayZipsStream = new Rx.ReplaySubject();
zipcodeStream.subscribe(replayZipsStream);

새로운 ReplaySubject를 만들고 zipcodeStream을 구독했다. 이것은 ReplaySubject가 입력하는 모든 우편번호를 기억할 것이라는 것이다. 한 번 사용해보자!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Create a timer to refresh the data
// and update the page
Rx.Observable
.interval(20000)
.flatMapLatest(() => replayZipsStream)
.flatMap(zipTemperatureStreamFactory)
.forEach(({ zip, temp }) => {
console.log('Updating!', zip, temp);

const locationEle = document.getElementById(`zip-${zip}`);
const tempEle = locationEle.querySelector('.temp');

tempEle.innerHTML = `${temp}&deg;F`;
});

첫 번째로 해야할 일은 지정한 시간마다 값을 출력하는 스트림을 만드는 것이다. 그 값은 신경쓸 부분이 아니다. 단지 그 값이 출력되는 시간이 필요한 것이다. 그리고 ReplaySubject인 콜백 함수로부터 얻을 수 있는 스트림을 납작하게 하기 위해서 새로운 메소드인 flatMapLatest를 사용한다. flatMap 대신에 flatMapLatest를 사용하는 이유는 replayZipsStream이 단 하나의 구독자만 가지는 것을 보장하고 싶기 때문이다. 그냥 flatMap을 사용했다면 같은 ReplaySubject에 여러 개의 구독자를 추가했을 것이고, 그렇게 되면 날씨 API로 쓸데 없는 여러 개의 요청을 날리게 될 것이다. 어쨌든 페이지에 추가했던 우편번호 스트림을 얻었을 것이다. 이제 이것을 같은 방식으로 사용할 수 있다. flatMap을 사용하여 날씨 API로 보내는 요청을 만드는 팩토리 함수에 통과시키도록 하자. 마지막으로, 그것들을 모두 순회하면서 각각 응답을 받아 페이지에 있는 데이터를 업데이트 하면 된다.

타이머 스트림

마지막으로 페이지를 새로고침 한 뒤에, 몇 개의 우편번호를 추가해보자. 정상적으로 페이지에 추가된 것을 볼 수 있을 것이다. 20초를 기다리면 개발자 콘솔에 값이 업데이트 되었다는 메시지를 볼 수 있을 것이다. 20초 동안 온도 변화가 없다면, 페이지에는 아무 변화도 없을 수 있다. 더 자주 혹은 뜸하게 시간 간격을 주고 싶다면, 필요에 알맞게 Rx.Observable.interval에 넘기는 값을 변경하면 된다.

Auth0 Lock과 함께 쓰기1

이제 당신은 스트림을 제대로 쓸 수 있게 되었다. 그런데 날씨 어플리케이션의 인증 솔루션으로 Auth0 Lock을 쓰고 싶을 수 있다. 어떻게 구현하면 될까? 뭐, 매우 간단하다. 대부분의 일은 라이브러리에 의해 처리된다. 그냥 버튼을 클릭했을 때 Lock 모달이 나오는 것만 확실하게 하면 된다. 한 번 해보자!

먼저, 라이브러리를 포함한 다음, Lock을 초기화 하고, 모달을 띄울 로그인 버튼을 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<button id="login">Login</button>
<script src="http://cdn.auth0.com/js/lock/10.x.y/lock.min.js"></script>
<script>
// Initiating our Auth0Lock
var lock = new Auth0Lock(
'YOUR_CLIENT_ID',
'YOUR_NAMESPACE'
);

// Listening for the authenticated event
lock.on("authenticated", function(authResult) {
// Use the token in authResult to getProfile() and save it to localStorage
lock.getProfile(authResult.idToken, function(error, profile) {
if (error) {
// Handle error
return;
}

localStorage.setItem('idToken', authResult.idToken);
localStorage.setItem('profile', JSON.stringify(profile));
});
});
</script>

마지막으로 남은 일은 버튼 이벤트를 스트림으로 전환하고, 그 스트림에서 데이터를 받을 때마다 모달을 여는 것이다.

1
2
3
Rx.Observable
.fromEvent(document.getElementById('login'), 'click')
.forEach(() => lock.open());

이렇게 완료됐다.

Gotta Stream Them All

Observable 혹은 스트림이라는 용어는 처음엔 약간 생소하게 느껴질 수 있다. 내가 접근했던 방법은 싱글 이벤트라는 생각 대신 일정 시간 동안의 이벤트 컬렉션이라고 생각하는 것이었다. 이렇게 하면 DOM으로부터 발생하는 모든 이벤트를 스트림으로 생각하는 것은 식은 죽 먹기일 것이다. RxJS는 쉽게 이벤트로부터 스트림을 만들고 상호작용할 수 있도록 돕는다. RxJS는 어플리케이션 로직을 유지하고, 다른 프레임워크 혹은 라이브러리를 사용할 떄보다 코드를 쉽게 만들어주는 강력한 방법이다. 늘 그렇듯이 코멘트 달아주시고 얼마나 RxJS를 좋아하는지 알려주시기 바란다.

[ 원문 보기 ]


  1. 1.역자 주: 이 쪽은 광고문단에 가까우니 참고.