잘게 나누어 생각하기: SolidJS는 어떻게 그렇게 높은 성능을 보여주는가?

Jung Wook ParkJung Wook Park
11 min read

원문: Ryan Carniato, "Thinking Granular: How is SolidJS so Performant?"

최근 들어 SolidJS가 자신의 최애 라이브러리보다 훨씬 빠른 이유에 대한 질문을 많이 받았습니다. Solid에 대한 기본 지식을 가지고 있고 각종 미사여구를 들어봤다 해도 Solid가 어떻게 다른지는 이해하지 못하죠. 저의 최선을 다해 설명해 보겠습니다. 때때로 많이 어려울 수도 있습니다. 잠깐씩 쉬어가며 읽으셔도 됩니다. 내용이 꽤 많거든요.

사람들은 반응성과 가상 DOM의 비용에 대해 많이 이야기하지만 그들이 사용하는 라이브러리들은 모두 같은 상황에 빠져있습니다. 여전히 하향식으로 차이점을 비교하는 템플릿 렌더링이든 항상 다를 것 없는 컴포넌트 시스템에 사용되는 반응형 라이브러리든 간에요. 우리가 여전히 동일한 성능을 유지하고 있다는 것이 그렇게까지 놀랄 일인가요?

브라우저 성능이 더 이상 높아지지 못하는 데에는 명확한 이유가 있습니다. DOM 때문이죠. 우리의 가장 궁극적인 한계입니다. 무조건 복종해야 하는 물리법칙이죠. 가장 영리한 알고리즘을 사용했는데도 눈에 보이지 않을 정도의 성능 향상밖에 얻지 못해 결과를 어리둥절하게 바라보는 사람들을 많이 봐왔습니다. 아이러니하게도 이런 문제를 대하는 가장 좋은 방법은 대충 넘어가기입니다. 중요한 점만 신경 쓰고 다른 것들은 남겨두는 거죠.

거의 틀림없이 현재 가장 빠른 독립형 DOM 비교 알고리즘 중 하나인 udomdiff가 이런 방식으로 등장했습니다. @webreflection은 여러 학술 알고리즘들을 미세하게 조정하다 진전이 없자 지쳐서 더 빠른 DOM 비교 알고리즘을 아는 사람이 있는지 트위터에서 묻고 있었습니다. 저는 그에게 @localvoid(ivi의 저자)가 사용하고 있는 알고리즘이 대부분의 라이브러리들에서 사용되고 있으며 특정 벤치마크에 대한 여러 최적화를 보여주는 것 같다고 알려 주었습니다. 이미 이야기한 바 있지만 이는 리스트를 조작하는 가장 일반적인 방법이며 거의 모든 벤치마크에서 찾을 수 있습니다. 다음 날 아침 그는 이러한 기법들과 합쳐져 너무나도 간단한 Set 조회를 사용하는 라이브러리와 함께 돌아왔습니다. 그리고 어찌 된 영문인지 그 라이브러리는 더 작아졌는데도 동일한 성능을 보여줬습니다. 아마 좀 더 나았을 수도 있습니다.

저는 이 분야에서 같은 경험을 해봤기 때문에 이 이야기를 좋아합니다. 똑똑한 알고리즘은 아니었지만 무엇이 중요한지 이해한 다음 약간의 노력을 더한거죠.

반응형 모델

현재 Solid에서 그 알고리즘의 변형을 사용하고 있지만 아이러니하게도 이 원시값 비교 구현조차 Solid의 사전 컴파일하지 않은 접근 방식에 비하자면 JS 프레임워크 벤치마크 성능이 떨어집니다. 사실 간단한 형태의 태그된 템플릿 리터럴 라이브러리들에 대해 이야기하는 경우 Solid의 접근 방식은 lit-html, uhtml 또는 이 접근 방식을 개척한 그 어떤 라이브러리들보다도 빠릅니다. 왜일까요?

자, 적어도 몇몇 분들은 Svelte 단물을 한 잔 하시고 "답은 반응형이야"라고 말할 준비가 되어 있으실 겁니다. 사실이긴 합니다만, Svelte는 지금까지 말씀드렸던 모든 라이브러리들보다 느리기 때문에 아직 부족합니다. Vue 또한 반응형이지만 이로 인해 얻을 수 있는 성능상의 이점을 가상 DOM의 사용으로 상쇄해 버리죠. '정답은 언제나 하나'가 아니고 여러 작은 것들의 조합이지만 반응형 시스템부터 시작해 보죠.

Solid의 반응형 시스템은 React HooksVue 3 컴포지션 API 사이의 이상한 혼종처럼 보입니다. 그 둘이 있기 전부터 존재하긴 했지만 API 측면에서 Hooks의 몇 가지 요소를 빌리긴 했습니다.

const [count, setCount] = createSignal(1);

createEffect(() => {
  console.log(count()); // 1
});

setCount(2); // 2

기본은 두 가지 기본 요소로 요약됩니다. 제가 신호(Signal)라고 부르는 반응형 아톰(atom)과 그 변화를 추적하는 계산(Computation, 또는 파생)이죠. 이 경우에는 부수 효과를 만들고 있네요 (계산된 값을 저장하는 createMemo도 있습니다). 이것이 세분화된 반응성의 핵심입니다. 어떻게 동작하는지는 이전에 다룬 바 있으니 오늘은 이에 기반하여 전체 시스템을 어떻게 만들 수 있는지 알아보겠습니다.

우선 이것들은 그저 기본 요소일 뿐임을 깨달으셔야 합니다. 잠재적으로 강력하고 매우 간단한 기본 요소 말이죠. 이들을 통해 원하는 것은 뭐든 하실 수 있습니다. 다음과 같은 예를 생각해 봅시다.

import { render, diff, patch } from "v-doms-r-us";
import App from "./app"

const [state, setState] = createSignal({ name: "John" }),
  mountEl = document.getElementById("app");

let prevVDOM = [];
createEffect(() => {
  const vdom = render(<App state={state()} />);
  const patches = diff(vdom, prevVDOM);
  patch(mountEl, patches);
  prevVDOM = vdom;
});

setState({ name: "Jake" });

동일한 예제지만 이번에는 부수 효과가 가상 DOM 트리를 만들고 이전 버전과 비교하여 실제 DOM을 수정합니다. 가상 DOM 라이브러리가 어떻게 동작하는지에 대한 기본적인 내용이죠. 이전 코드의 count처럼 이펙트 안에서 단순히 상태에 접근하기만 해도 상태가 갱신될 때마다 이펙트가 다시 실행됩니다.

따라서 반응성이란 문제를 모델링하는 방법이지 어떤 특정한 해결책이 아닙니다. 비교하는 방식이 유리하다면 그저 하면 될 일입니다. 서로 독립적으로 갱신되는 천 개의 독립 셀을 만드는 게 유리하다면 우리도 그렇게 할 수 있겠죠.

잘게 나누어 생각하기

아마도 가장 먼저 떠오르는 것은 우리가 무엇을 갱신했고 무엇이 달라졌는지에 대한 단일 계산만 가지고 갱신 시에 트리를 비교하면 어떨까 하는 것입니다. 결코 새로운 아이디어는 아니죠. 트레이드오프와 씨름하기 위해서 생각해 볼 것들이 좀 있습니다. DOM을 따라가며 여러 개의 구독을 만드는 것은 예를 들어 가상 DOM을 만드는 것보다 사실 더 큰 비용이 듭니다. 물론 어떤 접근 방식을 택하든 갱신 자체는 빠르겠지만 대부분의 갱신은 생성하는 것보다 비용이 덜 듭니다. 세분화를 해결한다는 것은 곧 생성 시 발생하는 불필요한 비용을 완화하는 것이라고 할 수 있습니다. 그래서 어떻게 할 수 있을까요?

1. 컴파일러를 사용하자

라이브러리들은 생성·갱신 시에 무엇을 할지 결정하는 데 상당한 시간을 소비합니다. 일반적으로 속성들을 순차적으로 탐색하여 필요한 작업을 자식들이 올바르게 수행하는 방법을 결정할 수 있도록 데이터를 파싱하죠. 컴파일러를 사용하면 순차 탐색 과정과 결정 트리를 제거하고 일어나야 하는 정확한 명령들을 단순히 적기만 하면 됩니다. 간단하지만 효과적이죠.

const HelloMessage = props => <div>Hello {props.name}</div>;

// 이렇게 변합니다
const _tmpl$ = template(`<div>Hello </div>`);
const HelloMessage = props => {
  const _el$ = _tmpl$.cloneNode(true);
  insert(_el$, () => props.name, null);
  return _el$;
};

태그된 템플릿 리터럴을 사용하는 버전의 Solid는 거의 동일한 작업을 JIT 컴파일과 함께 런타임에 수행하며 여전히 매우 빠릅니다. 하지만 HyperScript 버전은 이 작업을 한 번이라도 수행해야 한다는 오버헤드 때문에 일부 더 빠른 가상 DOM 라이브러리보다 느립니다. 반응형 라이브러리로 컴파일하지 않는 경우 구독을 구축하지 않기 때문에 똑같이 순차 탐색이 이루어질 것입니다. 생성 시에는 더 성능이 좋겠죠. 가상 DOM 같은 하향식 방식은 일반적으로 컴파일을 신경 쓰지 않아도 됩니다. 지속적으로 가상 DOM을 재생성하므로 어차피 갱신 시 생성 경로를 실행해야 하기 때문이죠. 메모이제이션을 통해 더 많은 이점을 얻을 수도 있습니다.

2. DOM 노드를 복제하자

맞습니다. 태그가 지정되지 않은 템플릿 라이브러리 중에는 놀랍게도 이 작업을 수행하는 라이브러리가 거의 없습니다. 뷰가 가상 DOM 같이 수많은 함수 호출로 조합되어 있다면 전체적으로 볼 기회가 없기 때문에 말이 되는 이야기죠. 더 놀라운 사실은 대부분의 컴파일된 라이브러리들도 마찬가지로 이런 식으로 동작하지 않습니다. 한 번에 하나의 요소만 만들게 되는데 이는 템플릿을 복제하는 것보다 느립니다. 템플릿은 크면 클수록 효율적이거든요. 하지만 목록이나 표 요소가 있는 경우에는 정말 좋은 이득을 볼 수 있습니다. 웹 세상에 이들이 얼마 없는 게 안타깝네요. 😄

3. 조금 덜 잘게 나누자

뭐라고요? 덜 잘게요? 그럼요. 갱신할 때 가장 비용이 많이 드는 곳은 어디일까요? 중첩되는 곳입니다. 목록 끝까지 재조정하면서 불필요한 작업이 수행되겠죠. 이제 왜 목록을 한 번에 함께 재조정해야 하는지 물으실 수도 있겠습니다. 같은 이유입니다. 물론 행을 교체할 때 해당 행만 직접 갱신하는 게 더 빠르겠죠. 그러나 순서가 중요한 갱신 일괄 처리를 해결하는 것은 그렇게 간단하지 않습니다. 아마 더 발전이 이루어질 수도 있지만 현재까지 제 경험상으로는 목록 비교가 일반적으로 더 좋습니다. 그럼에도 불구하고 항상 이러고 싶지는 않을 겁니다.

그렇다면 생성할 때 가장 비용이 많이 드는 곳은 어디일까요? 그 많은 계산을 생성하는 곳이겠죠. 만약 모든 속성을 처리하는 계산을 각 템플릿마다 하나씩 만들어 소형 차이점으로 취급하고 삽입을 위한 계산들은 별도로 생성하면 어떨까요? 속성에 할당하기 위한 몇 개의 값을 변경하는 비용은 매우 작지만 목록에서 한 행당 서너 개라는 상당한 양의 계산을 절약하게 되기 때문에 좋은 균형입니다. 삽입을 독립적으로 감싸 갱신 시에 불필요한 작업을 하지 않을 수 있게 되죠.

4. 계산을 덜 하자

물론입니다. 구체적으로는 개발자가 어떻게 계산을 덜 하게 만드는 방법을 의미하겠죠. 이는 파생될 수 있는 모든 것들은 파생되어야만 한다는 반응형 정신을 받아들이는 것부터 시작합니다. 제 첫 번째 예제보다 복잡해야 할 이유가 없습니다. 아마 이전에 세분화된 반응성에 대해 배우실 때 이런 형태의 예제를 보신 적 있으실 겁니다.

const [user, setUser] = createState({ firstName: "Jo", lastName: "Momma" });
const fullName = createMemo(() => `${user.firstName} ${user.lastName}`);

return <div>Hello {fullName}</div>;

놀라워라. fullName을 끌어냈고 이는 firstName이나 lastName이 갱신될 때마다 갱신될 것입니다. 전부 자동이며 강력하죠. 여러분이 보셨던 버전에서는 이를 computed라고 부르셨을 수도, $: 레이블을 사용하길 원했을 수도 있습니다. 저 계산을 생성할 만한 가치가 있는지 자문해 보신 적이 한 번이라도 있으신가요? 만약 그냥 createMemo를 제거하면 어떻게 될까요?

const [user, setUser] = createState({ firstName: "Jo", lastName: "Momma" });
const fullName = () => `${user.firstName} ${user.lastName}`;

return <div>Hello {fullName}</div>;

잘 맞추셨습니다. 같은 동작을 하는데 계산이 하나 줄었네요. 이 계산은 firstName이나 lastName이 바뀌지 않는 한 fullName 문자열을 다시 만들지 않고 다른 종속성이 있는 또 다른 계산 내 어딘가에서 사용되지 않는 한 다시 실행되지 않습니다. 이래도 이 문자열을 만드는 데 비용이 많이 든다고 생각하시나요? 그렇지 않습니다.

따라서 Solid에 대해 기억해야 할 핵심은 여러분이 직접 신호나 계산을 바인딩할 필요가 없다는 점입니다. 해당 함수의 어느 시점이든 신호나 상태 접근을 감싸는 한 변화를 추적할 수 있습니다. 값을 캐싱하려는 게 아닌 이상 중간에 많은 계산이 필요하지 않다는 말입니다. state.valueboxed.get에 대한 추적은 끊기지 않습니다. 신호에 대해 직접 함수를 호출하든, 프록시 뒤에 가려져 있든, 여섯 단계의 함수 변환에 감싸져 있든 항상 같습니다.

5. 생성에 대한 반응성을 최적화하자

저는 다양한 반응형 라이브러리를 연구했는데 생성 과정에서 발생하는 이들의 병목 현상의 가장 큰 원인은 구독 관리에 사용하는 자료 구조에서 비롯되었습니다. 신호들은 갱신될 때 알릴 수 있도록 구독자 목록을 가지고 있습니다. 문제는 계산이 각 실행마다 구독을 재설정하는 방식이 관찰되고 있는 신호에서 자신을 제거하는 과정을 필요로 한다는 점입니다. 이는 양쪽 모두가 목록을 유지하고 있어야 한다는 의미입니다. 순차 탐색과 갱신이 이루어지는 신호 측에서는 이러한 동작이 매우 간단하지만 계산 측에서는 자신이 제거되었는지 조회할 필요가 있습니다. 비슷하게 중복 구독을 방지하기 위해 신호에 접근하는 경우에도 항상 조회해야 합니다. 과거의 순진한 접근 방법은 심각하게 느린 indexOf 탐색과 요소 삭제를 위해 splice를 사용하는 배열을 활용하였습니다. 최근에는 집합을 사용하는 라이브러리를 봤습니다. 이는 일반적으로 더 좋지만 집합은 생성 시간에 비용이 많이 듭니다. 꽤 흥미로운 해결 방법으로는 양쪽에 배열을 두 개씩 두어 하나는 아이템을 저장하고, 다른 하나는 자신의 짝의 역인덱스를 가지도록 한 후 초기화하지 않는 형태였습니다. 필요할 때만 생성하고요. indexOf 조회를 피하고 splice 하는 대신 제거된 인덱스의 노드를 목록의 마지막 아이템으로 바꿀 수 있습니다. 푸시·풀 평가 및 실행 클록 개념 덕분에 갱신 순서가 계속 보장됩니다. 이를 통해 미숙한 메모리 할당을 방지하고 초기 생성 시 긴 검색을 제거할 수 있습니다.

반응형 컴포넌트

우리 모두는 컴포넌트의 모듈 방식에서 오는 융통성을 사랑하게 되었습니다. 하지만 모든 컴포넌트가 똑같지는 않죠. 가상 DOM 라이브러리에서 컴포넌트는 일종의 가상 DOM 노드에 대한 추상화에 불과합니다. 자신의 트리에 대한 조상 역할을 할 수 있지만 궁극적으로는 자료 구조의 연결 고리죠. 반응형 라이브러리에서는 살짝 다른 역할을 합니다.

관찰자 패턴(과 이를 사용하는 라이브러리들)의 고전적인 문제는 더 이상 필요 없는 구독을 처리하는 법이었습니다. 관찰되는 요소가 계산(관찰자)보다 오래 살아있는 경우에는 관찰되는 요소가 관찰자에 대한 구독 목록에 참조를 계속 보유하게 되며 갱신 시 호출을 시도하게 됩니다. 이를 해결하는 한 가지 방법은 컴포넌트를 사용하여 전체 사이클을 관리하는 것입니다. 컴포넌트는 생명주기를 관리하기 위해 정의된 경계를 제공하므로 앞서 언급했듯이 좀 덜 잘게 나누어도 크게 타격을 받지는 않습니다. Svelte는 이 접근 방식을 사용하며 한 발 더 나아가 아예 구독 목록을 유지하지 않습니다. 모든 갱신이 생성된 코드의 일부만 갱신하도록 트리거하죠.

그런데 여기서 문제가 있습니다. 반응성의 생명주기는 완전히 지역적으로 국한됩니다. 값을 반응적으로 외부에 내보내는 것은 어떻게 해야 할까요? 계산을 통한 동기화입니다. 계산 내에 감싸기 위해서 처음부터 다시 값들을 찾아 나가야 하는 거죠. 반응형 라이브러리에서는 매우 일반적인 방법이고 같은 작업을 수행하는 가상 DOM 방법보다 한없이 비용이 많이 듭니다. 이 방법은 항상 성능 한계에 부딪힐 겁니다. 그러니 이 방법은 "버려야"겠죠.

반응형 그래프

이것만 있으면 됩니다. 여기에 올라타 보는 건 어떨까요? 이 그래프는 구독을 통해 연결된 신호와 계산으로 구성되어 있습니다. 신호는 여러 개의 계산을 가질 수 있고 계산은 여러 개의 신호를 구독할 수 있죠. createMemo와 같은 몇몇 계산들은 그 자체를 구독할 수도 있습니다. 지금까지는 모든 노드가 연결되어 있다는 보장이 없기 때문에 그래프라고 하면 틀린 용어가 되겠네요. 그저 이렇게 생긴 반응하는 노드와 계산의 집단을 가질 뿐입니다.

Alt Text

그런데 이것은 어떻게 조합될 수 있을까요? 동적으로 변하는 것이 없다면 더 이상 할 이야기가 없을 테죠. 그러나 조건부 렌더링 또는 반복이 있으면 아마 이렇게 하실 겁니다.

createEffect(() => show() && insert(parentEl, <Component />))

가장 먼저 주목해야 할 것은 컴포넌트가 다른 계산 하에서 생성되고 있다는 점입니다. 그 아래에서 자체 계산을 만들 것이고요. 이는 반응형 콘텍스트를 스택에 푸시하고 당장 일어나야 하는 계산만 추적하기 때문에 동작합니다. 이러한 중첩은 뷰 코드 전체에서 발생합니다. 사실 최상위 레벨 계산 이외의 모든 것들이 다른 계산 하에서 생성됩니다. 반응형에 대한 기본 지식을 떠올리면 알 수 있듯이 계산은 다시 평가될 때마다 구독을 해제하고 다시 실행합니다. 고립된 계산들은 자기 자신을 해제할 수 없다는 사실 또한 알고 있죠. 해결책은 계산들이 자신의 부모 계산에 등록되도록 하고 부모가 다시 평가될 때마다 구독하는 것처럼 처리하는 것입니다. 따라서 최상위 레벨을 루트 계산 (추적하지 않는 비활성 상태)으로 감싸면 새로운 방식을 도입하지 않고도 반응형 시스템 전체에 대해 필요 없는 것들을 자동으로 처분할 수 있게 됩니다.

컴포넌트?

보시다시피 생명주기를 관리하기 위해 컴포넌트가 뭔가 해야 할 일은 없습니다. 컴포넌트는 자신이 가지고 있는 계산이 존재하는 한 항상 존재하므로 계산을 처분하는 주기에 맞추는 것이 별도로 방법을 가지고 있는 것만큼이나 효과적입니다. Solid에서는 어떤 계산이든 이벤트 핸들러를 해제하고 싶거나, 타이머를 멈추거나, 비동기 요청을 취소하고 싶을 때 onCleanup 메서드를 등록합니다. 초기 렌더링이든 반응형으로 트리거된 어떤 갱신이든 결국 계산 내에서 실행되기 때문에 세분화 정리가 필요한 곳이라면 어디든 이러한 메서드들을 위치시킬 수 있습니다. 요약하자면 Solid에서 컴포넌트는 그냥 함수 호출입니다.

만약 컴포넌트가 그냥 함수 호출이라면 자체 상태를 어떻게 유지할까요? 함수들이 하는 것과 같은 방식입니다. 클로저죠. 단일 컴포넌트 함수의 클로저는 아니고 각 계산 래퍼의 클로저입니다. 각 createEffect나 JSX 바인딩 말입니다. 런타임에 Solid는 컴포넌트 개념이 없어집니다. 알고 보면 이는 믿을 수 없을 정도로 가볍고 효율적입니다. 반응형 노드를 설정하는 데 드는 비용 이외에 다른 오버헤드는 들지 않거든요.

다른 고려 사항이라고는 바인딩할 대상이 없는 경우 반응형 prop들을 어떻게 처리하느냐 정도밖에 없습니다. 대답도 간단한데요. #4에서처럼 함수로 감싸면 됩니다. 컴파일러는 prop이 동적으로 변한다는 것을 볼 수 있고 간단한 객체 getter를 사용하여 컴포넌트가 사용할 수 있는 통합된 props 객체 API를 제공할 수 있습니다. 신호가 원래 어디서부터 오는 건지, 어떻게 렌더링 트리를 따라 전해져 내려오는지는 별로 상관없습니다. DOM을 갱신하거나 사용자 계산의 일부로 사용되는 가장 마지막 부분에서만 계산이 필요합니다. 계산을 소비할 때 의존성 접근이 필요하기 때문에 자식을 포함한 모든 prop들은 지연되어 평가됩니다.

렌더링 트리가 동작을 조합하는 동안 가장 마지막에 있는 노드들이 접근을 제어하기 때문에 이는 제어의 역전입니다. 조합을 위한 매우 강력한 패턴이죠. 또한 중개자가 없기 때문에 믿을 수 없을 정도로 효율적입니다. 갱신 시 원하는 정도의 세분화를 유지하면서 구독 그래프를 효과적으로 평평하게 만들 수 있죠.

결론

따라서 요약하자면 SolidJS의 성능은 컴파일을 통해 적절히 조절된 크기의 세분화, 가장 효과적인 DOM 생성 방법, 지역 최적화에 국한되지 않고 생성에 최적화된 반응형 시스템, 그리고 불필요한 반응형 래퍼를 필요로 하지 않는 API에서 옵니다. 하지만 여러분이 생각해 주셨으면 하는 점은 이들 중 얼마나 많은 것들이 구현 상세가 아니라 설계적인 내용이냐 하는 것입니다. 꽤 많은 것들이 그렇죠. 대부분의 성능이 뛰어나지만 가상 DOM을 사용하지 않는 라이브러리들은 이러한 작업들의 일부분을 하지만 전부 다 하지는 않습니다. 그러기 쉽지 않을 것이고요. 예를 들어 React의 React Fiber 전환은 다른 가상 DOM 라이브러리들이 따라 하기 쉽지 않습니다. 지금 작성된 방식의 Svelte가 프레임워크와 함께 컴포넌트를 사라지게 할 수 있을까요? 아마 그렇지 않겠죠. lit-html이 중첩된 갱신을 반응형 방식을 이용해 효과적으로 처리할 수 있을까요? 어려운 일일 것입니다.

내용이 참 많네요. 비밀을 많이 공개한 것 같습니다. 솔직히 말하자면 이미 소스 코드에 다 있는 내용입니다. 저는 여전히 매일 무언가를 배우고 있고 계속 발전할 거라고 기대합니다. 이 모든 의사 결정은 트레이드오프가 있습니다. 하지만 DOM을 렌더링하는 가장 효과적인 방법이라고 제가 생각하는 것을 모아 정리했고, 이게 바로 그 결과입니다.

3
Subscribe to my newsletter

Read articles from Jung Wook Park directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jung Wook Park
Jung Wook Park

Code Tinker. Interested in user interface software development. Trying to think functionally.