본문으로 건너뛰기
Advertisement

18.1 Solid.js 소개

Solid.js는 **세분화된 반응성(Fine-grained Reactivity)**을 핵심으로 하는 선언형 UI 라이브러리입니다. Ryan Carniato가 만들었으며, 가상 DOM 없이 Signal 기반의 직접 DOM 업데이트로 최고 수준의 성능을 달성합니다. React의 JSX 문법과 익숙한 컴포넌트 모델을 유지하면서도, 내부 동작은 근본적으로 다릅니다.


Solid.js란?

Solid.js는 2018년 Ryan Carniato가 개발을 시작하여 2021년 v1.0을 공개한 UI 라이브러리입니다. "Solid"는 견고하고 단단하다는 뜻으로, 예측 가능하고 효율적인 반응성 시스템을 목표로 합니다.

Solid.js의 핵심 철학

  1. 선언형 UI: 상태가 무엇인지 선언하면 Solid가 DOM을 알아서 동기화
  2. 세분화된 반응성: 컴포넌트 전체가 아닌 정확히 변경된 DOM 노드만 업데이트
  3. 가상 DOM 없음: 런타임에 가상 트리를 비교하지 않고 Signal이 직접 DOM을 조작
  4. 한 번만 실행되는 컴포넌트 함수: React처럼 리렌더링 시 함수가 재실행되지 않음

탄생 배경

Ryan Carniato는 가상 DOM의 비교(diffing) 비용 없이 반응성만으로 최적의 UI 업데이트를 달성할 수 있다는 아이디어를 오랜 기간 연구했습니다. Solid.js는 Knockout.js의 반응성 시스템과 React의 JSX/컴포넌트 모델을 결합하여 탄생했습니다.


가상 DOM 없이 최고 성능을 달성하는 원리

React의 방식 (가상 DOM)

상태 변경
→ 컴포넌트 함수 재실행 (새 가상 DOM 트리 생성)
→ 이전 가상 DOM과 새 가상 DOM 비교(diffing)
→ 변경된 부분만 실제 DOM에 적용(patching)

이 방식은 매번 전체 컴포넌트 함수가 재실행되고, 가상 DOM 비교 연산이 발생합니다.

Solid.js의 방식 (Signal 기반 직접 업데이트)

상태(Signal) 변경
→ Signal을 구독하는 Effect가 즉시 실행
→ 해당 DOM 노드만 직접 업데이트

Solid.js에서 컴포넌트 함수는 초기화 시 단 한 번만 실행됩니다. 이후 상태가 변경되면 컴포넌트 함수가 재실행되는 것이 아니라, Signal에 바인딩된 특정 DOM 업데이트 로직만 실행됩니다.

내부 동작 예시

// Solid.js 컴포넌트
function Counter() {
const [count, setCount] = createSignal(0); // Signal 생성

// 이 함수는 단 한 번만 실행됨
// JSX가 컴파일 시 createEffect로 감싸진 DOM 업데이트 코드로 변환됨
return (
<div>
{/* count()는 Signal getter — Effect 내에서 추적됨 */}
<p>카운트: {count()}</p>
<button onClick={() => setCount(count() + 1)}>증가</button>
</div>
);
}

컴파일 후 실제로 생성되는 코드(단순화):

// 컴파일된 결과 (개념적 표현)
function Counter() {
const [count, setCount] = createSignal(0);

// DOM 노드 생성 (한 번만)
const div = document.createElement('div');
const p = document.createElement('p');
const button = document.createElement('button');

p.textContent = '카운트: ' + count(); // 초기값 설정

// count Signal을 구독하는 Effect 등록
createEffect(() => {
// count()가 변경될 때만 이 텍스트 노드 업데이트
p.firstChild.nodeValue = '카운트: ' + count();
});

button.textContent = '증가';
button.addEventListener('click', () => setCount(count() + 1));

div.append(p, button);
return div;
}

컴포넌트 함수가 재실행되지 않으므로 불필요한 재계산이 전혀 없습니다.


React vs Vue vs Svelte vs Solid.js 비교

항목React 18Vue 3Svelte 5Solid.js 1.x
반응성 방식가상 DOM + diffing가상 DOM + Proxy컴파일러 생성 코드Signal 기반 세분화 반응성
번들 크기~45 KB (gzip)~33 KB (gzip)~3 KB (런타임)~7 KB (gzip)
컴포넌트 재실행상태 변경마다 재실행상태 변경마다 재실행재실행 없음재실행 없음
렌더링 성능우수우수최우수최우수
메모리 사용중간중간낮음낮음
학습 곡선중간낮음~중간낮음중간
생태계 크기매우 큼중간소~중간
TypeScript 지원우수우수우수우수
SSR 지원Next.jsNuxtSvelteKitSolidStart
상태관리 내장Context/Zustand 별도Pinia 별도Store 내장Store 내장
문법 친숙도JSXTemplate독자 문법JSX (React 유사)
파일 확장자.jsx/.tsx.vue.svelte.jsx/.tsx

결론 요약

  • React: 가장 큰 생태계, 검증된 기술, 엔터프라이즈 표준
  • Vue: 접근성 좋은 문법, 풍부한 한국어 자료
  • Svelte: 컴파일러로 최소 번들, 직관적 문법
  • Solid.js: 최고 성능, React와 유사한 문법, 최신 트렌드

js-framework-benchmark 상위권 성능의 이유

js-framework-benchmark는 Stefan Krause가 관리하는 프레임워크 성능 비교 프로젝트로, 1,000개 행 렌더링, 업데이트, 삭제, 선택 등 다양한 시나리오를 측정합니다.

Solid.js가 상위권인 이유

1. 세분화된 업데이트 (Fine-grained Updates)

React: 리스트의 한 항목 변경 → 부모 컴포넌트 재실행 → 리스트 전체 diffing
Solid: 리스트의 한 항목 변경 → 해당 항목의 Signal만 업데이트 → 해당 텍스트 노드만 변경

2. 초기화 오버헤드 최소화

컴포넌트 함수가 한 번만 실행되므로, 클로저 재생성, 의존성 배열 비교, Hook 규칙 검사 등이 필요 없습니다.

3. 배치 업데이트 자동화

여러 Signal이 동시에 변경될 때 자동으로 배치 처리됩니다.

import { batch } from 'solid-js';

// 두 Signal 변경이 단 한 번의 DOM 업데이트로 처리됨
batch(() => {
setName('홍길동');
setAge(30);
});

4. 불변성 강제 없음

React는 setState({ ...prev, name: '새 이름' })처럼 새 객체를 만들어야 하지만, Solid는 createStore로 직접 변경 가능합니다.

벤치마크 수치 예시 (2024년 기준, 낮을수록 빠름)

시나리오Solid.jsReact 18Vue 3
1,000행 생성 (ms)~35~55~45
10,000행 생성 (ms)~310~510~420
행 교체 100개 (ms)~16~28~22
선택된 행 하이라이트 (ms)~3~8~6
메모리 사용량 (MB)~4~7~6

실제 수치는 하드웨어, 브라우저, 버전에 따라 다릅니다.


기본 컴포넌트 예제

Hello World

// src/App.jsx
import { createSignal } from 'solid-js';

function App() {
return <h1>안녕하세요, Solid.js!</h1>;
}

export default App;
// src/index.jsx
import { render } from 'solid-js/web';
import App from './App';

render(() => <App />, document.getElementById('root'));

Counter 컴포넌트

import { createSignal, createMemo } from 'solid-js';

function Counter() {
const [count, setCount] = createSignal(0);
const [step, setStep] = createSignal(1);

// createMemo: 계산된 값 (count나 step이 바뀔 때만 재계산)
const doubled = createMemo(() => count() * 2);
const isEven = createMemo(() => count() % 2 === 0);

return (
<div class="counter">
<h2>카운트: {count()}</h2>
<p>두 배: {doubled()}</p>
<p>홀짝: {isEven() ? '짝수' : '홀수'}</p>

<div class="controls">
<label>
스텝:
<input
type="number"
value={step()}
onInput={(e) => setStep(Number(e.target.value))}
min="1"
max="10"
/>
</label>
<button onClick={() => setCount(c => c - step())}>-{step()}</button>
<button onClick={() => setCount(0)}>초기화</button>
<button onClick={() => setCount(c => c + step())}>+{step()}</button>
</div>
</div>
);
}

export default Counter;

TodoApp 컴포넌트

import { createSignal, createMemo, For, Show } from 'solid-js';

function TodoApp() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'Solid.js 배우기', done: false },
{ id: 2, text: '세분화된 반응성 이해하기', done: false },
{ id: 3, text: 'Signal 마스터하기', done: true },
]);
const [input, setInput] = createSignal('');
const [filter, setFilter] = createSignal('all'); // 'all' | 'active' | 'done'

const remaining = createMemo(() =>
todos().filter(t => !t.done).length
);

const filteredTodos = createMemo(() => {
switch (filter()) {
case 'active': return todos().filter(t => !t.done);
case 'done': return todos().filter(t => t.done);
default: return todos();
}
});

const addTodo = () => {
const text = input().trim();
if (!text) return;
setTodos(prev => [
...prev,
{ id: Date.now(), text, done: false },
]);
setInput('');
};

const toggleTodo = (id) => {
setTodos(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
};

const removeTodo = (id) => {
setTodos(prev => prev.filter(t => t.id !== id));
};

const clearDone = () => {
setTodos(prev => prev.filter(t => !t.done));
};

return (
<div class="todo-app">
<h1>
할 일 목록
<span class="badge">{remaining()}</span>
</h1>

{/* 입력 폼 */}
<div class="input-row">
<input
value={input()}
onInput={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
placeholder="새 할 일 입력..."
/>
<button onClick={addTodo}>추가</button>
</div>

{/* 필터 탭 */}
<div class="filters">
<For each={['all', 'active', 'done']}>
{(f) => (
<button
class={filter() === f ? 'active' : ''}
onClick={() => setFilter(f)}
>
{f === 'all' ? '전체' : f === 'active' ? '진행 중' : '완료'}
</button>
)}
</For>
</div>

{/* 목록 */}
<ul>
<For each={filteredTodos()} fallback={<p>항목이 없습니다.</p>}>
{(todo) => (
<li class={todo.done ? 'done' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>×</button>
</li>
)}
</For>
</ul>

{/* 완료 항목 일괄 삭제 */}
<Show when={todos().some(t => t.done)}>
<button class="clear-btn" onClick={clearDone}>
완료 항목 삭제
</button>
</Show>
</div>
);
}

export default TodoApp;

Solid.js가 적합한 프로젝트

잘 맞는 경우

  • 고성능이 핵심인 앱: 대규모 데이터 테이블, 실시간 대시보드, 주식/트레이딩 UI
  • React 경험자가 새 기술을 배울 때: JSX 문법이 동일하여 전환 비용이 낮음
  • 번들 크기 제약이 있는 환경: ~7KB gzip으로 React 대비 약 6배 작음
  • 반응성 원리를 깊이 이해하고 싶은 개발자
  • SPA (Single Page Application): SolidStart로 SSR도 가능
  • 게임 UI, 인터랙티브 시각화: 애니메이션이 많고 상태 업데이트가 빈번한 경우
  • 임베디드 위젯: 다른 앱에 삽입되는 소형 UI

덜 적합한 경우

  • 대규모 팀 프로젝트: React/Vue에 비해 경험자 채용이 어려움
  • 풍부한 UI 컴포넌트 라이브러리가 필요할 때: Material UI, Ant Design 등 React 생태계가 월등히 큼
  • CMS 연동 콘텐츠 사이트: Next.js/Nuxt가 더 성숙한 솔루션
  • 팀 전체가 프레임워크 처음 배울 때: React/Vue의 한국어 학습 자료가 훨씬 많음
  • Solid.js 특유의 주의사항을 모르는 팀: 구조 분해 할당 금지 등 함정이 있음

실전 예제: 실시간 검색 필터

import { createSignal, createMemo, For } from 'solid-js';

const FRUITS = [
'사과', '바나나', '체리', '딸기', '수박', '포도',
'복숭아', '망고', '파인애플', '블루베리', '라즈베리', '키위',
];

function SearchFilter() {
const [query, setQuery] = createSignal('');

// query()가 바뀔 때만 재계산됨 (컴포넌트 재실행 없음!)
const filtered = createMemo(() => {
const q = query().toLowerCase();
return FRUITS.filter(f => f.includes(q));
});

return (
<div>
<input
type="search"
value={query()}
onInput={(e) => setQuery(e.target.value)}
placeholder="과일 검색..."
style="padding: 0.5rem; width: 100%; margin-bottom: 1rem;"
/>

<p>
{filtered().length}개 결과
{query() && ` ("${query()}" 검색)`}
</p>

<ul>
<For each={filtered()} fallback={<li>검색 결과가 없습니다.</li>}>
{(fruit) => <li>{fruit}</li>}
</For>
</ul>
</div>
);
}

export default SearchFilter;

실전 예제: 타이머 (cleanup 포함)

import { createSignal, onCleanup, onMount } from 'solid-js';

function Timer() {
const [seconds, setSeconds] = createSignal(0);
const [running, setRunning] = createSignal(false);

let intervalId;

const start = () => {
if (running()) return;
setRunning(true);
intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};

const pause = () => {
setRunning(false);
clearInterval(intervalId);
};

const reset = () => {
pause();
setSeconds(0);
};

// 컴포넌트 언마운트 시 interval 정리
onCleanup(() => {
clearInterval(intervalId);
});

const formatTime = (s) => {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};

return (
<div class="timer">
<div class="display">{formatTime(seconds())}</div>
<div class="controls">
<button onClick={start} disabled={running()}>시작</button>
<button onClick={pause} disabled={!running()}>일시정지</button>
<button onClick={reset}>초기화</button>
</div>
</div>
);
}

export default Timer;

고수 팁

팁 1: React와 다른 근본적인 차이 — 컴포넌트 함수는 한 번만 실행된다

가장 중요한 개념입니다. React 개발자가 Solid로 이전할 때 가장 많이 하는 실수입니다.

// ❌ React 사고방식 (Solid에서 잘못된 예)
function BadComponent() {
const [count, setCount] = createSignal(0);

// 이 console.log는 초기화 시 단 한 번만 실행됨!
// count가 변해도 컴포넌트 함수가 재실행되지 않으므로 로그가 찍히지 않음
console.log('렌더링됨, count =', count()); // count()를 여기서 읽어도 추적 안 됨

return <div>{count()}</div>;
}

// ✅ 올바른 방식: createEffect 또는 JSX 내부에서 Signal 읽기
function GoodComponent() {
const [count, setCount] = createSignal(0);

createEffect(() => {
// Effect 내부에서 읽어야 반응성 추적됨
console.log('count 변경됨:', count());
});

return <div>{count()}</div>;
}

팁 2: 구조 분해 할당은 반응성을 파괴한다

// ❌ 절대 금지 — 반응성 손실!
function BadDestructure() {
const [state, setState] = createSignal({ name: '홍길동', age: 30 });
const { name, age } = state(); // ← 여기서 구조 분해하면 반응성 끊김!

return <div>{name}</div>; // name은 절대 업데이트되지 않음
}

// ✅ 올바른 방식: JSX에서 직접 Signal 접근
function GoodAccess() {
const [state, setState] = createSignal({ name: '홍길동', age: 30 });

return <div>{state().name}</div>; // 항상 최신값 반환
}

팁 3: Solid.js DevTools로 Signal 트리 시각화

브라우저 확장 프로그램 Solid DevTools를 설치하면 어떤 Signal이 어떤 컴포넌트/Effect에 연결되어 있는지 시각적으로 확인할 수 있습니다. 이를 통해 불필요한 업데이트를 즉시 발견할 수 있습니다.

팁 4: untrack으로 반응성 차단

import { createSignal, createEffect, untrack } from 'solid-js';

function Example() {
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);

createEffect(() => {
// a가 바뀔 때만 실행됨
// b()는 읽지만 추적되지 않음 (untrack 사용)
console.log('a =', a(), ', b (현재값) =', untrack(b));
});

return (
<div>
<button onClick={() => setA(a() + 1)}>A 증가 (Effect 트리거)</button>
<button onClick={() => setB(b() + 1)}>B 증가 (Effect 미트리거)</button>
</div>
);
}

팁 5: 성능 측정 — why-did-you-render 없이도 충분

Solid.js는 컴포넌트 자체가 재실행되지 않으므로 React의 why-did-you-render 같은 도구가 필요 없습니다. 대신 Chrome DevTools의 Performance 탭에서 실제 DOM 업데이트만 측정하면 됩니다.

팁 6: createRoot로 독립적인 반응성 컨텍스트 생성

import { createRoot, createSignal } from 'solid-js';

// 컴포넌트 외부에서 반응성 컨텍스트가 필요할 때
const dispose = createRoot((dispose) => {
const [count, setCount] = createSignal(0);

createEffect(() => {
console.log('count:', count());
});

// 나중에 dispose()를 호출하면 모든 반응성 정리
return dispose;
});

// 5초 후 정리
setTimeout(dispose, 5000);

정리

개념설명
세분화된 반응성정확히 변경된 DOM 노드만 업데이트
SignalSolid.js의 기본 반응형 상태 단위
컴포넌트 1회 실행리렌더링 없음, Effect가 대신 업데이트
가상 DOM 없음런타임 비교 비용 제로
번들 크기~7KB gzip (React 대비 ~6배 작음)
구조 분해 금지props/signal 구조 분해 시 반응성 손실
적합한 프로젝트고성능 SPA, 실시간 UI, 임베디드 위젯
JSX 문법React와 동일하지만 동작 방식이 근본적으로 다름

다음 장에서는 Solid.js 개발 환경을 설정하고 프로젝트 구조를 살펴봅니다.

Advertisement