July 5th 2019
Contents
Observables
자바스크립트로 비동기 프로그램을 만들면 여러 가지 어려운 문제를 겪게 됩니다. 유명한 callback hell이 발생할 수도 있고, 아래와 같은 여러 복잡한 문제들을 만나게 됩니다.
여러 비동기 작업은 동시에 일어나고, 우리는 특정 비동기 작업이 다른 비동기 작업보다 먼저 완료되기를 원할 수 있습니다. 또 DOM 이벤트를 추가한 후, 리스너를 제거하는 작업을 놓쳐서 메모리 누수를 만들 수도 있습니다. Complex State Machines에 관해서는 많은 Javascript 개발자들이 깊이 생각해 보지 않을 수 있지만, 프로그램을 만드는데 많은 복잡성의 원인이 될 수 있다고 합니다. 그리고 비동기 작업에서의 에러 발생 시 에러 처리 또한 까다로운 작업이 될 수 있습니다.
아래는 Netflix에서 영화를 재생할 시 사용하던 방식의 코드라고 합니다. 위 문제에 대한 예로 아래의 코드를 살펴볼 수 있습니다. play 함수는 argument로 movieID와 cancelButton, callback을 전달받습니다.
function play(movieId, cancelButton, callback) {
var movieTicket,
playError,
tryFinish = function() {
if (playError) {
callback(null, playError);
} else if (movieTicket && player.initialized) {
callback(null, ticket);
}
};
cancelButton.addEventListener('click', function() {
playError = 'canceled';
});
if (!player.intialized) {
player.init(function(error) {
playError = error;
tryFinish();
});
}
authorizeMovie(function(error, ticket) {
playError = error;
movieTicket = ticket;
tryFinish();
});
}
Netflix에서 player를 로딩하는 작업은 비동기라고 합니다. 그리고 영화를 재생하기 위해 서버의 승인을 얻는 작업 또한 비동기입니다. 주목해야 할 점은 이 두 작업이 동시에 일어난다는 것입니다. 우리는 두 비동기 작업을 순차적으로 처리하길 원하지 않을 것입니다. 시간이 더 소요되기 때문입니다. 때문에, 여기서 race condition이 발생합니다. 우리는 한 작업이 완료되면, 다른 한 작업 또한 완료되었는지 확인을 해야 합니다.
위의 코드에서는 player.initialized(Boolean)라는 값을 체크하고 있습니다. 이렇게 비동기 작업이 완료되었는지 확인을 하기 위해 프로그램에 state를 도입하게되면, 여러 state 값들을 항상 알맞은 값으로 유지해야 하는 어려움이 생깁니다. 하나라도 잘못된 값이 할당되면 버그가 발생할 수 있습니다. 이러한 상황은 프로그램의 복잡성을 증가시키는 원인이됩니다. 그리고 앱의 확장성은 그만큼 감소할 것입니다.
고려해야 할 또다른 문제는 에러 처리입니다. 두 가지의 비동기 작업 중 어느 곳에서든 에러가 발생할 수 있습니다. 에러가 발생하면 에러를 전달해야 하기도 하지만, 가능하다면 또 다른 비동기 작업을 취소하고 싶을 것입니다.
위의 코드에는 버그가 하나 존재합니다. cancelButton에 이벤트 리스너를 추가했지만 리스너를 제거하는 작업은 없습니다. 때문에 play 함수가 호출될 때마다 이벤트 리스너가 추가되고 메모리 누수가 발생합니다.
이 포스팅에서는 이러한 복잡한 비동기 프로그램을 몇몇 flexible한 함수를 이용해 작성하는 방법에 대해 알아보도록 하겠습니다.
Observable을 살펴보기 전 Array method를 사용해 film collection을 생성하는 함수를 먼저 살펴보겠습니다. getTopRatedFilms 함수는 user를 인자로 전달받습니다. user의 videoLists 속성은 배열이며 요소로는 영화 장르가(객체) 담겨 있습니다. 영화 장르의 videos 속성은 또한 배열이며 개별 영화가 담겨 있습니다. 아래의 코드는 concatAll이라는 커스텀 함수를 만들어 일차 배열의 영화를 반환합니다.
const getTopRatedFilms = user =>
user.videoLists.map(videoList => videoList.videos.filter(video => video.rating === 5.0)).concatAll();
getTopRatedFilms(user).forEach(film => console.log(film));
user라는 데이터를 인자로 전달받아 새로운 배열을 반환하는 익숙한 작업입니다. 뒤에서 더 깊게 살펴보겠지만, 위의 코드를 배열 뿐만 아니라 이벤트와 같은 비동기 데이터에도 적용할 수 있습니다. 예를 들어, 아래와 같이 브라우저의 drag event 또한 위와 거의 같은 코드로 생성할 수 있습니다.
const getElementDrags = elmt =>
elmt.mouseDown.map(mouseDown => document.mouseMoves.takeUntil(document.mouseUps)).concatAll();
getElementDrags(image).forEach(pos => (image.position = pos));
이전의 collection을 생성하는 코드와 크게 다르지 않습니다. Mouse Drag는 Mouse Down과 Mouse Up사이에 발생하는 모든 mouse move 이벤트입니다. 만약 이 이벤트가 앞서 살펴본 코드에서의 배열과 마찬가지로 우리가 지닐 수 있는 값이라고 생각한다면 어떨까요? 그리고 이 값은 map 또는 concatAll, filter와 같은 메소드를 가지고 있습니다.
모든 mouse down 이벤트가 담긴 컬렉션이 있다고 간주해 봅시다. 그리고 우리는 이 컬렉션을 맵핑해서 mouse move 컬렉션으로 변경할 수 있습니다. 앞선 작성한 코드에서는 filter를 사용해 영화 배열을 선별 및 반환했지만, 이번에는 takeUntil이라는 함수를 호출했습니다. 이를 이용해 mouse move 컬렉션이 무한히 커지지 않고 mouse up 이벤트가 발생할 때까지로 한정할 수 있습니다.
이러한 방식으로 우리가 이벤트를 컬렉션과 같이 간주한다면, 위와 같은 코드로 이벤트를 처리할 수 있습니다. 코드의 윗 부분은 이벤트를 원하는 컬렉션으로 만드는 과정이고, 아랫 부분은 이렇게 생성한 컬렉션을 forEach 메소드를 이용해 소비하는 과정입니다.
아래의 Design Pattern Diagram은 94년도에 출간한 Design Pattern이란 책에서 발췌한 것입니다. 저는 읽어보지 않았지만 당시에 영향력이 있었고, 오늘날까지 책에서 소개한 여러 Pattern들이 사용되고 있다고 합니다. 하지만, 당시 아래의 사진과 같이 Iterator와 Observer Pattern은 서로 연결되어있지 않고 관계가 없는 Pattern으로 인식했습니다.
하지만 이 둘은 연관이 있는 패턴이라고 합니다. 그리고 이 둘의 관계를 이해하는 것이 비동기 프로그래밍을 온전히 이해하는데 중요하다고 합니다.
iterator에는 producer와 consumer가 있습니다. produce는 전달할 데이터가 남아있으면, 그것을 전달하고 그렇지 않으면 에러를 발생시키던가 데이터가 없음을 알립니다.
const iterator = [1, 2, 3].iterator();
iterator.next(); // { value: 1, done: false }
iterator.next(); // { value: 2, done: false }
iterator.next(); // { value: 3, done: false }
iterator.next(); // { done: true }
Observer Pattern은 우리가 늘상 사용하는 DOM 이벤트를 예로 들 수 있습니다. 우리는 producer에 함수를 전달합니다. Iterator와의 유일한 차이점은 consumer가 데이터를 가져오는 것이 아니라 producer가 consumer 측에서 전달한 함수를 이용해 데이터를 push 합니다.
document.addEventListener('mousemove', function next(e) {
console.log(e);
});
위 DOM Event의 문제점은 우리가 하나의 callback만 전달할 수 있고, producer 측에서 데이터가 없거나 에러가 발생하면 consumer측에 전달할 방법이 없다는 것입니다.
이것은 위의 Observer Pattern이 UI event case에만 초점이 맞추어져 만들어졌기 때문이라고 합니다. 하지만, 현재 우리는 IO stream과 같이 언제든 종료될 수 있는 작업 또한 다루어야 합니다. 따라서, 우리는 producer가 consumer에게 전달할 데이터가 없을 때도 알림을 줄 수 있는 좀 더 명확한 방법이 필요합니다.
위와 같은 completion 또는 error를 전달할 방법의 부재로 많은 종류의 stream API가 생성되었다고 합니다.
각 API는 모두 조금씩 다른 Interface를 가지고 있습니다. 우리는 이러한 작업을 한 곳에서 처리할 수 있도록 할 수 있는 방법이 필요하며, 그것은 아마도 Observable이 가장 적합할 것입니다.
Observable === Collection + Time
Observer Pattern은 클라이언트 측 자바스크립트 프로그래밍에서 널리 사용되는 패턴입니다. mouseover, keypress 등 브라우저 이벤트도 Observer Pattern의 한 예입니다. Custom Event라고도 부르는데, 브라우저의 이벤트가 아닌 프로그램이 이벤트를 생성하기 때문입니다. 알림을 받는 구독자는 subscriber 또는 observer라고 부르며, 관찰되는 객체는 observable 또는 subject라고도 부릅니다. Observable은 중요 이벤트가 발생했을 때 구독자에게 알림을 전달합니다.
그러면 Observable이 왜 유용하고 강력한 패턴일까요? 한 예로, 브라우저에서 특정 이벤트가 발생했다고 가정해 봅시다. 이벤트가 발생한 후 우리의 애플리케이션은 서버에 비동기 요청을 보냅니다. 서버로부터 응답을 받으면, 응답에 따라 UI에 어떠한 애니메이션을 보여줄 수도 있습니다. Observable을 사용하면 이러한 일련의 작업들을 모델링 할 수 있습니다.
뒤쪽에서 Observable을 직접 만들어 보겠지만, 우선 RxJS를 이용해 간단한 Observable을 생성해 보도록 하겠습니다. Observable의 fromEvent 메소드는 DOM Object와 DOM Event 이름을 전달 받아 Observable에 적용합니다.
const mouseMoves = Observable.fromEvent(element, 'mousemove');
브라우저 API를 이용해 아래와 같이 'mousemove' 이벤트에 대해 subscribe하는 작업은 아마도 익숙할 것입니다.
// subscribe
const handler = e => console.log(e);
document.addEventListener('mousemove', handler);
// unsubscribe
document.removeEventListener('mousemove', handler);
위와 같은 작업을 Observable을 이용하면 아래와 같이 작성할 수 있습니다.
// subscribe
const subscription = mouseMoves.forEach(console.log);
// unsubscribe
subscription.dispose();
앞서 생성한 mouseMoves Observable의 forEach 메소드를 사용해 같은 기능을 구현했습니다. 다만, Observable의 forEach 메소드는 배열의 forEach와는 다르게 subscription object를 반환합니다. 앞서 Observable을 시간을 동반한 컬렉션으로 표기했습니다. Observable의 forEach는 도중에 멈추지 않고 호출이 되면 끝까지 실행이 됩니다. 하지만, Observable에 새로운 아이템이 추가되면 함수가 다시 호출됩니다.
우리는 또한 mouse move에 대한 관심을 끄고 구독을 멈추고 싶을 수 있습니다. 이러한 경우 consumer는 forEach가 반환한 객체를 이용해 unsubscribe를 할 수 있습니다.
이렇게 일반적인 이벤트 리스너를 추가하는 방법과 Observable을 사용하는 방법을 알아보았습니다. 위의 두 코드는 동일하게 작동합니다. 현재까지는 Observable을 이용함으로써 얻는 이점이 딱히 없어 보입니다. Observable의 장점을 취하도록 위의 코드를 수정하면 아래와 같이 작성할 수 있습니다.
// subscribe
const subscription = mouseMoves.forEach(
// next data
event => console.log(event),
// error
error => console.error(error),
// completed
() => console.log('done')
);
// unsubscribe
subscription.dispose();
위의 코드에서 mouseMoves의 forEach 메소드에 callback을 두 개 더 전달했습니다. 앞서 브라우저 API의 observer pattern에서는 producer 측에서 데이터가 없거나 에러가 발생했을 때 consumer 측에 이에 대해 알릴 방법이 없다고 했습니다.
하지만, 위의 코드에서 우리는 에러를 처리했을 때와 작업이 모두 끝났음을 consumer 측에 알리는 callback을 추가로 전달할 수 있습니다.
앞서 Observable을 생성하는데 사용한 fromEvent 메소드를 살펴보면 아래와 같이 구현할 수 있습니다. argument로 dom 요소와 eventName을 받고, forEach 메소드가 있는 객체를 반환합니다. argument로 전달받은 이벤트가 발생해 이벤트 데이터가 생성되면 observer의 onNext 함수를 통해 producer는 consumer 측에 데이터를 전달할 수 있습니다. forEach 메소드는 dispose 메소드가 담긴 subscription 객체를 반환하는데 이를 통해 consumer 측에서 subscription을 그만둘 수 있습니다.
Observable.fromEvent = function(dom, eventName) {
// returning Observable Object
return {
forEach: function(observer) {
const handler = e => observer.onNext(e);
dom.addEventListener(eventName, handler);
// returning Subscription object
return {
dispose: function() {
dom.removeEventListener(eventName, handler);
}
};
}
};
};
앞서 Observable을 이용해 Mouse Drag 이벤트를 구현했었고, 이때 takeUntil 함수를 사용해 Mouse Move 데이터를 Mouse Up 이벤트가 발생하기 전까지로 제한했었습니다. 잠깐 RxJS의 takeUntil 메소드의 definition을 참고하면, 아래와 같습니다.
Emits the values emitted by the source Observable until a notifier Observable emits a value.
가상의 syntax로 Observable을 표기해 보면 아래와 같이 나타낼 수 있습니다. 여기서 점은 비동기의 "시간"을 의미합니다. 1과 2가 출력이 되고 takeUntil에 넘겨준 Observable이 값을 방출해서 Source Observable의 뒤의 값인 3은 무시가 됩니다.
// Arbitrary Syntax:
{..1..2....3}.takeUntil(
{.......4})
==> {..1..2..}
takeUntil 함수를 이용하면 Source Observable은 동일하게 데이터를 방출하지만, takeUntil에 인자로 전달한 Stop Observable(or notifier Observable)이 어떠한 값을 방출하면 Source Observable은 unsubscribe하게 됩니다.
takeUntil 함수를 이용하면 다른 관점에서 비동기 프로그래밍을 할 수 있기 때문에 매우 중요하며, takeUntil 함수를 잘 이용하면 unsubscribe를 호출할 일이 거의 없다고 합니다.
일반적으로 브라우저에서 특정 이벤트에 이벤트 리스너를 추가함으로써, 우리는 이벤트 데이터를 소비합니다. 그리고 특정 상황 또는 특정 이벤트가 발생하면 더이상 이벤트 데이터에 관심이 없어지고 removeEventListener를 통해서 리스너를 제거합니다.
takeUntil 메소드를 이용하면, stop Observable을 전달하기 때문에 우리가 필요한 시점까지의 Source Observable을 이용할 수 있습니다. 우리는 별도의 state를 생성해 특정 정보를 트랙킹하거나 이벤트 리스너를 제거할 필요가 없어집니다.
앞서 살펴본 Mouse Drag 이벤트를 Observable과 takeUntil method를 이용해서 다시 살펴보면 아래와 같이 구현할 수 있습니다.
const getElementDrags = elmt => {
elmt.mouseDowns = Observable.fromEvent(elmt, 'mousedown');
elmt.mouseUps = Observable.fromEvent(elmt, 'mouseup');
elmt.mouseMoves = Observable.fromEvent(elmt, 'mousemove');
return elmt.mouseDowns.map(mouseDown => document.mouseMoves.takeUntil(document.mouseUps)).concatAll();
};
getElementDrags(images).forEach(pos => (image.position = pos));
mergeAll은 higher-order Observable로도 불리며, Observable을 방출하는 Observable을 subscribe합니다. inner Observable이 어떠한 값을 방출하면 즉시, outer Observable이 그 값을 방출합니다. 어떠한 inner Observable이든 먼저 값을 방출하는대로 outer Observable은 그 값을 방출합니다. RxJS의 mergeAll에 대한 정의는 아래와 같습니다.
Converts a higher-order Observable into a first-order Observable which concurrently delivers all values that are emitted on the inner Observables.
{
...{1}
......{2.........3}
.........{}
...........{4}
}.mergeAll();
//=> {..1..2..4...3}
switchAll은 concatAll과 마찬가지로 inner Observable을 가진 outer Observable을 subscribe 합니다. concatAll은 inner Observable을 순차적으로 subscribe하며 outer Observable이 값을 방출합니다. switchAll은 inner Observable 중 어떠한 Observable이 값을 방출하면 기존의 Observable은 unsubscribe하고 값을 방출한 Observable을 subscribe 합니다.
switchAll과 위에 언급한 takeUntil 함수를 함께 사용하면, removeEventListener를 사용할 필요없이 별도의 이벤트가 발생하면 complete 되도록 할 수 있습니다.
Converts a higher-order Observable into a first-order Observable producing values only from the most recent observable sequence
{
...{1}
......{2.........3}
.........{}
...........{4}
}.switchAll();
//=> {..1..2..4}
웹 애플리케이션의 UI는 크게 세 가지의 비동기 동작으로 구성되는 경우가 많습니다. 어떠한 이벤트가 발생하고, 이에 상응하는 어떠한 비동기 작업(예를 들면, 비동기 요청 작업)이 뒤를 이으고, 마지막으로 애니메이션을 보여줍니다. 그러면 Observable을 이용해 이벤트 및 비동기 요청 작업을 결합하는 방법에 대해 확인해 보도록 하겠습니다.
Autocomplete Box는 많은 애플리케이션에서 사용하고 있습니다. autocomplet box를 오픈소스 컴포넌트를 이용하지 않고 처음부터 만드는 것은 쉬운 작업이 아닙니다. Race condition이 발생하기 때문입니다. keypress 이벤트가 발생하면 서버로 요청이 나가고, 또다른 keypress 이벤트가 발생하면 다시 요청이 나갑니다. 하지만, 하지만, 전자에 대한 응답을 반드시 먼저 받는다는 보장이 없습니다.
아래의 코드는 netflix의 그것과 유사하게 구현된 search box라고 합니다.
var searchResultSets =
keypress
.throttle(250)
.map(key =>
getJSON('/searchResults?q=' + input.value)
.retry(3)
.takeUntil(keyPresses))
.concatAll();
searchResultsSets.forEach(
resultSet => updateSearchResults(resultSet),
error => showMessage('the server appears to be down.');
)
Search Box 문제를 해결하기 위해, 아래의 네 단계를 생각해 보는 것이 비동기 문제를 해결하는데 매우 중요하다고 합니다.
위 Search Box의 autucomplete 기능을 위해서, 서버에 search result를 얻기 위해 요청을 보내고 반환받는 observable이 필요할 것입니다.
위 코드의 searchResultSets를 살펴보면 keyPress 컬렉션을 취하고, throttle 함수를 이용해서 keypress 이벤트가 생성될 때마다 서버에 요청을 하지 않고 최소 일정 millisecond를 기다린 후 요청을 보냅니다. kerPress 컬렉션에서 keypress 이벤트 객체를 방출하면, keypress 객체를 map 함수를 거쳐 서버로부터 응답을 받은 Obeservable로 변환합니다.
현재의 상태를 보면 Observable안에 mapping으로 생성된 inner Observables이 있는 구조입니다. 참고로, getJSON 함수는 Observable을 반환하고, 이 Observable은 서버로부터 받은 응답과 함께 onNext를 실행한 후 onComplete을 실행합니다. 또 concatAll 메소드를 이용해 중첩된 Observable을 flatten하고 또한 race condition 문제를 해결합니다.
그리고 takeUntil 메소드를 이용해, 이미 서버에 요청을 보내고 응답을 받기 전 또 다른 keypress 이벤트가 발생하면 기존의 요청을 complete 시켜서 요청에 대한 응답이 무시되도록 설정했습니다.
프로그래밍을 익히는 가장 좋은 방법은 직접 만들어 구현해 보는것 같습니다. Observer Pattern 이해를 위해서, 일간 신문과 월간 신문을 출판하는 paper라는 Observable이 있고, 이를 구독하는 subscriber 'joe'가 있다고 가정해 봅니다.
paper는 자신의 구독자를 저장해두는 subscribers(Array) 속성을 가지고 있습니다. 이벤트가 발생하면 paper는 구독자를 순회하며 notificatioin을 전달합니다. notification이란 구독자가 구독시 전달한 함수를 호출하는 것을 말합니다. 정리하면 Observable은 다음의 속성을 가지고 있어야 합니다.
const observable = {
subscriber: {
any: []
},
subscribe: (fn, type) => {
type = type || 'any';
if (typeof this.subscribers[type] === 'undefined') {
this.subscribers[type] = [];
}
this.subscribers[type].push(fn);
},
unsubscribe: (fn, type) => {
this.visitSubscribers('unsubscribe', fn, type);
},
publish: (publication, type) => {
this.visitSubscribers('publish', publication, type);
},
visitSubscribers: (action, arg, type) => {
const pubType = type || 'any';
const subscribers = this.subscribers[pubType];
for (let i = 0; i < subscribers.length; i++) {
if (action === 'publish') {
subscribers[i](arg);
}
if (action === 'unsubscribe' && subscribers[i] === arg) {
subscribers.splice(i, 1);
}
}
}
};
아래의 함수는 객체를 전달 받으면, Observable로 변환해주는 역활을 합니다.
function makerObservable(o) {
for (let property in observable) {
if (observable.hasOwnProperty(property) && typeof observable[property] === 'function') {
o[property] = observable[property];
}
}
o.subscribers = { any: [] };
}
observable 또는 publisher를 생성했으니, 이제 paper 객체가 일간지 또는 월간지를 발행하는 기능을 만들고 위 makeObservable 함수를 사용해 발행자로 만들어 보도록 하겠습니다.
const paper = {
daily: function() {
this.publish('big news today');
},
monthly: function() {
this.publish('interesting analysis', 'monthly');
}
};
makeObservable(paper);
paper라는 observable을 생성했으니, subscriber 'joe'를 살펴보도록 하겠습니다.
const joe = {
drinkCoffee: function(paper) {
console.log(`${paper}를 읽었습니다.`);
},
sundayPreNap: function(monthly) {
console.log(`낮잠에 들기 전 ${monthly}를 읽고 있습니다.`);
}
};
그리고, joe가 paper를 구독을 한 후, paper에서 몇 가지 이벤트를 발생해 보도록 하겠습니다.
paper.subscribe(joe.drinkCoffee);
paper.subscribe(joe.sundayPreNap, 'monthly');
paper.daily(); // big news today를 읽었습니다.
paper.daily(); // big news today를 읽었습니다.
paper.daily(); // big news today를 읽었습니다.
paper.monthly(); // 낮잠에 들기 전 interesting analysis를 읽고 있습니다.
위의 코드를 살펴보면, paper와 joe 객체에 서로를 하드코딩하지 않았고, 중재자 객체도 존재하지 않습니다. 객체가 느슨하게 연결되었고 어떠한 수정 없이도 paper에 많은 구독자를 추가할 수 있습니다. 또, joe는 구독을 해지할 수도 있습니다.