제가 React에 익숙해질 무렵, 주변에서 React의 대안을 찾는 움직임이 보이기 시작했습니다. Angular, Svelte, Vue, Remix... 정말 다양한 선택지가 있었고, 그중 제 눈길을 끈 것은 SolidJS였습니다.
오늘은 SolidJS에 대해서 그리고 직접 사용해보면서 느낀 점을 공유해보려고 합니다.
SolidJS에 대하여
2021년 1.0을 릴리즈한 프론트엔드 라이브러리로 앞서 말했듯이 React와 문법이 비슷하지만 가상돔(Virtual DOM)을 사용하지 않으면서 세밀한 반응성(Fine-grained Reactivity)을 통한 빠른 성능을 강조합니다.
React는 변경사항을 DOM에 바로 적용하지 않고 가상돔을 거쳐 최소한의 변경만 반영하는 방식으로 성능을 확보해왔습니다. 그런데 SolidJS는 이 가상돔 자체를 사용하지 않고도 더 빠릅니다. 실제로 JS Framework Benchmark에서 꾸준히 상위권을 유지하며 이를 입증하고 있습니다.
SolidJS의 세밀한 반응성(Fine-grained Reactivity)
SolidJS의 핵심 철학은 아래와 같습니다.
컴포넌트는 한 번만 실행되고, 변경은 필요한 곳에만 정확히 전달된다.
이를 가능하게 하는 것이 세밀한 반응성(Fine-grained Reactivity) 모델입니다. 먼저 코드를 보겠습니다.
공식문서에서 제공하는 예제입니다. React를 사용해본 개발자라면 정말 익숙할 겁니다.
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount(count() + 1)}>
Count: {count()}
</button>
);
}SolidJS에서는 React의 State와 유사한 개념인 Signal이 존재합니다. 겉보기에 문법은 거의 비슷합니다.
// React
const [count, setCount] = useState(0)
// SolidJS
const [count, setCount] = createSignal(0)단, 내부 동작은 완전히 다릅니다. Signal은 값이 아닌 함수(accessor) 입니다. Reactive primitives(createMemo, createEffect)나 JSX 표현식 같은 추적 스코프 안에서 이 함수를 호출해 값을 읽는 순간, 그 스코프가 해당 Signal의 구독자로 등록됩니다.
const [count, setCount] = createSignal(0)
console.log("Count:", count());
// 추적 스코프 밖에서의 읽기 — 현재 값(0)을 반환할 뿐 구독은 등록되지 않는다.
// 따라서 이후 count가 바뀌어도 이 코드는 다시 실행되지 않는다.
return <button>{count()}</button>
// JSX의 {count()}는 컴파일 시 이펙트로 감싸지므로,
// 이 버튼의 텍스트가 count의 구독자가 된다React는 값을 어디서 읽는지 추적하지 않기 때문에 컴포넌트 전체를 재실행해서 변경 내용을 파악합니다. 반면 SolidJS는 Signal을 읽은 곳(=구독자)을 정확히 알고 있으므로, 그곳만 갱신합니다. 이것이 SolidJS가 말하는 세밀한 반응성(Fine-grained Reactivity)의 기반이 됩니다.
React와 비교
| 개념 | React | SolidJS |
|---|---|---|
| 상태 관리 | useState | createSignal, createStore |
| 이펙트 | useEffect | createEffect(* 의존성 배열이 없음) |
| 컨텍스트 | Context API | createContext |
| 값 메모이제이션 | useMemo | createMemo |
| 함수 메모이제이션 | useCallback, React.memo | 필요 없음 |
| DOM 참조 | useRef | ref |
비슷해 보이지만, 중요한 차이점이 있습니다. React에서 useCallback과 React.memo가 필요했던 이유는 컴포넌트가 재실행되기 때문입니다. 하지만 SolidJS에서는 재실행이 없으므로 관련 함수가 필요 없어집니다. createEffect에 의존성 배열이 없는 것도 같은 맥락입니다. 시그널을 읽는 순간 의존성이 자동으로 등록되기 때문에 의존성 배열도 필요 없어집니다.
Virtual DOM을 사용하지 않고 어떻게 빠를까?
Virtual DOM이 하는 일
React의 갱신 사이클은 대략 아래와 같습니다.
- 상태가 변경되면 해당 컴포넌트 함수를 다시 실행해서 새로운 Virtual DOM 트리를 만든다.
- 이전 트리와 새 트리를 비교(diffing)한다.
- 달라진 부분만 실제 DOM에 반영한다.
3번 덕분에 "전체 DOM을 다시 그리는 것"보다는 빠르지만, 1번과 2번은 매 갱신마다 발생하는 비용입니다. 결국 Virtual DOM은 "어디가 바뀌었는지 모르기 때문에, 전부 다시 계산해서 알아내는" 접근입니다.
SolidJS는 이 문제를 해결하기 위해 다음과 같이 질문합니다. 처음부터 어디가 바뀔지 알고 있다면, 비교 자체가 필요 없지 않을까?
컴파일러가 하는 일
SolidJS도 JSX를 사용하지만, 이 JSX는 Virtual DOM 객체가 아니라 실제 DOM 조작 코드로 컴파일됩니다. 앞의 Counter 예제를 Solid Playground에서 직접 컴파일해 보면 아래와 같은 출력이 나옵니다.
import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
// ① 정적 마크업은 템플릿으로 딱 한 번 생성
var _tmpl$ = /*#__PURE__*/_$template(`<button>Count: `);
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
return (() => {
var _el$ = _tmpl$(), // ① 호출할 때마다 내부에서 cloneNode로 복제
_el$2 = _el$.firstChild; // ① 동적 콘텐츠 위치("Count: " 텍스트 노드)를 미리 확보
_el$.$$click = () => setCount(count() + 1);
_$insert(_el$, count, null); // ② count 시그널을 텍스트 노드에 직접 연결
return _el$;
})();
}
// ③ Counter는 여기서 한 번 실행되는 것이 전부다
render(() => _$createComponent(Counter, {}), document.getElementById("app"));
_$delegateEvents(["click"]);위 컴파일 결과에서 확인할 수 있는 점은 세 가지입니다.
첫째, 정적 마크업과 동적 표현식이 컴파일 타임에 분리됩니다. Count: 라는 정적 부분과 {count()}라는 동적 부분을 컴파일러가 구분해서, 정적인 부분만 <template> 엘리먼트로 만들어 둡니다. 템플릿 생성은 모듈 로드 시 딱 한 번 일어나고, 이후 _tmpl$()를 호출할 때마다 내부에서 cloneNode로 복제된 DOM이 반환됩니다.(= Counter가 100개 생겨도 HTML 파싱은 1회)
둘째, 시그널과 DOM 노드가 직접 연결됩니다. _$insert(_el$, count, null)은 내부적으로 이펙트를 만들어 count 시그널을 해당 텍스트 노드에 구독시킵니다.
셋째, 컴포넌트 함수는 다시 실행되지 않습니다. Counter는 마운트 시점에 한 번 실행되어 DOM을 만들고 구독 관계를 설정하는 "셋업 함수" 역할을 합니다. 이후의 모든 갱신은 컴포넌트를 거치지 않고 시그널 → DOM 경로로 직접 흐릅니다. 그래서 SolidJS에는 "리렌더링"이라는 개념 자체가 없습니다.
세 가지가 각각 React 갱신 사이클의 비용에 하나씩 대응하며 컴포넌트 재실행(1번)은 셋업 1회로, diff(2번)는 시그널의 직접 통지로, 반영(3번)은 미리 확보해 둔 주소에 대한 최소 연산으로 대체합니다.
| 구분 | React (Virtual DOM) | SolidJS (Fine-grained) |
|---|---|---|
| 변경 감지 | 컴포넌트 재실행 후 트리 diff | 시그널이 구독자에게 직접 통지 |
| 갱신 단위 | 컴포넌트(및 하위 트리) | 개별 DOM 바인딩 |
| 컴포넌트 실행 | 상태 변경마다 재실행 | 최초 1회 |
직접 써보면서 헷갈렸던 부분
View Transition API를 공부해볼 겸, SolidJS를 사용하여 간단한 쇼핑몰 페이지를 구현해보았습니다. React와 문법이 비슷해서 금방 적응할 수 있을 줄 알았는데, 생각보다 어려운 점이 많았습니다.
1. 구조 분해는 반응성을 끊는다
처음 컴포넌트를 작성하고 React에서 자주 그러듯 props를 구조분해해서 사용했는데 props가 변경되어도 상태값이 변하지 않았습니다.
function Button({ type, children }: ButtonProps) {
return <button class={clsx("button", type)}>{children}</button>;
}위와 같이 작성할 경우, props 값이 변경되어도 추적이 되지 않습니다.
SolidJS에서는 props를 구조분해하는 것을 권장하지 않으며, 필요하다면 props에 접근하는 코드를 함수로 감싸서 읽는 시점을 지연시킵니다.
function Button(props: ButtonProps) {
// ❌ const { type } = props; — 구조분해 시점 값으로 고정, 반응성 손상
// ❌ const type = props.type; — 마찬가지. 컴포넌트 본문은 추적 스코프 밖
const type = () => props.type; // ✅ 함수로 감싸서 최신값 유지
return <button class={clsx("button", type())}>{props.children}</button>;
// ^^^^^^^ JSX 표현식(추적 스코프) 안에서 호출
}분리가 꼭 필요하다면 splitProps를 사용하여 반응성을 유지한 채 여러 세트로 분리할 수 있습니다.
interface ChildAProps { name: string }
interface ChildBProps { age: number }
interface ParentProps extends ChildAProps, ChildBProps { class?: string }
function Parent(props: ParentProps) {
const [aProps, bProps, restProps] = splitProps(props, ["name"], ["age"]);
return (
<div class={restProps.class}>
<ChildA {...aProps} />
<ChildB {...bProps} />
</div>
);
}2. 비동기 데이터는 Show와 함께 사용하기
createResource를 사용해 배너 API를 호출하고 응답값을 띄우는 코드를 작성했습니다. 처음엔 아래와 같이 작성했는데, API 응답을 받은 이후에도 화면이 렌더링되지 않는 문제가 있었습니다.
const [banner] = useMainBanner(); // useMainBanner return createResource()
<Banner {...banner()!} />처음에는 "스프레드가 banner()를 한 번만 실행해서 그런가?"라고 생각했지만, 컴파일 결과를 보니 그게 아니었습니다.
_$createComponent(Banner, _$mergeProps(banner));banner()의 결과가 아니라 accessor 자체가 mergeProps로 전달됩니다. 즉 스프레드도 반응성을 유지하고, Banner 내부에서 props.title처럼 추적 스코프 안에서 읽는 값은 resource가 resolve된 뒤 정상적으로 갱신됩니다.
진짜 문제는 응답이 오기 전까지 Banner의 모든 props가 undefined라는 것입니다. Banner는 마운트 시점에 한 번 실행되는데, 이때 빈 props 상태로 실행됩니다. 이때 Banner 본문에서 미리 읽어두는 코드가 있다면 undefined로 고정되고, 반응성이 깨지게 됩니다.
그래서 비동기 데이터는 Show와 함께 쓰는 것이 관용적인 패턴입니다.
<Show when={banner()}>
{(data) => <Banner {...data()} />}
</Show>Show의 when prop 안에서 banner()를 호출하므로 Show가 이 resource를 구독해 변경을 감지하고, 데이터가 준비된 뒤에야 내부의 Banner가 셋업됩니다. 콜백으로 전달되는 data는 non-null로 타입이 좁혀진 accessor이므로, 처음 코드에 있던 ! 단언도 필요 없어집니다.
마치며
평소에는 React로만 웹 개발을 하다 보니, SolidJS의 "컴포넌트는 한 번만 실행된다"라는 개념을 받아들이기까지 시행착오가 있었습니다. 같은 컴포넌트, 같은 기능이라도 React와 SolidJS로 만드는 차이가 있다 보니 그 점도 굉장히 새롭고 재밌었습니다.
다만, React에 비해서 생태계가 작아 직접 구현해야 하는 부분이 많다 보니 실제 프로젝트에서 사용할 때는 고민이 필요해 보입니다. 레퍼런스가 중요한 팀 프로젝트라면 아직 React가 무난하겠지만, 성능이 중요하거나 새로운 반응성 모델을 경험해보고 싶다면 충분히 시도해볼 만합니다.

