Reactive MVC와 Virtual DOM
2018.12.13
#Reactive • #Architecture • #Virtual-DOM
by Andre Medeiros, translated by pilgwon
웹 프론트엔드 업계에는 지금까지 많은 프레임워크가 쏟아져 나왔습니다. 제가 쓰고 있는 소프트웨어가 레거시가 되는 것을 보면 짜증나기도 합니다. 그렇지만 그런 혁신에서 더 나아질 수 있는 기회가 나타나는 법입니다. 프레임워크들은 사라지지만 그것들이 세상에 내놓은 좋은 아이디어는 남아있습니다. 오늘은 그것들의 좋은 점과 나쁜 점에 대해 알아보도록 하겠습니다.
React는 요즘 가장 핫한 프론트엔디 기술 중 하나입니다. React에서 새롭게 나온 아이디어 중 가장 훌륭한 것은 Virtual DOM 입니다. 한 마디로 말하자면 경량화되고 완성된 DOM의 표현을 자주 렌더링하는 것입니다. React 이전에는 게임 개발에서 이와 비슷한 기술이 있었습니다. 게임이 돌아갈 때 모든 화면을 다시 렌더링하는 것이 아니라 이전에 렌더링된 화면과 비교해서 바뀐 것이 있는 부분만 최소로 업데이트하는 것입니다.
React를 말할 때 Flux를 빼놓을 수는 없을 것입니다. React는 오로지 사용자 인터페이스만을 관리해서 Flux와 같이 사용하지 않으면 완벽한 프론트엔디 아키텍쳐라고 할 수는 없을 것이기 때문입니다. Flux에도 많은 아이디어가 들어있지만 요약해보자면 단방향적이고 순환하는 데이터 플로우를 가진 아키텍쳐라고 할 수 있습니다. 데이터 업데이트에 의존하는 코드 덕분에 이해하기 더 쉬워진다는 이점도 있습니다.
저는 RxMarbles.com이라는 도구를 만들면서 React를 처음 적용해봤고 동시에 Flux에 어느정도 시간을 쏟았습니다. React는 개발자로 하여금 복잡한 상태 머신을 만들고 하나의 컴포넌트에서 여러 관심사를 섞도록 한다는 면에서 결국엔 저를 실망시켰습니다. 저는 React를 virtual-dom이라는 훌륭한 라이브러리로 대체하기로 정했고 RxJS에 기반한 Reactive MVC 대체재를 만들기로 하였습니다. 그 결과 패턴은 성공했고 다른 웹 앱에도 적용했습니다. 그 중 하나는 저희의 고객 프로젝트였는데 자신있게 잘 작동하고 있다고 말씀드릴 수 있습니다.
React/Flux 콤보는 명백히 Reactive Programming 원칙에서 영감받았지만 API와 아키텍쳐는 정의되지 않은 Interactive와 Reactive 패턴의 혼합입니다. 이게 어떤 의미인지 그리고 어떻게 하면 더 잘 할 수 있는지 계속해서 말씀드리겠습니다.
내부 모듈 커뮤니케이션의 이중성
이중성(Duality)은 프로그래밍뿐만 아니라 수학에서도 가끔 만나게 되는 오래되고 강력한 개념입니다. 분명히 어떤 문제는 하나의 측면으로 작업하기는 다루기 힘들 수 있지만 그 측면이 두 개가 된다면 훨씬 쉬워진다는 개념입니다.
괜찮은 예제 중 하나는 젤다의 전설: 신들의 트라이포스 에 나오는 Hyrule 월드를 다크 월드와 라이트 월드로 이중으로 보여주는 것입니다. 라이트 월드에서 풀 수 없는 문제가 있다면 다크 월드로 가서 해결해야 합니다. 이 두 월드는 같은 공간이지만 서로 다른 시점을 가지고 있습니다.
그렇다면 앱 컴포넌트간의 커뮤니케이션에선 어떤 것을 이중성이라고 부를 수 있을까요? browserify로 만든 프로젝트에 모듈이 foo, bar, baz, quux가 있다고 가정해보겠습니다.
foo에서 bar로 이어지는 화살표는 foo가 bar에 어떻게든 영향을 끼치고 bar에선 데이터 업데이트가 일어난다는 것을 의미합니다. 보편적인 경우 foo의 코드는 다음과 같이 호출합니다.
bar.updateSomething(someValue);
질문: 각 화살표들은 프로그램의 어디에 존재하고 있을까요? 그건 그저 두 모듈 사이라고 간단하게 말할 순 없습니다. 왜냐하면 어떤 코드든 모듈 안에 존재하고 있으니까요. 대답은, 때때로 다르지만 보통은 화살표의 꼬리쪽에서 정의될거라고 예상할 수 있다는 것입니다.
1989년 논문에서 정의된 이 패러다임은 아마도 여러분 커리어의 대부분을 차지해왔을 “Interactive Programming”을 의미합니다. 이해하기 쉽게 다시 적어본다면 다음과 같이 얘기할 수 있을 것입니다.
Interactive 패턴에선 X 모듈은 X 모듈이 영향을 끼치는 곳을 정의합니다.
Interactive의 다크 월드는 Reactive로, 화살표가 Interactive와는 반대인 머리쪽에서 정의됩니다.
맞습니다. 그저 그래프에서 부모를 뒤집기만 하면 Reactive 패턴입니다.
Reactive 패턴에선 X 모듈이 다른 어떤 모듈이 X 모듈에게 영향을 끼치는지 정의합니다.
Reactive가 Interactive보다 더 나은 이유 중 가장 큰 것은 관심사의 분리입니다. Interactive에서 X에게 영향을 끼치는 것을 찾으려면 코드에 있는 다른 모든 모듈에서 X.update() 를 검색해야 합니다. 그러나 Reactive에서는 어떤 모듈이 X에 영향을 끼치는지 정의하기 때문에 모든 정보가 X 안에 다 있습니다. 예를 들면 스프레드 시트의 계산이 이와 매우 비슷합니다. 셀에 들어가는 컨텐츠에 대한 정의는 항상 그 셀 안에서 끝나고 다른 셀에선 어떤 일이 일어나는지에 상관없이 작동합니다.
Reactive 패턴은 어떻게 구현하는 걸까요?
Reactive 패턴의 기본적인 구현 방식 중 하나는 이벤트 발생기를 소개하는 것입니다. X 모듈은 Y 모듈에서 오는 데이터에 영향을 받는다고 정의하기 위해서 간단하게 Y에서 일어나는 이벤트를 듣거나 구독할 수 있습니다. 이러한 맥락에서 Y는 X에게서 분리되었고 X의 존재 여부는 상관이 없게 되었습니다. 이것을 구현하기 위해서는 RxJS나 Bacon.js같은 라이브러리가 필요 없으며, Flux와 React는 보통 Node.js의 EventEmitter를 사용합니다.
모든 모듈이 이벤트 발생기를 기본 빌딩 블록으로 쓰기 시작하면 여러분은 그 이벤트 발생기들을 영리하게 다룰 수 있는 방법을 찾게 될 것입니다. 예를 들어 보겠습니다. 하나의 이벤트 발생기가 있고 그 이벤트 발생기의 1초 딜레이된 버전인 이벤트 발생기를 정의하고 싶을 때는 어떻게 하실건가요? 대답은 아마도 setTimeout() 와 clearTimeout() 에 기반한 상태 머신 작업일 것입니다. 그리고 만약 두 이벤트 발생기를 합치고 싶을땐 어떻게 할까요? 확실히 이벤트 발생기를 넘어선 더 높은 차원의 함수가 필요하다는 것이 느껴지네요. 그런 것이 있다면 다음과 같이 x 에서 y 를 만드는 코드를 간단하게 작성할 수 있을 것입니다.
var y = delay(x, 1000 /* ms */);
현재 사용 가능한 이벤트에 대한 고차원 함수를 위한 최첨단 도구로는 RxJS, Bacon.js, 그리고 Kefir.js가 있습니다. 그 중에 고르자면 저는 저에게 편한 RxJS를 고르겠습니다. 하지만 강요하진 않습니다. RxJS에 비하면 EventEmitter는 아날로그적인거고 예를 들면 롤러 블레이드와 자동차의 차이라고 할 수 있겠습니다.
그러면 Reactive 모듈은 어떻게 생겼을까요? 당연히 명령형 함수인 update() 같은건 존재하지 않습니다. 그리고 RxJS Observable(우리의 “이벤트 발생기”죠)에 기반하고 있습니다. Reactive 모듈의 퍼블릭 인터페이스는 오직 앱의 나머지 구독해야할 부분들에 대한 Observable로 구성되어 있습니다. 이 Reactive 모듈은 자기 자신도 다른 모듈의 외부 Observable이 구독할 필요가 있는 것으로 표시할 것이고 내부적으로 그 모듈을 필요로 하거나 의존성 주입을 위한 함수를 가지게 될 것입니다.
아래는 다른 모듈의 이벤트를 관찰(observe)하고 그것들을 기반으로 자신의 이벤트를 내보내는 “Notifications Center” 모듈 예제입니다.
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?
모든 컴포넌트가 Reactive일 때 싱글 페이지 앱을 위한 MVC같은 아키텍쳐는 어떻게 생겼을까요? 그렇게 만들기 전에 정의 자체가 Interactive한 컴포넌트이고 명령형으로 다른 컴포넌트를 조종하는 Controller를 없애야 합니다. 실제로는 어떤 Reactive 컴포넌트도 다른 컴포넌트에게 명령형 스타일로 커맨드를 보내지 않습니다.
Model은 데이터와 앱의 현재 상태를 표시하는 역할을 하기 때문에 데이터 이벤트의 Observable을 내보낼 수 있어야 합니다. View는 Model의 이벤트를 구독하고 Model이 표현하는 것들을 그릴 수 있어야 합니다. 또한 그려진 결과는 Observable로 래핑될 수 있습니다. 알고보면 View 컴포넌트는 Model 이벤트를 입력으로 받고 렌더링을 출력으로 뱉는 하나의 함수에 불과합니다.
이제 Controller를 대체할 부분을 찾을 시간입니다. 보통 MV* 아키텍쳐에서 필요한 그것들 말이죠. 전통적인 Controller는 사용자의 이벤트를 입력으로 받고 그것들을 계산하고 Model.update(value) 같은 함수를 호출했습니다. 우리가 하고 있는 것이 Reactive이니 다른 길을 찾아보겠습니다. Model은 “Reactive Controller”가 무엇을 원하는지 관찰하고 “Reactive Controller” 이벤트를 기반으로 자기 자신을 업데이트하기로 결정하는 다른 길을 찾아보겠습니다.
이 Reactive Controller에게 제가 붙인 이름은 “Intent”입니다.
Model-View-Intent
Intent는 사용자의 입력 이벤트를 model 친화적인 이벤트로 바꿔주는 모든 책임을 가진 컴포넌트입니다. Intent는 사용자가 Model 업데이트 측면에서 무엇을 하려는지 이해하고 이러한 “사용자 의도”를 이벤트로 내보내야 합니다. Intent는 “View 언어”를 “Model 언어”로 번역합니다. Intent 자체는 다른 Reactive 컴포넌트가 그러지 않는 것처럼 아무 것도 바꾸지 않습니다.
Model은 Intent 이벤트를 관찰하고 Intent가 Model 내부적으로 정의된 제한을 지키는지에 따라 데이터를 바꿀지 결정합니다. 이는 데이터의 단방향 플로우를 순환하도록 매듭짓습니다.
각각은 이벤트를 입력으로 받고 이벤트를 출력으로 내보낸다는 측면에서 “함수의 형태”를 가지고 있습니다. 그러나 시작점 없이 재귀적으로 순환하기 때문에 엄격하게 보면 JavaScript 함수라고 할 수는 없습니다. 다음은 각 파트가 입력과 출력을 어떤 형식으로 받는지에 대한 설명입니다.
Model
입력: Intent에서 나온 사용자 상호작용 이벤트. 출력: 데이터 이벤트.
View
입력: Model에서 나온 데이터 이벤트. 출력: model의 Virtual DOM 렌더링 그리고 가공되지 않은 사용자 입력 이벤트. (클릭, 키보드 타이핑, 가속도 이벤트 등이 이에 해당합니다)
Intent
입력: View에서 나온 가공되지 않은 사용자 입력 이벤트. 출력: Model 친화적인 사용자 의도 이벤트.
순환적으로 임포트하는 것을 피하기 위해서 우리는 Node.js의 require 를 사용할 수 없습니다. 대신에 각각의 컴포넌트는 그들의 입력 모듈이 어떤 것인지 정의하는 의존성 주입 매커니즘을 위해 observe() 함수를 가지고 있습니다. 매듭을 묶기 위해서 우리는 셋 모두를 인스턴스화하고 세 개의 observe() 함수를 호출합니다. 이게 실제로 어떻게 작동하는지 궁금하시다면 이 예제를 확인해보세요.
내부 모듈 의존성을 가지기 위해서는 하나의 Model이 다른 Model을 임포트하고 그것이 발생시키는 이벤트를 들어야(listen) 합니다. 이는 Intent들 사이의 의존성에서도 똑같이 적용됩니다.
Virtual DOM에서 DOM으로
Reactive 컴포넌트는 정의에 의하면 외부 세계의 어떤 것도 변경하지 않습니다. 그러니 MVI 트리오들도 그럴 것입니다. 외부 세계를 변경하기 위해선 사이드 이펙트(side effect)가 필요합니다. 갑작스럽다고 생각하실수도 있겠지만 MVI 아키텍쳐에선 View가 실제로 사용자에게 렌더링하는 것은 아무것도 없습니다. View는 VTree(virtual-dom의 전문 용어입니다. 그냥 Virtual DOM의 “element”라고 생각하시면 됩니다)의 Observable을 내보낼 뿐입니다. View의 의무는 DOM이 어떻게 생겼는지 표현해주는 것이지만 DOM 자체를 변경하는 일은 하지 않습니다.
그러니 Renderer라는 컴포넌트에게 DOM을 변경할 의무를 넘겨주겠습니다. 간단하게 말하면, Renderer는 앱에 있는 모든 View의 VTree Observable을 구독하고 이 VTree를 실제 DOM 요소로 변경하는 것입니다. 이 작업은 이전 VTree와 지금 적용된 DOM과의 차이(diff)를 비교하며 바뀐 것이 있을 때만 업데이트합니다.
Renderer는 사이드 이펙트이자 View에서 이벤트를 받아서 실제 세계를 변경하는 “sink” 타입의 컴포넌트입니다. View에서 실제로 그리는 작업을 Renderer로 분리하는 이점 중 하나는 환경을 따지지 않기 때문에 더 테스트하기 쉬워진다는 것입니다.
듣자 하니, React에는 백엔드에서만 쓰여서 출력되는 View를 테스트하기 좋은 포맷은 아닌 renderToString()만 존재한다고 합니다. MVI에선 가상 요소(virtual element)를 테스트하기 더 편할 것입니다.
Example
몇 주 전에 저는 React 예제들을 보았는데 그 예제들을 MVI와 Virtual DOM으로 구현한 예제를 만들기로 결심했습니다. 그러니 React와 비교할 수도 있습니다.
React/Flux에 비해 다른 점
MVI는 React/Flux처럼 렌더링을 위해 Virtual DOM을 사용한 단방향 데이터 플로우 아키텍쳐입니다. 하지만 거기까지 비슷하고 다음과 같은 이유들이 차이를 만듭니다.
순수하게 Reactive입니다. React는 Reactive 프로그래밍 패턴과 Interactive 프로그래밍 패턴을 섞어서 setState, forceUpdate, setProps, render 같은 명령형 API를 사용할 수 있게 만들었습니다. Flux는 Store를 Dispatcher 이벤트를 듣게 만들어서 Reactive하게 만드려고 시도합니다. 하지만 중앙화된 Dispatcher는 Action을 관찰하는 책임을 가지는 것이 아닌 Action에 의해 명령적으로 조종됩니다. 또한 Action은 명령적으로 View에서 생성됩니다. 반면에 MVI는 일관된 reactive 접근으로 컴포넌트들이 내부적으로 어떤 구조를 가지는지에 대한 사유를 하기 쉽게 만들어줍니다.
MVI는 탈중앙화되었습니다. Flux에는 중앙화된 Dispatcher가 존재하고 항상 어플리케이션 내에서 이 Dispatcher를 싱글톤으로 유지하는 것을 추천하는 가이드라인도 존재합니다. 이는 어플리케이션이 커질수록 큰 파일에 대한 유지보수성이 떨어지는 등의 중앙화에서 나오는 보편적인 문제들로 이어집니다. Dispatcher는 중앙화된 Event Bus보다 실제로는 이벤트에 관련된 모든 파티를 이어주는 역할을 하는 이벤트 배관공이라고 할 수 있습니다. MVI 구조에서는 내부 모델 의존성이 각각 모델에 따라 쉽게 설명될 수 있습니다. X 모델이 먼저 Y 모델과 Z 모델에서 이벤트가 일어나는 것에 대해 의존성을 가지고 있다면 간단하게 내부적으로 Y와 Z로 의존성을 가진다고 정의하면 됩니다.
RxJS의 좋은 점만을 가져왔습니다. Flux는 수동 이벤트 핸들링을 요구하는 로우 레벨 EventEmitter 사용을 추천하는 반면에, RxJS와 비슷한 이벤트 처리 도구들은 보편적인 Flux 어플리케이션이 가지는 보일러 플레이트 코드들을 대체할 수 있습니다.
View와 Renderer가 분리되어 있습니다. View 렌더링에서 View 로직을 분리하면서 어플리케이션에선 관심사의 분리를 더 잘 할 수 있게 되고 View는 더 테스트하기 편하고 변경하기도 편해집니다. Renderer가 모듈화되어있고 절대 다른 컴포넌트에 의해 참조되지 않기 때문에 다른 구현 방식의 Renderer가 대체되더라도 문제 없이 작동합니다. Renderer에선 View Observable에 후가공 처리를 해서 요소를 수정하거나 컨테이너 div에 래핑할 수 있습니다. 예를 들면 UI 스킨을 구현하기 매우 쉽게 되는거죠. 이 모듈화 능력은 React에 존재하지 않습니다.
테스트하기 더 좋습니다. Renderer 같은 사이드 이펙트를 제외하고 MVI의 모든 컴포넌트는 사이드 이펙트에서 자유롭고 입력과 출력이 있다는 면에서 함수의 모양을 가진다고 할 수 있습니다. 이건 자동화 테스트에서 이상적인 상황인데, 특히 View 로직이 브라우저 컨텍스트 바깥에서 테스트 실행이 더 빨라진다면 더더욱 그렇습니다.
결합(coupling)이 약합니다. React/Flux에서 종종 보이는 Interactive 패턴은 파트 사이의 결합이 더 강해진다는 것을 의미합니다. 예를 들면 Action은 Dispatcher를 임포트하면서 명백하게 영향을 끼치고 Dispatcher를 다른 것으로 대체하기 더 어렵게 만들어버립니다. MVI는 Reactive 프로그래밍 원칙을 철저히 지키기 때문에 핵심부터 관심사의 분리를 유지합니다. Reactive 모듈끼리는 임포트할 필요가 없기 때문에 Model과 View 또는 Reactive 모듈과 Reactive 모듈 사이의 중계기도 넣을 수 있습니다. 입력되는 값이 예상되는 인터페이스를 만족시키기만 하면 MVI 사이클의 각 컴포넌트는 의존성 주입 시스템에 의해 제공되는 입력 컴포넌트에 대해 불가지론적입니다.
virtual-dom은 React보다 빠릅니다. 벤치마크에 의하면 virtual-dom이 React를 포함한 몇몇 프레임워크보다 더 빠르다고 합니다. 저도 (Virtual DOM 렌더링 도구로서의) React가 virtual-dom보다 더 나은 퍼포먼스를 보여준 경우는 못 본 것 같습니다.
Model이 다른 Model을 관찰(observe)합니다. Flux에서는 내부 모델 의존성은 Dispatcher에 있어야 한다고 설명합니다. 그러나 MVI에선 그러한 의존성들이 각 Model 내부에서 정의됩니다.
내부 상태가 없습니다. MVI는 속성만 사용하는 React 구현 방식과 유사합니다(상태 없이요). View는 관심사를 헤칠 수 있다는 이유로 내부 상태를 가지면 안됩니다. MVI에선 심지어 순수하게 UI와 관련된 상태일지라도 모든 상태가 Model에 존재해야 합니다. 이는 다음과 이어지는 내용입니다.
재사용되는 UI 컴포넌트가 없습니다. MVI가 제안하는 것과는 다르게 React는 “재사용 UI 컴포넌트”에 대해 아주 강력하게 강조합니다. MVI는 함수 형태의 모듈들의 관심사를 분리하는 것에 초점을 두고 있습니다. 지금의 MVI에선 재사용 UI 컴포넌트를 적절하게 사용하는 법은 여전히 해결되지 않은 문제입니다. React View 컴포넌트가 Model, View 그리고 Intent 모두를 포함할 수 있기 때문입니다. 저는 이 문제를 Virtual DOM의 컨텍스트안의 Web Components를 더 발전시켜서 해결하는 것이 가장 이상적이라 생각합니다. 이상적으로 생각해보면 보통 div 로 하던 것처럼 우리는 가상으로 custom-element 를 내부 상태와 복잡한 행동을 렌더링해야 합니다. 이러한 가능성에 대한 장황한 이야기가 궁금하신 분은 Jarno Rantanen의 글을 읽어보세요.
MVI의 미래
Model-View-Intent는 프레임워크로 발전할 수도 있고 적어도 프레임워크를 정의하기 위한 보일러 플레이트 코드를 적게 요구하게 될 것입니다. 그러기 위한 문제점은 아직 발견되지 않았습니다. 지금 가장 큰 도전 과제는 재사용을 위해 UI 컴포넌트를 행동(behavior)과 같이 캡슐화하는 것입니다. 우리는 Web Components가 Virtual DOM과 같이 쓸 수 있게 되기를 간절히 희망하고 있습니다.
저는 프론트엔드 개발자들이 React 이전에 virtual-dom을 시도해보기를 강력히 추천합니다. API는 간소하며 하나의 일만 합니다. 그러니 적은 코드로 DIY JavaScript 코드만 짜면 됩니다. virtual-dom은 유효하지 않은 입력 이벤트가 발생했을 때 어떤 에러인지 잘 알려준다고 생각하며 virtual-dom만을 위한 훌륭한 라이브러리도 존재합니다.
다른 프레임워크에서 제공하는 것과 다르게 로우 레벨 이벤트 유틸리티를 작성하지 않아도 되게 해주는 결정적인 이유는 RxJS입니다. 이는 종종 프레임워크가 될 필요를 없애버리기도 합니다.
Model-View-Intent는 잘 작동하는 것 같아 보이는 그저 하나의 실험입니다. Virtual DOM과 단방향 데이터 플로우는 React와 Flux의 컨텍스트 바깥에 있습니다. 이 아키텍쳐의 다양화에 대한 가능성은 아주 많고 프론트엔드 기술이 발전함에 따라 미래에는 의미있는 발전이 있을 것으로 보입니다. 그런 일이 생긴다면 정말 신날 것 같네요!