본문으로 건너뛰기
Advertisement

18.6 SolidStart 기초

SolidStart는 Solid.js 팀이 공식으로 개발한 풀스택 메타프레임워크입니다. Next.js가 React 위에 있듯이, SolidStart는 Solid.js 위에 파일 기반 라우팅, 서버 함수, SSR/SSG, 스트리밍을 추가합니다. 이 장에서는 SolidStart의 핵심 개념을 처음부터 실전 수준까지 단계별로 배웁니다.


1. SolidStart란?

다른 메타프레임워크와 비교

기능Next.js (React)SvelteKitSolidStart
기반 프레임워크ReactSvelteSolid.js
라우팅 방식파일 기반파일 기반파일 기반
서버 함수Server ActionsForm Actions"use server" RPC
데이터 로딩loader, fetch in RSCload 함수createAsync + cache
스트리밍React SuspenseStreamingSolid Suspense
배포 어댑터Vercel/커스텀Vercel/CF/NodeVercel/CF/Node/static
번들러Webpack/TurbopackViteVite (Vinxi 기반)

SolidStart는 Vinxi(Vite 기반 앱 번들러)를 사용하며, 서버와 클라이언트 코드를 하나의 파일에 함께 작성하는 "같은 파일에서 서버/클라이언트 혼합" 패턴을 지원합니다.

언제 SolidStart를 선택하나?

  • SEO가 중요한 공개 사이트 (SSR 필요)
  • 블로그, 문서 사이트 (SSG 적합)
  • API 라우트와 풀스택 기능이 필요한 앱
  • 최고 성능의 서버 렌더링이 필요한 경우

순수 CSR(싱글 페이지 앱)이면 SolidStart 없이 Solid.js + Vite만으로도 충분합니다.


2. 프로젝트 생성

새 프로젝트 시작

npm create solid@latest my-solid-app
cd my-solid-app
npm install
npm run dev

대화형 설정에서 선택 옵션:

  • Template: bare (빈 프로젝트) 또는 with-tailwindcss, hackernews
  • Server Side Rendering: Yes (SSR 사용 권장)
  • TypeScript: Yes (강력 권장)

프로젝트 구조

my-solid-app/
├── src/
│ ├── routes/ # 파일 기반 라우팅 루트
│ │ ├── index.tsx # / 경로
│ │ ├── about.tsx # /about 경로
│ │ ├── blog/
│ │ │ ├── index.tsx # /blog 경로
│ │ │ └── [slug].tsx # /blog/:slug 동적 라우트
│ │ └── (auth)/ # 레이아웃 그룹 (URL 미포함)
│ │ ├── login.tsx # /login
│ │ └── register.tsx # /register
│ ├── components/ # 재사용 컴포넌트
│ ├── lib/ # 유틸리티, DB 연결 등
│ ├── app.tsx # 앱 루트 컴포넌트
│ └── entry-server.tsx # SSR 진입점
├── public/ # 정적 파일
├── app.config.ts # SolidStart 설정
└── package.json

app.config.ts 기본 설정

// app.config.ts
import { defineConfig } from '@solidjs/start/config';

export default defineConfig({
// Vite 플러그인 추가
vite: {
plugins: [],
},
// 서버 설정
server: {
preset: 'node', // 'vercel' | 'cloudflare' | 'netlify' | 'static'
},
});

3. 파일 기반 라우팅

라우트 파일 명명 규칙

src/routes/
├── index.tsx → /
├── about.tsx → /about
├── contact.tsx → /contact
├── blog/
│ ├── index.tsx → /blog
│ └── [slug].tsx → /blog/:slug
├── shop/
│ ├── [...path].tsx → /shop/* (와일드카드)
│ └── (category)/ → 레이아웃 그룹 (URL 미포함)
│ ├── electronics.tsx → /shop/electronics
│ └── clothing.tsx → /shop/clothing
└── api/
└── users.ts → /api/users (API 라우트)

기본 라우트 컴포넌트

// src/routes/index.tsx
export default function HomePage() {
return (
<main>
<h1>홈페이지</h1>
<p>SolidStart 앱에 오신 것을 환영합니다!</p>
</main>
);
}

동적 라우트

// src/routes/blog/[slug].tsx
import { useParams } from '@solidjs/router';

export default function BlogPost() {
const params = useParams();

return (
<article>
<h1>포스트: {params.slug}</h1>
</article>
);
}

중첩 레이아웃

// src/routes/(app).tsx — 중첩 레이아웃 파일
import { Outlet } from '@solidjs/router';

export default function AppLayout() {
return (
<div class="app-layout">
<header>
<nav>
<a href="/"></a>
<a href="/dashboard">대시보드</a>
<a href="/profile">프로필</a>
</nav>
</header>
<main>
<Outlet /> {/* 자식 라우트가 여기 렌더링 */}
</main>
<footer>© 2025 My App</footer>
</div>
);
}
// src/routes/(app)/dashboard.tsx — 레이아웃의 자식
export default function Dashboard() {
return <h1>대시보드</h1>;
}

Link와 Navigate

import { A, useNavigate } from '@solidjs/router';

function NavMenu() {
const navigate = useNavigate();

return (
<nav>
{/* A 컴포넌트 — active 클래스 자동 추가 */}
<A href="/" activeClass="active" end></A>
<A href="/about" activeClass="active">소개</A>
<A href="/blog" activeClass="active">블로그</A>

{/* 프로그래매틱 내비게이션 */}
<button onClick={() => navigate('/dashboard')}>
대시보드로 이동
</button>
</nav>
);
}

4. 서버 함수 — "use server"

기본 개념

서버 함수는 "use server" 지시자를 붙이면 서버에서만 실행되는 함수입니다. 클라이언트에서 호출하면 자동으로 RPC(원격 프로시저 호출) 방식으로 요청을 보냅니다.

// src/routes/index.tsx
import { createSignal } from 'solid-js';

// 이 함수는 절대 클라이언트 번들에 포함되지 않음
async function getServerData() {
'use server';
// DB 직접 접근, 환경 변수 사용, 비밀 키 사용 가능
const db = await connectDB();
return db.query('SELECT * FROM products LIMIT 10');
}

export default function HomePage() {
const [data, setData] = createSignal(null);

onMount(async () => {
const result = await getServerData();
setData(result);
});

return <div>{/* ... */}</div>;
}

파일 최상단 "use server" — 파일 전체를 서버 전용으로

// src/lib/db.ts
'use server';

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);

export async function getUsers() {
return db.select().from(users);
}

export async function createUser(data: NewUser) {
return db.insert(users).values(data).returning();
}

폼 Action

SolidStart에서 폼 제출은 action으로 처리합니다. JavaScript 없이도 동작하는 Progressive Enhancement를 지원합니다.

// src/routes/contact.tsx
import { action, useSubmission } from '@solidjs/router';

// action 정의
const sendMessage = action(async (formData: FormData) => {
'use server';

const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;

if (!name || !email || !message) {
throw new Error('모든 필드를 입력해주세요');
}

await sendEmail({ name, email, message });

return { success: true };
}, 'sendMessage');

export default function ContactPage() {
const submission = useSubmission(sendMessage);

return (
<div>
<h1>문의하기</h1>

{/* action="..."으로 연결 */}
<form action={sendMessage} method="post">
<input name="name" placeholder="이름" required />
<input name="email" type="email" placeholder="이메일" required />
<textarea name="message" placeholder="메시지" required />

<button type="submit" disabled={submission.pending}>
{submission.pending ? '전송 중...' : '전송'}
</button>
</form>

<Show when={submission.error}>
<p class="error">{submission.error.message}</p>
</Show>

<Show when={submission.result?.success}>
<p class="success">메시지가 전송되었습니다!</p>
</Show>
</div>
);
}

redirect와 쿠키

import { action, redirect } from '@solidjs/router';
import { setCookie } from 'vinxi/http';

const loginAction = action(async (formData: FormData) => {
'use server';

const email = formData.get('email') as string;
const password = formData.get('password') as string;

const user = await verifyCredentials(email, password);
if (!user) throw new Error('이메일 또는 비밀번호가 올바르지 않습니다');

const token = await generateToken(user.id);

// 쿠키 설정
setCookie('auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7일
});

// 리다이렉트
throw redirect('/dashboard');
}, 'login');

5. 데이터 로딩 — createAsync, cache, query

cache와 createAsync — 기본 패턴

// src/lib/queries.ts
import { cache } from '@solidjs/router';

// cache로 감싸면 서버에서 한 번만 실행, 결과를 캐시
export const getProducts = cache(async () => {
'use server';
const products = await db.select().from(productsTable);
return products;
}, 'products'); // 두 번째 인자는 캐시 키

export const getProduct = cache(async (slug: string) => {
'use server';
const [product] = await db
.select()
.from(productsTable)
.where(eq(productsTable.slug, slug));
return product ?? null;
}, 'product');
// src/routes/shop/index.tsx
import { createAsync } from '@solidjs/router';
import { getProducts } from '~/lib/queries';

export default function ShopPage() {
// createAsync — 서버에서 미리 실행, 클라이언트에서 hydration
const products = createAsync(() => getProducts());

return (
<Suspense fallback={<p>상품 로딩 중...</p>}>
<For each={products()}>
{(product) => <ProductCard product={product} />}
</For>
</Suspense>
);
}

동적 파라미터와 데이터 로딩

// src/routes/blog/[slug].tsx
import { useParams, createAsync } from '@solidjs/router';
import { cache } from '@solidjs/router';

const getPost = cache(async (slug: string) => {
'use server';
const post = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
return post[0] ?? null;
}, 'post');

export default function BlogPostPage() {
const params = useParams();
const post = createAsync(() => getPost(params.slug));

return (
<Suspense fallback={<ArticleSkeleton />}>
<Show when={post()} fallback={<p>포스트를 찾을 수 없습니다</p>}>
{(p) => (
<article>
<h1>{p().title}</h1>
<time>{p().publishedAt}</time>
<div innerHTML={p().content} />
</article>
)}
</Show>
</Suspense>
);
}

preload — 데이터 사전 로딩

// src/routes/blog/[slug].tsx
import { cache, createAsync, useParams } from '@solidjs/router';

const getPost = cache(async (slug: string) => {
'use server';
return fetchPost(slug);
}, 'post');

// preload: 라우트 진입 전에 데이터를 미리 가져옴
export const route = {
preload: ({ params }) => getPost(params.slug),
};

export default function BlogPostPage() {
const params = useParams();
const post = createAsync(() => getPost(params.slug));
// preload 덕분에 Suspense가 즉시 해소됨
return <article>{/* ... */}</article>;
}

6. SSR / SSG 모드 설정

SSR (서버 사이드 렌더링) — 기본값

// app.config.ts
import { defineConfig } from '@solidjs/start/config';

export default defineConfig({
server: {
preset: 'node', // Node.js 서버에서 SSR
},
});

모든 라우트는 기본적으로 서버에서 HTML을 렌더링한 뒤 클라이언트에서 hydration합니다.

SSG (정적 사이트 생성)

// app.config.ts
import { defineConfig } from '@solidjs/start/config';

export default defineConfig({
server: {
preset: 'static', // 빌드 시 정적 HTML 파일 생성
prerender: {
crawlLinks: true, // 링크를 따라가며 자동 사전 렌더링
routes: ['/'], // 루트부터 크롤 시작
ignore: ['/admin'], // 제외할 경로
},
},
});

동적 라우트의 경우 prerender 배열에 명시합니다.

prerender: {
routes: [
'/',
'/about',
'/blog/post-1',
'/blog/post-2',
// 실제로는 DB에서 슬러그를 가져와 동적으로 생성
],
},

라우트별 설정

// src/routes/dashboard.tsx — 이 라우트는 CSR만 사용
export const route = {
// 서버 사이드 preload 없음
};

// SPA 모드로 내보내기
export default function Dashboard() {
return <div>대시보드 (클라이언트 전용)</div>;
}

7. 배포 어댑터

Vercel

npm install @solidjs/start
// app.config.ts
export default defineConfig({
server: { preset: 'vercel' },
});
// vercel.json (선택 사항)
{
"buildCommand": "npm run build",
"outputDirectory": ".vercel/output"
}

Cloudflare Pages

// app.config.ts
export default defineConfig({
server: { preset: 'cloudflare-pages' },
});
# 배포
npx wrangler pages deploy .output/public

Cloudflare Workers에서는 process.env 대신 import.meta.env를 사용합니다.

Node.js (자체 서버)

// app.config.ts
export default defineConfig({
server: { preset: 'node' },
});
npm run build
node .output/server/index.mjs

프록시(Nginx) 설정:

server {
listen 80;
server_name example.com;

location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

Static (완전 정적)

// app.config.ts
export default defineConfig({
server: {
preset: 'static',
prerender: { crawlLinks: true, routes: ['/'] },
},
});

빌드 결과물인 .output/public 폴더를 어떤 정적 호스팅(GitHub Pages, S3, Netlify 등)에도 배포 가능합니다.


8. API 라우트

// src/routes/api/users.ts
import type { APIEvent } from '@solidjs/start/server';

export async function GET(event: APIEvent) {
const users = await db.select().from(usersTable);
return new Response(JSON.stringify(users), {
headers: { 'Content-Type': 'application/json' },
});
}

export async function POST(event: APIEvent) {
const body = await event.request.json();

const [newUser] = await db
.insert(usersTable)
.values(body)
.returning();

return new Response(JSON.stringify(newUser), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
}
// src/routes/api/users/[id].ts
export async function GET(event: APIEvent) {
const id = event.params.id;
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, parseInt(id)));

if (!user.length) {
return new Response('Not Found', { status: 404 });
}

return Response.json(user[0]);
}

export async function DELETE(event: APIEvent) {
const id = event.params.id;
await db.delete(usersTable).where(eq(usersTable.id, parseInt(id)));
return new Response(null, { status: 204 });
}

9. 실전 예제

예제 1: 블로그 — 목록 + 상세 + API

// src/lib/blog.ts
'use server';

import { cache } from '@solidjs/router';

export interface Post {
id: number;
slug: string;
title: string;
excerpt: string;
content: string;
publishedAt: string;
author: { name: string; avatar: string };
tags: string[];
}

// 목록 조회 (캐시 5분)
export const getPosts = cache(async (tag?: string) => {
const query = db.select().from(postsTable).orderBy(desc(postsTable.publishedAt));
if (tag) query.where(sql`${tag} = ANY(tags)`);
return query;
}, 'posts');

// 단건 조회
export const getPost = cache(async (slug: string) => {
const [post] = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug));
return post ?? null;
}, 'post');
// src/routes/blog/index.tsx
import { createAsync, A } from '@solidjs/router';
import { getPosts } from '~/lib/blog';
import { For, Suspense } from 'solid-js';

export const route = {
preload: () => getPosts(),
};

export default function BlogListPage() {
const posts = createAsync(() => getPosts());

return (
<main class="max-w-3xl mx-auto py-12 px-4">
<h1 class="text-4xl font-bold mb-8">블로그</h1>
<Suspense fallback={<PostsSkeleton />}>
<ul class="space-y-8">
<For each={posts()}>
{(post) => (
<li class="border-b pb-8">
<A href={`/blog/${post.slug}`}>
<h2 class="text-2xl font-semibold hover:underline">{post.title}</h2>
</A>
<time class="text-gray-500 text-sm">{post.publishedAt}</time>
<p class="mt-2 text-gray-700">{post.excerpt}</p>
<div class="mt-3 flex gap-2">
<For each={post.tags}>
{(tag) => (
<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs">
{tag}
</span>
)}
</For>
</div>
</li>
)}
</For>
</ul>
</Suspense>
</main>
);
}
// src/routes/blog/[slug].tsx
import { createAsync, useParams, A } from '@solidjs/router';
import { getPost } from '~/lib/blog';
import { Show, Suspense } from 'solid-js';

export const route = {
preload: ({ params }) => getPost(params.slug),
};

export default function BlogDetailPage() {
const params = useParams();
const post = createAsync(() => getPost(params.slug));

return (
<Suspense fallback={<ArticleSkeleton />}>
<Show
when={post()}
fallback={
<div class="text-center py-24">
<h1 class="text-2xl">포스트를 찾을 수 없습니다</h1>
<A href="/blog" class="text-blue-600 mt-4 block">블로그 목록으로</A>
</div>
}
>
{(p) => (
<article class="max-w-3xl mx-auto py-12 px-4">
<header class="mb-8">
<h1 class="text-4xl font-bold">{p().title}</h1>
<div class="flex items-center gap-4 mt-4">
<img
src={p().author.avatar}
alt={p().author.name}
class="w-10 h-10 rounded-full"
/>
<div>
<p class="font-medium">{p().author.name}</p>
<time class="text-sm text-gray-500">{p().publishedAt}</time>
</div>
</div>
</header>
<div class="prose" innerHTML={p().content} />
</article>
)}
</Show>
</Suspense>
);
}

예제 2: 인증 흐름 — 로그인/로그아웃

// src/lib/auth.ts
'use server';

import { cache, action, redirect } from '@solidjs/router';
import { getCookie, setCookie, deleteCookie } from 'vinxi/http';
import { db } from '~/lib/db';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET!;

// 현재 세션 유저 조회
export const getSessionUser = cache(async () => {
const token = getCookie('auth_token');
if (!token) return null;

try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number };
const [user] = await db
.select({ id: users.id, email: users.email, name: users.name, role: users.role })
.from(users)
.where(eq(users.id, payload.userId));
return user ?? null;
} catch {
return null;
}
}, 'session-user');

// 로그인 action
export const loginAction = action(async (formData: FormData) => {
'use server';
const email = String(formData.get('email'));
const password = String(formData.get('password'));

const [user] = await db
.select()
.from(users)
.where(eq(users.email, email));

if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return { error: '이메일 또는 비밀번호가 올바르지 않습니다' };
}

const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
setCookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});

throw redirect('/dashboard');
}, 'login');

// 로그아웃 action
export const logoutAction = action(async () => {
'use server';
deleteCookie('auth_token');
throw redirect('/login');
}, 'logout');
// src/routes/login.tsx
import { createAsync, useSubmission } from '@solidjs/router';
import { loginAction, getSessionUser } from '~/lib/auth';
import { Show } from 'solid-js';
import { redirect } from '@solidjs/router';

export const route = {
preload: async () => {
const user = await getSessionUser();
if (user) throw redirect('/dashboard');
},
};

export default function LoginPage() {
const submission = useSubmission(loginAction);

return (
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md bg-white rounded-xl shadow p-8">
<h1 class="text-2xl font-bold mb-6">로그인</h1>

<form action={loginAction} method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">이메일</label>
<input
name="email"
type="email"
required
class="w-full border rounded-lg px-3 py-2"
placeholder="you@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">비밀번호</label>
<input
name="password"
type="password"
required
class="w-full border rounded-lg px-3 py-2"
/>
</div>

<Show when={submission.result?.error}>
<p class="text-red-500 text-sm">{submission.result?.error}</p>
</Show>

<button
type="submit"
disabled={submission.pending}
class="w-full bg-blue-600 text-white rounded-lg py-2 font-medium disabled:opacity-50"
>
{submission.pending ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
</div>
);
}
// src/routes/(app)/dashboard.tsx — 보호된 라우트
import { createAsync } from '@solidjs/router';
import { getSessionUser, logoutAction } from '~/lib/auth';
import { Show } from 'solid-js';
import { redirect } from '@solidjs/router';

export const route = {
preload: async () => {
const user = await getSessionUser();
if (!user) throw redirect('/login');
},
};

export default function DashboardPage() {
const user = createAsync(() => getSessionUser());

return (
<div class="p-8">
<Show when={user()}>
{(u) => (
<div>
<h1 class="text-3xl font-bold">대시보드</h1>
<p class="mt-2 text-gray-600">안녕하세요, {u().name}님!</p>

<form action={logoutAction} method="post" class="mt-6">
<button type="submit" class="bg-red-500 text-white px-4 py-2 rounded">
로그아웃
</button>
</form>
</div>
)}
</Show>
</div>
);
}

10. 고수 팁

팁 1: 에러 바운더리와 ErrorBoundary

// src/app.tsx
import { ErrorBoundary } from 'solid-js';

export default function App() {
return (
<ErrorBoundary
fallback={(err, reset) => (
<div class="error-page">
<h1>오류가 발생했습니다</h1>
<pre>{err.message}</pre>
<button onClick={reset}>다시 시도</button>
</div>
)}
>
<Router />
</ErrorBoundary>
);
}

팁 2: Middleware로 인증 처리

// src/middleware.ts
import { createMiddleware } from '@solidjs/start/middleware';

export default createMiddleware({
onRequest: [
async (event) => {
const url = new URL(event.request.url);
const token = getCookie(event, 'auth_token');

const protectedPaths = ['/dashboard', '/profile', '/settings'];
if (protectedPaths.some(p => url.pathname.startsWith(p))) {
if (!token || !(await verifyToken(token))) {
return new Response(null, {
status: 302,
headers: { Location: '/login' },
});
}
}
},
],
});
// app.config.ts
export default defineConfig({
middleware: './src/middleware.ts',
});

팁 3: createAsync의 초기 데이터 패턴

// 서버에서 이미 받은 데이터를 클라이언트에서 재사용
const data = createAsync(() => getData(), {
initialValue: props.serverData, // SSR에서 받은 데이터로 초기화
deferStream: true, // 스트리밍 지연
});

팁 4: revalidate로 캐시 무효화

import { revalidate } from '@solidjs/router';
import { getProducts } from '~/lib/queries';

const deleteProduct = action(async (id: number) => {
'use server';
await db.delete(productsTable).where(eq(productsTable.id, id));
// 캐시 무효화 → 다음 렌더링 시 자동 재조회
revalidate(getProducts.key);
}, 'deleteProduct');

팁 5: 스트리밍 SSR 활용

// 무거운 컴포넌트는 Suspense로 감싸서 나머지 페이지를 먼저 전송
export default function ProductPage() {
const product = createAsync(() => getProduct(params.slug));
const reviews = createAsync(() => getReviews(params.slug));

return (
<div>
{/* 빠른 데이터 — 먼저 스트리밍 */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail product={product()} />
</Suspense>

{/* 느린 데이터 — 나중에 스트리밍 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewList reviews={reviews()} />
</Suspense>
</div>
);
}

정리

기능SolidStart API설명
라우팅src/routes/ 파일 구조파일 이름 = URL 경로
동적 라우트[param].tsxuseParams()로 접근
레이아웃(group).tsx + <Outlet />URL 영향 없는 레이아웃 그룹
서버 함수"use server"서버 전용 실행, RPC 자동화
폼 처리action() + useSubmission()Progressive Enhancement
데이터 로딩cache() + createAsync()SSR + 캐싱
사전 로딩route.preload라우트 진입 전 데이터 fetch
캐시 무효화revalidate()mutation 후 자동 갱신
배포preset 설정Vercel/CF/Node/Static

다음 장에서는...

18.7 실전 고수 팁에서는 Solid.js를 실무에서 능숙하게 사용하기 위한 고급 패턴들을 다룹니다. 구조 분해 할당의 위험성, batch/untrack을 활용한 성능 최적화, TypeScript 통합, 테스팅, 그리고 React에서 Solid.js로 마이그레이션하는 방법까지 배웁니다.

Advertisement