[번역] The process: Making Vue 3

Evan You의 The process: Making Vue 3 번역글입니다. 굳이 이 글이 아니더라도 모든 번역글은 역자의 의도와 상관없이 원문의 내용과 다르게 전달될 수 있으니 원문도 같이 보시는 걸 권해드립니다.

지난 1년동안 Vue 개발팀은 Vue.js의 다음 메이저 버전을 준비해왔으며, 2020년 상반기 release를 목표로 하고 있다. (역주: 참고로 원문은 2020년 5월 3일에 포스팅되었다.) 새로운 메이저 버전은 2018년 말, Vue 2의 코드베이스가 약 2년 반정도 되었을 때 구체화되었다. 소프트웨어 수명치고 그리 오래되지 않았을 수 있지만 해당 기간동안 프론트엔드 환경은 크게 바뀌었고, 다음 두 요소로 인해 Vue의 새로운 메이저 버전을 만들게 되었다.

  • 주요 브라우저에서 JavaScript 신규 기능들의 가용성을 어느정도 보장함
  • 시간이 흐르면서 현재 코드베이스의 설계 및 구조에서 오는 문제점들이 드러남

image


왜 재구현을 하게 되었나

JavaScript의 신규 기능 활용

ES2015가 표준화되면서 JavaScript가 크게 개선되었고, 주요 브라우저들은 이러한 신규 기능을 지원하기 시작했다. 그 중 일부는 Vue가 가진 기능을 크게 향상시킬 수 있는 기회를 제공했다.

특히 주목할만한 것은 Proxy인데, 이는 Vue가 object에서 이루어지는 작업들을 intercept 할 수 있게 해준다. Vue의 핵심 기능은 사용자가 정의한 state의 변경을 감지하고 이에 반응하여 DOM을 수정하는 것이다. Vue 2는 state 객체의 속성을 getter와 setter로 대체하여 이러한 반응성을 구현한다. 이를 Proxy로 전환하면 새로운 속성 추가를 감지하지 못하는 등의 제약사항을 제거할 수 있고, 성능 향상을 꾀할 수 있다.

하지만 Proxy는 오래된 브라우저에서는 완전하게 pollyfill 될 수 없는 기능이며, 이를 활용하기 위해서는 새로운 메이저 버전의 브라우저 지원 범위를 조정해야만 한다는 것을 인지하고 있다.

구조적 문제 해결

Vue 2를 유지보수하는 과정에서 기존 구조의 한계로 인해 해결하기 어려운 문제들이 쌓여왔다. 예를 들어 템플릿 컴파일러는 적절한 source-map을 지원하기가 매우 어렵게 구현되어 있다. 또한, Vue 2는 기술적으로 DOM 이외의 플랫폼을 대상으로 고수준의 렌더러 역할을 할 수 있지만, 이를 가능하게 하기 위해 수많은 fork와 중복된 코드를 만들어 냈다. 현재 코드베이스에서 이러한 문제들을 해결하려면 거의 재구현 수준의 매우 위험한 리팩토링이 필요하다.

이와 동시에, 우리는 다양한 모듈들의 내부와 어디에도 속하지 않는 코드들을 암묵적 결합의 형태로 구현하여 기술 부채를 쌓아왔다. 이로 인해 코드의 일부를 독립적으로 이해하기가 어려워졌으며, contributor들이 사소한 변경을 하는 것에도 확신이 없다는 것을 알게 되었다. 재구현을 하게 되면 이러한 점들을 염두에 두고 코드 구성을 다시 생각할 수 있다.


초기 프로토타이핑 단계

우리는 상기 문제들에 대한 솔루션을 검증하는 예비 목표를 세우고 2018년 말에 Vue 3의 프로토타이핑을 시작했다. 이 단계에서 우리는 추가 개발을 위한 견고한 기반을 닦는 것에 중점을 두었다.

TypeScript로의 전환

Vue 2는 일반 ES로 구현되었다. 프로토타이핑 단계 직후, 우리는 타입 시스템이 이 정도 규모의 프로젝트에 큰 도움이 될 것임을 깨달았다. 타입 체크는 리팩토링 중에 예기치 않은 버그가 발생할 가능성을 크게 줄여주고, contributor들이 확신을 갖고 코드에 기여할 수 있게 해준다. 우리는 ES에 추가될 가능성을 보고 Facebook의 Flow를 채택했었다. Flow는 어느 정도 도움이 되었지만 우리가 원하는 수준만큼은 아니었는데, 특히 잦은 변화로 인해 계속 대응하며 업그레이드하는 것이 어려웠다. IDE에 대한 지원도 TypeScript와 비교하면 만족스럽지 않았다.

또한 Vue와 TypeScript를 함께 사용하는 사용자가 점점 증가하는 것을 알게 되었다. 이러한 케이스를 지원하기 위해서는 Flow로 구현된 코드와 별도로 TypeScript 선언을 작성하고 유지해야만 했다. TypeScript로 전환하면 이러한 고민이 필요없으므로 유지관리 부담을 줄일 수 있다.

내부 모듈들의 Decoupling

우리는 프레임워크가 각각의 개별 API, 타입 정의, 테스트 등이 내부 패키지로 구성되는 monorepo 설정을 채택했다. 각 모듈들 간의 의존성이 명확해져 개발자들이 더 이해하기 쉽게 만들고자 했다. 이러한 구성의 핵심은 프로젝트의 contribution 장벽을 낮춰 장기적인 유지보수성을 향상시키고자 함이다.

RFC 프로세스 설정

2018년 말에 새로운 반응형 시스템과 가상 DOM 렌더러로 작동하는 프로토타입을 가지게 되었다. 우리는 우리가 원했던 내부 아키텍처 개선을 검증했지만 사용자 API 변경에 대한 대략적인 초안만 작성되어 있었고, 이를 구체적인 설계로 바꿀 때였다.

우리는 이것을 일찍, 신중하게 해야 한다는 것을 알고 있었다. Vue가 널리 사용되고 있다는 것은 곧 이러한 큰 변화가 사용자의 막대한 마이그레이션 비용을 유발하고 잠재적인 생태계 파편화로 이어질 수 있음을 의미하기 때문이다. 하여 변경사항에 대한 피드백을 사용자로부터 받을 수 있도록 2019년 초에 RFC 프로세스를 적용했다. 각 RFC는 템플릿을 따르며 이는 동기, 설계 세부사항, 장단점, 채택 전략에 중점을 둔 섹션으로 구성되어 있다. GitHub 레포에서 PR을 통해 프로세스가 진행되기 때문에 유기적으로 논의가 진행된다.

우리가 잠재적 변화의 모든 측면을 충분히 고려할 수 있게끔 하고, 커뮤니티가 설계 프로세스에 참여하도록 하여 충분히 검토된 기능을 요청하게 한다는 점에서 RFC 프로세스는 사고방식의 도구로써 매우 유용하다는 것이 입증되었다.


더 빠르게, 더 작게

성능은 프론트엔드 프레임워크에 필수적이다. Vue 2는 경쟁력있는 성능을 자랑하지만, 재구현을 통해 새로운 렌더링 전략을 실험함으로써 훨씬 더 발전할 수 있는 여지를 갖게 되었다.

가상 DOM의 병목 현상 극복

Vue는 매우 독특한 렌더링 전략을 갖고 있다. HTML과 비슷한 템플릿을 제공하고, 이는 가상 DOM 트리를 반환하는 렌더 함수를 통해 컴파일된다. Vue는 두 개의 가상 DOM 트리를 재귀적으로 탐색하여 모든 노드의 모든 속성을 비교 후 실제 DOM의 어느 부분을 업데이트할 지 파악한다. 이러한 brute-force 식 알고리즘은 최신 JavaScript 엔진에서 수행하는 최적화 덕분에 대체로 매우 빠르지만, 여전히 불필요한 CPU 작업을 많이 필요로 한다. 특히 거대한 정적 컨텐츠 내부에 약간의 동적 바인딩이 포함되어 있는 템플릿의 경우 위와 같이 가상 DOM 트리 전체를 훑는 것은 매우 비효율적이다.

다행스럽게도 템플릿 컴파일 단계에서 템플릿에 대한 정적 분석을 수행하여 동적인 부분에 대한 정보를 추출할 수 있다. Vue 2에서는 정적 서브트리를 건너 뛰는 방식으로 어느 정도까지는 이를 수행했지만 컴파일러 아키텍처가 지나치게 단순한 관계로 이 이상의 최적화를 구현하기 어려웠다. Vue 3에서는 적절한 AST 변환을 사용해 컴파일러를 재구현했고, 이를 통해 플러그인 형식으로 컴파일 타임 최적화를 구현할 수 있다.

또한 새로운 아키텍처로 구성하게 된김에 우리는 가능한 많은 오버헤드를 제거하는 렌더링 전략을 찾고 싶었다. 한 가지 옵션은 가상 DOM을 버리고 명령형 DOM 작업을 직접 생성하는 것이었는데, 이렇게 하면 가상 DOM 렌더링 기능을 직접 작성할 수 있는 기능이 제거될 것이고, 이는 고급 사용자 및 라이브러리 제작자에게 매우 유용한 기능을 없애버리는 꼴이었다. Vue와 그 생태계의 거대한 변화까지 불러오는 것은 덤이다.

다음으로 생각한 것은 DOM 업데이트 시 가장 많은 오버헤드를 유발하는 불필요한 가상 DOM 트리 탐색과 속성 비교를 제거하는 것이었다. 이를 위해서는 컴파일러와 런타임이 유기적으로 동작해야 하는데, 컴파일러가 템플릿을 분석하고 최적화 힌트와 함께 코드를 생성하면 런타임은 힌트를 보고 가능한 빠른 경로를 택해야 한다. 이 과정은 다음 세 가지의 주요 최적화 과정을 통해 이루어진다.

  • 첫째로, 우리는 트리 레벨에서 노드 구조를 동적으로 변경하는 directive(v-if, v-for 등)가 없는 경우, 노드 구조가 완전히 정적으로 유지된다는 것을 알게되었다. 템플릿을 이러한 directive로 구분하여 중첩된 블록으로 나누면 각 블록 내의 노드 구조는 완전히 정적으로 된다. 때문에 블록 내의 노드를 업데이트할 때 더이상 트리를 재귀적으로 탐색할 필요가 없으며, 블록 내의 동적 바인딩은 flat array로 추적할 수 있게 되었다. 이러한 최적화는 트리 탐색의 양을 크게 줄여 주어 대부분의 가상 DOM 오버헤드를 피할 수 있다.
  • 두번째로, 컴파일러는 정적 노드, 서브트리, 데이터 객체 등을 적극적으로 감지하여 렌더 함수 바깥으로 hoist 한다. 이렇게 하면 각 렌더링마다 객체들을 다시 생성하지 않아도 되므로 메모리 효율을 크게 높일 수 있고, 가비지 콜렉션의 빈도가 줄어들게 된다.
  • 마지막으로, DOM element 레벨에서 컴파일러는 수행해야 하는 업데이트 유형에 따라 동적 바인딩이 있는 각 element에 대한 최적화 플래그를 생성한다. 예를 들어 동적 클래스 바인딩과 여러 개의 정적 속성을 가진 element는 클래스 검사만 필요하다는 플래그를 받는 식이다. 런타임은 이러한 힌트를 보고 더 빠른 업데이트 경로를 판단하게 된다.

벤치마크 시 Vue 3의 CPU Time(브라우저의 DOM 조작을 제외한 JavaScript 연산에 소요된 시간)은 Vue 2의 1/10도 안 걸릴 때가 종종 있을만큼 이러한 기술들을 통해 렌더 성능이 크게 개선되었음을 확인했다.

번들 크기 최소화

프레임워크의 사이즈도 성능에 영향을 미친다. 이는 웹 애플리케이션에 국한된 문제인데, 브라우저가 필요한 asset들을 전부 다운로드 받고 JavaScript를 파싱할 때까지 애플리케이션이 반응하지 않기 때문이다. 특히 SPA의 경우 이러한 경향이 더욱 두드러진다. Vue는 Vue 2의 gzip으로 압축된 런타임 크기가 23KB일만큼 상대적으로 경량화된 크기라고 볼 수 있지만, 우리는 다음과 같은 문제들을 발견했다.

  • 첫째로, 모든 사용자가 Vue의 모든 기능들을 사용하는 것은 아니다. 예를 들어 transition 기능을 사용하지 않는 애플리케이션도 의도와 상관없이 무조건 transition 관련 코드의 다운로드와 파싱을 수행해야 한다.
  • 두번째는 우리가 기능을 추가할 때마다 프레임워크의 크기 역시 계속 커진다는 것이다. 우리가 새로운 기능을 추가하려 할 때마다 번들 크기를 고려하지 않을 수 없고, 결과적으로 대다수의 사용자가 사용할만한 기능만 포함하게 되는 경향이 있다.

결국 Tree-shaking이라고 부르는 사용하지 않는 코드를 빌드 타임에 제거할 수 있는 기능을 지원해야 하며, 사용자가 포함한 기능만 번들에 포함되어야 한다. 이렇게 되면 우리가 유용하다고 생각하는 기능들도 부담없이 추가하여 제공할 수 있게 된다.

Vue 3에서는 대부분의 전역 API와 내부 헬퍼들을 ES 모듈로 변경하여 번들러가 사용하지 않는 모듈과 관련된 종속성 및 코드들을 제거할 수 있도록 지원한다. 템플릿 컴파일러도 실제로 템플릿에서 사용되는 기능만 import 한 다음 코드를 생성하여 번들러가 tree-shaking을 할 수 있도록 구현했다.

하지만 일부는 Vue를 사용하는 모든 유형의 애플리케이션에 필수적이기 때문에 tree-shaking을 지원할 수 없다. 우리는 이런 필수불가결한 부분의 크기를 baseline size라고 부르는데, Vue 3의 baseline size는 수많은 기능이 추가되었음에도 Vue 2의 절반보다도 작은 10KB 정도이다.


대규모 애플리케이션 지원에 대한 필요성

우리는 Vue가 대규모 애플리케이션을 다루기 위해 더 개선되길 원했다. 우리가 처음 Vue를 설계할 때는 진입장벽을 낮추고 더 배우기 쉽게 하는데 초점을 맞췄었다. 하지만 Vue가 점점 널리 쓰이면서 수백 개의 모듈을 포함하고, 수십명의 개발자가 관리하는 프로젝트의 요구사항들을 알게 되었다. 이정도 규모의 프로젝트는 TypeScript와 같은 타입 시스템과 재사용 가능한 코드를 깔끔하게 관리하는 기능이 중요하며 이러한 측면에서 Vue는 썩 좋지는 않았다.

Vue 3 설계 초기 단계에는 클래스를 사용한 컴포넌트 구현을 빌트인으로 지원하여 Vue에서의 TypeScript 사용성을 개선하려고 시도했다. 문제는 이를 구현하기 위해 필요한 클래스 필드나 데코레이터 등이 여전히 proposal 상태라 JavaScript에 공식적으로 포함되기 전에 어떻게 변할지 장담할 수 없다는 것이었다. 클래스 API의 추가는 다소 개선된 TypeScript와의 통합 지원 외의 어떠한 것도 제공하지 않았기 때문에, 이러한 불확실성과 구현의 복잡도를 고려하면 과연 이 방법이 맞는 것인지 의문을 가질 수 밖에 없었다.

결국 우리는 다른 방법을 찾기로 결정했다. React의 Hooks에서 영감을 받아 하위 레벨의 반응성과 컴포넌트의 lifecycle API를 밖으로 드러내어 좀 더 자유롭게 컴포넌트 구현이 가능하도록 Composition API라 부르는 방법을 생각해냈다. 이는 길게 option API를 나열하여 컴포넌트를 정의하는 대신, 사용자가 마치 함수를 작성하듯이 자유롭게 상태 기반 로직을 작성하고 재사용할 수 있으며, 우수한 TypeScript 지원을 제공한다.

우리는 이 아이디어에 매우 흥분했다. 비록 composition API는 특정 문제를 해결하기 위해 설계되었지만 기술적으로는 컴포넌트를 구현할 때 사용될 수 있다. 너무 고취한 나머지 proposal의 첫번째 초안에서 조금 이른감은 있지만 향후 릴리즈에서 기존 option API를 composition API로 바꿀 수 있다고 암시했다. 이는 커뮤니티의 엄청난 반발을 불러왔고, 이로부터 장기적인 계획과 의도를 명확하게 전달하고 사용자의 요구사항을 충분히 이해해야 한다는 귀중한 교훈을 얻었다. 커뮤니티로부터 피드백을 받은 후, proposal을 완전히 재작성하여 composition API가 option API에 부가적이고 보완적인 기능이라는 점을 분명히 했다. 수정된 proposal의 반응은 훨씬 긍정적이어서 많은 건설적인 제안을 받았다.


균형 추구

백만명이 넘는 Vue 개발자 중에는 HTML/CSS에 대한 기초 지식만 있는 초보자들, jQuery에서 넘어온 전문가들, 다른 프레임워크에서 마이그레이션을 한 베테랑들, 프론트엔드 솔루션을 찾는 백엔드 엔지니어들, 대규모로 소프트웨어를 다루는 아키텍트 등 다양한 사용자들이 있다. 이러한 만큼 그에 맞는 다양한 사용 사례에 대한 대응이 필요하다. 어떤 개발자는 기존 애플리케이션에 간단한 상호작용 정도만 보수적으로 도입하고자 하는 반면, 다른 개발자는 유지보수 걱정이 덜한 일회성 프로젝트에서 다양한 기능들을 실험해볼 수도 있다. 아키텍트는 수년동안 대규모 프로젝트와 개발팀을 이끌어야 할 것이다.

Vue의 구조는 이런 다양한 trade-offs 사이에서 균형을 유지하기 위해 지속적으로 다듬어지고 있다. Vue의 슬로건인 'The progressive framework'는 이러한 과정에서 비롯되는 계층화된 API 디자인을 캡슐화함을 뜻한다. 덕분에 초보자는 CDN, HTML 기반 템플릿, 직관적인 option API 등을 통해 원활하게 학습할 수 있고, 전문가는 완전한 기능을 갖춘 CLI, 렌더 함수, compsition API 등을 통해 그들에 맞는 다양한 사례를 처리할 수 있다.

이러한 우리의 비전을 Vue 3에서 실현하기 위해 해야할 일이 아직도 많이 남아있다. 가장 중요한 것은 원활한 마이그레이션을 위해 지원 라이브러리, 문서 및 도구들을 업데이트하는 것이다. 이를 위해 향후 몇 개월동안 열심히 일할 것이며, Vue 3를 통해 사용자들이 어떤 것들을 만들어낼지 무척이나 기대된다.