Skip to content

Instantly share code, notes, and snippets.

@casamia918
Forked from staltz/introrx.md
Last active April 9, 2024 13:50
Show Gist options
  • Star 73 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save casamia918/93b8db69beb9ee06b92a96b2a234d48e to your computer and use it in GitHub Desktop.
Save casamia918/93b8db69beb9ee06b92a96b2a234d48e to your computer and use it in GitHub Desktop.
[Korean ver.] The introduction to Reactive Programming you've been missing

당신이 놓치고 있던 Reactive Programming에 대한 안내 (한글 번역)

The introduction to Reactive Programming you've been missing (Korean ver.)

(by @andrestaltz)

(orginal source: https://gist.github.com/staltz/868e7e9bc2a7b8c1f754)


이 튜토리얼은 비디오 시리즈도 있습니다

만약 실제 코딩하는 비디오 튜토리얼을 보고싶다면, 이 글과 같은 내용으로 녹화한 비디오 시리즈를 확인하세요: Egghead.io - Introduction to Reactive Programming.


아마 당신은 Reactive Programming, 특히 이것의 다양한 형태인 Rx, Bacon.js, RAC 등등을 배우는 것에 대해서 관해서 관심있는 사람일겁니다.

Reactive Programming은 어렵습니다. 심지어 좋은 학습자료가 별로 없기 때문에 배우기가 더 어렵습니다. 제가 Reactive Programming을 처음 시작했을때, 튜토리얼을 찾아보려 했지만, 소수의 가이드 문서가 전부였습니다. 게다가 그것들은 수박 겉핥기식 내용이였고, Reactive Programming 으로 아키텍쳐를 쌓는 어려운 부분은 전혀 다루지 않았지요. 라이브러리 문서는 어떤 함수를 이해하려고 할때 거의 도움이 되지 않았습니다. 진짜에요. 이걸 보세요:

Rx.Observable.prototype.flatMapLatest(selector, [thisArg])

Observable sequence에 있는 각 요소를 요소의 index와 연계하여 새로운 Observable sequence의 sequence로 project시킵니다. 그리고나서, observable sequence들의 observable sequence를 가장 최근 observable sequence으로부터 값을 생산해내는 observable sequence로 바꿉니다.

Projects each element of an observable sequence into a new sequence of observable sequences by incorporating the element's index and then transforms an observable sequence of observable sequences into an observable sequence producing values only from the most recent observable sequence.

제기랄...

전 책을 두권 읽었는데요, 하나는 그냥 큰그림을 그려줬고, 다른 하나는 Reactive library 를 사용하는 방법만 알려줬습니다. 이쯤에서 전 Reactive Programming 을 이런 방법으로 배우는걸 포기했고, 직접 만들어보면서 알아보기로 했습니다. Futurice 에서 일할 때, 저는 Reactive Programming 을 실제 프로젝트에 사용해보기 시작했고, 문제가 부딪혔을 땐 몇몇 동료들의 도움을 얻을 수 있었습니다.

Reactive Programming을 배울때 가장 어려웠던 부분은, Reactive처럼 생각하기 였습니다. 그것은, 명령(imperative)과 상태(stateful)를 서술하는 전형적인 프로그래밍방식을 버리고, 새로운 패러다임으로 사고를 전환하는 것이었습니다. 저는 이런 관점으로 쓰인 가이드를 인터넷에서 찾지 못했고, 처음 시작하는 사람들을 위해 Reactive로 생각하는 방법을 알려주는 현실적인 튜토리얼이 필요하다고 보았습니다. 라이브러리 문서는 그 이후에 도움이 될 겁니다. 이 가이드가 당신에게 도움이 되길 바랍니다.

"Reactive Programming이 뭘까요?"

인터넷에는 Reactive Programming에 대한 나쁜 설명과 정의들이 널려있습니다. Wikipedia 는 늘 그렇듯이 너무 일반적이고 이론적입니다. Stackoverflow에 있는 표준 답변은, 초심자들한테 결코 적절하지 않습니다. Reactive Manifesto 는 마치 회사에서 프로젝트 매니저나 거래처 사람한테 소개하는 것처럼 들립니다. Microsoft의 Rx terminology "Rx = Observables + LINQ + Schedulers" 은 설명이 너무 무겁고, 우리 대부분을 혼란스럽게하는 Microsoftish한 스타일 입니다. "reactive"와 "propagation of change" 라는 용어는 전형적인 MV* 와 인기있는 언어에서 이미 쓰고있는 개념이라서 전혀 새롭게 느껴지지 않습니다. 아 물론, 제 프레임웍의 view들은 모델들로부터 반응(react)하고 있습니다. 변화가 전파된다는 것은(change is propagated) 당연합니다. 만약 그게 안된다면, 아무것도 렌더되지 않겠죠.

헛소리는 여기까지로 끝냅시다.

Reactive programming 은 비동기 데이터 스트림으로 프로그래밍을 하는 겁니다.

Reactive programming is programming with asynchronous data streams.

어떤 면에서 이것은 전혀 새롭지 않습니다. 이벤트 버스나, 전형적인 클릭 이벤트는 당연히 비동기 이벤트들입니다. 당신은 이것들을 observe할 수 있고, side effect를 줄 수 있습니다. Reactive는 이 아이디어를 확장시킨겁니다. 당신은 클릭이나 hover 이벤트 외에, 어떤것으로도 데이터 스트림을 만들 수 있습니다. 스트림은 가볍고, 흔합니다. variables, user inputs, properties, caches, data structures, 기타 등등, 어떤것이든 스트림이 될 수 있습니다. 예를들어, 트위터 피드를 클릭 이벤트와 같은 데이터 스트림이라고 생각해 볼 수 있습니다. 당신은 그 스트림에 listen 할 수 있고, 적절하게 react 할 수 있습니다.

그에 더하여, 당신은 스트림들을 조합하고, 만들고, 필터링할 수 있는 어마어마한 함수 꾸러미들을 얻게 됩니다. 이건 "함수형(functional)"이라는 마법이 빛을 발하는 순간입니다. 스트림은 다른 스트림의 input이 될 수 있습니다. 복수의 스트림들도 다른 스트림의 input이 될 수 있습니다. 두 개의 스트림을 합칠수도 있습니다. 스트림을 필터링 시켜서, 당신이 관심있는 이벤트들만 있는 스트림으로 만들 수도 있습니다. 스트림에 있는 데이터 값들을 새로운 스트림으로 map 시킬 수도 있습니다.

스트림이 Reactive에서 그렇게 중요한 것이라면, 좀더 자세하게 살펴보도록 합시다. 이미 다들 익히 알고 있을 "버튼 클릭" 이벤트 스트림부터 시작해봅시다.

Click event stream

스트림은 시간 순으로 정렬된 진행중인 이벤트 들의 나열 입니다. 스트림은 (타입을 갖고 있는) 값, 에러, 완료 신호, 이 세가지 종류의 이벤트를 발생(emit)시킬 수 있습니다. "완료"는 창이나 이 버튼을 포함하고 있는 화면이 닫혀지는 순간을 의미한다고 봅시다.

우리는 각 이벤트(값, 에러, 완료)가 발생할때 실행되어질 함수들을 각각 정의함으로써, 이 발생한 이벤트들을 비동기적으로만 포착할 수 있습니다. 종종, 에러와 완료 이벤트는 생략하고, 값을 내보내는 이벤트만 포착하는 함수를 정의할 때가 많습니다. 스트림을 "listening"하는 것은, subscribing(구독) 이라고 부릅니다. 우리가 정의한 함수들은 observer들입니다. 스트림은 observed 되는 대상(subject)입니다("observable"이라고도 하죠) 이것은 Observer Design Pattern 과 정확히 일치합니다.

위 도표는 ASCII로도 그릴 수 있습니다. 이 튜토리얼에서도 일부 사용할겁니다.

--a---b-c---d---X---|->

a, b, c, d are emitted values
X is an error
| is the 'completed' signal
---> is the timeline

너무 익숙한 것들이기 때문에, 더 지루하게 만들고 싶지 않네요. 이번엔 새로운걸 해봅시다. 기존의 클릭 이벤트 스트림을 변형시켜서, 새로운 클릭 이벤트 스트림을 만들어 보겠습니다.

먼저, 버튼이 몇번이나 클릭되었는지를 의미하는 계수(counter) 스트림을 만들어봅시다. 보통 Reactive library에는, 스트림에 붙일 수 있는 많은 함수 목록(map, filter, scan)을 갖고 있습니다. clickStream.map(f)처럼 그 함수들 중 하나를 호출하게 되면, 클릭 스트림에 기초해서 새로운 스트림 을 되돌려줍니다. 이것은 기존의 클릭 스트림을 전혀 변형하지 않습니다. 이런 속성은 불변성(immutability) 이라고 불리며, 막걸리와 파전이 찰떡궁합인 것처럼 Reactive 스트림과 잘 어울리는 속성입니다. 이 덕분에 우리는 clickStream.map(f).scan(g)처럼 함수 체이닝도 할수 있게 됩니다.

  clickStream: ---c----c--c----c------c-->
               vvvvv map(c becomes 1) vvvv
               ---1----1--1----1------1-->
               vvvvvvvvv scan(+) vvvvvvvvv
counterStream: ---1----2--3----4------5-->

map(f) 함수는 각각 발생(emit)하는 값들을, 당신이 정의한 함수 f에 통과시킨 후, 이 새로운 값들을 가지는 새로운 스트림을 만들어냅니다. 우리 상황을 예로 들어보자면, 우리는 각각의 클릭을 1로 map시켰습니다. scan(g) 함수는, 스트림에서 이전 값들을 모두 합쳐서, x = g(accumulated, current) 라는 새로운 값을 만들어냅니다. g는 이 예제에서 정의한 단순히 더하는 함수를 의미합니다. 이후, counterStream은 클릭이 발생할때마다 총 클릭 횟수를 발생시킵니다.

Reactive의 진짜 힘을 보기 위해, 이번엔 당신이 "더블 클릭" 이벤트 스트림을 갖길 원한다고 가정해봅시다. 더 재밌게 하기 위해서, 우리는 새 스트림이 더블클릭을 받듯이 삼중클릭도 받도록 취급하던가, 아니면 아예 일반화해서, 다중 클릭을 받도록 하고 싶다고 생각해보죠. 자, 숨 한번 크게 들이쉬고, 전통적인 명령(imperative)와 상태(stateful)를 서술하는 방식으로, 이걸 어떻게 해야할 지 상상해보세요. 장담컨데 아주 형편 없는 방법일겁니다. 상태를 유지하는 변수를 만들고, 시간 간격 조작을 좀 해주겠죠.

훗, Reactive에선 간단하다구요. 사실, 그 로직은 4줄짜리 코드 입니다. 하지만, 코드는 잠깐 잊도록 합시다. 초심자건 전문가건, 스트림을 이해하고 만드는 가장 좋은 방법은, 도표(다이어그램, 그림)를 생각하는겁니다.

Multiple clicks stream

회색 상자는 한 스트림을 다른 스트림으로 바꾸는 함수를 의미합니다. 첫째로,buffer(clickStream.throttle(250ms))은, 250ms의 "이벤트 침묵"이 발생하면 클릭을 리스트에 누적시킵니다. 당장 너무 자세하게 다 알 필요는 없습니다. 지금은 그저 Reactive의 시연을 해보고 있는 거니깐요. 그 결과는, 리스트를 가진 스트림입니다. 우리는 이 스트림에 map()을 적용시켜서, 각각의 리스트를 그 리스트의 길이를 가진 정수 값으로 mapping 시킬겁니다. 마지막으로, 우리는 filter(x >= 2) 함수를 사용하여서 정수 1을 무시하도록 하겠습니다. 이게 다입니다. 우리가 원하는 스트림을 만들기 위해서 세 단계의 작업이 전부였습니다. 그러고나면 마지막 스트림에 subscribe를 함으로써(스트림을 listen하는겁니다), 우리가 원하는대로 반응하게 만들면 됩니다.

부디 당신이 이런 접근의 아름다움을 즐겼으면 하네요. 이 예제는 빙산의 일각입니다. 당신은 똑같은 작업을 서로 다른 종류의 스트림에 적용시킬 수 있습니다. 예를들자면, API 응답 스트림 말이지요. 또 한편으론, 그 외 사용 가능한 많은 함수들도 있습니다.

"왜 내가 Reactive Programming을 도입할 지 고려해야 하나요?"

Reactive Programming은 코드의 추상화 레벨을 끌어올려줌으로써, 당신으로 하여금 비즈니스 로직을 규정하는 이벤트들 간의 상호 관계에만 집중할 수 있게 해줍니다. 더이상 세부 구현을 어떻게 하나 계속 만지작거리고 있을 필요가 없게 됩니다. Reactive Programming 으로 짠 코드는 매우 간결합니다.

이러한 장점은, 데이터 이벤트들이 많은 UI 이벤트들과 연계하여 상호작용하고 있는 최근의 웹앱이나 모바일앱들에게 뚜렷하게 도드라집니다. 10년전에는, 웹 페이지들의 상호작용이 단순하게 긴 폼을 백엔드에 보내고 프론트엔드에선 단순한 렌더링을 수행하는 게 전부였습니다. 하지만 지금의 앱들은 더욱 진화하여 실시간 작업을 수행하고 있습니다. 한 폼 필드의 값을 수정하면 백엔드에서 자동으로 저장이 되어버리죠. 어떤 컨텐츠에 "좋아요"를 누르면, 다른 연결되어 있는 유저에게 실시간으로 반영될 수 있습니다.

지금의 앱들은 높은 수준으로 사용자에게 상호작용 경험을 제공하는 거의 모든 종류의 실시간 이벤트들이 넘쳐나고 있습니다. 우리는 이러한 것들을 적절하게 다룰 도구가 필요하며, Reactive Programming은 바로 그 해답입니다.

예시를 통해서 Reactive Programming 으로 생각해보기

이번엔 실제적인 것들을 다뤄보도록 합시다. Reactive Programming 방식으로 생각하기 위한 현실 세계 예시입니다. 억지로 만든 예시도 아니고, 반쯤 설명된 컨셉도 아닙니다. 이 튜토리얼이 끝날때쯤이면, 우리가 각각의 것들을 왜 했는지 이해할 수 있게 됨과 동시에, 진짜 함수형 코드를 짤 수 있을 겁니다.

저는 JavaScriptRxJS 를 다음과 같은 이유 때문에 학습 도구로 선택했습니다. JavaScript는 지금 시점에서 가장 널리 알려진 언어 중 하나이고, Rx* library family는 많은 언어와 플랫폼에서 사용 가능하기 때문입니다. (.NET, Java, Scala, Clojure, JavaScript, Ruby, Python, C++, Objective-C/Cocoa, Groovy, etc). 그래서, 당신이 어떤 도구를 쓰던 간에, 이 튜토리얼을 따라할 수 있을겁니다.

"Who to follow" 추천 박스 구현하기

트위터에 보면, 당신이 팔로우 할 만한 다른 계정을 추천해주는 UI 요소가 있습니다.

Twitter Who to follow suggestions box

우리는 다음과 같은 핵심 기능들을 모방해보고자 합니다:

  • 화면이 개시되면, API로부터 계정 정보를 로드하고, 3개를 추천합니다.
  • "Refresh"를 눌렀을 때, 새로운 3개의 계정을 로드하고 3행으로 넣습니다.
  • 계정 행에서 'x'버튼을 눌렀을 때, 해당 계정만 비워내고 새로운 것을 보여줍니다.
  • 각 행은 계정의 아파타와 그들의 페이지로 연결된 링크를 보여줍니다.

그 외 중요하지 않은 기능과 버튼들은 배제하도록 하겠습니다. 그리고, 트위터가 최근 unauthrorized public에게 API를 닫아버렸기 때문에, 해당 UI를 Github 에서 구현해볼겁니다. 자세한건 Github API for getting users 를 참고하세요.

완성된 코드를 보고싶으시면 http://jsfiddle.net/staltz/8jFJH/48/ 에 가시면 됩니다.

요청과 응답(Request and response)

이 문제를 Rx로 어떻게 접근할 것인가? 글쎄, 시작하기에 앞서서, (거의) 모든것은 스트림이 될 수 있다. 라는 Rx의 주문을 먼저 읊어본다음, 맨 처음 기능부터 시작해보도록 합시다. "화면이 개시되면, API로부터 3개의 계정을 가져옵니다". 여기선 별로 특별한게 없습니다. 이건 단순히 (1)요청을 보내고 (2)응답을 받고 (3)응답을 렌더링 하면 되는거니깐요. 그렇다면, 좀 더 나아가서, 우리의 요청을 스트림으로 표현 해보도록 합시다. 처음엔 너무 오버하는것처럼 느껴질수도 있을거에요. 하지만 우린 기초부터 시작할 필요가 있어요. 그렇죠?

화면이 개시되면, 우리는 하나의 요청만 실행하면 됩니다. 이걸 데이터 스트림으로 모델링하면, 한개의 값이 발생하는 스트림으로 볼 수 있겠네요. 차후에는 많은 요청이 발생할수도 있겠지만, 일단 지금은, 한개만 생각합시다.

--a------|->

Where a is the string 'https://api.github.com/users'

이것은 우리가 요청하고자 하는 URL의 스트림입니다. 요청 이벤트는 우리에게 두가지를 알려줍니다 : 언제, 그리고 무엇을. (when and what). 요청이 실행되어지는 "때"는, 요청이 발생한 때입니다. 요청하는 "것"은 발생한 값입니다. 즉 URL을 담고있는 string이죠.

단일 값을 담고 있는 스트림을 만드는 것은 Rx*에서 매우 쉽습니다. 스트림을 일컫는 공식적인 용어는 "Observable" 입니다. 관찰될 수 있다, 이거죠 뭐. 근데 저는 좀 멍청한 작명이라고 생각하기 때문에, 그냥 스트림 이라고 계속 부르겠습니다.

var requestStream = Rx.Observable.just('https://api.github.com/users');

그런데 지금 저것은, 한 string을 가진 스트림일 뿐입니다. 아무런 작업도 하지 않고 있어요. 그래서 우리는, 저 값이 발생(emit)할때 무슨 일이 생겨나도록 해야합니다. 이것은 스트림에 subscribing을 해주면 됩니다.

requestStream.subscribe(function(requestUrl) {
  // execute the request
  jQuery.getJSON(requestUrl, function(responseData) {
    // ...
  });
}

여기서 주목할 점은 요청 작업을 비동기적으로 처리하는 jQuery Ajax callback을 사용하고 있다는 점입니다. (당신이 사전지식을 알고 있다고 가정합니다) 그런데 잠깐, Rx는 비동기적인 데이터 스트림을 다룬다면서요? 저 요청에 대한 응답이 미래 어느 시점에 도착할 데이터를 담고있는 스트림이 될 순 없나요? 개념적으로, 당연히 그렇게 보이는군요. 그럼 한번 시도해봅시다.

requestStream.subscribe(function(requestUrl) {
  // execute the request
  var responseStream = Rx.Observable.create(function (observer) {
    jQuery.getJSON(requestUrl)
    .done(function(response) { observer.onNext(response); })
    .fail(function(jqXHR, status, error) { observer.onError(error); })
    .always(function() { observer.onCompleted(); });
  });
  
  responseStream.subscribe(function(response) {
    // do something with the response
  });
}

Rx.Observable.create()이 하는 일은, observer에게 (observer의 다른 말은 "subscriber" 입니다.) 데이터 이벤트(onNext()), 에러 이벤트(onError()), 완료 이벤트(onCompleted())가 언제 발생하는지를 명시적으로 알려줌으로써 커스텀 스트림을 만드는 것입니다. 여기서 한건, jQuery Ajax Promise를 단순히 Wrap 한거에요. 잠깐, 그건 곧 Promise가 Observable하단 소리 아니요?

         

Amazed

맞습니다.

Observable은 Promiss++ 입니다. Rx에선 'var stream = Rx.Observable.fromPromise(promise)' 를 통해서 Promise를 손쉽게 Observable로 변환할 수 있습니다. 이걸 한번 써보도록 하죠. 딱 한가지 차이는, Observable이 Promises/A+ 를 따르고 있지 않다는것 뿐입니다만, 개념상으로 충돌하진 않습니다. Promise는 단일 값만 발생시키는 Observable일 뿐입니다. Rx 스트림은 다양한 리턴 값을 허용하기 때문에, Promises를 넘어섰습니다.

꽤 훌륭하네요. 그리고 Observable이 어떻게 최소한 Promises 만큼 유용한지 알 수 있게 해주는군요. 만약 당신이 Promises의 선전 문구를 믿는다면, Rx Observables 로는 어떤게 가능한지 지켜보시길 바랍니다.

우리 예제로 돌아오겠습니다. 눈치가 좀 빠르다면 알아채셨겠지만, 우리는 다른 subscribe()안에서 콜 되고 있는 한 subscribe()를 가지고 있습니다. 콜백 지옥의 낌새가 나는군요. 또한, responseStream의 생성이 requestStream에게 의존되어 있습니다. 알다시피, Rx는 새로운 스트림을 별개의 것으로 생성하고 변형하는 단순한 메커니즘을 갖고 있습니다. 그러니 여기서도 그렇게 따라야 합니다.

당신이 꼭 알아야하는 함수 중 하나는 map(f) 입니다. 이건 스트림 A의 각 값을 취한 다음, 각 값들에게 f()를 적용시키고, 생성된 값을 스트림B에 집어넣는 일을 합니다. 만약 그걸 이 요청, 응답 스트림에 적용시킨다면, 우리는 요청 URL을 응답 (스트림의 형태를 하고 있는) Promises에 map 시킬 수 있습니다.

var responseMetastream = requestStream
  .map(function(requestUrl) {
    return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl));
  });

이로써 우리는, "metastream"이라고 하는 괴물을 만들어냈습니다. 이건 스트림의 스트림입니다. 겁먹지마세요! 메타스트림은 각각의 발생된 값이(each emitted value) 다른 스트림에 있는 것 뿐입니다. 이건 마치 포인터 처럼 생각할 수 있습니다. 각각의 발생된 값(each emitted value)는 다른 스트림의 포인터 입니다. 우리 예제에선, 각각의 요청 URL이 연관된 응답을 갖고 있는 Promise 스트림을 가리키는 포인터로 매핑된 것으로 볼 수 있습니다. (each request URL is mapped to a pointer to the promise stream containing the corresponding response.)

Response metastream

응답에 대한 메타스트림은 꽤 복잡해보이는데다가, 별로 도움도 안 되어 보입니다. 우리는 그냥 단순한 응답을 가진 스트림을 원할 뿐이에요. 이 스트림은 JSON 객체를 담고있는 Promise가 아닌, 그냥 JSON object 를 발생시키면 된다구요. 그렇다면 이제 Flatmap 을 만날 차례가 되었군요. Flamtmap은 메타스트림을 평면화(flatten)시키는 map()의 한 버전입니다. 다시 말하자면, 가지(branch)스트림에서 발생시키는 모든 것들을, 줄기(trunk)스트림에서 발생시키도록 하는거죠. 메타스트림은 버그가 아니고, flatmap이 그걸 고친다는 것은 더더욱 아닙니다. 이건 단지 Rx의 비동기 응답을 다루는 도구일 뿐이에요.

var responseStream = requestStream
  .flatMap(function(requestUrl) {
    return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl));
  });

Response stream

좋네요. 게다가 응답 스트림이 요청 스트림에 뒤따라서 정의되고 있기 때문에, 만약 추후에 요청 스트림에서 이벤트가 더 발생한다면, 아래 보이는 것처럼 연관되는(corresponding) 응답 이벤트가 응답 스트림에서 발생하는 것을 볼 수 있을겁니다.

requestStream:  --a-----b--c------------|->
responseStream: -----A--------B-----C---|->

(lowercase is a request, uppercase is its response)

이제 드디어 응답 스트림을 갖게 됐으니, 받은 데이터를 렌더링 할 수 있게 됐습니다..

responseStream.subscribe(function(response) {
  // render `response` to the DOM however you wish
});

지금까지의 모든 코드를 합치면, 이렇게 됩니다:

var requestStream = Rx.Observable.just('https://api.github.com/users');

var responseStream = requestStream
  .flatMap(function(requestUrl) {
    return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl));
  });

responseStream.subscribe(function(response) {
  // render `response` to the DOM however you wish
});

새로고침 버튼

이런, 깜박한게 있습니다. 응답에 있는 JSON에는 유저 100명의 리스트가 담겨있어요. API는 page size가 아닌, page offset만 명시할수 있도록 하고 있습니다. 덕분에 우리는 3개의 데이터 오브젝트를 얻기 위해 97개를 낭비하게 됩니다. 이 문제는 지금 당장은 무시할 거지만, 차후에 응답을 어떻게 캐싱하는지 살펴보도록 하죠.

새로고침 버튼을 누를 때마다, 요청 스트림은 새로운 URL을 발생시키고, 이후에 우리는 새로운 응답을 받게 됩니다. 이제 우리는 두가지를 해야 합니다. 새로고침 버튼의 클릭 이벤트로 이루어진 스트림을 만드는 것과 (주문: 모든것은 스트림이 될 수 있습니다.), 요청 스트림이 새로고침 클릭 스트림에 의존하도록 해야합니다. 고맙게도, RxJS는 이벤트 리스너를 Observables로 만들어주는 도구가 있습니다.

var refreshButton = document.querySelector('.refresh');
var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');

새로고침 클릭 이벤트 자체로는 API URL을 담아내지 못하기 때문에, 우리는 각각의 클릭을 실제 URL로 매핑 시켜줘야 합니다. 새로고침 클릭 스트림을 랜덤한 offset 파라미터를 담고 있는 API endpoint 로 매핑 되게 한 후, 기존에 만든 요청 스트림을 이것으로 바꿔봅시다.

var requestStream = refreshClickStream
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });

제가 좀 멍청하고, 자동화 테스트를 안만들어놔서, 저는 방금 우리가 이전에 만들었던 기능을 갈아엎어버렸습니다. 이제 웹페이지를 처음 시작했을 때 요청은 일어나지 않고, 새로고침 버튼을 클릭할때만 요청이 일어날겁니다. 아이고. 새로고침을 버튼을 눌렀을때와 웹페이지가 개시되었을때 둘다 요청이 일어나도록 해야 하는데 말이죠.

우리는 각각의 케이스에 대해서 분리된 스트림을 만드는 방법을 이미 알고있습니다.

var requestOnRefreshStream = refreshClickStream
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });
  
var startupRequestStream = Rx.Observable.just('https://api.github.com/users');

그런데 이 두개를 어떻게 하나로 "합칠"수 있을까요? merge() 를 이용하면 됩니다. 아래 그림이 merge가 무엇을 하는지 보여줍니다.

stream A: ---a--------e-----o----->
stream B: -----B---C-----D-------->
          vvvvvvvvv merge vvvvvvvvv
          ---a-B---C--e--D--o----->

아주 쉽군요.

var requestOnRefreshStream = refreshClickStream
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });
  
var startupRequestStream = Rx.Observable.just('https://api.github.com/users');

var requestStream = Rx.Observable.merge(
  requestOnRefreshStream, startupRequestStream
);

한편, 중간단계 스트림을 제거하려면, 이렇게도 할 수 있습니다.

var requestStream = refreshClickStream
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  })
  .merge(Rx.Observable.just('https://api.github.com/users'));

더 짧게, 더 가독성을 높여보면 아래와 같습니다.

var requestStream = refreshClickStream
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  })
  .startWith('https://api.github.com/users');

startWith() 함수는 당신이 생각하는 그 작업을 합니다. input 스트림이 어떻게 생겨먹었든, startWith(x)가 만들어낸 output 스트림은, x를 시작 단계에 갖게 됩니다.

StartWith

이부분을 그림으로 나타내면 아래와 같습니다

atream A: ----c--------c----c---->
          vvvv map(return E) vvvvv
stream B: ----E--------E----E---->
          vvvv startWith( S ) vvvv
stream C: S---E--------E----E---->

c is click event
E is API Endpoint
S is Startup API Endpoint

여기서 제가 map(return E) 라고 쓴 부분을 잘 기억해두시길 바랍니다. map(f)함수 안에 있는 project function f는 input 인자가 없고, API Endpoint만 되돌려주고 있기 떄문입니다.

하지만 아직도 충분히 DRY 하질 않군요. (주석: 코드를 덜 축약시켰다는 말입니다.) 그 이유는 API endpoint 문자열이 여전히 반복되고 있기 때문입니다. 어떻게하면 이걸 고칠 수 있을까요? startWith() 함수에 주목해봅시다. 지금의 startWith()함수는, API Endpoint 스트림 앞에, Startup API Endpoint를 붙여서 새 스트림을 만들고 있습니다. 이걸 발상을 바꿔서, refresh 클릭 스트림이 개시될 때, 클릭 이벤트가 있었던 것처럼 "emulate"시켜버리는 겁니다. 즉, startWith()함수를 refreshClickStream 뒤에 붙이면 됩니다.

var requestStream = refreshClickStream.startWith('startup-click')
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });

이걸 그림으로 나타내면 이렇게 됩니다.

atream A: ----c--------c----c---->
          vvvv startWith( s ) vvvvv
stream B: s---c--------c----c---->
          vvvv map(return E) vvvvv
stream C: E---E--------E----E---->
          
c is click event
s is startup event (in case of this, 'startup-click' string event)
E is API Endpoint

앞서 언급했다시피, 여기서 사용된 map(f) 함수의 project function인 f는, input 인자를 다 무시하기 때문에, startWith() 함수에서 인자로 들어간 'startup-click'이라고 쓴 부분은 어떤 문자열이 와도 상관 없습니다.

훌륭합니다. 이제 아까 제가 "멍청하고, 자동화 테스트를 안만들어놔서" 라고 말했던 부분에 있는 코드와 지금 코드를 비교해보면, startWith()가 달려있는것 빼곤 동일하다는 것을 알 수 있을겁니다.

(주석: 원문에서 startWith() 가 등장하는 부분의 설명이 다소 부족해서, 원문에 있는 댓글과 레퍼런스 문서를 참고하여 제가 설명을 좀 덧붙였습니다.)

3개의 추천을 스트림으로 모델링하기

지금까지는, response 스트림의 subscribe()를 통해서 생겨난 suggestion UI 요소를 렌더링 하는 부분까지만 다루고 있었습니다. 이제 새로고침 버튼을 살펴보자면, 버튼을 누른 바로 직후에는 기존에 있던 3개의 추천이 없어지지 않는 문제가 있음을 알 수 있습니다. 새로운 추천은 응답이 도착한 이후에만 생겨나기 때문입니다. UI를 좀더 보기 좋게하기 위해선, 새로고침 버튼을 클릭하자마자 기존의 추천을 없애주는게 좋을 것 같습니다.

refreshClickStream.subscribe(function() {
  // clear the 3 suggestion DOM elements 
});

그런데 이런 방식은 좋지 않습니다. 우리는 이미 기존에 만든 responseStream.subscribe() subscriber가 있기 때문에, 위 코드를 넣으면 추천 DOM에 영향을 끼치는 2개의 subscriber를 갖게 되는 겁니다. 이건 Separation of concerns 와는 다른 겁니다. 아까 몇번 언급했던 Reactive의 주문을 기억하나요?

       

Mantra

그렇다면, 추천을 스트림으로 모델링 해 봅시다. 이 스트림이 발생(emit)시키는 값들은, 추천 데이터를 담고 있는 JSON 객체입니다. 우리는 3개의 추천에 대해서 각각 분리해서 스트림을 만들어볼까 합니다. 아래는 추천1을 스트림으로 만든 코드입니다.

var suggestion1Stream = responseStream
  .map(function(listUsers) {
    // get one random user from the list
    return listUsers[Math.floor(Math.random()*listUsers.length)];
  });

나머지 suggestion2Streamsuggestion3Streamsuggestion1Stream을 단지 복사 붙여넣기만 하면 됩니다. 이건 당연히 DRY하지 않지만, 이 튜토리얼을 진행할때 예시를 좀 단순하게 해줄 수 있을것같습니다. 덧붙여서, 이런 상황에서 반복을 어떻게 피하는지 고민하는건 좋은 연습이 될 것 같네요.

자 이번엔, responseStream의 subscribe()에서 렌더링이 일어나게 하지 말고, 추천 스트림에서 렌더링이 일어나게 합시다.

suggestion1Stream.subscribe(function(suggestion) {
  // render the 1st suggestion to the DOM
});

"새로고침 할때, 추천을 초기화 한다" 부분으로 돌아가면, 우리는 새로고침 클릭을 null 추천 데이터로 매핑시킬 수 있고, 이것을 suggesion1Stream에 합쳐버릴 수 있습니다. 이렇게 말이죠.

var suggestion1Stream = responseStream
  .map(function(listUsers) {
    // get one random user from the list
    return listUsers[Math.floor(Math.random()*listUsers.length)];
  })
  .merge(
    refreshClickStream.map(function(){ return null; })
  );

그리고 렌더링을 할 때, null을 "데이터 없음"으로 취급하면서, UI 요소를 감춰버리면 됩니다.

suggestion1Stream.subscribe(function(suggestion) {
  if (suggestion === null) {
    // hide the first suggestion DOM element
  }
  else {
    // show the first suggestion DOM element
    // and render the data
  }
});

이 과정들을 그림으로 그려보면 이렇게됩니다

refreshClickStream: ----------o--------o---->
     requestStream: -r--------r--------r---->
    responseStream: ----R---------R------R-->   
 suggestion1Stream: ----s-----N---s----N-s-->
 suggestion2Stream: ----q-----N---q----N-q-->
 suggestion3Stream: ----t-----N---t----N-t-->

 N stands for null

추가적으로, 우리는 웹페이지 시작단계에서 "비어있는" 추천을 보여줄 수도 있습니다. 이건 startWith(null) 을 추천 스트림에 추가해주면 됩니다.

var suggestion1Stream = responseStream
  .map(function(listUsers) {
    // get one random user from the list
    return listUsers[Math.floor(Math.random()*listUsers.length)];
  })
  .merge(
    refreshClickStream.map(function(){ return null; })
  )
  .startWith(null);

그러면 이렇게 되겠죠

refreshClickStream: ----------o---------o---->
     requestStream: -r--------r---------r---->
    responseStream: ----R----------R------R-->   
 suggestion1Stream: -N--s-----N----s----N-s-->
 suggestion2Stream: -N--q-----N----q----N-q-->
 suggestion3Stream: -N--t-----N----t----N-t-->

추천 한개만 닫아버리고, 캐시된 응답을 사용하기

아직 구현해야 하는 한가지 기능이 남아있습니다. 각각의 추천은 'x' 버튼을 갖고 있어야하고, 이 버튼이 눌렸을 때 다른 추천을 로드해와야 합니다. 일단 보자마자 드는 생각은, 'x' 버튼이 클릭 되었을 때 새로운 요청을 만들면 될 것 같아 보입니다.

var close1Button = document.querySelector('.close1');
var close1ClickStream = Rx.Observable.fromEvent(close1Button, 'click');
// and the same for close2Button and close3Button

var requestStream = refreshClickStream.startWith('startup click')
  .merge(close1ClickStream) // we added this
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });

이건 작동하지 않습니다. 이렇게하면, 모든 추천을 비워버리고 모든 추천을 다시 로드하게 됩니다. (주석: refresh 버튼 클릭 스트림과 close1 버튼 클릭 스트림이 단순히 merge되었으므로, 이 두개의 버튼 클릭은 동일한 동작을 하게 됩니다) 이걸 해결하는 방법은 여러가지가 있는데요, 우리는 이전의 응답을 재사용하면서 해결해보려고 합니다. API가 응답한 페이지 크기는 100명의 유저입니다. 우린 그중에서 3개만 필요할 뿐이에요. 따라서 사용 가능한 새 데이터가 많이 있습니다. 요청을 다시 보낼 필요가 없어요.

이번에도, 스트림으로 생각해봅시다. 'close1' 클릭 이벤트가 생겨났을 때, 우리는 응답 스트림 중에서 가장 최근에 발생한 응답을 사용하고 싶습니다. 이 응답이 갖고 있는 리스트에서 한명의 랜덤한 유저를 가져오면 되는겁니다. 이렇게요

    requestStream: --r--------------->
   responseStream: ------R----------->
close1ClickStream: ------------c----->
suggestion1Stream: ------s-----s----->

Rx*에서 이런 기능을 제공하는 함수로는, combineLatest 라는 조합 함수(combinator function)가 있습니다. 이 함수는, 두개의 스트림을 입력으로 받은 뒤, 각각의 입력 스트림이 새로운 값을 발생(emit)시킬 때, combineLastest함수는 각각 스트림의 가장 최근 값을 이용해서 만든 새로운 값을 발생시킵니다. 각각 입력 스트림의 가장 최근 값을 a, b라고 하고, combineLatest에서 정의한 project function을 f라고 하면, 새로 만들어지는 값은 c=f(a,b) 입니다. 그림으로 나타내면 아래와 같아요

combineLatest

(주석: 원문에 나와있는 diagram이 오해할 수 있는 부분이 있어서 공식 레퍼런스에서 이미지를 가져왔습니다)

우리는 combineLatest()함수를 close1ClickStreamresponseStream에 적용시켜보겠습니다. close1 버튼이 클릭될 때마다, 가장 마지막으로 발생한 응답을 사용해서 suggestion1Stream의 들어갈 새로운 값으로 만들어주는 겁니다. 한편, combineLatest()는 대칭적입니다. 다시 말하자면, responseStream에서 새로운 값이 발생하게 되면, close1 클릭과 조합되어서 새로운 추천을 생성한다는 소리죠. 흥미롭군요. 이걸 잘 이용하면 이전에 만든 suggestion1Stream을 단순화 할 수 있을 것 같습니다. 이렇게 말이죠.

var suggestion1Stream = close1ClickStream
  .combineLatest(responseStream,             
    function(click, listUsers) {
      return listUsers[Math.floor(Math.random()*listUsers.length)];
    }
  )
  .merge(
    refreshClickStream.map(function(){ return null; })
  )
  .startWith(null);

아직 한가지 남은 것이 있습니다. combineLatest()는 두 input 에서 가장 최근의 것을 사용합니다. 그런데 두 input 중에서 아직 아무것도 발생시키지 않은 것이 있다면, combineLatest() 는 output 스트림으로 아무런 데이터 이벤트를 발생시키지 못한다는거죠. 위의 combineLatest() 의 다이어그램을 보면, 첫번째 input 스트림이 a를 발생했을대, 두번째 input스트림은 아무 것도 발생하지 않은 상태라서, output으로 나오는 값은 아직 없습니다. 두번째 input스트림이 1을 발생했을 때 비로소 output값인 a1이 나오게 되죠.

이걸 해결하는 방법은 역시 여러가지가 있습니다만, 가장 단순한 방법을 써볼까 합니다. 웹페이지 시작 단계에서, 'close1' 버튼이 클릭된 것처럼 만들어주는거죠.

var suggestion1Stream = close1ClickStream.startWith('startup click') // we added this
  .combineLatest(responseStream,             
    function(click, listUsers) {l
      return listUsers[Math.floor(Math.random()*listUsers.length)];
    }
  )
  .merge(
    refreshClickStream.map(function(){ return null; })
  )
  .startWith(null);

마무리

다 했습니다. 완성된 코드는 이겁니다.

var refreshButton = document.querySelector('.refresh');
var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');

var closeButton1 = document.querySelector('.close1');
var close1ClickStream = Rx.Observable.fromEvent(closeButton1, 'click');
// and the same logic for close2 and close3

var requestStream = refreshClickStream.startWith('startup click')
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });

var responseStream = requestStream
  .flatMap(function (requestUrl) {
    return Rx.Observable.fromPromise($.ajax({url: requestUrl}));
  });

var suggestion1Stream = close1ClickStream.startWith('startup click')
  .combineLatest(responseStream,             
    function(click, listUsers) {
      return listUsers[Math.floor(Math.random()*listUsers.length)];
    }
  )
  .merge(
    refreshClickStream.map(function(){ return null; })
  )
  .startWith(null);
// and the same logic for suggestion2Stream and suggestion3Stream

suggestion1Stream.subscribe(function(suggestion) {
  if (suggestion === null) {
    // hide the first suggestion DOM element
  }
  else {
    // show the first suggestion DOM element
    // and render the data
  }
});

** http://jsfiddle.net/staltz/8jFJH/48/** 에 오시면 실제 동작하는 예제를 볼 수 있습니다.

저 코드는 짧지만 꽤 많은 것을 함축하고 있습니다. 적절하게 관심사를 분리한 다양한 이벤트들을 처리하고 있고, 심지어 응답을 캐싱도 하고 있습니다. 이런 함수형 스타일은 코드를 명령형(imperative)가 아니라, 선언적(declarative)로 만듭니다. 우리는 실행해야 하는 sequence of instruction을 주는 것이 아니라, 이게 뭔지 말하고 있을 뿐입니다. 예를 들어, 우리는 Rx로, 컴퓨터에게, suggestion1Stream close1스트림과, 가장 최근의 응답으로부터 가져온 유저 한명을 조합하는 것이고, 새로고침이나 웹페이지가 시작했을 땐 null 이라고 말하고 말하고 있는겁니다.

또한 주목해야 할 점은, if, for, while같은 control flow 요소와, JavaScript 어플리케이션에서 항상 들어갔던 콜백 구문이 전혀 보이지 않는 다는 점입니다. 위 코드에서 subscribe()에 있는 if, elsefilter()를 사용해서 없앨 수 있습니다. (당신이 연습삼아 해볼 수 있도록 제가 구현해놓진 않았습니다) Rx에서, 우리는 map, filter, scan, merge, combineLatest, startWith외에도 더 많은 스트림 함수들을 사용함으로써, 이벤트 기반 프로그램의 흐름을 제어 할 수 있습니다. 이런 도구들은 적은 코드로도 더 많은 일을 할 수 있게 해줄겁니다.

다음 할일

만약 Reactive Programming을 하기 위해서 Rx*가 괜찮은 라이브러리라고 느껴진다면, 시간을 좀 내어서 big list of functions 에 있는 변형, 조합, 그리고 생성과 관련된 부분을 읽어보길 바랍니다. 만약 해당 함수들을 스트림 다이어그램으로 이해하고 싶다면, RxJava's very useful documentation with marble diagrams 을 보길 바랍니다. 뭔가를 하다 막힌다면, 다이어그램을 먼저 그린 다음, 다이어그램으로 생각하고, 쭉 나열된 함수들의 목록을 쳐다보면서, 더 생각해보시길 바랍니다. 제 경험상 이런 방식은 효과적이었습니다.

당신이 Rx*로 프로그래밍을 할 줄 알게 되면, Cold vs Hot Observables 의 개념을 이해하는건 반드시 필요합니다. 이걸 무시하면, 언젠가 치명적인 문제로 되돌아올겁니다. 분명 경고했어요. 진짜 함수형 프로그래밍을 배우면서 실력을 더 키우시고, and getting acquainted with issues such as side effects that affect Rx*.

하지만 Reactive Programming은 Rx* 뿐만이 아닙니다. Rx* 를 하다보면 가끔 이상한 점들을 느낄 때가 있을 텐데, Bacon.js 을 쓰면 직관적으로 작업할 수 있습니다. Elm Language 은, JavaScript + HTML + CSS 으로 컴파일되는 Functional Reactive Programming 언어로써, 독자적인 분야를 구축하고 있습니다. Elm에는 time travelling debugger 이란게 있는데, 굉장한 기능입니다.

Rx는 이벤트가 넘쳐나는 프론트엔드와 앱 단에서 훌륭하게 쓰일 수 있습니다. 하지만 단지 클라이언트 쪽 뿐만 아니라, 백엔드, DB와 연결되는 부분에서도 훌륭하게 쓸 수 있습니다. 사실 RxJava는 Netflix의 API에서 서버사이드 concurrency를 가능케하는 핵심 부분입니다. Rx는 특정 타입의 어플리케이션이나 언어에 쓰이는 프레임워크 수준에 제한되어 있지 않습니다. 이것은 이벤트-기반 소프트웨어를 프로그래밍할 때 쓸 수 있는 패러다임 그 자체입니다.

이 튜토리얼이 도움이 되었다면, 트위터로 공유해주세요.

keyword: rxjs, rxjs 입문, rxjs 가이드, rxjs 기초, rxjs 소개, rxjs 설명, rxjs 인트로, rxjs intro, rxjs 이란

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment