Reactive MVC 그리고 Virtual DOM

🗓 2015-06-01

Reactive MVC and the Vitual DOM by Andre Medeiros

http://futurice.com/blog/reactive-mvc-and-the-virtual-dom



웹 프론트 엔드 씬에는 수많은 새로운 프레임웍과 작업 방식들이 나타났었다. 어느 때보다 빠르게 소프트웨어는 레거시화 되었고, 이게 사실 좀 짜증 난다. 그러나 사실 이것은 좋은 혁신이다 왜냐하면 개선의 기회가 거기에 있기 때문이다. 프레임웍은 나타났다 사라지지만 그것들이 세상에 가져온 좋은 아이디어들은 남아있다. 우리는 여기서 그 좋은 아이디어와 그리고 그리 좋지 않았던 아이디어에 대해 이야기하려 한다.

**리액트(React)**는 프론트엔드 기술의 뜨거운 감자 중에 하나이다. 리액트에서의 새롭고 훌륭한 아이디어는 바로 Virtual DOM Rendering이다 요점은 가벼운 DOM을 자주 re-render하고 DOM을 만드는데 필요한 최소한의 변경을 감지하는 필터를 적용하는 것이다. 리액트 이전에 이미 비슷한 기술이 게임 개발에 사용되어 왔는데, 게임 화면을 매 프레임마다 다시 그리는 것이 아니라, 이전 화면과 비교하여 변경이 있는 부분만 최소한으로 업데이트하는 기술이다.

리액트를 이야기할때 **플럭스(Flux)**를 빼놓을수가 없다. 리액트가 유저 인터페이스에 집중한 이후로 둘이 함께여야 완전한 프론트엔드 아키텍처를 설명할 수 있기 때문이다. 플럭스는 많은 아이디어를 가지고 있는데 unidirectional하고 circular한 데이터의 흐름을 가진 아키텍처로 요약된다. 그로 인해 코드는 쉽게 데이터의 업데이트를 감지할 수 있게 된다.

나는 RxMarbles.com이라는 툴에 리액트를 적용하기 시작했었다. 그리고 약간의 시간을 들여 플럭스를 조사했다. 리액트는 여러 가지 이유로 나를 실망시켰다. 주로 적합하지 않게 디자인된 API로 프로그래머로 하여금 complex state machine을 만들게 했고 한 컴포넌트에 여러 가지 관심사가 섞여있게 했다. 나는 리액트를 훌륭한 virtual-dom 라이브러리로 교체하고 RxJS에 기반을 둔 Reactive MVC 대안을 만들기로 결정했다. 이 패턴은 성공적이었고 나는 다른 웹앱들에도 적용했다. 그것들 중 하나는 정말 성공적이었다고 말할 수 있는 프로젝트도 있다.

React/Flux 콤보는 명확하게 Reactive 프로그래밍 원리에 영향을 받았지만 API와 아키택처는 일관성 없이 interactive와 reactive 패턴의 잡종이다. 이게 무슨 의미인지 그리고 어떤 더 좋은 방법이 있는지는 이어서 설명하겠다.

모듈간 통신의 이중성

이중성(Duality)은 가끔 수학(물론 프로그래밍도)에서 직면할 수 있는 오래되고 강력한 컨셉이다. 간단히 말해 어떤 문제는 한 개의 관점으로 작업하기에는 어려울 수 있는데, 이를 두 개의 관점에서 바라보면 작업이 더 쉬워진다는 것이다.

재미있는 이중성의 예는 The Legend of Zelda:A link to the Past라는 게임의 "어두운 세상"과 "밝은 세상"에서 볼 수 있다. 어떤 퀘스트가 "밝은 세상"에서는 해결이 불가능한데 그럴 때는 "어두운 세상"으로 가서 이를 해결할 수 있다. 비록 두 종류의 월드는 같은 장소에 대한 다른 관점일 뿐인데도 말이다.

역: 같은 맵이 다크월드 컨셉과 라이트월트컨셉으로 나뉘는 것 같음

Light and Dark Hyrule in the Legend of Zelda: Link to the Past

그럼 앱의 모듈 간 통신에서의 이중성은 무엇일까? Browserify를 이용해 foo, bar, baz, quux라는 모듈들을 가진 프로젝트가 있다고 가정해보자. 아래와 같은 모습이다.

modules relationship

foo에서 bar로 향하는 화살표의 의미는 bar에 있는 데이터가 업데이트되면 foo는 어떤 방식으로든 bar에 영향을 준다는 뜻이다. 일반적으로 foo에서는 아래와 같이 구현한다.

bar.updateSomething(someValue);

여기서 질문이 있다. 프로그램 어디에서 각각의 화살표들이 표현될 수 있을까? 아마 간단하게는 모듈 간의 화살표를 표현할 수 없을 것이다. 왜냐하면 모든 코드들은 특정 모듈 안에 존재하기 때문이다. 대답은 상황에 따라 다르겠지만 일반적으로 화살표는 그 꼬리(영향을 주는 쪽)에서 정의될 거라 예상한다.

Arrows defined at their tails (Interactive)

역: 관계에 대한 정의를 영향을 주는 쪽에서 정의하고 있다.

아마 당신의 작업물 대부분에서 자주 사용되었을 이 패러다임은 "인터렉티브 프로그래밍"이라고 한다. 그리고 우리는 이를 재해석해서 아래와 같이 정의한다.

"인터랙티브 패턴에서는 모듈 X는 X가 영향을 주는 다른 모듈들을 정의한다."

이중의 인터랙티브는 리액티브이고 화살표는 반대쪽 끝인 화살표의 머리 쪽에서 정의된다.

Arrows defined at their heads (Reactive)

이거다, 단순히 그래프에서 화살표에 대한 책임을 지는 부모를 뒤집는 것으로 리액티브 패턴을 사용할 수 있다.

"리액티브 패턴에서는 모듈 X에게 영향을 주는 다른 모듈들에 대한 정의를 X가 한다."

리액티브가 인터랙티브에 비해서 주로 줄 수 있는 이점은 관심사의 분리이다. 인터랙티브에서는 무엇이 X에 영향을 주는지 알려면 x.update()와 같은 형태로 x를 사용하는 것들을 모두 검색해야 했다. 그러나 리액티브에서는 이런 것들이 모두 X에 정의되어 있으니 X만 살펴보면 된다. 이런 속성은 스프레드시트 계산에서 일반적이다. 예를 들면 하나의 셀의 컨텐츠의 정의는 종속된 다른 셀의 변화에 관계없이 언제나 셀안에서 정의된다.

어떻게 리액티브 패턴을 구현할 것인가?

일반적인 리액티브 패턴의 구현은 이벤트 이미터(event emitters)로 알려진 것이다. 그래서 모듈X는 간단하게 모듈Y의 이벤트를 구독할 수 있고 X는 Y의 데이터를 통해 받는 영향을 정의할 수 있다. 이렇게 되면 Y는 X와 커플링이 전혀 없고, 심지어 X의 존재 몰라도 된다. 이것을 구현하기 위해 RxJS나 Bacon.js와 같은 라이브러리를 사용할 필요는 없다. 사실 플럭스와 리액트의 사용 사례는 Nodejs의 EventEmitter에서 따온 것이다.

모든 모듈들이 기본 블럭을 구성하기 위해 이벤트 이미터를 사용하기 시작하면 당신은 이벤트 이미터를 핸들링할 지능적인 방법이 필요하다. 예를 들면, 만약 다른 이벤트 이미터 보다 1초 정도 딜레이 되는 버전의 이벤트 이미터를 정의해야 한다면 어떻게 하겠는가? 아마도 setTimeout()과 clearTimeout()을 이용하는 것이 답이 될 것이다. 그리고 만약 두 종류의 이벤트 이미터를 합쳐야 하는 경우는 어떨까? 이벤트 이미터에는 확실히 higher-order functions가 필요할 것이고 그래서 아래와 같은 간단한 x에서의 y로의 이벤트 이미터를 작성할 수 있을 것이다.

var y = delay(x, 1000);

이벤트를 통한 higher-order functions를 위한 툴로 RxJS와 Bacon.js 그리고 Kefir.js를 사용할 수 있다. 나는 RxJS를 선호하지만 당신의 선택은 다양할 것이다. EventEmiter와 RxJS의 비교는 마치 롤러블레이드와 자동차의 차이와 유사하다.

리액티브 모듈은 어떻게 보일까? update()와 같은 명령적인 함수는 존재하지 않고 RxJS의 Observables(우리의 "이벤트 이미터")로 이루어질 것이다. 리액티브 모듈의 모든 퍼블릭 인터페이스는 Observables로 구성이 되어 앱의 다른 부분에게 구독할 수 있게 노출되어있다. 이 리액티브 모듈은 자신도 다른 모듈의 Observables를 구독하고 있을 것이고 내부적으로 requires(노드 스타일의 임포트)나 디펜던시 인젝션을 위한 펑션을 통해 모듈들을 불러올 것이다.

예를 들면, 아래의 예제는 "Notification Center" 모듈로 다른 모듈들의 이벤트를 관찰하고 그것들로 자신의 이벤트를 exports한다.

var breakingNews = require('myapp/breakingNews');
var sms = require('myapp/sms');

var notifications = Rx.Observable
  .merge(breakingNews.newsEvents, sms.messageEvents);

module.exports = {
  notifications: notifications
}

Reactive MVC?

모든 컴포넌트들이 리액티브한 싱글 페이지 앱의 MVC-like 아키텍처는 어떻게 보일까? 컨트롤러를 제거하는 것으로 시작한다. 왜냐면 컨트롤러는 인터랙티브 컴포넌트들이 다른 컴포넌트를 다루기 위해 필요한 것이기 때문이다. 리액티브 모듈은 명령적으로(imperative fashion) 다른 컴포넌트에게 명령을 내리면 안된다.

모델은 데이터와 앱의 현 상태를 관리하고 Observables의 데이터 이벤트로 노출한다. 뷰는 모델의 이벤트를 구독하고 모델의 표현을 그리게 된다 그려진 결과 역시 Observables로 감싸져 뷰를 노출할 수있다. 뷰 컴포넌트는 단지 모델의 이벤트를 입력받아 렌더링된 아웃풋을 만드는 기능만 하게된다.

잃어버린 조각은 보통 MV* 아키텍처에서의 컨트롤러에 대한 대안이다. 우리는 유저의 인풋 이벤트들을 관리하기 위해 컴포넌트를 사용한다. 전통적인 컨트롤러는 유저의 인풋 이벤트들을 받아 그들을 통해 약간의 계산을 수행하고 Model.update(value) 와 같은 펑션을 호출한다. 우리는 리액티브로 처리하기에 다른 방법을 이용해야 한다. 모델은 리액티브 컨트롤러가 업데이트하기 원하는 것을 관찰하고 리액티브 컨트롤러 이벤트들에 따라 자신을 업데이트할지를 결정한다.

그 리액티브 컨트롤러를 "인텐트(Intent)"라고 부른다.

Model-View-Intent

인텐트는 유저의 인풋 이벤트를 모델이 이해하는 이벤트로 번역해주는 책임을 지고있다. 모델의 업데이트 측면에서 유저가 뭘하려 하는지를 설명하고 이런 "유저의 의도들"을 이벤트로 노출한다. "뷰의 언어들"을 "모델의 언어들"로 번역하는 것이다. 인텐트 자신은 다른 리액티브 컴포넌트들이 그렇듯 그 무엇도 변경하거나 하질 않는다.

모델은 인텐트의 이벤트들을 관찰하고 모델 내부에 정의된 조건에 따라 자신의 데이터를 변경한다.

이는 일정한 방향의 원형 데이터 흐름을 만들어낸다.

View observes Model, Intent observes View, Model observes Intent

각 컴포넌트들은 이벤트를 인풋으로 받고 이벤트를 출력하는 일종의 펑션같다. 물론 엄밀히 따지면 자바스크립트의 평션이 될 순 없다. 왜냐하면 시작점이 없이는 재귀적인 싸이클이 생길것이기 때문이다. 아래는 각 요소들이 인풋과 아웃풋으로 갖는 것들에 대한 설명이다.

Model

  • 인풋: 인텐트에서 발생한 유저 인터렉션 이벤트
  • 아웃풋: 데이터 이벤트들

View

  • 인풋: 모델에서 발생한 데이터 이벤트들
  • 아웃풋: 모델을 렌더링한 Virtual DOM, 순수한 유저 인풋 이벤트(click 등등)

Intent

  • 인풋: 뷰에서 발생하는 순수한 유저 인풋 이벤트
  • 아웃풋: 모델 친화적인 유저의 의도 이벤트들

Circular Imports를 피하기 위해서 우리는 nodejs의 require를 이용하면 안 된다. 대신에 각각의 컴포넌트는 observe()펑션들을 가지고 있어 컴포넌트의 인풋 모듈이 무엇인지 정의하는 디펜던시 인젝션 메카니즘을 이용한다. 예를 들면, *view.observe(model);*는 모든 매듭을 연결하기 위해서는 세 가지를 연결해야 하므로 세 번의 observe() 펑션이 실행되게 될 것이다. 실제로 어떻게 구현되는지 보고 싶다면 여기를 클릭

모델 간의 의존성을 가지려면 한 개의 모델이 다른 모델의 이벤트를 구독해야 하고 인텐트 간의 의존성도 같은 방식으로 구현한다.

Virtual DOM에서 DOM으로

정의에 따른다면 리액티브 컴포넌트들은 자신 외에 다른 컴포넌트에 직접적으로 변화를 주면 안된다. 그래서 MVI 트리오 역시 그렇게 하지 않는다. 외부에 변화를 주기 위해서는 우린 사이드 이팩트가 필요하다. 놀랍겠지만 MVI 아키텍처에서 뷰는 사실 렌더된 화면을 직접적으로 유저에게 제공하지 않는다. 뷰는 Obervable로 VTRee를 관찰할 수 있게 제공되는데 이는 virtual-dom 용어이며 말그대로 가상의 DOM 엘리먼트이다. 뷰 DOM이 보여야하는 방식을 표현하는 책임을 지고 있지만 직접적으로 DOM을 수정한다는 말은 아니다.

우리는 DOM을 변경하는 책임을 렌더러라고 불리는 컴포넌트에게 위임한다. 간단하게 모든 뷰에서 VTree Observables를 구독하고 있다가 필요할 때 VTree를 실제 DOM으로 전환하는 작업을 한다. 렌더러는 이전 VTree와 비교하여 변경된 부분의 DOM만 패치한다.

Renderer component as a side effect

렌더러는 사이트 이팩트이자 "Sink"타입 컴포넌트이다. 뷰의 이벤트를 소비하여 진짜 세계에 반영하는 것이다. 이런식의 뷰의 분리는 브라우저 환경 없이도 뷰를 테스트할 만큼 testable하게 해준다. 뷰를 펑션과 같은 컴포넌트로 활용하여 목업 데이터를 전달하고 어떤 Virtual DOM 엘리먼트가 만들어지는지 조사할 수 있다.

명백히 리액트는 renderToString()를 가지고 있지만(백엔드에서 사용할 수 있도록) 이것은 아웃풋 뷰를 테스트하는 좋은 포맷은 아니다. MVI에서는 쉽게 순수한 버츄얼 엘리먼트들을 테스트할 수 있다.

Example

몇 주전 나는 이런 리액트의 사용 예시들을 보게 되었고 이것들 중 하나를 MVI와 Virtual DOM을 이용해 구현해 봤다. 이를 통해 리액트와 비교해 볼 수 있을 것이다.

React/Flux와의 차이점이 어떻게 되는가?

MVI는 렌더링에 Virtual DOM을 사용하는 방향성이 있는 데이터 흐름의 아키텍처이다. React/Flux 역시 같은데 유사점은 거기까지다. MVI와의 다른 점은 아래와 같다.

순수하게 리액티브다

리액트는 리액티브 패턴과 인터랙티브 패턴이 섞여있다. 때마다 setState, forceUpdate, setProps, render와 같은 imperatively한 API들을 사용하게 한다. 플럭스는 디스패처 이벤트를 구독하는 스토어를 만들고 컨트롤-뷰들이 스토어의 이벤트를 구독하게 하는 것으로 리액티브를 구현한다. 그러나 집중된 디스패처는 액션을 관찰하는게 아니고 액션에 의해 imperatively하게 컨트롤된다. 그리고 액션은 imperatively하게 View에 의해 만들어진다. MVI는 일관되게 리액티브로 접근해 컴포넌트가 갖는 내부적인 구조에 이유를 쉽게 알 수 있게 한다.

MVI는 분산구조이다

플럭스는 집중적인 디스패처를 가지고있고 애프리케이션 안에서 싱글톤으로 유지되도록 가이드하고 있다. 이는 어플리케이션이 커질수록 파일 크기가 커져 유지 보수성이 떨어지는 일반적인 집중화의 문제점이 발생할 수 있다. 디스패처는 사실 모든 파트에 그와 관련 있는 이벤트들을 연결해주는 Plumber로 집중된 이벤트 버스와 다르지 않다. MVI 스트럭처에서는 모델 간의 디펜던시는 쉽게 각각의 모델에서 개별적으로 쉽게 기술할 수 있다.

RxJS의 영향력

플럭스가 이벤트를 직접 핸들링해야 하는 저레벨의 이벤트의 사용을 권장하는 동안 RxJS와 비슷한 이벤트 처리 도구는 일반적인 플럭스 어플리케이션이 가지고 있는 많은 것들을 대체할 수 있는 powerhouses가 되었다. RxJS는 리액티브 해질 수 있는 컴포넌트의 내부 구조를 허용한다.

렌더러와 뷰의 분리

뷰 로직을 뷰 렌더링과 분리함으로 어플리케이션은 관심사들의 분리가 더 명확해졌고 뷰는 더 테스터블해졌으며 교체 가능해졌다. 렌더러는 모듈화가 되고 어떤 컴포넌트에서도 참조되지 않는다. 렌더러는 또 다른 구현으로 만들어져 쉽게 교체할 수 있다. 렌더러에서는 뷰의 Obeservables를 통해 post-processing을 처리할 수 있고 엘리먼트를 수정하거나 컨테이너 div로 래핑할 수 있다. 그래서 UI 스킨의 구현이 쉬워지고 이런 능력은 리액트에는 존재하지 않는다.

테스트 하기 쉽다

렌더러를 제외한 모든 MVI의 컴포넌트들은 인풋을 받고 아웃풋을 생성하므로 펑션과 비슷하다. 이것은 자동화된 테스트에 걸맞는 상황으로 브라우저 밖에서도 뷰 로직을 텍스트할 수 있어 브라우저보다 더 빠른 환경에서도 테스트를 실행할 수 있다.

커플링이 적다

React/Flux에서는 자주 인터랙티브 패턴이 발견되는데 이는 각 부분들 사이의 더 많은 커플링을 유발한다. 예를 들면, 액션은 디스패처를 불러오고 명시적으로 사용해 영향을 준다. 그래서 디스패처를 다른것으로 교체하기 힘들게 만든다. MVI는 리액티브 프로그래밍의 철학대로 코어 자체에서 관심사가 분리되어 있다. 명시적인 호출이 필요가 없으니 모델과 뷰 사이에 미디에이터를 추가 할수도 넣는 등의 작업을 쉽게 할 수 있다. MVI의 사이클에서 각 컴포넌트들은 인풋컴포넌트가 고정되어 있지 않고 디펜던시 인젝션시스템에 의해 주어지고 이벤트 인터페이스만 맞추면 된다.

virtual-dom은 리액트보다 빠르다

Preliminary benchmarks에 따르면 virtual-dom과 리액트를 포함한 다른 프레임웍과의 속도 차이를 볼 수 있다. 나는 아직 리액트가 virtual-dom보다 빠른 경우를 본 적이 없다.(Virtual DOM 렌더링 툴로써..)

모델이 다른 모델을 관찰한다

플럭스는 모델간의 디펜던시들이 디스패처에 존재하도록 했다. 그러나 MVI에서는 각각의 디펜던시는 각 모델안에서 정의된다.

내부 상태가 없다

MVI 리액트의 상태없이 프로퍼티만 사용한다는 점에서 리액터와 비교된다. 뷰는 내부 상태를 가지고 있으면 안된다. 왜냐하면 뷰는 단순하게 버츄얼 엘리먼트를 만든다는 뷰의 목적에 부합하지 않기 때문이다. MVI에서는 모든 상태는 모델에 존재한다(심지어 그게 UI에만 관련있는 것이라도). MVI 뷰들에게 상태를 구현하게 하는 자유가 있기 때문에 제한하는 것 보다 더 추천되는 방법이다. 이 내용은 다음 항목과도 관련이 있다.

재사용하는 UI 컴포넌트가 없다

리액트는 "재사용 가능한 UI 컴포넌트"를 강하게 강조하는데, 이것은 MVI가 추구하는 방향이 아니다. MVI가 집중하는것은 펑션 같은 모듈들의 관심사의 분리를 가능케 하는 것이다. 현재 MVI에서도 올바르게 재사용가능한 UI 컴포넌트를 만드는건 아직 문제이다. 왜냐하면 리액트 뷰 컴포넌트는 3개의 모델과 뷰 그리고 Intent 책임들을 가지고 있기 때문이다. 나는 이 문제의 가장 이상적인 해결책은 Virtual DOM 컨텍스트 안에서 사용 가능한 Web Component라고 생각한다. 이상적으로 우리는 내부 상태와 복잡한 동작을 포함하고 있는 <custom-element>를 만들어 사용할 수 있어야 한다. 마치 <div>처럼말이다. 자세한 내용은 여기를 참고하기 바란다

향후 계획

Model-View-Intent는 프레임웍으로 진화하게 될 것이거나 혹은 프레임웍이라고 하기에는 충분하지 않을 수도 있다. 문제들은 아직 발견되지 않았다 현재 제일 큰 과제는 재사용을 위해 UI컴포넌트를 그것의 행동과 캡슐화하는 것이다. 웹컴포넌트가 Virtual DOM과 정상적으로 동작하게 될지는 미지수이다.

나는 리액트를 사용하기 전에 virtual-dom을 사용해보기를 프론트엔드 개발자에게 강력하게 추천한다. API가 간단하고 한가지만 하는데, 나머지 작업은 약간의 자바스크립트로 직접 구현하면 된다.

내 생각엔 "Just the UI"에 대해 리액트 보다는 조금 더 신용할 수 있는 것 같다. virtual-dom은 잘못된 인풋의 이벤트에 대해 유용한 에러를 던진다. 그리고 유용한 것들이 더 있다.

또 다른 유용한 것들은 RxJS이다. 이것은 다른 프레임웍에서 제공하고있는 저레벨의 이벤트 유틸리티 같은 것 들을 작성할 필요가 없게 해준다. 애초에 프레임웍 자체가 필요 없을 수도 있다.

Model-View-Intent는 작동하긴 하지만 아직 실험적이다. Virtual DOM과의 방향성있는 데이터의 흐름은 리액트와 플럭스의 환경 밖에 존재한다. 이 아키택처는 많은 변종이 있을수 있고 프론트엔드 기술이 정상적으로 발전하게 됨에 따라 미래에는 확실히 진화하게 될 것이다.

♥ Support writer ♥
with kakaopay

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© Sungho Kim2023