objectel: Event-Driven Reactive Programming Library

programmers, it's time to be reactive

저는 리액트의 열렬한 팬입니다. 와! virtual dom! 와! component!
리액트의 이러한 개념들은 수많은 개발자들을 매료시켰는데요, 단순히 ‘react‘ 라는 키워드를 검색하면 나오는 결과가 이 사실을 보란듯이 증명해줍니다.

react-npm
react-google

확실히, 리액트의 등장으로 ‘거대한‘ 웹 어플리케이션 제작은 기존보다 난이도가 낮아졌습니다.
리액트가 채택한, 프로그래밍의 변치 않는 본질적인 요소, ‘분해와 합성‘이 정확하게 먹혀들어간거죠.

지금도 우리는 리액트를 통해 충분히 행복하게 개발하고 있다고 생각합니다.
하지만.. 더 나아질 수는 없을까요?

Way back Event-Driven programming

리액트는 ‘분해와 합성‘을 주 패턴으로 채택한 프레임워크로서
High Order Component, Functional Component 등을 통해 계층적인 웹 어플리케이션을 구성하는 디자인을 택했습니다.
이 디자인은 모던 웹 어플리케이션을 개발할 때 아주 효과적인데요, 이런 리액트의 디자인에도 몇 아쉬운 점이 있습니다.

1: 컴포넌트끼리의 상호작용이 힘듭니다.

기본적으로 컴포넌트간의 상호작용은 인접한 컴포넌트끼리 props 를 통해 콜백이나 데이터 등을 전달하는 방식으로 이루어지기 때문에,
서로 상호작용을 하는 두 컴포넌트가 있을 때, 이 둘의 거리가 멀어질 수록
더 많은 컴포넌트들이 두 컴포넌트의 상호작용을 위해 희생(props 항목 증가)을 하게 됩니다.

물론 이 문제는 해결책이 등장하긴 했습니다. 바로 Context API 지요.
하지만 저는 아직도 불만이 있습니다.

1.1: 상호작용을 위한 값을 제공할 Provider 아래에 상호작용할 모든 컴포넌트들이 존재해야 한다.

이렇게 되면 결국 다른 컴포넌트 하나가 다른 컴포넌트들의 상호작용을 위한 업무를 떠안거나 이 업무만을 위한 컴포넌트를 하나 만들어야 합니다.

그리고 두 컴포넌트 간의 너비가 넓으면 넓을 수록 Provider(혹은 그에 준하는 역할을 가진 컴포넌트)의 높이는 점점 낮아지게 됩니다.
또한 Provider의 범위 밖에 있는 컴포넌트와 상호작용을 하려면 Provider의 높이를 낮추기 위해, 혹은
그 컴포넌트를 Provider의 범위 안에 들어오게 하기 위해 기존의 코드를 뜯어고치는 상황이 발생하게 됩니다.

1.2: 여러 Context를 동시에 참조하기 어렵습니다.

클래스 컴포넌트의 경우 Context를 참조하는 특별한 방법으로 Class.contextTypethis.context가 있지만,
아쉽게도 Class.contextType으로 지정한 하나의 Context만 참조할 수 있습니다.

그럼 남은 방법은 Consumer 뿐인데요, 얘도 자신과 함께 생성된 Provider가 제공하는 Context만 참조할 수 있습니다.
그렇기에 일반적으로 여러 Context를 동시에 참조하려면 아래와 같이 해야 하는데요,

harmony
1
2
3
4
5
6
7
8
9
10
11
<MyContext1.Consumer>
{ x => (
<MyContext2.Consumer>
{ y => (
<Result>
{ x + y }
</Result>
)}
</MyContext2.Consumer>
)}
</MyContext1.Consumer>

이런 방식은 상당히 불편합니다. 다행스럽게도 HOC를 통해 컨텍스트를 전부 하드코딩하는 최악의 상황은 면할 수 있지만
그래도 코드가 더러워지는 건 사실입니다. 우리가 jsx를 통해 알고 싶은 건 ‘웹 어플리케이션이 어떻게 보일 지‘, ‘어떤 주요 로직이 있는지‘ 이지
어떤 컨텍스트들이 내려가는 지, 제공되는 지는 아니잖아요.

2: 컴포넌트 바깥의 사정을 고려해야 합니다.

매 컴포넌트를 만들 때 마다 누가 어떻게 이 컴포넌트를 다룰 지, 어떻게 결과를 내보내 주어야 하는지 등에 대해 고민해야 합니다.
이런 부수적인 고민들은 가장 중요한 ‘컴포넌트의 메인 로직‘(어떻게 데이터가 다뤄지는 가, 무엇이 랜더링되는 가 등..)을 고민하지 못하게 프로그래머를 방해합니다.

예를 하나 들어보지요, 우리는 지금 입력 폼의 입력 컴포넌트를 작성하고 있습니다.
const InputField = () => {} 먼저 컴포넌트의 뼈대를 작성하자 마자 수많은 질문이 쏟아져 나옵니다.
“입력 검증은 어떻게 하지? 정규식? 검증 함수?”, “controlled component로 만들까?”, “그럼 값이 변경되었음은 어떻게 알리지?”, “오류가 발생하였음은 어떻게 알리지?”,
“드롭다운 버튼이라면 입력 리스트는 어떻게 받고?” 이 질문들 중, ‘컴포넌트의 메인 로직‘작성 과 직결된 질문은 몇 가지일까요?
정답은 2개 입니다. 입력 검증과, 컴포넌트의 형식을 제외한 나머지 질문들은 컴포넌트가 입력을 어떻게 받을 지, 어떻게 내보낼 지에 대한 질문일 뿐입니다.
그리고 이런 질문들은 각 컴포넌트들이 ‘캡슐화가 덜 되었음‘을 여실히 알려주지요. 하지만 아시다시피 리액트는 여기서 더 이상의 캡슐화를 할 수 없습니다.

3: 별로 리액티브 하지 않습니다.

모든 컴포넌트는 기본적으로 Props, State의 변화에만 반응합니다.
어플리케이션(혹은 다른 컴포넌트의 상태)의 변화에 반응하려면 콜백과 같은 수단을 사용하여야만 하지요.
하지만 앞서 언급했듯이 props는 기본적으로 근접한 ‘자식’ 컴포넌트와만 교환할 수 있기 때문에 멀리 떨어진 컴포넌트에게 콜백을 전달하기란..
쉽지 않은 일인 건 확실합니다.

그리고 전 제 컴포넌트들이 자신과 관련없는 ‘상태 알리미 콜백‘으로 점칠되는 게 그닥 마음에 들지 않습니다.

좋아요, 여기까지는 제 불만 사항들입니다.
그럼.. 이 불만 사항들을 어떻게 해결할 수 있을까요?

몇 달 전, 저는 굉장히 고전적이면서도 효과적인 방법을 찾아냈습니다.
저 처럼 문제에만 집중하고 싶어하는 사람들을 위한 방법이였죠.

동화책에서나 나올 법한 유니콘이 아닙니다.
Event-Driven Reactive Programming 입니다.

Don’t try to control everything, just believe them

아주 어렸을 때 읽었던 고사성어 책에서 이런 이야기를 소개했었습니다.

먼 옛날, 한 도시에 새로이 부임한 관리가 있었습니다. 그 관리는 도시를 더 좋게 만들고자
제 딴에는 노력을 기울여 도시와 관련된 일이라면 하나부터 열까지 직접 지휘했습니다.

하지만 이런 관리의 노력과는 달리, 백성들을 불만을 토로하고 있었습니다. 도대체 어떻게 된 일일까요?

자신이 무엇을 잘못했는지 알지 못했던 관리는 자신이 부임되기 전에 도시를 맡았던, 백성들이 무한한 존경심을 보냈던
은퇴한 관리를 찾아가서 물었습니다. “저는 백성들을 아끼기에 손수 나서서 모든 걸 해결해주었는데 왜 백성들을 제게 불만을 토로하는걸까요? 저는 도통 모르겠습니다.”
그러자 관리가 답했습니다. “자네는 백성들을 아끼지만, 백성들을 믿지 못해서 문제라네. 백성들에게 일을 믿고 맡기게나, 자네가 믿음을 표현한다면 백성들도 그 믿음에 보답하고자 제 일에 최선을 다할걸세”

믿음을 가져라, 신뢰해라, 비단 과거에만 통하는 이야기는 아닙니다.
넷플릭스, 에어비엔비 등 빠른 시간에 지대한 성장을 이루어 낸 스타트업 회사들의 업무 처리 방식이기도 합니다.
계획서 대신 업무를 맡은 직원의 아이디어로, 명령 대신 “이 사람은 이 분야의 전문가“라는 믿음으로 기다립니다.

프로그래밍은 현실의 문제를 푸는 일입니다. ‘다른 어떤 미지의 문제‘가 아니라요.
이런 프로그래밍에서, 현실에서 성공한 문제 해결 방식을 도입하는 건 분명 효과적이지 않을 수가 없습니다.

그렇기에 저는 새로운 라이브러리를 만들기 위해 현실에서 성공한 두 가지 문제 해결 방식을 선택했습니다.
바로 ‘분업‘과 ‘믿을만한 전문가‘ 입니다.

이 중에서 ‘분업‘은 분할 정복이라는 이름으로 이미 프로그래밍 계에 소개되었습니다.
위에서 설명한 리액트도 분할 정복을 잘 적용한 예시 중 하나구요.
하지만 ‘믿을만한 전문가‘는요..?

사실 따지고 보면 프로그램을 함수로, 객체로, 아니 그 무엇으로 나누어도 우리는 ‘믿을만한 전문가‘ 방식을 사용하고 있다고 주장할 수 있습니다.
자기 코드를 믿지 않는 프로그래머는 세상에 없거든요!‘, 우리는 코드를 작성할 때 “이 코드가 돌아가지 않으면 어쩌지?”보다
“이 코드가 돌아가면 이 코드도 돌아가고 여차저차해서 프로그램이 잘 돌아갈거야!” 라고 생각하지요. 그래서 오류를 발견하면 놀라는 거구요.
기대가 없으면 실망도 없잖아요!.

이쯤 되면 이런 생각을 하시는 분들도 계실겁니다.

나는 내 프로그램도 작게 쪼갰고, 내 코드도 믿는 데 왜 프로그래밍이 더 힘들어졌어..

다원적인 고민이지요, 하지만 대부분의 경우 이 문제는 단 하나의 도구를 들여오면 해결됩니다.
바로 ‘개체 간의 상호작용을 위한 도구‘ 입니다.

문제를 쪼개어 여러 전문가에게 나누어 풀게 하고 싶으면 당연히 그 전문가들과, 그 전문가들끼리 상호작용할 수 있게 해 주는 도구가 있어야 합니다.
이 도구를 만드는 방식은 정말 많은데요. 저는 그 중에서도 ‘이벤트 스트림‘을 선택했습니다.

즉, 저는 리액트의 문제를 해결하기 위해 ‘이벤트 스트림을 통해 각 전문가 코드가 소통을 할 수 있게 만들었다‘ 라는 이야기입니다.

objectel: Event-Driven Reactive Programming Library

좋아요, ‘문제의 분해와 합성’, ‘이벤트 스트림을 통한 상호작용’, ‘리액티브 프로그래밍’ 이게 지금까지 다루어 온 주제입니다.
이제 본격적으로 위의 논의 사항을 바탕으로 만들어진 라이브러리, objectel을 소개하도록 하겠습니다.

objctel은 컴포넌트 단위로 문제를 쪼갠 후, 이벤트 스트림을 통해 다시 병합하여 문제를 해결할 수 있게 해주는 라이브러리입니다.

그럼, 지금부터 아래의 간단한 예제 코드를 살펴보면서 objectel이 어떻게 제가 위에서 제시한 문제점들을 해결하였는지 살펴보도록 하겠습니다.

P.S objectel의 API에 익숙하지 않으신 분들을 위해 소소하게 주석을 달아두었습니다.
다만 생략한 몇몇 부분이 있기에 objectel을 공부하시려는 분들은 저장소의 API문서를 읽으시는 걸 권장드립니다.

harmony
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import * as Ol from 'objectel';
import merge from 'callbag-merge';

/**
* Ol.component(propsToModel, modelToResult, eventHandlerMap)
* propsToModel: 엘리먼트가 활성화 될 때 호출됩니다.
* 함수의 반환값은 엘리먼트의 초기 상태를 결정하기 위해 사용됩니다.
* modelToResult: 엘리먼트의 model이 수정되었을 때 호출됩니다.
* 함수의 반환값은 즉시 출력 스트림을 통해 전파됩니다.
* eventHandlerMap: event type: handler 형태의 객체입니다.
* 입력 스트림으로 지정된 타입의 이벤트가 들어왔을 때 event.payload, props, prevModel 형태로 호출됩니다.
*/
const Counter = Ol.component(
props => props.startValue,
count => Ol.createEvent('log', { from: Counter, value: count}),
{
'increase': (amount, _, prevCount) => prevCount + amount,
},
);

const Logger = Ol.component(
() => 'logger has been initialized',
console.log,
{
'log': ({ from, value }) => `Log: ${value} / from: ${from}`,
}
);
/**
* Functional Component
* props => event$ => output stream 형태의 함수입니다.
*/
const App = ({ children }) => {
if (!Array.isArray(children)) children = [children];

return event$ => {
const { Emit$, Listen$ } = Ol.createEventBus();
Emit$(event$);

return merge(children.map(element => element(Listen$)));
}
};

/**
* Ol.createEventBus(...initEvents)
* 이벤트 버스를 생성하고 initEvent 들로 초기화합니다.
* listener sink인 Emit$과 listenable source인 Listen$를 반환합니다.
* Emit$은 주어진 source가 emit하는 값을 이벤트 버스의 리스너들에게 전달해줍니다.
* Listen$은 자신을 listen하는 sink에게 이벤트 버스에 Emit 된 값을 전달해줍니다.
*/
const globalEventBus = Ol.createEventBus();
/**
* Ol.createElement(component, props, children)
* 주어진 component와 props를 통해 element를 생성합니다.
*/
const app = (
<App>
<Counter startValue={0} />
<Logger />
</App>
);

globalEventBus.Emit$(app(globalEventBus.Listen$));

objectel은 event(혹은 여러분이 만든 어떤 것)을 스트림을 통해 모든 엘리먼트가 공유하게 함으로서
서로 상호작용을 할 컴포넌트끼리 서로 알지 못하여도 상호작용을 할 수 있게 만들었습니다.

Counter 컴포넌트

1
2
3
4
5
6
7
const Counter = Ol.component(
props => props.startValue,
count => Ol.createEvent('log', { from: Counter, value: count}),
{
'increase': (amount, _, prevCount) => prevCount + amount,
},
);

Logger 컴포넌트

1
2
3
4
5
6
7
const Logger = Ol.component(
() => 'logger has been initialized',
console.log,
{
'log': ({ from, value }) => `Log: ${value} / from: ${from}`,
}
);

그 예로, 이 두 컴포넌트는 서로를 알지 못합니다. 하지만 App 컴포넌트를 통해 Counter의 출력 스트림과
Logger의 입력 스트림이 연결되어 상호작용을 하는 방법에 대한 고민 없이, 서로 이벤트를 통해 상호작용 할 수 있는 상태가 되었습니다.

또한, prop, model의 변화가 아닌 그저 ‘이벤트의 수신’을 기반으로 모든 것이 작동하기 때문에 더욱 더 수준 높은
리액티브 프로그래밍이 가능합니다.

어떠신가요? 한번 써 보고 싶으시지 않으신가요?
저는 여기서 objectel 소개를 마치고 다음 글인 ‘objectel로 간단한 카운터 웹 어플리케이션 만들기’로 돌아오겠습니다.


More reading

objectel project repository: objectel 프로젝트의 저장소입니다. 아래 저장소들의 모노레포입니다.
objectel repository: objectel의 저장소입니다.
objectel with snabbdom: Vue가 포크하여 사용하고 있는 Virtual DOM 구현체인 snabbdom을 objectel에서 쉽게 사용할 수 있게 컴포넌트 형태로 제공해주는 snabbel 라이브러리 사용 예시가 있는 저장소입니다.
snabbel: snabbel의 저장소입니다.
objectel-events: objectel의 이벤트를 다루는 유틸리티 함수를 제공하는 라이브러리입니다.

objectel 프로젝트에 대한 컨트리뷰트는 언제나 환영입니다 :)

공유하기