Solid.js & SolidStart 완벽 가이드 — 진짜 반응성으로 React를 대체하는 프레임워크
이 글의 핵심
Solid.js는 "JSX는 React처럼, 반응성은 MobX/Vue처럼, 런타임은 바닐라 JS처럼"이라는 독특한 조합의 프레임워크입니다. 가상 DOM 없이 **컴포넌트가 1회만 실행**되고 signal 기반 fine-grained 업데이트로 DOM을 직접 갱신해 최상위 성능을 냅니다. SolidStart는 SSR·Streaming·File-based Routing·Server Functions를 갖춘 메타 프레임워크로 2024년 GA됐습니다.
설치
Solid.js 단독 (Vite)
pnpm create vite@latest my-app --template solid-ts
cd my-app && pnpm install && pnpm dev
SolidStart (메타 프레임워크)
pnpm create solid@latest
# 템플릿 선택: bare / minimal / with-tailwindcss / with-auth / ...
Signal 기초
import { createSignal } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
return (
<button onClick={() => setCount(count() + 1)}>
count: {count()}
</button>
)
}
count는 함수: 호출해야 값이 나옴 (구독 추적을 위해)setCount(next)또는setCount((c) => c + 1)- 컴포넌트
Counter()자체는 1회만 실행
createEffect
import { createSignal, createEffect } from "solid-js"
function Example() {
const [count, setCount] = createSignal(0)
createEffect(() => {
document.title = `count: ${count()}`
})
return <button onClick={() => setCount(count() + 1)}>+</button>
}
- 의존성 배열 불필요 (자동 추적)
- 첫 실행 + 의존 변경 시 실행
- cleanup은
onCleanup()사용
createMemo
const [items] = createSignal([1, 2, 3])
const total = createMemo(() => items().reduce((a, b) => a + b, 0))
Show / For / Switch
React의 조건/리스트 렌더링 대안.
import { Show, For, Switch, Match } from "solid-js"
<Show when={user()} fallback={<p>Loading…</p>}>
{(u) => <p>Hello {u().name}</p>}
</Show>
<For each={items()}>
{(item, i) => <li>{i() + 1}. {item.name}</li>}
</For>
<Switch fallback={<p>none</p>}>
<Match when={status() === "ok"}><p>✅</p></Match>
<Match when={status() === "error"}><p>❌</p></Match>
</Switch>
<For>는 key 기반 fine-grained DOM 재사용. map()·filter()로 JSX를 만들면 전체 리스트가 재생성되니 <For> 사용이 권장.
Store (중첩 상태)
import { createStore } from "solid-js/store"
const [state, setState] = createStore({
user: { name: "JB", prefs: { theme: "dark" } },
todos: [{ id: 1, done: false, text: "hi" }],
})
setState("user", "prefs", "theme", "light")
setState("todos", 0, "done", true)
setState("todos", (todos) => [...todos, { id: 2, done: false, text: "new" }])
MobX·Vue reactive와 유사. 깊은 경로도 세밀하게 업데이트.
Resources (비동기 데이터)
import { createResource, Show } from "solid-js"
const fetchUser = (id: number) =>
fetch(`/api/users/${id}`).then((r) => r.json())
function UserCard(props: { id: number }) {
const [user] = createResource(() => props.id, fetchUser)
return (
<Show when={!user.loading} fallback={<p>Loading…</p>}>
<Show when={!user.error} fallback={<p>Error</p>}>
<p>{user()!.name}</p>
</Show>
</Show>
)
}
- 인수 getter → 변경 시 자동 리페치
refetch()·mutate()·loading·error상태
Context
import { createContext, useContext } from "solid-js"
const ThemeContext = createContext<"light" | "dark">("light")
<ThemeContext.Provider value="dark">
<App />
</ThemeContext.Provider>
const theme = useContext(ThemeContext)
React와 비슷하지만 provider가 리렌더 안 됨(컨포넌트 1회 실행) → 성능 부담 없음.
SolidStart: File-based Routing
src/routes/
index.tsx → /
about.tsx → /about
blog/
[slug].tsx → /blog/:slug
index.tsx → /blog
(group)/
settings.tsx → /settings (그룹화, URL 영향 없음)
// src/routes/blog/[slug].tsx
import { RouteSectionProps, createAsync } from "@solidjs/router"
import { Show } from "solid-js"
async function getPost(slug: string) {
"use server"
const res = await fetch(`https://api.example.com/posts/${slug}`)
return res.json()
}
export default function BlogPost(props: RouteSectionProps) {
const post = createAsync(() => getPost(props.params.slug))
return (
<Show when={post()}>
<article>
<h1>{post()!.title}</h1>
<div innerHTML={post()!.html} />
</article>
</Show>
)
}
"use server": Server Function (Next.js와 동일 개념)createAsync: Suspense 통합 비동기 데이터
Server Functions
// src/routes/posts.tsx
import { action, useSubmission } from "@solidjs/router"
const createPost = action(async (form: FormData) => {
"use server"
const title = form.get("title") as string
const db = await import("~/db")
const row = await db.insertPost(title)
return { id: row.id }
}, "createPost")
export default function Posts() {
const submission = useSubmission(createPost)
return (
<form method="post" action={createPost}>
<input name="title" required />
<button disabled={submission.pending}>Submit</button>
{submission.result && <p>Created #{submission.result.id}</p>}
</form>
)
}
서버·클라이언트 경계가 자연스럽게 타입 안전 연결되고 progressive enhancement 지원.
Data Loaders (load)
// src/routes/dashboard.tsx
import { createAsync, query } from "@solidjs/router"
const getDashboardData = query(async () => {
"use server"
return { stats: await loadStats(), recent: await loadRecent() }
}, "dashboard")
export default function Dashboard() {
const data = createAsync(() => getDashboardData())
return (
<Show when={data()}>
<Stats data={data()!.stats} />
<RecentList items={data()!.recent} />
</Show>
)
}
query는 캐시·deduplication·재검증까지 지원하는 SolidStart 공식 데이터 패턴.
Streaming SSR
SolidStart는 기본적으로 streaming SSR: 각 Suspense 경계가 준비되는 대로 HTML이 흘러나옵니다.
import { Suspense } from "solid-js"
export default function Page() {
return (
<>
<Hero />
<Suspense fallback={<SkeletonFeed />}>
<Feed />
</Suspense>
<Suspense fallback={<SkeletonSidebar />}>
<Sidebar />
</Suspense>
</>
)
}
Time-to-first-byte·LCP 개선에 직접 기여.
Islands Architecture
import { clientOnly } from "@solidjs/start"
const HeavyWidget = clientOnly(() => import("~/components/HeavyWidget"))
<HeavyWidget fallback={<p>Loading…</p>} />
정적 HTML + 부분 하이드레이션 패턴도 지원.
성능 특성
- 가상 DOM 없음 → 업데이트 오버헤드 최소
- 번들 크기: core ~5KB gzip
- 벤치마크(JS Framework Benchmark)에서 상위 3위 안
- React 대비 메모리 사용 20-40% 감소
TanStack 통합
pnpm add @tanstack/solid-query
import { createQuery } from "@tanstack/solid-query"
function Posts() {
const query = createQuery(() => ({
queryKey: ["posts"],
queryFn: () => fetch("/api/posts").then(r => r.json()),
}))
return (
<Show when={!query.isLoading}>
<For each={query.data}>{(p) => <li>{p.title}</li>}</For>
</Show>
)
}
TanStack Query·Table·Virtual·Router 모두 공식 Solid 포트가 있어 프레임워크 간 지식 이전이 쉽습니다.
UI 라이브러리
- Kobalte (
@kobalte/core): Radix 스타일 접근성 프리미티브 Solid 구현 - SolidAria: react-aria 포트
- solid-ui: shadcn/ui 솔리드판
- Motion One for Solid: 애니메이션
배포
Vercel / Netlify / Cloudflare
// vite.config.ts
import { defineConfig } from "@solidjs/start/config"
export default defineConfig({
server: {
preset: "cloudflare_pages", // 또는 "vercel", "netlify", "node"
},
})
Nitro 기반 어댑터로 거의 모든 플랫폼 타겟 가능.
Docker
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/.output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
디버깅
- Solid Devtools (
solid-devtools): signal·effect 트리 시각화 console.log는 setup 1회에만 실행되므로 반응 로그는createEffect(() => console.log(...))안에 작성
트러블슈팅
값이 업데이트 안 됨
- Signal은 함수 호출 필수:
count()(O),count(X) - store 깊은 변경은
setState의 경로 스타일 사용
<For> 아이템이 리렌더 안 됨
- 아이템 객체를 교체 시 signal로 감싸거나 store 사용
- 프리미티브 배열은 값 변경이 감지되도록 setter 사용
Server Function이 클라이언트로 번들됨
- 함수 최상단에
"use server"지시어 - 서버 전용 모듈은
import "./server-only"대신.server.ts네이밍
React 라이브러리 포팅
- 단순 컴포넌트는 수동 포팅 가능
- Context 위주·상태 거의 없는 컴포넌트는 10-30분 작업
- 순수 계산 로직은 그대로 복사
채택 기준
쓰기 좋은 경우
- 성능·번들 크기 최우선(위젯, 임베디드, 대시보드)
- React 문법은 유지하되 훅 재실행 이슈에서 벗어나고 싶음
- SSR/Streaming·Server Functions가 필요
- TanStack 생태계 이미 활용
피해야 할 경우
- React Native 필요 (Solid에도 대체가 있으나 성숙도 낮음)
- 방대한 팀·사내 React 컴포넌트 자산
- 사용자 다수가 React-only 경험
체크리스트
- Signal·Resource·Store 3대 개념 이해
-
<For>·<Show>렌더링 패턴 정착 - SolidStart의 Server Functions로 데이터 페칭 통합
- Streaming SSR + Suspense 설계
- TanStack Query·Table·Router 조합
- 배포 어댑터(Nitro preset) 선택
- Kobalte 등 접근성 프리미티브 활용
마무리
Solid.js는 “React의 문법으로 더 빠른 반응성을 쓸 수 없을까”라는 질문에 가장 완성도 높은 답을 내놓은 프레임워크입니다. 가상 DOM 없이 fine-grained 업데이트를 하면서도 JSX·Context·Suspense 같은 React 개념을 대부분 공유해 학습이 빠릅니다. SolidStart가 2024 GA로 메타 프레임워크 공백을 메워 Next.js 수준의 DX로 프로덕션 앱을 작성할 수 있게 되었고, TC39 Signals 프로포절의 기반이 될 만큼 설계가 검증되어 있습니다. 당장 React 팀 전체를 옮기는 것은 아니더라도, 성능 민감한 제품·위젯·임베디드 UI에서 Solid로 작은 영역을 실험해보는 것만으로도 현대 반응성의 방향을 체감할 수 있습니다.
관련 글
- React 완벽 가이드
- Svelte 5 Runes 완벽 가이드
- Next.js 완벽 가이드
- TanStack Query v5 완벽 가이드