회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
오늘 써볼 이야기는 RxJS 라이브러리입니다. 제가 글을 쓰면서 반응형이나 함수형 프로그래밍을 설명하면서 한 번씩 언급했던 그 RxJS입니다.
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
오늘 써볼 이야기는 RxJS 라이브러리입니다. 제가 글을 쓰면서 반응형이나 함수형 프로그래밍을 설명하면서 한 번씩 언급했던 그 RxJS입니다.
자바스크립트에서 시간과 비동기를 다루는 방법은 어려운 쪽에 속합니다. 개인적으로 RxJS는 이러한 비동기를 다루는 데 있어서 탁월하며 새로운 패러다임을 알려주는 좋은 라이브러리라고 생각합니다. 하지만 단순히 특정 기능을 쉽게 사용할 수 있게 하는 유틸성 라이브러리가 아니라 개발과 비동기를 바라보는 패러다임을 바꿔야 하는 만큼 선행할 학습이 많아 진입 장벽이 굉장히 높은 편에 속합니다.
그래서 RxJS를 배우고 있거나 처음인 분들을 위해 어려운 개념들에 대해 조금 더 이해를 높일 수 있는 중요한 내용들을 한번 적어보았습니다. RxJS의 강의나 교과서적인 내용보다는 개인적으로 RxJS를 이해하고 나서 알게 된 인사이트를 바탕으로 재해석한 글입니다. 지금 RxJS를 공부 중인 개발자에게 조금 더 와닿을 것 같으며, 아예 모르는 분들은 그냥 흥미로 읽어도 괜찮습니다.
이번 글은 크게 2가지로 나눠서 작성했습니다. 1부에서는 ‘RxJS 개론’을 통해 왜 배우면 좋은지에 관해 얘기하고, 2부에서는 실제 프로젝트에 적용하면서 어려웠던 개인적인 현실에 대해 설명할 예정입니다.
RxJS 공식 홈페이지로 들어가서 RxJS의 정의를 한번 살펴보겠습니다.
보통 공식문서에 적힌 첫 번째 정의는 ‘실제 정의(?)’가 아니라 배워야 할 목차와 키워드와 같은 거라고 생각하면 좋습니다. 새로운 것을 공부할 때는 일단 간단히 이해하고 내가 알고 있는 내용을 바탕으로 대략적인 감을 잡는 걸 추천합니다.
Keywords: 관찰가능한 시퀸스, 비동기, 이벤트, Observable, Observer, Subjects, 메소드, 연산자, Observer 패턴, Iterator 패턴, 함수형 프로그래밍, 컬렉션 |
이 공식문서를 만드는 작성자도 정의를 쓰다가 이렇게 쓰면 이해하기 어려울거라고 생각했는지 중간에 ‘Think of RxJS as Lodash for events.’라고 힌트를 주었습니다. 하지만 underscore.js 혹은 lodash는 요새 많이 쓰는 라이브러리가 아니니 생소하신 분들도 있을 겁니다.
그래서 저는 RxJS를 일단 다음과 같이 정의하려고 합니다.
이벤트나 비동기, 시간을 마치 Array 처럼 다룰 수 있게 만들어 주는 라이브러리 |
이 말도 당장은 이해가 되지 않을 것이기에 지금까지의 키워드와 내용들을 잠시 머릿속에 넣어두고 예시를 통해 살펴보도록 하겠습니다.
다음과 같이 두 수로 이루어진 배열이 있습니다. 이 중 1사분면에 있는 점을 5개만 골라 각 점과 원점과의 거리의 합을 구하는 코드를 작성해 보시오.
|
초급 수준의 코딩테스트 문제를 하나 만들어보았습니다. 해당 문제를 해결하기 위해서 다음과 같은 코드를 어렵지 않게 작성할 수 있을 것 같습니다.
let sum = 0
let count = 0
for (let i = 0; i < points.length; i++) {
const pos = points[i];
const [x, y] = pos;
// 1사분면에 있는 값만,
if (x > 0 && y > 0) {
// 원점과의 거리의 합을 더해서,
sum += Math.sqrt(x*x + y*y)
count++
}
// 5개면 그만,
if (count === 5) {
break;
}
}
console.log(sum)
자바스크립트 프로그래밍을 더 심도 있게 배우다 보면 같은 문제를 조금 더 고급스럽게(?) 풀기 위해 for
문을 이용해서 프로그램을 작성하기보다 Array
와 Method
를 이용하라고 권장합니다. 그래서 다음과 같이 Array
와Method
의 함수를 활용해서 작성하면 훨씬 더 간결하고 라인별 의미가 분명해서 재사용하기 용이한 코드가 만들어집니다.
const sum = points // 점들 중에서
.filter(([x, y]) => x > 0 && y > 0) // 이중 1사분면에 있는 값을 추려내
.slice(0, 5) // 5개만 골라서
.map(([x,y] => Math.sqrt(x*x + y*y)) // 원점과의 거리들의
.reduce((a, b) => a + b) // 총합
이러한 방식을 우리는 선언적 프로그래밍이라고 부릅니다.
이런 식으로 데이터 객체의 메소드 함수를 조립해서 데이터가 파이프라인으로 연결된 함수형 프로그래밍처럼 작성할 수 있습니다. 위 코드는 객체 지향과 함수형 프로그래밍이 적절히 잘 섞여 있는 매우 좋은 자바스크립트다운 방식입니다.
저희는 보통 이런 코드를 좋은 코드라고 부릅니다.
레벨업을 해봅시다. 다 똑같은 문제입니다. 조금 더 차원을 높여봅시다.
위와 로직은 동일합니다. 1사분면에 있는 점을 5개만 골라 각 점과 원점과의 거리의 합을 구하는 코드를 작성해 보시오. 단, 배열이 아니라 마우스를 통한 입력으로 받겠습니다.
|
어렵지 않죠? 아까 만들었던 코드에 적당히 이벤트 처리를 통해서 다음과 같이 간단히 코드를 작성해 볼 수 있습니다.
let sum = 0
let count = 0
window.onclick = function(event) {
const [x, y] = [event.pageX - screen.width/2, event.pageY - screen.height/2]
// 1사분면에 있는 값만,
if (x > 0 && y > 0) {
// 원점과의 거리의 합을 더해서,
sum += Math.sqrt(x*x + y*y)
count++
}
// 5개면,
if (count === 5) {
window.onclick = null // 이벤트를 더 이상 입력받지 않도록 한다.
console.log(sum)
}
}
그런데 우리는 앞서 배열에서 선언적으로 함수형 프로그래밍처럼 작성하는 게 더 나은 방법이라고 배웠습니다. 그렇다면 지금과 같이 이벤트를 다루는 경우에도 선언적으로 작성할 수는 없을까요?
// 선언적으로 코드를 짜면 훨씬 더 간결해질텐데 배열이 아니라 이벤트라서...
// clicks:??? = [... click, ... click, ...click, ...click, ...]
const sum = clicks
.map(event => [event.pageX - screen.width/2, event.pageY-screen.height/2])
.filter(([x, y]) => x > 0 && y > 0) // 1사분면
.slice(0, 5) // 5개만
.map(([x,y] => Math.sqrt(x*x + y*y)) // 원점과의 거리
.reduce((a, b) => a + b) // 총합
아쉽게도 우리가 알고 있는 자바스크립트에서는 이러한 객체와 메소드는 존재하지 않습니다.
우리가 짜는 비동기 코드가 어려워지는 것은 결국 이벤트와 시간을 다루기 위해서는callback
과setTimeout
등의 코드들로 작성해야만 하기 때문입니다. 이는 곧 복잡한 코드를 만들게 됩니다.
자바스크립트의 기본 객체에는 이러한 API가 없지만, 뭔가 코드를 작성해보니 위와 같은 이벤트도 선언적으로 작성 못할 이유가 없어 보입니다.
points = [ [1,-1], [5,10], [10,-2], [-3,-5], [-10,9], ... ]
clicks = [... click, ... click, ...click, ...click, ...]
Event
역시Array
처럼 같은 타입의 데이터를 여러 개 가지고 있습니다. 다만 미리 존재하고 있는 것이 아니라 비동기이며 아직은 존재하지 않을 뿐이죠. 그러나 우리에게는 callback이 있고 선언적 프로그래밍에서도 callback을 사용하기에 사실 같은 형식으로 작성할 수 있을 것 같습니다.
const points = clicks.map(event => [event.pageX - screen.width/2, event.pageY - screen.height/2])
// 같은 코드네??
const sum = points // 점들 중에서
.filter(([x, y]) => x > 0 && y > 0) // 이중 1사분면에 있는 값을 추려내
.slice(0, 5) // 5개만 골라서
.map(([x,y] => Math.sqrt(x*x + y*y)) // 원점과의 거리들의
.reduce((a, b) => a + b) // 총합
그래서 이벤트를 다루는 새로운 객체인 Observable
을 만들고 Array와 같은 메소드들을 추가하면 이벤트를 선언적으로 Array를 다루듯이 만들 수 있는 라이브러리 Rx가 탄생하게 됩니다.
// click을 통해 Observable
const clicks:Observable<MouseEvent> = fromEvent(window, "click")
// fromEvent 코드는 어떻게 생겼을까?
const fromEvent = (target, type) => new Observable(observer => {
target.addEventListener(type, (event) => observer.next(event))
return () => target.removeEventListener(type)
})
Observable를 통해 우리는 여러 Event를 마치 Array처럼 다룰 수 있는 새로운 객체타입을 하나 얻게 되었습니다. Observable을 생성하는 패턴은 Promise를 만들어내는 방식과 매우 흡사해 보입니다. 사실 Observable은 Promise의 상위호환입니다. Promise
로 만들 수 있는 값은Observable
방식으로도 생성이 가능합니다.
// 통신이나 시간과 같은 비동기로직을 다루기 쉽게 만들어주는 Promise
// Promise는 비동기 로직을 값으로 만들 수 있다.
const fetchXXX = (props) => new Promise((resolve, reject) => {
fetch(url, props).then(res => resolve(res), err => reject(err))
})
// Observable은 Promise 대신 쓸 수 있다.
const fetchXXX = (props) => new Observable(observer => {
fetch(url, props).then(res => observer.next(res), err => observer.error(err))
})
// 반대로 Promise는 Observable가 될 수 없다.
const fromEvent = (target, type) => new Promise(resolve => {
// Promise는 여러개의 값을 받을 수 없다.
target.addEventListener(type, (event) => resolve(event))
// Promise는 종료 시 cleanup을 할 수가 없다.
return () => target.removeEventListener(type)
})
자바스크립트 세상에서 Array는 비동기를 다룰 수 없지만, 여러 개의 값을 다룰 수 있는 반면 Promise는 비동기를 다룰 수 있지만 하나의 값만 다룰 수 있습니다. Observable은 이 2가지의 역할을 동시에 수행할 수 있습니다. 여러 값을 다루면서 비동기를 다룰 수 있고, Array와 Promise와 같이 메소드를 가지고 있습니다.
이러한 성질을 가진 Observable 객체를 통해 우리는 비동기를 선언적인 방식으로 개발을 할 수 있게 됩니다. 더 자세한 내용은 다음 챕터에서 이어서 설명하겠습니다.
지금까지의 공식문서의 RxJS 정의를 제 방식대로 한번 정리해보았습니다. RxJS가 어떤 라이브러리인지 감이 오셨기를 바랍니다.
요약하면,
= 비동기 이벤트와 시간을 Array처럼 다룰 수 있게 만들어주는 라이브러리
A: 비동기 이벤트를 컬렉션 다루듯이, 즉 Array를 다루듯이 선언적으로 개발할 수 있습니다.
트리플 클릭을 구현한다고 생각해봅니다. 0.25초
이내에 클릭한 개수가 3개
라면 트리플 클릭이라고 가정하고 한번 코드를 작성하겠습니다. 굉장히 단순할 것 같은 이 코드를 막상 eventListener와 setTimeout 등을 이용해 작성하려면 매우 막막해집니다. 하지만 RxJS를 이용하면 굉장히 직관적으로 작성할 수가 있습니다.
const tripleClicks$ = fromEvent(window, "click")
.bufferTime(250) // 0.25초간
.filter(clicks => clicks.length === 3) // 클릭이 3개면
.subscribe(...)
뿐만 아니라 우리가 흔히 작성하는 서버와의 통신에 관한 예외처리들, 가령 ‘서버와 통신을 시도했을 때 5초 이내에 응답이 없으면 실패
로 간주하고 다시 시도하고, 실패 시 1초뒤에 재시도
하나 3번 연속으로 실패 시
에는 별도 처리한다. 서버 응답을 연속으로 요청할 경우 이미 처리되고 있는 통신이 있다면 무시
하도록 한다’라는 등의 요구사항을 그냥 작성하려면 막막하지만, RxJS에서는 꽤 직관적으로 작성할 수 있습니다.
request$
// 아직 진행중이면 skip
.exghustMap(params => post_some_request(params) // 서버와의 통신
.timeout(5000) // 5초간 응답이 없으면 에러로 취급
.retryWhen(error => error.delay(1000).take(3)) // 에러가 발생시 1초 지연, 3번까지
)
.subscribe(...)
구현하기 까다로운 이벤트와 비동기 그리고 시간을 복잡한 코드가 아니라 값으로 다루게 되는 시각을 가지게 되면 훨씬 더 프로그램을 단순한 시각에서 개발을 할 수 있게 해줍니다.
뿐만 아니라 Value와 Array와 Promise, 그리고 Observable 모두 (비)동기 컬렉션, 즉 스트림으로 추상화하여 생각할 수 있습니다. Value는 그냥 값이고, Array는 동기 컬렉션, Promise는 비동기 값, Observable은 비동기 컬렉션으로 모든 것은 스트림으로 되어 있으며, 프로그램은 결국 스트림을 다루는 것으로 귀결되는 아주 심플한 반응형 프로그래밍 패러다임으로 개발할 수 있게 됩니다.
웹에서 화상회의 서비스를 개발하기로 가정해 보겠습니다. 화상회의를 만들기 위해서는 다소 어려운 API들을 다뤄야 합니다. WebRTC라는 것도 해야 하고 카메라나 마이크 데이터를 가져오기 위해서는 로컬 미디어 스트림 API도 알아야 합니다. 미디어 장비의 조회나 권한 API까지 알아야 합니다?! 보통 개발 초기에는 이러한 API들을 배우고 학습하는 것이 오래 걸리는 작업이 됩니다.
그런데 실제 프로그램을 개발하면서 복잡해지고 어려워지는 이유는 API가 어려워서가 아닙니다. 사실 전체 코드에서 이러한 핵심 API가 차지하는 비중은 얼마 되지 않습니다. 실제로 우리가 구현하고 기획서에서 요구하는 내용은 이러한 동작들이 언제, 어떤 조건에 발동할지를 정하는 시나리오 구현 비중이 훨씬 큽니다.
보통 개발자가 흔히 받는 기획서의 내용
... 하면 ... 이렇게 ... 하면 ... 저렇게 |
그리고 이러한 동작은 대부분이 비동기 형태로 발생합니다.
우리가 개발하는 앱이 복잡해지는 이유는 이러한 비동기의 ... 하면 ... 이라는 구현이 많아서입니다. RxJS는 이러한 ... 하면 ... 의 구현을 로직 조합이 아니라 값으로 처리할 수 있도록 해줍니다.
뿐만 아니라 여러 Operator을 제공하면서 적절한 조합을 통해서 우리가 원하는 형태의 코드를 직관적이며 단순하고 선언적으로 작성할 수 있도록 만들어 줍니다.
Array를 절차형이 아니라 함수형으로 다루면 코드가 간결해지듯이, 비동기 로직도 데이터로 취급해 함수형으로 다루면 코드가 간결해집니다. |
Array를 다루기 위해서는 map
, filter
, reduce
뿐만 아니라 every
, concat
, fill
, join
등 다양한 Array를 다루는 Method들이 존재합니다.
마찬가지의 개념으로 Observable은 비동기 Array의 형태이며, 이러한 데이터 형태를 다루는 여러 가지 Operator들이 존재합니다.
각각의 주요한 메소드들을 이해한 뒤에 원본 Observable에 적절히 Operator를 조합하여 원하는 형태의 결과물을 만들기 위한 코드를 작성하면 됩니다.
|
const participants$:Observable(Array<Participants) = ...
participants$
.distintUntilChanged((a, b) => a.length === b.length) // 참여자 명수가 변할때만,
.bufferCount(2, 1) // 2개씩 짝지어 전후를 비교하여
.filter(([prev, curr]) => curr.length > prev.length) //
// => 새로운 참여자가 입장했는가?
// 전후 데이터를 비교하여 새롭게 참가한 사람만 추려내어
.map(([prev, curr]) => array_diff(prev, curr, p => p.uid))
// 2초간 모아보고 새로운 참가자가 있으면
.bufferTime(2000)
.filter(x => x.length)
// 동시참가자수에 따라 토스트 팝업 출력
.tap(participants => {
if (participants.length === 1) show_toast("OO 님이 입장")
else show_toast("OO 님외 N명 입장")
})
어떠신가요? 한번 RxJS를 해보고 싶다는 생각이 드시나요? RxJS를 내 기술 스택에 탑재하면 다음과 같은 것들을 할 수 있게 됩니다.
RxJS의 활용이나 장점이나 개념들을 공부하다 보면 정말 새로운 세상을 만난 것 같고 내가 작성하던 어려운 코드들이 정말로 쉽게 작성될 것 같은 희망을 품게 됩니다. 그렇지만 정작 실제 코드에 도입하기에 많은 장벽이 존재합니다. 2부에서는 그러한 이야기들을 다루고자 합니다.
RxJS를 배울 때 Rx를 참 낯설게 만드는 것이 RxJS 5.5부터 도입된 pipe라는 개념입니다. Rx의 초창기는 Event를 Array처럼 다루는 개념으로 시작한 것이기에 Array의 Method 문법과 많이 닮아 있었습니다. 그러나 이후 module과 번들 개념이 도입되고 RxJS의 기본 덩치가 커짐에 따라서 Method 방식은 Tree-Shaking에 불리하다는 문제가 있었습니다.
그래서 각 Method를 함수로 만들어서 함수형 프로그래밍의 파이프방식을 차용하여 다음과 같은 방식으로 코드를 작성할 수 있도록 만들었습니다.
// 기존 Method 방식 (dot-chain 방식)
import {Observable} from "rxjs"
const tripleClicks$ = Observable.fromEvent(window, "click")
.bufferTime(250)
.filter(clicks => clicks.length === 3)
.subscribe(...)
// Pipe Method 방식
import {fromEvent, bufferTime, filter} from "rxjs"
const tripleClicks$ = fromEvent(window, "click").pipe(
bufferTime(250),
filter(clicks => clicks.length === 3),
).subscribe(...)
이렇게 Method가 아니라 Operator 함수로 분리하면 import를 한 만큼만 번들에 포함할 수 있습니다. 그래서 조금 번거롭고 코드의 가독성을 희생되어도 사용하지 않은 Operator를 코드에 포함하지 않도록 하여 번들의 크기를 줄일 수 있게 되었습니다. 이렇게 만들면 Custom한 Operator를 Method에 포함하지 않고 얼마든지 만들 수 있는 장점도 있습니다.
그렇지만 그 조금 번거롭고 가독성이 희생된다는 단점과 타입스크립트와 호환도 좋지 않다는 점이 처음 배우는 사람에게는 굉장히 낯설어서 상당히 큰 허들로 다가오게 됩니다. 그렇기에 처음 RxJS를 접하시는 분들이라면 혹은 아직 낯선 분들이라면 pipe의 개념을 Array의 Method와 비슷한 형태로 간주하고 바라보는 게 조금 더 친숙하게 사용할 수 있는 팁입니다.
RxJS측도 이러한 점을 충분히 인지하고 있어도 새로운 대안을 제시하였습니다. 그것은 바로 Pipeline Operator |>
입니다. 가독성의 문제는 결국 문법적인 문제이므로 JS에 새로운 문법을 도입하여 더 간결한 형태로 함수형 프로그래밍을 잘 할 수 있는 Operator를 제안합니다.
// Pipe Operator란?
const lowercase = (str) => str.toLowerCase()
const capitalize = (str) => str.slice(0, 1).toUppercase() + str.slice(1)
const wow = (str) => str + "!"
wow(capitalize(lowercase("hEllo wORlD"))) // Hello world!
// => 함수를 연속해서 호출을 하려니 적용되는 순서가 반대가 되어 헷갈린다.
"hEllo wORlD" |> lowercase |> capitalize |> wow // Hello world!
// => 값이 함수를 통해 전달되는 코드를 작성할 수 있게 되어 함수 조립을 훨씬 더 직관적인 코드형태로 작성할 수 있다.
이와 같이 Pipeline Operator를 쓸 수 있게 되면 RxJS는 Method가 아닌 함수로 분리한 장점을 다 가져가면서도 훨씬 더 함수형스러운 방식으로 코딩할 수 있게 됩니다.
// Pipeline Operator 방식
import {fromEvent, bufferTime, filter} from "RxJS"
const tripleClicks$ = fromEvent(window, "click")
|> bufferTime(250),
|> filter(clicks => clicks.length === 3),
|> subscribe(...)
그렇지만 이 방식은 현재 ES2022가 된 지금까지도 아직 표준 제안에만 계류 중이며 정식 표준은 되지 못하고 있습니다.
Observable과 Pipeline은 아직도 논쟁 중 현재 자바스크립트의 표준 API인 Promise도 처음에는 포함이 되어있지 않았습니다. 자바스크립트가 싱글쓰레드의 이벤트루프 방식을 택하면서 필연적으로 비동기처를 콜백으로 해야만 했기에 코드가 callback 지옥이 되었고 여기에 예외처리까지 더해지면 복잡한 코드가 만들어지기 십상이었죠.
그때 Promise A+라는 라이브러리가 만들어졌고 비동기를 다루는 방식이 획기적으로 쉬워지면서 Promise가 자바스크립트의 표준 API가 되었습니다. 이후
제가 이 모든 역사를 실제로 겪고, 또 RxJS를 접했을 때
하지만 7년이 지난 지금 아직도 두 개의 방식은 현재 표준이 되지는 못하고 아직까지 논의 중입니다. 매번 자바스크립트 표준에 추가되어야 설문항목에 항상 포함이 되어 있지만, 모든 사람이 공감하는 주제는 아직 아닌 듯합니다. 반응형 프로그래밍과 함수형 프로그래밍 패러다임을 너무 사랑하는 저로서는 이러한 주류의 시각이 아쉬울 따름입니다.
|
그렇기에 현재 자바스크립트 문법체계에서는 낯설고 다소 번거로운 방식으로 작성해야 하는 문제가 있어서 Array Method를 사용하는 관점이라는 것을 알면 조금 더 쉽게 적응을 할 수 있습니다. 추후 Pipeline Operator가 표준이 되는 날이 와서 간결한 문법으로 사용할 수 있게 되기를 희망합니다.
RxJS를 공부하고 나서 실전 적용 시 제일 막막한 부분은 무엇일까요? 많은 것을 배웠는데도 실전에서 어떻게 써야 할지 알기 어려운 점입니다. 그리고 그 이유는 Rx 학습의 대부분이 map()
, filter()
, tap()
, take()
등의 Operator에 있기 때문입니다.
이러한 Operator들을 배울 때 ‘이런 경우에는 이렇게 쓰면 되겠구나’를 상상하면서 머릿속에 분류체계들을 만들곤 하지만, 실전에 오게 되면 Operator를 몰라서가 아니라 Operator를 적용할 Source인 Observable이 없어서 쓸 수가 없습니다.
기껏해야 사용하는 것이 fromEvent()
를 통한 이벤트처리나 timer()
등을 이용한 시간처리, ajax()
를 이용한 서버응답처리가 있습니다. 하지만 이벤트 처리는 이미 프레임워크에서 이벤트 핸들러
로 처리하고 있고 시간을 다루는 경우는 사실 많지 않으며, Ajax의 경우에는 어차피 데이터를 1번만 전달해서 Promise로도 충분합니다. 그러다 보니 RxJS를 현재 웹 프레임워크에서 어디서부터 적용해야 할지 찾는 것이 참 어렵습니다.
제가 처음으로 RxJS를 실무에 적용했던 것은 Realtime Database
인 Firebase
를 연동하면서였습니다. Ajax와는 다르게 1번의 값이 아니라 변경되는 값을 연속적으로 전달받는 방식이기에 훨씬 Stream
한 방식과 잘 어울린다고 생각했습니다.
// Firebase는 이런식으로 Callback과 API를 조합해서 데이터를 사용해야해서 복잡한 코드를 만들어야 했다.
const ref = firebase.database().ref(path)
ref.on("value", snapshot => {
const value = snapshot.toJSON()
// doSomething here
})
ref.off()
위와 같이 콜백을 통해 로직 형태로 개발해야 하는 방식에서 RxJS를 활용해 Firebase API를 Observable의 문법으로 변경해보았습니다.
const fromFirebase = (path:string) => new Observable(observer => {
const ref = firebase.database().ref(path)
ref.on("value", snapshot => {
const value = snapshot.toJSON()
observer.next(value)
})
return () => ref.off()
})
위와 같이 RxJS로 Firebase API를 캡슐화하고서 더 이상 로직이 아니라 값으로 다루기 시작하니 신세계가 열렸습니다. 연관된 computed Value를 만들거나 참가자가 새롭게 들어왔다는 것을 알아내는 이벤트도 데이터를 기준으로 아주 쉽게 작성을 할 수 있었습니다.
const users_in_chat$ = fromFirebase("/chat/users").pipe(
map(users => Object.values(users))
)
const num_users_in_chat$ = users_in_chat$.pipe(
map(users => users.length),
distinctUntilChanged() // 같은 값이면 중복 전달 방지
)
num_users_in_chat$.pipe(
bufferCount(2, 1), // 예전 값을 가져와서
filter(([prev, curr]) => curr > prev), // 참가자 수가 커진 상황에서만
tap(() => { // 새로운 참가자가 들어왔다!! })
).subscribe()
상태관리도 RxJS로 다 할 수 있을 것 같은데? Firebase의 데이터를 Rx로 다루는 방식을 경험하고 나니 너무 편하다는 생각이 들면서 이러한 방식을 토대로 ‘프론트엔드의 전체 로직인 상태관리를 Stream이라고 생각하고 RxJS로 만들 수 있지 않을까’라는 생각을 했습니다. 그리고 실제로 RxJS를 기반으로 하는 상태관리 라이브러리를 만들어 사용하고 있습니다.
사실 RxJS를 이용한 프론트엔드 상태관리와 패러다임에 관한 글을 먼저 작성하려고 했지만 Rx에 대한 배경지식이 없으면 설명하기가 힘들다 보니 먼저 지금 Rx에 대한 글을 쓰고 있는 중입니다. |
그 밖에 API 라이브러리
, Aniamtion 라이브러리
, Drag 라이브러리
도 RxJS로 만들게 되면 훨씬 더 단순화된 관점으로 풍성하게 다룰 수 있습니다. RxJS를 이용한 다른 응용에 대해서는 한데 모아서 따로 글을 작성해 볼 계획입니다.
RxJS로 Source를 만들 수 있게 되면 기존의 복잡한 콜백 로직 등을 모두 Observable의 세계로 전환하여 선언적으로 코드를 다룰 수 있습니다. 자, 이제 우리는 훨씬 더 고급스러운(?) 코드를 작성하게 될 수 있게 되었습니다!
그다음 마주치는 허들이 바로 Hot
과 Cold
의 개념입니다.
하나씩 다 파고들면 내용이 상당히 복잡하기 때문에 우리가 잘 알고 있는 Promise와 비교를 통해서 핵심개념만 공유하고자 합니다.
Promise
의 경우는 함수를 사용하는 순간 이미 실행이 되며 데이터가 공유되는 Hot
방식입니다. 예제코드를 통해 Promise의 동작방식을 상상해봅시다.
// 생성 시점에 이미 실행이 되어 1초 뒤 Promise에 값이 보관된다.
const p = new Promise(resolve => {
setTimeout(() => resolve("promise!"), 1000)
})
// 아직 값이 없으니 기다렸다가 1초 뒤 값이 출력이 된다.
p.then(res => console.log(res))
// 2초 후에 요청을 한다면
setTimeout(() => {
// 이미 Promise는 이미 1초대에 값이 보관이 되어 있으므로 호출 즉시 출력이 된다.
p.then(res => console.log(res))
}, 2000)
하지만 RxJS는 함수형 프로그래밍 패러다임에 근간을 두고 있기 때문에 지연평가(Lazy evaluation
) 방식을 사용합니다. 그렇기에 Observable의 세상에서는 생성 시에 선언만 하고 구독이 요청한 시점에 해당 코드를 실행하는 Cold
방식을 가지고 있습니다.
// 생성을 했지만 실행은 시키지 않고 선언만 해둔 상태이다.
const p$ = new Observable(observer => {
setTimeout(() => observer.next("observable!"), 1000)
})
// 구독 요청을 했으니 이 함수가 실행된 시점부터 1초 뒤 값이 출력이 된다.
p$.subscribe(res => console.log(res))
// 2초 후에 요청을 한다면
setTimeout(() => {
// 지금 요청을 했기 때문에 별도의 observer가 생성이 되기 때문에 바로 출력이 되지 않고 다시 1초를 더 기다린 뒤에 출력이 된다.
p$.subscribe(res => console.log(res))
}, 2000)
즉 Observable은 데이터를 요청할 때마다 함수가 실행되어 결과를 전달해주는 방식이라서 여러 군데서 같은 값을 사용하면 데이터를 공유하지 않고 그때마다 선언된 함수를 호출하는 방식이 됩니다.
이걸 모르고 있으면 API를 Observable로 만들고 난 뒤 여러 군데에서 해당 데이터를 사용했을 때 API 콜을 중복으로 계속 호출되는 문제가 발생하게 됩니다. 애니메이션과 같은 경우에는 Cold
방식이 어울리나 서버 데이터나 상태관리와 같이 API를 다루는 데이터는 Hot
방식이 더 잘 어울립니다.
그렇지만 RxJS에서 Cold한 Observable을 Hot하게 만들기 위한 과정(Multicasting)
이 상당히 복잡합니다. RxJS에서도 이걸 인지했는지 Rx7에서 여러 가지 개편을 시도했고 앞으로 나오게 될 RxJS8에서도 개념이 있을 거라고 하네요. 그래서 복잡하게 파고들기보다는 이러한 Hot
, Cold
에 대한 개념만 이해하고, 개선된 버전을 기다려보는 것도 좋겠습니다.
RxJS Observable의 기본이 Cold인데 그렇다면 Multicasting을 쓰지 않고 Hot Observable을 만들려면 어떻게 해야 할까요?
그래서 Hot Observable 전용 객체인 Subject
와 BehaviorSubject
가 존재합니다. Hot이 필요하다면 복잡한 Multicasting보다는 Subject를 사용해보면 좋을 것 같습니다.
프로그램에서 값을 계속 공유
해서 사용해야 하는 경우에는,
1) 이벤트나 동작과 같이 시점이 중요한 경우 -> Subject
2) API 등의 동작 결괏값을 계속 공유해야 하는 경우 -> BehaviorSubject
와 같이 사용을 할 수 있습니다. 그래서 Firebase의 API는 BehaviorSubject를 이용하여 다음과 같이 변경합니다.
const memo = Object.create(null)
export const fromFirebase = (path:string, initValue:T) => {
const r$ = memo[path] = memo[path] || new BehaviorSubject(initValue)
const ref = firebase.database().ref(path)
ref.on("value", snapshot => {
const value = snapshot.toJSON()
r$.next(value)
})
return r$.asObservable()
})
Subject
와 BehaviorSubject
는 특히 프론트엔드 상태관리를 만들기 위해서 정말 중요한 개념이므로 RxJS와 프론트엔드 상태관리를 함께 설명할 때 더 자세하게 설명하겠습니다.
프론트엔드 프로그래밍을 하다 보면 특정 기능을 구현하는 것보다 사용자들의 동작과 조건에 맞춰 적절히 연결하는 것이 더 복잡하다는 것을 알게 됩니다. 이러한 연결 과정이 복잡한 이유는 비동기라는 문제가 있기 때문입니다. 그래서 이 비동기를 잘 다루는 것이 참 어렵고 고급 개발자로 가기 위해서 정복해야 할 분야라는 것을 깨닫게 됩니다.
RxJS는 이러한 비동기를 잘 다루기 위해 만들어졌으며 특히 함수형 프로그래밍, 반응형 프로그래밍, 선언적 프로그래밍이라는 새로운 패러다임을 기반으로 하는 멋진 라이브러리입니다. 물론 그 새로운 패러다임이 훌륭하지만 배워야 할 것도 많고 낯설기도 한 것도 사실입니다.
그러다 보니 만들어진 지 꽤 오래된 라이브러리임에도 사용하는 유저층이 많지 않습니다. 하지만 RxJS를 다룰수록 패러다임이 바뀌어 기존 방식으로는 이제 개발하기 힘든 몸(?)이 되는 저 같은 골수팬들도 많이 보유하고 있습니다. 또한 Google의 웹 프레임워크인 Angular에서 사용하는 공식 상태관리용 라이브러리이기도 합니다. Promise에 이어 자바스크립트 라이브러리에서 웹 표준 API로 등록되기 위해 TC39에 계류되어 있는 라이브러리이기도 합니다. (물론 벌써 7년째 계류 중이지만요... ㅠㅠ)
처음 배울 때의 제 예측과 달리 RxJS는 아직 주류가 되지 않았고, 웹 표준 API가 되지 못했고, pipeline operator |>로 개발하는 함수형 프로그래밍의 세상은 오지 않았습니다. 그렇지만 정말로 이 한 번의 개발 패러다임을 바꾸기만 한다면 기존과는 달리 정말로 편하게 비동기 프로그래밍을 할 수 있는 세상이 펼쳐집니다.
RxJS의 학습 허들은 높지만, 사실 이 모든 것이 필요하다고 생각하지는 않습니다. 그래서 꼭 알아야 할 핵심만 알려주려고 쉽게 시작한 글인데 줄이고 줄였는데도 정작 알려주고 싶은 프론트엔드에서 RxJS로 상태관리 하는 법을 위한 배경지식조차 다 설명하지 못했습니다.
이러한 허들로 인해 아직 프론트엔드에서는 RxJS가 주류가 아니지만 충분히 배워볼 만한 가치가 있습니다. Everything is Stream이라는 새로운 패러다임은 프로그래밍을 정말로 단순하게 만들어주니까요. 그리고 정말로 Observable이 표준이 되거나 pipeline operator가 표준이 될 수도 있습니다!
무엇보다 Rx는 javascript에만 국한되어 있지 않습니다. 수많은 언어와 플랫폼에서 동작하기 때문에 하나의 패러다임과 방식으로 다양한 언어와 프레임워크에서도 동일한 개념과 방식으로 코딩을 할 수 있게 됩니다.
뿐만 아니라 생각보다 쟁쟁한 회사들이 RxJS를 사용하고 있습니다.
이 글이 RxJS를 지금 배우고 있는 데 어려움을 겪고 있거나 RxJS를 새롭게 익혀보려고 하는 분들에게 너무 어렵지 않게 다가갈 수 게 도와줄 수 있는 글이 되기를 바랍니다. RxJS에 많은 관심 부탁드리며, 궁금한 내용이 있거나 어려운 개념 설명이 필요한 내용이 있으면 편하게 댓글로 질문 남겨주세요.
감사합니다.