SvelteKit
SvelteKit
Svelte 기반 풀스택 프레임워크. SSR, SSG, API 라우트 지원.
SvelteKit
Svelte 기반 풀스택 프레임워크. SSR, SSG, API 라우트 지원.
SvelteKit(스벨트킷)은 Svelte를 기반으로 한 풀스택 웹 애플리케이션 프레임워크입니다. Next.js가 React에게, Nuxt가 Vue에게 하는 것처럼, SvelteKit은 Svelte 위에서 라우팅, 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG), API 엔드포인트 등 프로덕션 웹앱에 필요한 기능을 제공합니다. 2022년 12월 1.0 버전이 출시되었으며, Svelte 팀이 공식으로 개발합니다.
SvelteKit의 핵심 특징은 파일 기반 라우팅입니다. src/routes 디렉토리 구조가 곧 URL 경로가 됩니다. +page.svelte는 페이지 컴포넌트, +page.server.ts는 서버에서만 실행되는 데이터 로딩(load 함수) 및 폼 처리(actions)를 담당합니다. +server.ts로 REST API 엔드포인트를 만들 수 있어, 별도 백엔드 없이 풀스택 앱을 구축할 수 있습니다.
렌더링 전략을 페이지별로 유연하게 설정할 수 있습니다. 기본적으로 SSR(서버 사이드 렌더링)을 사용하지만, +page.ts에서 export const prerender = true를 설정하면 빌드 타임에 정적 HTML을 생성(SSG)합니다. export const ssr = false로 클라이언트 사이드 렌더링만 사용하거나, export const csr = false로 JavaScript 번들 없는 순수 HTML 페이지도 만들 수 있습니다.
SvelteKit은 어댑터(Adapter) 시스템으로 다양한 플랫폼에 배포할 수 있습니다. adapter-vercel, adapter-cloudflare, adapter-netlify, adapter-node 등 공식 및 커뮤니티 어댑터가 있어, 서버리스 엣지부터 전통적인 Node.js 서버까지 유연하게 배포 가능합니다. Svelte의 컴파일러 기반 최적화와 결합하여 매우 작은 번들 크기와 빠른 초기 로딩을 달성합니다.
my-sveltekit-app/
├── src/
│ ├── routes/
│ │ ├── +page.svelte # / (홈페이지)
│ │ ├── +page.server.ts # 서버 로직
│ │ ├── +layout.svelte # 공통 레이아웃
│ │ ├── about/
│ │ │ └── +page.svelte # /about
│ │ ├── blog/
│ │ │ ├── +page.svelte # /blog (목록)
│ │ │ ├── +page.server.ts
│ │ │ └── [slug]/
│ │ │ ├── +page.svelte # /blog/:slug (상세)
│ │ │ └── +page.server.ts
│ │ └── api/
│ │ └── posts/
│ │ └── +server.ts # /api/posts (API)
│ ├── lib/
│ │ ├── components/ # 재사용 컴포넌트
│ │ └── server/ # 서버 전용 코드
│ ├── app.html # HTML 템플릿
│ └── app.d.ts # 타입 정의
├── static/ # 정적 파일
├── svelte.config.js
├── vite.config.ts
└── package.json
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
// data는 +page.server.ts의 load 함수에서 반환
</script>
<svelte:head>
<title>{data.post.title} | My Blog</title>
<meta name="description" content={data.post.excerpt} />
</svelte:head>
<article>
<h1>{data.post.title}</h1>
<p class="meta">
{new Date(data.post.createdAt).toLocaleDateString('ko-KR')}
· {data.post.author.name}
</p>
<div class="content">
{@html data.post.content}
</div>
</article>
<style>
article {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.meta {
color: #666;
margin-bottom: 2rem;
}
</style>
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/database';
export const load: PageServerLoad = async ({ params, locals }) => {
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true }
});
if (!post) {
throw error(404, {
message: '포스트를 찾을 수 없습니다.'
});
}
// 조회수 증가 (비동기, 응답 대기 안 함)
db.post.update({
where: { id: post.id },
data: { views: { increment: 1 } }
});
return {
post,
user: locals.user // hooks.server.ts에서 설정
};
};
// 정적 생성 옵션
export const prerender = false; // 동적 페이지
// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { sendEmail } from '$lib/server/email';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
const message = formData.get('message')?.toString();
// 유효성 검사
const errors: Record<string, string> = {};
if (!name || name.length < 2) {
errors.name = '이름은 2자 이상이어야 합니다.';
}
if (!email || !email.includes('@')) {
errors.email = '올바른 이메일을 입력하세요.';
}
if (!message || message.length < 10) {
errors.message = '메시지는 10자 이상이어야 합니다.';
}
if (Object.keys(errors).length > 0) {
return fail(400, { errors, name, email, message });
}
try {
await sendEmail({
to: 'contact@example.com',
subject: `[문의] ${name}님의 메시지`,
body: `이름: ${name}\n이메일: ${email}\n\n${message}`
});
// 성공 시 감사 페이지로 리다이렉트
throw redirect(303, '/contact/thanks');
} catch (e) {
if (e instanceof Response) throw e; // redirect는 throw
return fail(500, { error: '전송에 실패했습니다.' });
}
}
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<h1>문의하기</h1>
<form method="POST" use:enhance>
<div class="field">
<label for="name">이름</label>
<input
type="text"
id="name"
name="name"
value={form?.name ?? ''}
/>
{#if form?.errors?.name}
<p class="error">{form.errors.name}</p>
{/if}
</div>
<div class="field">
<label for="email">이메일</label>
<input
type="email"
id="email"
name="email"
value={form?.email ?? ''}
/>
{#if form?.errors?.email}
<p class="error">{form.errors.email}</p>
{/if}
</div>
<div class="field">
<label for="message">메시지</label>
<textarea
id="message"
name="message"
rows="5"
>{form?.message ?? ''}</textarea>
{#if form?.errors?.message}
<p class="error">{form.errors.message}</p>
{/if}
</div>
<button type="submit">전송</button>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
</form>
// src/routes/api/posts/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/database';
// GET /api/posts
export const GET: RequestHandler = async ({ url }) => {
const page = parseInt(url.searchParams.get('page') ?? '1');
const limit = parseInt(url.searchParams.get('limit') ?? '10');
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
slug: true,
title: true,
excerpt: true,
createdAt: true
}
});
const total = await db.post.count();
return json({
posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
});
};
// POST /api/posts
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, '로그인이 필요합니다.');
}
const body = await request.json();
const post = await db.post.create({
data: {
title: body.title,
slug: body.slug,
content: body.content,
authorId: locals.user.id
}
});
return json(post, { status: 201 });
};
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel'; // 또는 adapter-node
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
runtime: 'edge', // Vercel Edge Functions
regions: ['icn1'] // 서울 리전
}),
alias: {
$components: 'src/lib/components',
$utils: 'src/lib/utils'
},
// CSP 설정
csp: {
mode: 'auto',
directives: {
'script-src': ['self']
}
},
// 환경 변수 접두사 (기본: PUBLIC_)
env: {
publicPrefix: 'PUBLIC_'
}
}
};
export default config;
+page.server.ts의 코드는 서버에서만 실행되고, +page.ts는 서버와 클라이언트 모두에서 실행될 수 있습니다. DB 커넥션, API 키 등 민감한 코드는 반드시 +page.server.ts나 $lib/server/ 내에 작성하세요.
Svelte/SvelteKit 생태계는 React/Next.js에 비해 작습니다. 서드파티 라이브러리 선택지가 제한적일 수 있으며, 커뮤니티 지원이나 채용 시장에서 고려해야 할 사항입니다.
SSR된 HTML과 클라이언트 Hydration 결과가 다르면 경고가 발생합니다. 브라우저 전용 API(window, localStorage)는 onMount 내에서 또는 browser 체크 후 사용하세요.