[번역] 당신이 놓친 반응형 프로그래밍에 대한 모든 것

본문 링크

반응형 프로그래밍이 처음이신가요? Rx, Bacon.js, RAC에 대해 들어본 적이 있으시다면 반응형 프로그래밍은 그것들을 아우르는 범주라고 생각하시면 됩니다.


영상으로 된 튜토리얼을 더 선호하시나요?

이 글과 동일한 내용의 라이브 코딩 영상도 있으니 영상을 선호하신다면 추천합니다. Egghead.io - Introduction to Reactive Programming

반응형 프로그래밍을 배우는 것은 어렵고, 좋은 자료를 찾기 힘들기 때문에 더 어렵게 느껴질 것입니다. 제가 처음 반응형 프로그래밍에 대해 접했을 때도 튜토리얼을 찾으려 했습니다. 하지만 제가 유일하게 찾은 것은 적은 양의 실전 가이드였는데, 이들 또한 수박 겉핥기였을 뿐이고 전체적인 아키텍쳐를 설계하는 도전 같은 일에 대한 자료는 찾을 수 없었습니다. 어떤 함수를 이해하는데 있어서 라이브러리의 문서조차도 도움이 안 될 경우도 종종 있었습니다. 정말입니다. 이걸 보시면 아실 것입니다.

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

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.

(역자: 옵저버블 시퀀스라는 어려운 단어를 복잡하게 반복해서 이해하지 못하는 내용을 설명하려고 한 것 같습니다.)

오 마이 갓.

책도 두 권 읽었습니다. 하나는 아주 큰 그림만 그리는 책이었고 다른 하나는 반응형 라이브러리를 어떻게 쓰는지에만 초점을 맞춘 책이었습니다. 결국 저는 만들면서 개념에 대해 이해하는 가장 어려운 방식으로 반응형 프로그래밍을 익혔습니다. 그리고 제가 다니는 회사인 Futurice에서 실제로 적용해봤고, 문제가 생겼을 때는 동료들의 도움도 받았습니다.

반응형 프로그래밍을 배우는 여정에서 가장 어려운 부분은 바로 반응형으로 생각하는 것입니다. 반응형으로 생각하기 위해서는 문제를 명령형으로 생각하지 않고 기존 프로그래밍의 stateful 한 습관을 버려야했으며, 뇌를 다른 패러다임으로 작동하게 강제해야 했습니다. 저는 이런 측면에 관해 설명하는 내용은 인터넷에서 전혀 찾지 못했는데 이제는 반응형으로 생각하게 만들어주는 튜토리얼이 생길 때가 됐다고 생각합니다. 그 이후에는 라이브러리 문서가 오히려 여러분이 가는 길을 밝혀 줄 것입니다. 이 글이 여러분에게 도움이 되었으면 좋겠습니다.

반응형 프로그래밍이 뭔가요?

인터넷의 반응형 프로그래밍에 대한 설명이나 정의들은 대부분 나쁜 편입니다. Wikipedia는 너무 일반적이고 이론적입니다. Stackoverflow의 표준 답변은 뉴비들에게 좋은 답변은 절대 아니라고 생각합니다. Reactive Manifesto는 여러분보다 회사의 프로젝트 관리자나 영업 담당자에게 보여주면 좋을 글이라고 생각합니다. 마이크로소프트의 Rx(Rx = 옵저버블 + LINQ + 스케쥴러)는 우리에겐 너무 무거운데다가 너무 마이크로소프트스러워서 혼란스럽기만 합니다. “반응형(Reactive)”와 “변경 사항의 전달(propagation of change)” 같은 용어는 기존의 MV*와 익숙한 언어들이 해오던 것들과 같은 맥락의 내용을 전달합니다. 제 프레임워크의 뷰는 모델에 반응합니다. 당연히 변경된 사항도 전달됩니다. 그렇지 않으면 아무것도 화면에 그려지지 않을 것이니까요.

그럼 이제 쓸데없는 내용을 잘라내봅시다.

반응형 프로그래밍은 비동기 데이터의 스트림을 프로그래밍하는 것입니다.

어떤 면에서는 반응형 프로그래밍이란 개념은 전혀 새로운 것이 아닙니다. 이벤트 버스나 기존의 클릭 이벤트는 비동기 이벤트 스트림이고, 우리는 이를 관찰(observe)하며 사이드 이펙트를 실행합니다. 반응형은 그러한 방식에 대한 아이디어 중 가장 강력합니다. 반응형 프로그래밍이 있다면 클릭이나 호버 이벤트뿐만 아니라 모든 이벤트에 대해 그것에 대한 데이터 스트림을 만들 수 있습니다. 스트림은 저렴하고 아주 흔하며 변수, 사용자 입력, 속성(프로퍼티), 캐시, 데이터 구조 등 어떤 것이든 스트림이 될 수 있습니다.

한 마디로, 함수를 합성하고 생성하며 필터링할 수 있는 엄청난 도구상자라고 생각하시면 됩니다. 여기서 “함수형”이라는 마법이 시작되는데요. 스트림은 다른 스트림의 입력값으로 사용될 수 있습니다. 심지어 여러 개의 스트림이 다른 스트림의 입력이 되기도 합니다. 우리는 두 스트림을 merge 할 수도 있고, 기존의 스트림을 filter 해서 필요한 정보만 가지고 있는 새로운 스트림을 만들 수도 있으며, 다른 스트림에 값을 map 할 수도 있습니다.

만약 반응형에서 가장 중요한 것이 스트림이라면, 우리에게 친숙한 “버튼을 클릭하는” 이벤트 스트림을 통해 더 자세히 알아보도록 하겠습니다.

클릭 이벤트 스트림

스트림이란 시간순으로 정렬한 이벤트의 시퀀스입니다. 스트림은 서로 다른 세 가지의 값을 발생할 수 있는데, 값(또는 어떠한 타입), 에러 또는 “완료 신호”입니다. 주어진 예시로 생각해보면 완료 신호는 보고 있는 창을 닫는 것입니다.

스트림에서 발생하는 이벤트는 오로지 비동기로 전달받으며, 그것도 각 경우에 대한 함수가 정의가 돼있어야 받을 수 있습니다. 때로는 에러나 완료 신호에 대한 함수는 작성하지 않고 값에만 집중할 때도 있습니다. 스트림에 대해 “듣는” 행위는 구독(subscribing) 이라고 합니다. 우리가 정의하는 함수는 옵저버(observer)입니다. 스트림은 관측되는 주제(또는 “옵저버블(observable)”)입니다. 더 자세한 내용은 Observer Design Pattern을 확인하세요.

스트림을 기호로 나타내면 아래와 같습니다.

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

a, b, c, d : 발생한 값
X : 에러
| : 완료 신호
---> : 타임라인

위 내용은 익숙하실 테니 기존 클릭 이벤트 스트림을 변형시킨 새로운 클릭 이벤트 스트림을 만들어보는 것을 해보겠습니다.

먼저 버튼이 몇 번이나 클릭 됐는지 나타내는 카운터 스트림을 생성해보겠습니다. 보통의 반응형 라이브러리에서는 map, filter, scan 등 스트림에 붙일 수 있는 함수를 많이 제공합니다. clickStream.map(f)처럼 함수를 호출하면 클릭 스트림을 기반해서 새로운 스트림을 생성해서 반환합니다. 오리지널 클릭 스트림을 수정하지는 않습니다. 우리는 이를 불변성(immutability)라고 부릅니다. 불변성은 팬케이크의 시럽처럼 반응형 스트림에서는 빼놓을 수 없는 존재인데, 덕분에 우리는 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) 함수는 발생하는 값을 제공된 f 함수가 반환하는 값을 새로운 스트림에 매핑합니다. 위 예의 경우에는 클릭할 때마다 숫자 1을 매핑하게 해두었습니다. scan(g) 함수는 x = g(accumulated, current)와 같이 모든 값을 누적해서 새로운 값을 생성합니다. 우리의 경우에는 간단한 더하기 함수입니다. 그러면 결과 스트림인 counterStream은 클릭이 발생할 때 지금까지 발생한 총 클릭 수를 발생시킵니다.

이제 반응형의 숨겨둔 진짜 힘을 보여드리겠습니다. 이번엔 “더블 클릭” 이벤트에 대한 스트림을 예로 들겠습니다. 더 나아가서 삼중 클릭, 두 번 이상의 멀티 클릭에 대해 해보는 것도 흥미롭겠네요. 숨 크게 한 번 쉬고 이를 전통적인 명령형과 stateful한 방식으로 만든다고 상상해볼까요? 상태를 유지하고 시간 간격을 다루기 위해 변수를 선언할 생각을 하니 끔찍하네요.

반응형으로 생각하면 편-안합니다. 실제로 로직을 코딩해봐도 4줄이면 끝납니다. 하지만 코드 창은 잠시 닫아두겠습니다. 초보자부터 전문가까지 모두에게 스트림을 설계하고 이해하는 가장 좋은 방법은 다이어그램을 생각하는 것입니다.

Multiple clicks stream

회색 상자는 스트림을 다른 스트림으로 변형하는 함수를 의미합니다. 첫 번째 회색 상자를 통해 250밀리초의 “이벤트 침묵”이 발생하면 지금까지의 클릭을 리스트로 만듭니다. 이게 바로 buffer(stream.throttle(250ms))가 하는 일입니다. 지금은 알아보는 단계니 자세히 이해하지 않아도 됩니다. 그래서 결과는 리스트의 스트림이 됩니다. 이 스트림에는 map() 함수를 적용해서 각 리스트의 요소 개수로 변형합니다. 마지막에는 filter(x >= 2) 함수를 통해 1이라는 정수를 무시합니다. 끝입니다! 만들고 싶었던 스트림을 생성하는데 3개의 연산이 필요했습니다. 그 후엔 subscribe ("listen")를 통해 원하는 액션이 발생했을 때 하고 싶은 일을 합니다.

이러한 접근이 보여주는 아름다움을 즐기셨으면 좋겠습니다. 앞서 보여드린 예시는 빙산의 일각입니다. 우리는 같은 연산을 서로 다른 스트림에 적용할 수도 있습니다. 예를 들면, API 리스폰스 스트림의 경우 많은 함수가 적용될 수 있습니다.

왜 반응형 프로그래밍을 적용해야 하나요?

반응형 프로그래밍은 코드의 추상화 단계를 끌어올려 줘서 어떻게 구현할지에 대한 것보단 비즈니스 로직을 어떻게 만들 것인가에 집중할 수 있게 해줍니다. 반응형 프로그래밍의 코드는 상대적으로 간결하기도 합니다.

이런 이점은 모던 웹앱이나 모바일 앱에서 데이터에 관련된 다수의 UI 이벤트를 다룰 때 더욱더 빛을 발합니다. 10년 전에는 웹 페이지의 인터랙션이란 백엔드에 긴 폼을 제출하고 프론트엔드에 렌더링하는 것이 대부분이었습니다. 앱은 과거에 비하면 거의 실시간이라고 할 수 있습니다. 폼의 필드 하나를 바꾸더라도 자동으로 백엔드에 저장되고, 어떤 콘텐츠에 “좋아요”하는 것은 다른 유저들에게 실시간으로 알려집니다.

오늘날의 앱은 사용자에게 뛰어난 상호작용 경험을 가능하게 하는 아주 많은 종류의 실시간 이벤트로 가득 차 있습니다. 우리는 이런 이벤트를 다룰 적절한 도구가 필요해졌고 그에 대한 해결책은 반응형 프로그래밍인 것 같습니다.

예제를 통해 반응형으로 생각해보기

이제 현실 세계에선 어떻게 반응형으로 생각하는지에 대해 알아봅시다. 개념부터 실제 어떻게 돌아가는지 설명드리겠습니다.

저는 설명을 위한 도구로 자바스크립트RxJS 를 선택하겠습니다. 왜냐하면 자바스크립트는 이 시점에 우리에게 가장 친숙한 언어이고 Rx* 라이브러리 시리즈는 수많은 언어와 플랫폼에서 사용이 가능하기 때문입니다. (.NET, Java, Scala, Clojure, JavaScript, Ruby, Python, C++, Objective-C/Cocoa, Groovy 등) 그러니 여러분의 도구가 어떤 것이든 이 튜토리얼을 따라하는 것이 도움이 안되진 않을 것입니다.

“팔로우할 사용자” 추천 박스 만들어보기

Twitter를 보면 팔로우할만한 다른 사용자를 추천하는 UI가 존재합니다.

Twitter Who to follow suggestions box

우리가 집중할 사항은 이 UI의 핵심 기능을 똑같이 만드는 것입니다.

  • 시작 시, API에서 계정 데이터를 불러오고 3개의 추천을 보여줍니다
  • “Refresh” 버튼을 클릭하면 또 다른 3개의 계정을 추천합니다
  • 각 줄의 X 버튼을 누르면 해당 부분만 비운 다음 다른 계정을 보여줍니다
  • 각 줄은 계정의 아바타와 페이지의 링크로 구성돼 있습니다

나머지 기능과 버튼들은 중요하지 않으니 무시하도록 하겠습니다. Twitter는 최근에 인증되지 않은 접근에 대해서 API를 폐쇄했기 때문에 그 대신 GitHub의 API를 사용해서 만들어보겠습니다. Github 사용자 불러오기 API

완성된 코드는 이 곳에 있으니 막혔을 때 보시면 됩니다.

리퀘스트와 리스폰스

Rx로 이 문제를 푼다면 어떻게 접근하는 것이 좋을까요? 일단 Rx 만트라를 외우고 시작하겠습니다. 모든 것은 스트림이 될 수 있다. 가장 쉬운 기능부터 만들어볼까요? “시작하면 3개의 계정 데이터를 API에서 가져온다” 이 문장엔 특별한 기능이 들어있지 않습니다. 그저 (1) 리퀘스트를 하고, (2) 리스폰스를 받은 다음, (3) 렌더링하는 것입니다. 리퀘스트를 스트림으로 나타내는 것부터 해봅시다. 너무 쉬운 일이라 생각하실 수도 있지만, 기본부터 시작하는 것이 좋겠죠?

시작 시에 우리가 필요로 하는 리퀘스트는 딱 하나입니다. 그러니 이를 데이터 스트림으로 모델링한다고 하면 하나의 값을 반환하는 스트림으로 표현할 수 있을 것입니다. 나중에는 많은 리퀘스트가 발생하겠지만 지금 당장은 하나입니다.

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

a는 'https://api.github.com/users' 스트링을 의미합니다

리퀘스트를 위해 필요한 URL 스트림이 완성됐네요. 리퀘스트 이벤트가 발생하면 우리에게는 두 가지 정보가 전달됩니다. 언제(when)와 무엇(what)이죠. “언제”는 리퀘스트 이벤트가 발생하는 시점을 의미합니다. 그리고 “무엇”은 URL을 포함하고 있는 스트링이 되겠죠.

하나의 값으로 그러한 스트림을 생성하는 것은 Rx에선 흔한 일입니다. Rx에서는 스트림을 공식적으로 “옵저버블”이라고 부릅니다. 스트림이 관찰이 가능해서(can be observed) 그렇게 부르는 것 같은데 저는 이 이름이 너무 별로라 생각하기 때문에 스트림 이라고 계속해서 부르겠습니다.

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

지금 당장은 그저 스트링의 스트림일뿐 다른 어떤 연산을 하지 않습니다. 그러니 값이 발생했을 경우 어떤 일이 일어나도록 바꿔보겠습니다. 우리는 이러한 행위를 스트림을 구독(subscribing)한다고 부릅니다.

requestStream.subscribe(function(requestUrl) {
  // 리퀘스트 실행
  jQuery.getJSON(requestUrl, function(responseData) {
    // ...
  });
}

위 예시는 jQuery Ajax 콜백을 사용해서 리퀘스트를 비동기로 다루는 방법에 대해 작성한 것입니다. 그런데 Rx도 비동기 데이터 스트림을 다루는데에 사용한다고 하지 않았나요? 그렇다면 우리가 보낸 리퀘스트에 대해 미래 어느 시점에 올 데이터를 포함하고 있는 스트림을 리스폰스로 받는다고 생각할 수는 없을까요? 개념적으론 맞는 것 같으니 한 번 시도해보겠습니다.

requestStream.subscribe(function(requestUrl) {
  // 리퀘스트 실행하는 곳
  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) {
    // 리스폰스 처리하는 곳
  });
}

Rx.Observable.create()가 하는 일은 데이터 이벤트(onNext())나 에러(onError())가 발생했을 경우 명시적으로 옵저버를 적어두어서 자신만의 커스텀 스트림을 만들 수 있게 하는 것입니다. 위 예시에서 우리는 jQuery Ajax Promise를 한 겹 감쌌습니다. 잠깐, 그렇다면 Promise도 Observable이라는 말인가요?

         

😮

맞습니다.

옵저버블은 프로미스++이라고 생각하셔도 됩니다. Rx에선 var stream = Rx.Observable.fromPromise(promise) 이런 문법을 통해 프로미스를 옵저버블로 쉽게 변환할 수 있습니다. 단 하나 다른 점은 옵저버블은 Promises/A+을 따르지 않는다는 점입니다. 그렇다고 문제가 생기는 점은 없으니 걱정마세요. 프로미스는 단 하나의 값만 발생하는 옵저버블입니다. Rx 스트림은 여러 개의 값을 발생시킨다는 점에서 프로미스를 넘었다고 할 수 있겠습니다.

적어도 옵저버블이 프로미스보다 강력하다는 점을 하나 배웠네요. 여전히 프로미스가 더 좋다고 생각하시는 분이 있을 수 있으니 Rx 옵저버블이 할 수 있는 것에 대해 알아보겠습니다.

다시 예제로 돌아가겠습니다. 눈치가 빠르신 분은 subscribe()subscribe()안에서 호출하고 있다는 것을 보셨을 것입니다. 이것은 콜백 지옥이랑 비슷한 모양인데다가 resposeStream의 생성 작업이 requestStream에 의존적이라는 것도 확인하실 수 있습니다. 앞에서 Rx엔 하나의 스트림을 다른 스트림으로 변환할 수 있는 간단한 매커니즘이 존재한다고 말씀드렸으니 그렇게 풀어보겠습니다.

많은 함수 중 기본적으로 알아야하는 딱 하나는 바로 map(f)입니다. map은 A라는 스트림이 있을 때 A의 값에 f() 함수를 적용하고 그 결과를 B라는 스트림으로 생성합니다. 리퀘스트 URL 스트림을 리스폰스 프로미스 스트림으로 바꿔야하는 우리에게는 map이 아주 적절하겠네요.

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

우리는 스트림의 스트림인 “Metastream“이라는 괴물을 만들어버렸습니다. 놀라지 마세요. 메타스트림은 매번 발생하는 값이 또 다른 스트림이라는 점에서 이해하기 어려운 개념은 아닙니다. 익숙한 개념으로 설명하자면 포인터가 있겠네요. 매번 발생되는 값은 다른 스트림에 대한 포인터 입니다. 우리 예제에서는 각 리퀘스트 URL이 그에 대응하는 리스폰스가 담긴 프로미스 스트림의 포인터로 매핑되어 있는 상태입니다.

Response metastream

리스폰스에 대한 메타스트림이라니. 이름부터 혼란스럽고 우리에게 도움이 될 것 같지는 않네요. 우리가 필요한 것은 그저 리스폰스를 담고 있고 JSON 객체에 대한 ‘프로미스’가 아닌 그냥 JSON 객체를 발생시키는 간단한 스트림입니다. 정확히 그런 역할을 해주시는 분이 나타나셨습니다. 미스터 Flatmap씨 입니다. Flatmap은 map()의 한 종류로, 메타스트림을 “flatten” 시켜줍니다. 그렇다고 메타스트림이 버그고 Flatmap을 통해서 고치는 것은 아닙니다. Flatmap은 Rx에서 비동기 리스폰스를 다루는 진짜 그냥 도구입니다.

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

Response stream

좋습니다. 그리고 리스폰스 스트림은 리퀘스트 스트림에 따라 정의되기 때문에, 나중에 더 많은 이벤트가 리퀘스트 스트림에 추가된다 하여도 그에 대응되는 리스폰스 이벤트가 리스폰스 스트림에서 일어날 것입니다. 아래 그림처럼요.

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

(소문자는 리퀘스트고 대문자는 리스폰스입니다)

드디어 리스폰스 스트림이 생겼네요. 이제 데이터를 렌더링할 수 있는 조건이 다 갖춰졌습니다.

responseStream.subscribe(function(response) {
  // `response`를 우리가 원하는대로 DOM에 렌더링하면 되는 부분
});

잠시 중간 점검을 하자면 지금까지 만든 코드를 다 합치면 아래와 같을 것입니다.

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) {
  // `response`를 우리가 원하는대로 DOM에 렌더링하면 되는 부분
});

새로고침 버튼

제가 리스폰스로 오는 사용자가 100명이라고 말씀드렸던가요? 우리가 쓸 API는 불러오고자 하는 페이지만 지정할 수 있고 한 페이지에 몇 명을 불러올지는 만질 수 없게 만들어져있습니다. 그러면 우리가 만들 에제는 3개의 데이터 객체만 사용하고 나머지 97개를 버리겠지만 이 문제는 나중에 리스폰스 캐싱을 다루면서 생각하는 것으로 하겠습니다.

새로고침 버튼이 클릭될 때마다 리퀘스트 스트림은 새로운 URL을 발생시키고 새로운 리스폰스를 받게 될 것입니다. 우리한테 필요한 것들을 정의해볼까요?

  1. 새로고침 버튼의 클릭 이벤트 스트림
  2. 새로고침 버튼 클릭 스트림에서 이벤트가 발생하면 리퀘스트 스트림을 변경 즐겁게도 RxJS에는 이벤트 리스너에서 옵저버블을 만드는 도구가 이미 준비돼있습니다.
var refreshButton = document.querySelector('.refresh');
var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');

새로고침 클릭 이벤트 자체가 API URL을 만들어내는 것은 아니기 때문에 우리에겐 각 클릭을 실제 URL로 이어주는 작업이 필요합니다. 그럼 리퀘스트 스트림을 변경해서 새로고침 클릭 스트림으로 바꾸고 다시 API 엔드포인트에 랜덤 페이지를 지정해주게 만들어보겠습니다.

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() 연산자가 있습니다. 아래 그림에서 자세한 작동 방식을 확인해보겠습니다.

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() 함수는 여러분이 예상하는 바로 그 작업을 합니다. 입력 스트림이 어떤 모양을 가지든 startWith(x)이 포함된 출력 스트림은 항상 x로 시작합니다. 지금까지의 코드는 충분히 DRY하지 않으니, 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;
  });

끝내주네요. 앞에서 실수를 했다고 한 부분과 지금 코드를 비교해봤을 때 startWith()에 대해 알아보고 추가한 것 밖에 없습니다.

사용자 추천을 스트림으로 모델링하기

이전까지 우리가 건드렸던 것은 responseStream의 subscribe()에서 일어나는 렌더링 단계의 추천 UI였습니다. 그런데 새로고침 버튼이 생기면서 문제가 생겼습니다. ‘새로고침’을 눌렀을 때 지금 추천돼있는 세 명의 사용자가 없어지지 않는다는 것입니다. 새로운 추천은 리스폰스가 도착해야만 받을 수 있지만 UI를 더 좋아보이도록 만들기 위해서 클릭 이벤트가 발생하면 현재 추천된 사용자를 삭제하는 작업을 해보겠습니다.

refreshClickStream.subscribe(function() {
  // 이전에 추천했던 세 명의 사용자를 삭제합니다
});

위 코드는 매우 나쁩니다. 왜냐하면 추천 UI의 DOM 요소에 영향을 주는 두 개의 구독자가 생겼기 때문입니다. (responseStream.subscribe()refreshClickStream.subscribe()) 그리고 이것은 전혀 관심사의 분리(Separation of concerns)라고 할 수도 없는 것 같습니다. 만트라를 기억하십니까?

       

Mantra

이번에는 사용자 추천을 추천 데아터를 포함한 JSON 객체를 발생시키는 스트림으로 모델링 해보겠습니다. 일단 세 개의 추천 중 첫 번째 추천을 스트림으로 나타낸다면 다음과 같겠네요.

var suggestion1Stream = responseStream
  .map(function(listUsers) {
    // 목록에서 무작위 사용자를 하나 가져옵니다
    return listUsers[Math.floor(Math.random()*listUsers.length)];
  });

나머지 suggestion2Streamsuggestion3Streamsuggestion1Stream를 복붙하면 간단하게 만들어질 것입니다. 이건 DRY 원칙에 위배되지만 이 튜토리얼의 코드를 이해하기 쉽게 만든다는 점과 이 문제를 해결하는 것이 하나의 좋은 과제가 될 수 있다는 점에서 긍정적이라 생각합니다.

responseStream의 subscribe()에서 렌더링하는 대신에 다음과 같이 해보겠습니다.

suggestion1Stream.subscribe(function(suggestion) {
  // 첫 번째 추천 사용자를 DOM에 렌더링합니다
});

“새로고침 버튼을 누르면 기존에 추천 UI를 정리한다”로 돌아가서 생각해보면, 새로고침 클릭을 한다는 것은 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; })
  );

그리고 렌더링 시에는 null을 “데이터 없음”으로 해석해서 UI를 숨기는거죠.

suggestion1Stream.subscribe(function(suggestion) {
  if (suggestion === null) {
    // 첫 번째 추천 UI의 DOM을 숨깁니다
  }
  else {
    // 첫 번째 추천 UI의 DOM을 보여주고 데이터를 채웁니다
  }
});

지금 그리고 있는 큰 그림에 대해 스트림으로 설명하자면 다음과 같습니다.

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-->

Nnull을 의미합니다.

추가적으로, 추천 스트림에 startWith(null)을 추가하면 처음 시작 시에 빈 추천을 렌더링하는 것도 가능해집니다.

var suggestion1Stream = responseStream
  .map(function(listUsers) {
    // 목록에서 무작위 사용자를 한 명 가져옵니다
    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-->

추천 UI 닫는 기능 및 캐싱된 리스폰스 사용하기

드디어 구현할 기능이 딱 하나 남았네요. 추천 UI는 각자 ‘x’ 버튼을 하나씩 가지고 있습니다. 이 ‘x’ 버튼을 누르면 해당 추천 UI를 닫고 다른 사용자를 추천해줍니다. 얼핏 생각해봤을 때 닫기 버튼이 클릭되면 새로운 리퀘스트를 만드는 것만으로도 충분하다고 생각하실 수 있습니다.

var close1Button = document.querySelector('.close1');
var close1ClickStream = Rx.Observable.fromEvent(close1Button, 'click');
// close2Button과 close3Button는 복붙하면 되겠죠?

var requestStream = refreshClickStream.startWith('startup click')
  .merge(close1ClickStream) // 이 줄이 추가됐습니다
  .map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });

위 코드는 제대로 작동하지 않을 것입니다. 닫기는 제대로 할지라도 모든 추천을 새롭게 불러올텐데 우리가 원하는 것은 단 한 명의 사용자만 새롭게 불러오는 것입니다. 이것을 해결하기 위해서는 살짝 다른 방식의 접근을 하려고 합니다. 이전에 받았던 리스폰스를 사용하는 것이죠. API로 사용자 목록을 불러오면 한 번에 100명을 불러오는데 현재는 단 세 명의 데이터만 사용하고 있습니다. 다른 사용가능한 신선한 데이터가 97개나 더 있다는 것이죠! 더 리퀘스트할 이유가 없습니다.

다시 한 번 스트림으로 생각하는 시간을 가지겠습니다. ‘close1’ 클릭 이벤트가 발생했을 때, 우리는 responseStream에서 가장 최근에 발생한 리스폰스를 사용해서 목록의 무작위 사용자 한 명을 가져오고 싶습니다. 다음과 같이 말이죠.

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

Rx에선 combineLatest라는 컴비네이터 함수가 존재하는데, 이 함수는 A, B 두 스트림을 입력으로 받아서 두 스트림 중 하나에서 이벤트가 발생하면 각 스트림의 가장 최근에 발생한 값(a, b)을 가져와서 우리가 넘겨준 함수인 f()를 적용한 c = f(a, b)를 반환합니다. 그림으로 설명하는 것이 더 낫겠네요.

stream A: --a-----------e--------i-------->
stream B: -----b----c--------d-------q---->
          vvvvvvvv combineLatest(f) vvvvvvv
          ----AB---AC--EC---ED--ID--IQ---->

이렇게 좋은 함수가 있으니 우리는 close1ClickStreamresponseStream에 대해서 combineLatest()를 해서 close1button이 클릭되면 가장 최근에 발생한 값을 받아서 새로운 suggestion1Stream이라는 스트림을 만들 수 있겠네요. 물론 반대의 경우도 가능합니다. responseStream에서 새로운 리스폰스가 발생했을 경우에도 가장 최근의 close1button을 클릭하도록 만들 수 있습니다. 그런게 가능하다면 이전에 작성했던 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()는 두 스트림의 가장 최근 값을 사용하는데, 만약 둘 중 하나가 한 번도 값을 발생시키지 않았다면 combineLatest()는 값을 반환하지 않을 것입니다. 위의 문자로된 그림을 보면 가장 처음 값인 a가 발생했을 때는 아무 일도 일어나지 않는 것이 보이실 것입니다. 다른 스트림의 첫 번째 값인 b가 발생해야만 결과값이 반환됩니다.

이 문제를 해결하기 위해서는 또 다른 방식이 필요한데, 우리는 가장 심플한 방법을 선택하는 것으로 하겠습니다. 그것은 바로 웹 페이지 시작 시 close1button을 클릭하는 것입니다.

var suggestion1Stream = close1ClickStream.startWith('startup click') // 이 부분이 추가됐습니다
  .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');
// close2와 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);
// suggestion2Stream과 suggestion3Stream도 동일한 로직을 가집니다

suggestion1Stream.subscribe(function(suggestion) {
  if (suggestion === null) {
    // 첫 번째 추천 UI의 DOM을 숨깁니다
  }
  else {
    // 첫 번째 추천 UI의 DOM을 보여주고 데이터를 렌더링합니다
  }
});

실제로 작동하는 예제는 이 곳에서 보실 수 있습니다.

코드가 많지는 않은데 아주 밀도가 있네요. 오늘 추천 사용자 UI 박스를 만들면서 우리는 다양하고 복잡한 이벤트를 적절한 관심사의 분리를 통해 관리했고, 리스폰스를 캐싱하기도 했습니다. 함수형 스타일로 프로그래밍하는 것은 명령형으로 짜는 것과 비교해서 코드가 더욱 더 선언적으로 만들어줍니다. 우리가 코드에게 제공한 것은 실행해야 할 명령의 목록이 아닌 스트림과 스트림 사이의 관계에 대한 설명이었습니다. 예를 들어, Rx와 함께라면 우리는 컴퓨터에게 suggestion1Stream은 ‘close 1’ 스트림은 가장 최근의 리스폰스의 유저를 하나 가져와서 그린 것이고, 프로그램이 시작될 때 혹은 새로고침이 발생했을 경우 null이 된다 라고 설명할 수 있게 됩니다.

또 하나 인상적인 부분은 if, for, while과 같은 흐름을 제어하는 요소들이 없었으며 Javascript 어플리케이션에서 예상되는 콜백 기반 흐름 제어조차 없었다는 부분입니다. (subscribe()에서 filter()를 사용한다면 if, else처럼 사용할 수 있습니다. 이건 과제로 남겨두곘습니다.) Rx에선 map, filter, scan, merge, combineLatest, startWith을 포함한 수많은 이벤트 기반 프로그램의 흐름을 제어하는 스트림 함수들이 존재합니다. 이러한 함수들은 우리가 적은 코드로 많은 일을 할 수 있는 힘을 줍니다.

이제 어떤 걸 해야할까요

오늘은 계기로 Rx가 여러분의 반응형 프로그래밍에 쓰일 라이브러리 중 원픽이 됐다면, RxJS 연산자 목록을 살펴보고 변형하고 합성하고 옵저버블을 만들어보면서 연산자와 친해지는데 시간을 쓰세요. 만약 연산자를 그림으로 이해하고 싶다면 RxJava의 marble diagram을 정리한 문서을 보시면 됩니다. 정말 잘 정리돼있습니다. 어떤 작업을 하고 싶은데 막힌다면 그림을 그리고 생각한 다음, 연산자 목록을 보고 더 생각하시면 됩니다. 이러한 순서는 제 경험상 도움이 많이 되었습니다. (역자: 글쓴이가 marble diagram에 대해 쉽게 이해할 수 있는 사이트를 직접 만드셨습니다. https://rxmarbles.com)

Rx로 프로그래밍을 시작하려고 할 때, 옵저버블의 cold와 hot의 차이를 이해하는 것은 아주 중요한 개념 중 하나입니다. 이를 무시하고 개발한다면 발목을 잡힐 것입니다. 그것도 아주 끔찍하게요. 현실 세계의 함수형 프로그래밍에 대해 더 많이 공부하고 여러분의 실력을 날카롭게 만드세요. 그리고 Rx에 영향을 끼치는 사이드 이펙트에 대한 이슈와도 친해지시면 되겠습니다.

한 가지 짚고 넘어가야 할 점은, 반응형 프로그래밍은 Rx로만 할 수 있는 방법은 아니라는 점입니다. Bacon.js같은 경우엔 직관적이며 가끔씩 Rx가 보여주는 기이한 점을 안 봐도 되게 해줍니다. Elm 언어는 자신만의 영역이 정해져 있을 정도입니다. Elm은 JavaScript + HTML + CSS에서 컴파일되는 함수형 반응형 프로그래밍을 위한 언어 입니다. 정말 멋집니다.

Rx는 이벤트가 많은 프론트엔드와 앱과 찰떡입니다. 하지만 그렇다고 클라이언트에서만 쓰이는 그런 기술은 아닙니다. Rx는 백엔드와 DB와도 궁합이 잘 맞습니다. 실제로 RxJava는 Netflix의 API의 서버 사이드 동시성(concurrency)을 가능하게 해주는 핵심 컴포넌트였습니다. Rx는 하나의 애플리케이션이나 언어에 제한된 프레임워크가 아닙니다. 이벤트 기반 소프트웨어라면 어디서든 사용할 수 있는 하나의 패러다임이라고 보는 것이 더 옳을 것 같습니다.

이 튜토리얼이 도움이 됐다면, 글쓴이에게 트윗을 날려주세요!