각종 자바스크립트 프레임워크의 렌더링 전략을 이론과 실전 벤치마크로 비교한 2021년 연구를 바탕으로, Angular·React·Vue·Svelte·Blazor의 성능을 살펴본다. 결과적으로 Svelte가 거의 모든 시나리오에서 가장 뛰어난 성능을 보이며, 그 이유를 리액티비티·컴파일러 최적화·바인딩 기반 렌더링에서 찾는다.
온라인을 검색해 보면, 여러 자바스크립트 프레임워크의 성능을 서로 비교한다는 벤치마크가 넘쳐난다. 하지만 대개 지나치게 단순화되어 실제 시나리오를 잘 반영하지 못한다. 또 어떤 경우에는, 프레임워크가 제공하는 기능의 극히 일부분만을 다루는 경량 라이브러리와 완전한 프레임워크를 비교하는 식으로 사과와 오렌지를 견주기도 한다.
지금 나는 경력에서 다시금 새 웹 애플리케이션 개발을 시작하는 시점에 와 있고, “올바른” 자바스크립트 프레임워크를 선택해야 한다. 이번에는 자바스크립트 프레임워크 성능에 관한 학술 연구를 찾아보기로 했는데, 아쉽게도 기대만큼 많지는 않았다. 다만 Angular, React, Vue, Svelte, Blazor를 서로 비교한 한 편의 연구를 발견했다. 2021년에 이루어진 비교라서—테크 업계 시간으로는 아주 오래전—한계가 있지만, 그래도 읽어볼 가치는 충분하다고 생각한다.
요약으로 들어가기 전에, 약간 재미있었던 것을 하나 공유하고 싶다. 그 논문은 Journal of Web Engineering에 게재되었는데, 웹사이트를 방문해 보면 URL에 /index.php/가 들어가 있다. 왜 그런지는 모르겠다. 의도적인 선택일까? 아니면 의심스러운 웹 엔지니어링의 신호일까?
오늘날 웹사이트의 최대 97%가 자바스크립트를 사용하며, 그중 80% 이상은 라이브러리나 프레임워크에 의존하는 것으로 추정된다. 자바스크립트는 단일 페이지 애플리케이션(SPA)에서 UI 상태 변화를 관리하는 데 자주 사용되어, 페이지 전체를 새로 고치지 않고도 사용자 상호작용을 가능하게 한다. 물론 DOM API로 수작업할 수도 있지만, 오류가 발생하기 쉽다.
현대적인 웹 프레임워크는 DOM API를 감싸고, 자체 선언적 문법을 제공한다. 즉 애플리케이션 코드는 원하는 UI 상태만 기술하면 되고, 프레임워크가 자동으로 필요한 DOM API 호출을 생성해 브라우저의 상태를 반영한다.
DOM API를 직접 사용할 때는, UI를 갱신하기 위한 스크립트 실행량이 변경의 복잡도에 선형적으로 비례한다. 그러나 프레임워크가 DOM API 호출을 동적으로 생성하는 경우, 어떤 업데이트가 필요한지 먼저 정확히 파악해야 하므로 추가 오버헤드가 생긴다. 더불어 선택한 렌더링 전략에 따라 수행되는 DOM API 호출의 수가 크게 달라질 수 있다. 나쁜 전략을 택하면 작은 업데이트에도 눈에 띄는 지연이 발생할 수 있다. 그래서 주요 자바스크립트 프레임워크가 사용하는 전략을 비교해 보는 것이 의미가 있다.
연구에서는 Angular, React, Vue, Svelte, Blazor라는 다섯 가지 인기 프레임워크를 살펴본다. Blazor를 제외하면 모두 자바스크립트 기반이다. Blazor 애플리케이션은 C#으로 작성되며, WebAssembly로 컴파일된 .NET 런타임 위에서 실행된다. WebAssembly 모듈은 DOM에 직접 접근할 수 없기 때문에, 추가적인 자바스크립트 상호운용 계층에 의존해야 하고, 이는 추가 오버헤드를 유발할 수 있다.
| 프레임워크 | 버전 |
|---|---|
| Angular | 11.2.3 |
| React | 17.0.1 |
| Vue | 3.0.7 |
| Svelte | 3.35.0 |
| Blazor | 5.0.3 |
다섯 프레임워크 모두 MVVM(Model-View-ViewModel) 패턴의 변형을 따른다. MVVM에서 개발자는 애플리케이션의 데이터 소스를 뷰에 바인딩하는 컴포넌트를 정의하고, 데이터 변경이 UI에 자동으로 반영된다.
각 프레임워크는 애플리케이션 코드에 정의된 컴포넌트 트리의 상태와 DOM 트리의 상태를 지속적으로 동기화하려고 한다. 이는 두 가지 서로 다른 방식으로 이루어진다.
첫 번째 방식은 가상 DOM(vDOM) 기반 렌더링으로, React, Vue, Blazor가 사용한다. 이는 두 트리를 비교해 한쪽을 다른 쪽으로 변환하는 데 필요한 최소 변경 집합을 계산하는 방식이다. 일반적으로 시간 복잡도는 O(n^3)이지만, 브라우저 애플리케이션에서 보통 성립하는 가정을 두면 O(n)으로 단순화할 수 있다.
두 번째 방식은 Angular와 Svelte가 사용한다. 여기서는 DOM에 필요한 변경을 일괄 계산하는 별도의 단계가 없다. 대신 각 컴포넌트가 데이터 바인딩 값을 추적하면서 자신에게 해당하는 DOM 영역을 직접 갱신한다.
성능 관점에서 보면, 가상 DOM을 사용하면 바인딩 기반 렌더링 전략에는 없는 오버헤드가 생길 수 있다.
검토한 모든 프레임워크는 컴포넌트 트리를 순회하는 렌더 루프 안에서 DOM 업데이트를 수행한다. 이 렌더 루프의 비용은 입력 크기와 고정 비용에 좌우된다.
렌더 루프에는 두 가지 종류의 작업이 있다. 새로운 요소를 생성하는 일과 기존 요소를 업데이트하는 일이다. 요소 생성은 일반적으로 단순하며, 렌더링 전략과 무관하게 각 프레임워크에서 거의 동일한 비용이 든다.
차이가 두드러지는 부분은 기존 요소 업데이트다. 예를 들어 Angular는 항상 전체 컴포넌트 트리를 순회하므로, 트리의 아주 일부분만 업데이트가 필요해도 불필요한 작업이 많아진다. React와 Blazor는 렌더 루프를 시작한 컴포넌트의 서브트리만 순회하므로 보통 더 효율적이지만, 출력이 바뀌지 않은 자손까지 어느 정도 불필요한 처리가 발생할 수 있다. 반면 Vue와 Svelte는 출력이 바뀐 “더티” 컴포넌트만 처리한다. 이를 위해 프레임워크는 어떤 컴포넌트가 더티인지 추적해야 한다. Vue는 런타임에, Svelte는 컴파일 단계에서 이를 처리한다.
마지막으로 컴포넌트의 출력은 정적 또는 동적 콘텐츠로 분류할 수 있다. 정적 콘텐츠는 컴포넌트의 초기 렌더 이후에는 변하지 않는다. 정적 콘텐츠를 최적화할 수 있는 프레임워크는 그렇지 못한 프레임워크보다 성능상 이점을 가질 수 있다.
Angular, React, Vue, Svelte, Blazor를 대상으로 여러 벤치마크를 수행했다. 특히 입력 크기가 커질수록 프레임워크 간 성능 차이가 크게 나타났다. Svelte는 모든 벤치마크에서 말 그대로 다른 모든 프레임워크를 앞서며 명확한 승자로 떠올랐다(여담: 참고로 나는 Svelte를 직접 써 본 적은 없지만, 이 결과를 보니 이번에는 정말 써 볼까 한다).
n개의 정적 요소를 생성할 때 Svelte가 가장 빠르고, React는 대체로 가장 느린 축에 속한다:
| n | Angular (ms) | React (ms) | Vue (ms) | Svelte (ms) | Blazor (ms) |
|---|---|---|---|---|---|
| 100 | 3 | 2 | 1 | 1 | 3 |
| 500 | 9 | 9 | 3 | 2 | 8 |
| 1000 | 16 | 11 | 6 | 3 | 13 |
| 5000 | 85 | 77 | 28 | 14 | 61 |
| 10000 | 177 | 200 | 47 | 24 | 123 |
| 25000 | 844 | 956 | 95 | 63 | 371 |
| 50000 | 2520 | 3559 | 173 | 98 | 964 |
n개의 컴포넌트를 이진 트리 형태로 배치해 생성할 때도 Svelte가 가장 빠르다. 다만 이 경우 Blazor는 크게 뒤처진다:
| n | Angular (ms) | React (ms) | Vue (ms) | Svelte (ms) | Blazor (ms) |
|---|---|---|---|---|---|
| 128 | 20 | 7 | 16 | 3 | 17 |
| 512 | 75 | 32 | 53 | 10 | 59 |
| 1024 | 120 | 55 | 84 | 22 | 128 |
| 4096 | 216 | 137 | 223 | 83 | 485 |
| 8192 | 297 | 233 | 313 | 142 | 966 |
| 16384 | 469 | 394 | 485 | 233 | 1870 |
| 32768 | 774 | 733 | 897 | 482 | 3644 |
n개의 컴포넌트로 이루어진 트리에서 루트 컴포넌트를 업데이트할 때, Vue와 Svelte가 바인딩 기반 렌더링 전략의 이점을 명확히 보여준다(여담: 여기서 Blazor의 결과도 꽤 흥미롭다…):
| n | Angular (ms) | React (ms) | Vue (ms) | Svelte (ms) | Blazor (ms) |
|---|---|---|---|---|---|
| 128 | 3 | 7 | <1 | <1 | 3 |
| 512 | 12 | 23 | <1 | <1 | 3 |
| 1024 | 14 | 42 | <1 | <1 | 2 |
| 4096 | 32 | 92 | <1 | <1 | 3 |
| 8192 | 32 | 92 | <1 | <1 | 3 |
| 16384 | 43 | 211 | <1 | <1 | 2 |
| 32768 | 103 | 379 | <1 | <1 | 3 |
n개의 컴포넌트로 이루어진 컴포넌트 트리에서 리프(leaf) 컴포넌트를 업데이트할 때는, 실제로 무엇이 바뀌었는지와 무관하게 동일한 양의 작업을 수행하는 Angular만 약간 뒤처진다:
| n | Angular (ms) | React (ms) | Vue (ms) | Svelte (ms) | Blazor (ms) |
|---|---|---|---|---|---|
| 128 | 3 | <1 | <1 | <1 | 1 |
| 512 | 13 | <1 | <1 | <1 | 1 |
| 1024 | 14 | 1 | <1 | <1 | 1 |
| 4096 | 33 | 4 | <1 | <1 | 3 |
| 8192 | 33 | 3 | <1 | <1 | 5 |
| 16384 | 44 | 5 | <1 | <1 | 4 |
| 32768 | 104 | 4 | <1 | <1 | 8 |
마지막으로 아래 표는 각 컴포넌트가 주로 정적 콘텐츠를 포함하는, n개의 컴포넌트로 이루어진 전체 트리를 업데이트할 때의 스크립트 실행 시간을 보여준다:
| n | Angular (ms) | React (ms) | Vue (ms) | Svelte (ms) | Blazor (ms) |
|---|---|---|---|---|---|
| 128 | 4 | 34 | 20 | 2 | 28 |
| 256 | 8 | 44 | 32 | 3 | 60 |
| 512 | 17 | 66 | 42 | 5 | 101 |
| 1024 | 27 | 101 | 72 | 10 | 250 |
| 2048 | 29 | 235 | 91 | 20 | 502 |
| 4096 | 44 | 289 | 149 | 54 | 1020 |
| 8192 | 238 | 841 | 311 | 80 | 2013 |
전반적으로, 결과는 각 렌더링 전략의 특성을 고려했을 때 예상과 부합한다.
WebAssembly 기반의 Blazor는 자바스크립트 기반 경쟁자들보다 성능이 현저히 떨어진다. 다만 이 벤치마크만으로는 그것이 Blazor 자체의 문제인지, 아니면 이 목적에 WebAssembly를 사용하는 것의 근본적 한계 때문인지는 단정하기 어렵다.
한편 Svelte는 다음 세 가지 핵심 특성을 통해 성능 향상에 가장 크게 기여하는 것으로 보인다: