SolidJS 완전 가이드 | React보다 빠른 반응형 UI 프레임워크
이 글의 핵심
React와 문법은 유사하지만 2배 빠른 SolidJS. Virtual DOM 없이 진짜 반응성(Fine-grained Reactivity)으로 작동하며, 번들 크기는 React의 1/3입니다. JSX를 그대로 사용할 수 있어 마이그레이션이 쉽습니다.
이 글의 핵심
SolidJS는 React보다 2배 빠르고 가벼운 프론트엔드 프레임워크입니다. Virtual DOM 없이 진짜 반응성(Fine-grained Reactivity)으로 작동하며, JSX를 그대로 사용할 수 있어 React 개발자에게 친숙합니다. 번들 크기는 React의 1/3입니다.
아래에서는 Signal·Effect·메모가 어떻게 엮이는지, React와 멘탈 모델이 어디서 갈라지는지, Solid Router·Context·Store로 상태를 나누는 실전 패턴, 배치·세밀 반응성 측면의 성능, SolidStart, 그리고 프로덕션에서의 솔직한 체감과 React 마이그레이션 시 자주 부딪히는 함정까지 한 번에 정리합니다.
목차
- SolidJS란?
- 핵심 개념: Signal, Effect, Fine-grained Reactivity
- React와의 근본적 차이
- SolidJS 시작하기
- 컴포넌트 작성 패턴
- 반응성 (Reactivity)
- 조건부·리스트 렌더링
- 상태 관리: Store와 Context 전략
- Solid Router (심화)
- 성능: 자동 최적화와 batch
- SolidStart 프레임워크
- 프로덕션 사용 후기 (솔직 평)
- React에서 마이그레이션할 때
- 정리
SolidJS란?
SolidJS는 2018년 Ryan Carniato가 개발한 반응형 UI 프레임워크입니다.
🚀 핵심 특징
1. Virtual DOM 없음
React:
컴포넌트 렌더링 → Virtual DOM 생성 → Diffing → 실제 DOM 업데이트
SolidJS:
Signal 변경 → 실제 DOM 직접 업데이트
→ 2배 빠름
2. Fine-grained Reactivity
// 상태가 변경되면 정확히 그 부분만 업데이트
const [count, setCount] = createSignal(0);
// count()가 사용된 곳만 재실행
<div>{count()}</div> // 이 부분만 업데이트
3. JSX 그대로 사용
// React와 거의 동일한 문법
function App() {
const [count, setCount] = createSignal(0);
return (
<div>
<p>Count: {count()}</p>
<button onClick={() => setCount(count() + 1)}>
Increment
</button>
</div>
);
}
4. 번들 크기
React + ReactDOM: 45KB (gzip)
SolidJS: 13KB (gzip)
→ 1/3 크기
핵심 개념: Signal, Effect, Fine-grained Reactivity
Solid의 반응성은 구독(subscription) 그래프로 이해하는 것이 가장 빠릅니다. createSignal로 만든 읽기 함수 count()를 JSX나 다른 반응형 프리미티브 안에서 호출하면, 런타임이 그 의존성을 기록합니다. 값이 바뀌면 그 구독자만 다시 실행되고, 화면에서는 해당 텍스트 노드·속성만 갱신합니다. 컴포넌트 함수 전체가 매번 도는 React의 리렌더와 달리, Solid의 컴포넌트 본문은 초기 한 번 실행된 뒤 지속적인 “스코프”로 남고, 이후 움직이는 단위는 Signal·Effect·Memo입니다.
createEffect는 의존하는 Signal을 읽을 때마다 그 Effect가 그 Signal의 구독자가 됩니다. 그래서 데이터 패칭 후 파생 상태를 만드는 패턴, DOM 측정, 외부 라이브러리와의 동기화에 쓰입니다. 다만 Effect 안에서 또 Signal을 쓰지 않고 부수 효과만 남발하면 추적이 꼬일 수 있으므로, 파생 값은 createMemo로, 부수 효과는 createEffect로 역할을 나누는 습관이 중요합니다.
Fine-grained라는 말은 “한 화면 단위”가 아니라 관찰 단위가 DOM 조각·계산식·Effect 블록까지 쪼개진다는 뜻입니다. 덕분에 불필요한 Virtual DOM 비교나 대규모 컴포넌트 트리 전체의 조정 없이 업데이트 비용이 예측 가능해집니다. 반대로 개발자는 “어디서 Signal을 읽었는가”를 의식해야 하며, 이는 React의 “한 컴포넌트 = 한 번의 리렌더” 직관과는 다릅니다.
React와의 근본적 차이
1) 렌더링 모델
React는 함수 컴포넌트가 상태가 바뀔 때마다 다시 호출되고, 그 결과로 Virtual DOM 트리를 만듭니다. Solid는 컴포넌트 함수가 초기 마운트 시 한 번 실행되고, 이후 업데이트는 Signal이 연결된 세밀한 업데이트 함수가 담당합니다. 그래서 React에서 useRef로 “리렌더 사이에 유지할 값”을 숨기던 패턴이 Solid에서는 그냥 함수 스코프의 일반 변수로도 충분한 경우가 많습니다(다만 반응형이 아닌 값은 Signal이 아닌 곳에 두어야 합니다).
2) Hooks vs Primitives
React의 useState/useEffect는 매 렌더마다 호출 순서가 고정되어야 합니다. Solid의 createSignal/createEffect는 한 번 설정되면 구독 관계가 유지되는 쪽에 가깝습니다. 조건문 안에 Effect를 넣는 것처럼 React와 동일하게 “위험한” 패턴은 여전히 피해야 하지만, “의존성 배열 누락” 문제는 자동 추적으로 상당 부분 완화됩니다.
3) children·렌더 prop
React에서 children이 바뀔 때 상위가 리렌더되면서 비용이 커질 수 있는 부분이, Solid에서는 props 접근이 반응형으로 추적될 수 있어 세밀하게 최적화됩니다. 다만 이만큼 “언제 구독이 생기는지”를 읽는 연습이 필요합니다.
4) 생태계와 채용
React는 압도적인 라이브러리·레퍼런스·인력 풀을 갖습니다. Solid는 번들·런타임 성능과 DX의 균형이 좋지만, 팀 합류 난이도·서드파티 호환 측면에서는 아직 타협이 필요합니다.
SolidJS 시작하기
프로젝트 생성
# Vite 템플릿
npm create vite@latest my-solid-app -- --template solid
cd my-solid-app
npm install
npm run dev
# 또는 Degit
npx degit solidjs/templates/js my-app
cd my-app
npm install
npm run dev
# TypeScript 템플릿
npx degit solidjs/templates/ts my-app
프로젝트 구조
my-solid-app/
├── src/
│ ├── App.tsx
│ ├── index.tsx
│ └── index.css
├── index.html
├── package.json
└── vite.config.ts
컴포넌트 작성 패턴
아래는 대시보드 한 화면에서 자주 쓰는 세트업입니다. UI에 직접 바인딩할 값은 createSignal, 파생 지표는 createMemo, 브라우저 API·로깅·구독은 createEffect로 나눕니다.
import { createSignal, createMemo, createEffect, onCleanup } from 'solid-js';
function Dashboard() {
const [items, setItems] = createSignal<{ id: string; price: number }[]>([]);
const [taxRate, setTaxRate] = createSignal(0.1);
const subtotal = createMemo(() =>
items().reduce((s, it) => s + it.price, 0)
);
const withTax = createMemo(() => subtotal() * (1 + taxRate()));
createEffect(() => {
// 예: subtotal이 바뀔 때마다 analytics 전송
console.log('subtotal', subtotal());
});
createEffect(() => {
const id = setInterval(() => {
// 외부 폴링 등
}, 30_000);
onCleanup(() => clearInterval(id));
});
return (
<section>
<p>소계: {subtotal()}</p>
<p>세후: {withTax()}</p>
<button type="button" onClick={() => setTaxRate(0.12)}>
세율 12%로
</button>
</section>
);
}
실무 팁: createMemo는 동기적인 순수 계산에만 쓰고, 비동기 fetch는 createResource(별도 도입)나 Effect + Signal 조합으로 분리하세요. Effect 안에서 setState를 연쇄적으로 호출하면 batch 덕에 대부분 한 번에 반영되지만, 무한 루프(Effect가 Signal을 읽고 또 쓰고…)에 빠지기 쉬우니 설계를 단순하게 유지하는 것이 좋습니다.
반응성 (Reactivity)
createSignal (State)
import { createSignal } from 'solid-js';
function Counter() {
// Signal 생성
const [count, setCount] = createSignal(0);
const [name, setName] = createSignal('Alice');
// Getter 함수로 값 읽기
console.log(count()); // 0
console.log(name()); // 'Alice'
// Setter 함수로 값 변경
setCount(10);
setName('Bob');
// 함수형 업데이트
setCount(c => c + 1);
return (
<div>
<p>Count: {count()}</p>
<p>Name: {name()}</p>
<button onClick={() => setCount(count() + 1)}>
Increment
</button>
</div>
);
}
createEffect (Side Effects)
import { createSignal, createEffect } from 'solid-js';
function App() {
const [count, setCount] = createSignal(0);
// count()가 변경될 때마다 실행
createEffect(() => {
console.log('Count changed:', count());
});
// 정리 함수 (cleanup)
createEffect(() => {
const timer = setInterval(() => {
console.log('Tick');
}, 1000);
// 컴포넌트 언마운트 시 실행
return () => clearInterval(timer);
});
return <button onClick={() => setCount(count() + 1)}>Count: {count()}</button>;
}
createMemo (Computed Values)
import { createSignal, createMemo } from 'solid-js';
function App() {
const [count, setCount] = createSignal(0);
// count()가 변경될 때만 재계산 (캐싱)
const doubled = createMemo(() => count() * 2);
const squared = createMemo(() => count() ** 2);
console.log(doubled()); // 0
console.log(squared()); // 0
return (
<div>
<p>Count: {count()}</p>
<p>Doubled: {doubled()}</p>
<p>Squared: {squared()}</p>
<button onClick={() => setCount(count() + 1)}>Increment</button>
</div>
);
}
컴포넌트
함수 컴포넌트
// 컴포넌트는 한 번만 실행됨 (중요!)
function Greeting(props) {
console.log('Component created'); // 한 번만 출력
return <h1>Hello, {props.name}!</h1>;
}
// 사용
<Greeting name="Alice" />
Props
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
function Button(props: ButtonProps) {
// props는 반응형 프록시
return (
<button
onClick={props.onClick}
disabled={props.disabled}
>
{props.label}
</button>
);
}
// 사용
<Button label="Click me" onClick={() => console.log('Clicked')} />
Children
import { JSX } from 'solid-js';
interface CardProps {
title: string;
children: JSX.Element;
}
function Card(props: CardProps) {
return (
<div class="card">
<h2>{props.title}</h2>
<div class="content">
{props.children}
</div>
</div>
);
}
// 사용
<Card title="Welcome">
<p>This is the content</p>
</Card>
조건부 렌더링
Show 컴포넌트
import { Show } from 'solid-js';
function App() {
const [loggedIn, setLoggedIn] = createSignal(false);
return (
<div>
<Show
when={loggedIn()}
fallback={<p>Please log in</p>}
>
<p>Welcome back!</p>
</Show>
<button onClick={() => setLoggedIn(!loggedIn())}>
Toggle
</button>
</div>
);
}
Switch/Match
import { Switch, Match } from 'solid-js';
function App() {
const [status, setStatus] = createSignal<'loading' | 'success' | 'error'>('loading');
return (
<Switch>
<Match when={status() === 'loading'}>
<p>Loading...</p>
</Match>
<Match when={status() === 'success'}>
<p>Success!</p>
</Match>
<Match when={status() === 'error'}>
<p>Error occurred</p>
</Match>
</Switch>
);
}
리스트 렌더링
For 컴포넌트
import { For } from 'solid-js';
function TodoList() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'Learn SolidJS', completed: false },
{ id: 2, text: 'Build an app', completed: false },
]);
return (
<ul>
<For each={todos()}>
{(todo, index) => (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => {
setTodos(todos => {
const newTodos = [...todos];
newTodos[index()].completed = e.target.checked;
return newTodos;
});
}}
/>
{todo.text}
</li>
)}
</For>
</ul>
);
}
Index 컴포넌트 (Key 기반)
import { Index } from 'solid-js';
// Index는 각 항목이 변경되지 않고 순서만 바뀔 때 최적화됨
function App() {
const [items, setItems] = createSignal(['A', 'B', 'C']);
return (
<ul>
<Index each={items()}>
{(item, index) => (
<li>{index + 1}: {item()}</li>
)}
</Index>
</ul>
);
}
이벤트 처리
function App() {
const [value, setValue] = createSignal('');
const handleClick = (e: MouseEvent) => {
console.log('Clicked!', e);
};
const handleInput = (e: InputEvent) => {
setValue((e.target as HTMLInputElement).value);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
console.log('Submitted:', value());
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={value()}
onInput={handleInput}
placeholder="Type something..."
/>
<button type="submit" onClick={handleClick}>
Submit
</button>
</form>
);
}
상태 관리: Store와 Context 전략
createStore는 객체·배열의 중첩 구조를 다룰 때 강력합니다. 배열의 특정 항목 필드만 바꿔도 나머지는 그대로 두는 경로 기반 갱신이 가능해, 리스트+세부 필드가 많은 폼·테이블에 잘 맞습니다. 전역으로는 createContext + Provider 아래에 Signal 묶음이나 store 인스턴스를 내려주는 방식이 일반적입니다. Redux를 그대로 들고 올 이유는 거의 없고, 서버 캐시가 필요하면 TanStack Query 포팅 체인 등을 먼저 확인하는 편이 낫습니다.
import { createStore } from 'solid-js/store';
import { createContext, useContext, ParentComponent } from 'solid-js';
type Cart = { id: string; qty: number }[];
const CartCtx = createContext<{
cart: ReturnType<typeof createStore<Cart>>[0];
setCart: ReturnType<typeof createStore<Cart>>[1];
}>();
export const CartProvider: ParentComponent = (props) => {
const [cart, setCart] = createStore<Cart>([]);
return (
<CartCtx.Provider value={{ cart, setCart }}>{props.children}</CartCtx.Provider>
);
};
export function useCart() {
const v = useContext(CartCtx);
if (!v) throw new Error('CartProvider 필요');
return v;
}
전략 요약:
- 뷰에 가까운 일시적 UI 상태:
createSignal - 도메인이 복잡한 구조화 상태:
createStore - 트리 전역 공유(테마, 인증 토큰, 사용자 설정): Context + Signal/Store
- 서버 동기화·캐시: 전용 데이터 레이어(예:
createResource/ 외부 클라이언트)
Stores (복잡한 상태)
createStore
import { createStore } from 'solid-js/store';
function TodoApp() {
const [todos, setTodos] = createStore([
{ id: 1, text: 'Learn SolidJS', completed: false },
{ id: 2, text: 'Build an app', completed: false },
]);
// 특정 항목만 업데이트 (효율적!)
const toggleTodo = (id: number) => {
setTodos(
todo => todo.id === id,
'completed',
completed => !completed
);
};
// 항목 추가
const addTodo = (text: string) => {
setTodos(todos.length, {
id: Date.now(),
text,
completed: false,
});
};
return (
<ul>
<For each={todos}>
{(todo) => (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</li>
)}
</For>
</ul>
);
}
Context API
import { createContext, useContext } from 'solid-js';
// Context 생성
const ThemeContext = createContext();
// Provider
function ThemeProvider(props) {
const [theme, setTheme] = createSignal('light');
const value = {
theme,
toggleTheme: () => setTheme(theme() === 'light' ? 'dark' : 'light'),
};
return (
<ThemeContext.Provider value={value}>
{props.children}
</ThemeContext.Provider>
);
}
// Consumer (Hook)
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// 사용
function Button() {
const { theme, toggleTheme } = useTheme();
return (
<button
class={theme()}
onClick={toggleTheme}
>
Current theme: {theme()}
</button>
);
}
function App() {
return (
<ThemeProvider>
<Button />
</ThemeProvider>
);
}
Solid Router (심화)
npm install @solidjs/router
// App.tsx
import { Router, Route, Routes, A } from '@solidjs/router';
import { lazy } from 'solid-js';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const User = lazy(() => import('./pages/User'));
function App() {
return (
<Router>
<nav>
<A href="/">Home</A>
<A href="/about">About</A>
<A href="/user/123">User</A>
</nav>
<Routes>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/user/:id" component={User} />
</Routes>
</Router>
);
}
// pages/User.tsx
import { useParams, useSearchParams, useNavigate } from '@solidjs/router';
function User() {
const params = useParams();
const [search, setSearch] = useSearchParams();
const nav = useNavigate();
return (
<>
<h1>User ID: {params.id}</h1>
<p>tab: {search.tab ?? 'default'}</p>
<button
type="button"
onClick={() => {
setSearch({ tab: 'settings' });
nav(`/user/${params.id}#prefs`, { scroll: false });
}}
>
설정 탭
</button>
</>
);
}
라우트 데이터 로딩은 React Router v6+의 loader와 비슷하게, Route에 data를 붙이거나 createAsync·리소스 패턴(프로젝트에 따라 SolidStart 쪽 API)을 쓰는 팀도 많습니다. SPA만 쓴다면 lazy 분할 + Suspense(필요 시) 조합이 가볍고, SolidStart로 가면 파일 기반 라우트·서버 함수에 맞춰 구조를 잡는 편이 낫습니다.
성능: 자동 최적화와 batch
Solid는 Signal 갱신을 배치(batch)로 묶어한 번의 반응 사이클에 처리하는 경우가 많습니다. 그래서 연속 set 호출이 화면마다 중간 프레임을 남기지 않는 식으로 동작합니다(환경/버전에 세부는 있을 수 있으나, 멘탈 모델은 “불필요한 중간 UI는 적다” 쪽). 또한 createMemo·For·Show는 각각 필요한 하위만 갱신하므로, React에서 useMemo/callback로 막느라 애쓰던 부분을 덜 수동으로 쓰는 케이스가 많습니다.
주의할 점은 “거대한 배열을 한 Signal에 올리고 전부 순회”하는 설계는 여전히 비쌉니다. createStore로 항목 단위 잘게 쪼개거나, 가상 스크롤·페이지네이션을 먼저 고려하세요. 디버깅은 브라우저 프로파일 + Solid DevTools(도입 시)로 어떤 Effect가 누구를 구독하는지 확인하는 흐름이 유효합니다.
import { batch, createSignal } from 'solid-js';
function manyUpdates() {
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
batch(() => {
setA(1);
setB(2);
});
// batch 없이도 최적화가 되는 경로가 많지만, 명시적 batch가 읽기 쉬울 때가 있음
}
SolidStart 프레임워크
SolidStart는 Solid 생태계의 풀스택 프레임워크로, Vite·파일 기반 라우트·서버/클라이언트 경계, 데이터 로딩을 한데 묶는 것을 목표로 합니다. Next.js에 익숙하다면 “페이지 단위 API + server function + 클라이언트 반응성”의 조합으로 이해할 수 있습니다. 정적 홈페이지·마케팅 사이트는 Astro나 순수 SSG에 비해 과할 수 있고, 리치 인터랙션 + 서버가 함께 도는 앱에 더 잘 맞습니다.
도입을 검토할 때는 호스팅/런타임(Edge, Node) 요구, 배포 경로(Cloudflare, Node 어댑터), 그리고 팀이 이미 쓰는 데이터 레이어와의 궁합을 먼저 짚는 것이 좋습니다. 작은 MPA부터 Solid만으로 시작하고, 성장에 따라 Start로 승격시키는 전략도 흔합니다.
프로덕션 사용 후기 (솔직 평)
저는 Solid를 “성능 민감한 내부 툴·대시보드”에 먼저 써 보는 것을 권하는 편입니다. 체감 성능과 번들 크기는 대체로 기대에 부응하고, Vite 기반이라 DX도 무난합니다. 다만 서드파티가 “React용으로만” 나온 경우 포팅·래핑이 필요하고, 스택오버플로 답변의 밀도는 React·Next 대비 희박한 편이라 공식 문서·Discord를 붙잡는 시간이 늘 수 있습니다. 또한 팀에 Solid 경험자가 없다면, 코드리뷰에서 반응형 구독을 놓친 PR이 잠시 늘 수 있습니다(도입 직후 가장 흔한 이슈).
반대로, UI가 단순하고 인력/레퍼런스가 최우선이면 React가 여전히 리스크 조정 면에서 유리합니다. Solid는 “기술적으로 매력”과 “조직의 학습 비용” 사이에서 균형을 잡는 선택입니다.
React에서 마이그레이션할 때
- 상태를 전부
useState로 옮기듯 옮기지 말고, Solid에서 Signal이 읽히는 경로를 기준으로 다시 쪼갤 것. - useEffect의 의존성 배열에 익숙하다면, Effect가 “무엇을 읽는지”만 추적하도록 쓰고, 동기 파생은
createMemo로. - React 전용 훅 라이브러리는 대부분 그대로 못 씁니다. 대안을 문서/커뮤니티에서 먼저 찾을 것.
- 라우터·데이터는 @solidjs/router / SolidStart 문서에 맞춰 한 번에 이주하는 것이 중복 코드를 줄입니다.
- 점진 이행이 필요하면, iframe·마이크로 프론트 분리, 또는 도메인별 라우트로 Solid 앱을 새로 띄우는 식이 현실적입니다.
SolidJS vs React
| 기능 | SolidJS | React |
|---|---|---|
| 렌더링 | 실제 DOM | Virtual DOM |
| 반응성 | Fine-grained | Re-render 전체 |
| 성능 | ⚡⚡ 2배 빠름 | ⚡ 빠름 |
| 번들 크기 | 13KB | 45KB |
| 문법 | JSX (거의 동일) | JSX |
| 학습 곡선 | 🟡 중간 | 🟡 중간 |
| 생태계 | 🌱 성장 중 | 🌳 성숙 |
| 서버 컴포넌트 | ✅ SolidStart | ✅ Next.js |
실전 프로젝트: Counter 앱
// App.tsx
import { createSignal, createEffect, Show, For } from 'solid-js';
function App() {
const [count, setCount] = createSignal(0);
const [history, setHistory] = createSignal<number[]>([]);
// count가 변경될 때마다 history에 추가
createEffect(() => {
setHistory(h => [...h, count()]);
});
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => {
setCount(0);
setHistory([]);
};
return (
<div class="app">
<h1>Counter: {count()}</h1>
<div class="buttons">
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+</button>
</div>
<Show when={history().length > 0}>
<div class="history">
<h2>History</h2>
<ul>
<For each={history()}>
{(value, index) => (
<li>Step {index() + 1}: {value}</li>
)}
</For>
</ul>
</div>
</Show>
</div>
);
}
export default App;
(참고: history 누적을 Effect에서 매번 setHistory하는 예제는 학습용이며, 실제로는 createMemo로 파생하거나 on 이벤트에서만 쌓는 편이 더 예측 가능합니다.)
핵심 정리
✅ SolidJS의 장점
- 압도적인 성능: React보다 2배 빠름
- 작은 번들 크기: React의 1/3 (13KB)
- 진짜 반응성: Virtual DOM 없이 직접 업데이트
- 익숙한 문법: JSX 그대로 사용
- 쉬운 마이그레이션: React 개발자에게 친숙(개념 전환은 필요)
🚀 다음 단계
- SolidJS 공식 문서에서 심화 학습
- SolidStart로 풀스택 앱 개발
- SolidJS Discord에서 커뮤니티 참여
시작하기:
npm create vite@latest my-app -- --template solid로 5분 만에 프로젝트를 시작하고, React보다 2배 빠른 성능을 경험하세요! 🚀