17.6 SvelteKit 기초
SvelteKit은 Svelte의 공식 풀스택 프레임워크입니다. 파일 기반 라우팅, 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG), API 엔드포인트를 모두 지원합니다.
SvelteKit 아키텍처
브라우저 요청
↓
SvelteKit 라우터 (파일 기반)
↓
load 함수 실행 (서버 또는 클라이언트)
↓
페이지 컴포넌트 렌더링
↓
클라이언트에 HTML + JS 전달
SvelteKit은 요청마다 렌더링 모드를 결정할 수 있습니다:
- SSR: 서버에서 HTML 생성 후 전달 (기본값)
- SSG: 빌드 시 미리 HTML 생성
- CSR: 클라이언트에서 완전히 렌더링
파일 기반 라우팅
SvelteKit은 src/routes/ 디렉토리 구조를 기반으로 라우트를 생성합니다.
src/routes/
├── +layout.svelte # 루트 레이아웃 (모든 페이지에 적용)
├── +layout.js # 루트 레이아웃 데이터 로드
├── +page.svelte # / (홈)
├── +error.svelte # 에러 페이지
├── about/
│ └── +page.svelte # /about
├── blog/
│ ├── +page.svelte # /blog
│ ├── +page.js # /blog 데이터 로드
│ └── [slug]/
│ ├── +page.svelte # /blog/:slug
│ └── +page.server.js # /blog/:slug 서버 데이터
├── api/
│ └── users/
│ └── +server.js # /api/users API 엔드포인트
└── (auth)/ # 그룹 (URL에 영향 없음)
├── login/
│ └── +page.svelte # /login
└── register/
└── +page.svelte # /register
핵심 파일 유형
| 파일 | 역할 | 실행 환경 |
|---|---|---|
+page.svelte | 페이지 컴포넌트 | 클라이언트 |
+page.js | 페이지 데이터 로드 | 서버 + 클라이언트 |
+page.server.js | 서버 전용 데이터/액션 | 서버만 |
+layout.svelte | 레이아웃 컴포넌트 | 클라이언트 |
+layout.js | 레이아웃 데이터 로드 | 서버 + 클라이언트 |
+layout.server.js | 서버 전용 레이아웃 데이터 | 서버만 |
+server.js | API 엔드포인트 | 서버만 |
+error.svelte | 에러 페이지 | 클라이언트 |
+page.svelte 기본
<!-- src/routes/+page.svelte -->
<script>
// load 함수의 반환값을 data prop으로 받음
let { data } = $props();
</script>
<svelte:head>
<title>홈 페이지</title>
<meta name="description" content="SvelteKit 예제 홈 페이지" />
</svelte:head>
<main>
<h1>안녕하세요!</h1>
<p>서버에서 받은 메시지: {data.message}</p>
<p>사용자 수: {data.userCount}</p>
</main>
+layout.svelte — 레이아웃
<!-- src/routes/+layout.svelte -->
<script>
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
let { data, children } = $props();
</script>
<Header user={data.user} />
<main>
{@render children()}
</main>
<Footer />
<style>
main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
min-height: calc(100vh - 120px);
}
</style>
데이터 로드: load 함수
+page.js — 유니버설 load 함수
서버와 클라이언트 모두에서 실행됩니다.
// src/routes/blog/+page.js
export async function load({ fetch, url }) {
const page = Number(url.searchParams.get('page') ?? 1);
const res = await fetch(`/api/posts?page=${page}&limit=10`);
if (!res.ok) throw new Error('포스트를 불러올 수 없습니다.');
const { posts, total } = await res.json();
return { posts, total, page };
}
<!-- src/routes/blog/+page.svelte -->
<script>
let { data } = $props();
</script>
<h1>블로그 ({data.total}개 포스트)</h1>
{#each data.posts as post}
<article>
<h2><a href="/blog/{post.slug}">{post.title}</a></h2>
<p>{post.excerpt}</p>
<time>{post.publishedAt}</time>
</article>
{/each}
<div class="pagination">
{#if data.page > 1}
<a href="?page={data.page - 1}">← 이전</a>
{/if}
<span>페이지 {data.page}</span>
{#if data.posts.length === 10}
<a href="?page={data.page + 1}">다음 →</a>
{/if}
</div>
+page.server.js — 서버 전용 load 함수
DB 직접 접근, 비공개 API 키 사용 등 서버에서만 실행해야 하는 경우:
// src/routes/blog/[slug]/+page.server.js
import { db } from '$lib/server/db.js';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true, tags: true },
});
if (!post) {
throw error(404, `포스트 '${params.slug}'를 찾을 수 없습니다.`);
}
// 민감한 필드 제외하고 반환
return {
post: {
title: post.title,
content: post.content,
publishedAt: post.publishedAt.toISOString(),
author: { name: post.author.name },
tags: post.tags.map(t => t.name),
},
};
}
Form Actions
HTML 폼을 서버에서 처리하는 방법입니다. JavaScript 없이도 동작하며, JS가 있으면 향상된 경험을 제공합니다.
// src/routes/contact/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { sendEmail } from '$lib/server/email.js';
export const actions = {
// 기본 액션 (form의 action 없을 때)
default: async ({ request }) => {
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const email = formData.get('email')?.toString().trim();
const message = formData.get('message')?.toString().trim();
// 유효성 검사
const errors = {};
if (!name) errors.name = '이름을 입력해주세요.';
if (!email || !email.includes('@')) errors.email = '유효한 이메일을 입력해주세요.';
if (!message) errors.message = '메시지를 입력해주세요.';
if (Object.keys(errors).length > 0) {
return fail(400, { errors, values: { name, email, message } });
}
try {
await sendEmail({ name, email, message });
throw redirect(303, '/contact/success');
} catch (err) {
if (err instanceof Response) throw err; // redirect는 다시 던짐
return fail(500, { errors: { general: '이메일 전송에 실패했습니다.' } });
}
},
};
<!-- src/routes/contact/+page.svelte -->
<script>
import { enhance } from '$app/forms';
let { form } = $props(); // actions의 반환값
</script>
<h1>문의하기</h1>
<!-- use:enhance로 점진적 향상 -->
<form method="POST" use:enhance>
{#if form?.errors?.general}
<p class="error">{form.errors.general}</p>
{/if}
<div>
<label for="name">이름</label>
<input
id="name"
name="name"
value={form?.values?.name ?? ''}
required
/>
{#if form?.errors?.name}
<p class="field-error">{form.errors.name}</p>
{/if}
</div>
<div>
<label for="email">이메일</label>
<input
id="email"
name="email"
type="email"
value={form?.values?.email ?? ''}
required
/>
{#if form?.errors?.email}
<p class="field-error">{form.errors.email}</p>
{/if}
</div>
<div>
<label for="message">메시지</label>
<textarea
id="message"
name="message"
rows="5"
required
>{form?.values?.message ?? ''}</textarea>
{#if form?.errors?.message}
<p class="field-error">{form.errors.message}</p>
{/if}
</div>
<button type="submit">전송</button>
</form>
API 엔드포인트 (+server.js)
// src/routes/api/posts/+server.js
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db.js';
// GET /api/posts
export async function GET({ url }) {
const page = Number(url.searchParams.get('page') ?? 1);
const limit = Number(url.searchParams.get('limit') ?? 10);
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
db.post.findMany({
skip,
take: limit,
orderBy: { publishedAt: 'desc' },
}),
db.post.count(),
]);
return json({ posts, total, page, limit });
}
// POST /api/posts
export async function POST({ request, locals }) {
// 인증 확인
if (!locals.user) {
throw error(401, '로그인이 필요합니다.');
}
const body = await request.json();
const { title, content, tags } = body;
if (!title || !content) {
throw error(400, '제목과 내용은 필수입니다.');
}
const post = await db.post.create({
data: {
title,
content,
slug: title.toLowerCase().replace(/\s+/g, '-'),
authorId: locals.user.id,
tags: { connect: tags?.map(name => ({ name })) ?? [] },
},
});
return json(post, { status: 201 });
}
// src/routes/api/posts/[id]/+server.js
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db.js';
// GET /api/posts/:id
export async function GET({ params }) {
const post = await db.post.findUnique({ where: { id: Number(params.id) } });
if (!post) throw error(404, '포스트를 찾을 수 없습니다.');
return json(post);
}
// PUT /api/posts/:id
export async function PUT({ params, request, locals }) {
if (!locals.user) throw error(401, '로그인이 필요합니다.');
const body = await request.json();
const post = await db.post.update({
where: { id: Number(params.id) },
data: body,
});
return json(post);
}
// DELETE /api/posts/:id
export async function DELETE({ params, locals }) {
if (!locals.user) throw error(401, '로그인이 필요합니다.');
await db.post.delete({ where: { id: Number(params.id) } });
return new Response(null, { status: 204 });
}
동적 라우트
src/routes/
├── blog/[slug]/ # /blog/hello-world
├── shop/[category]/[id]/ # /shop/electronics/123
└── docs/[...path]/ # /docs/a/b/c (rest parameter)
// src/routes/blog/[slug]/+page.server.js
export async function load({ params }) {
// params.slug = 'hello-world'
const post = await getPostBySlug(params.slug);
return { post };
}
선택적 파라미터
src/routes/[[lang]]/about/ # /about 또는 /ko/about 모두 매칭
export async function load({ params }) {
const lang = params.lang ?? 'ko'; // 기본값 설정
return { lang };
}
레이아웃 중첩
src/routes/
├── +layout.svelte # 루트 레이아웃 (Header, Footer)
├── +page.svelte # 홈
└── admin/
├── +layout.svelte # 관리자 레이아웃 (사이드바)
├── +layout.server.js # 관리자 인증 체크
├── +page.svelte # /admin
└── users/
└── +page.svelte # /admin/users
<!-- src/routes/admin/+layout.svelte -->
<script>
let { data, children } = $props();
</script>
<div class="admin-layout">
<aside class="sidebar">
<nav>
<a href="/admin">대시보드</a>
<a href="/admin/users">사용자 관리</a>
<a href="/admin/posts">포스트 관리</a>
<a href="/admin/settings">설정</a>
</nav>
</aside>
<div class="content">
{@render children()}
</div>
</div>
// src/routes/admin/+layout.server.js
import { redirect } from '@sveltejs/kit';
export async function load({ locals }) {
// 관리자 인증 확인
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(303, '/login?redirect=/admin');
}
return { user: locals.user };
}
어댑터 설정
Cloudflare Pages (권장)
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
export default {
kit: {
adapter: adapter({
routes: {
include: ['/*'],
exclude: ['<all>'],
},
}),
},
};
정적 사이트 생성
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: '404.html',
}),
prerender: {
entries: ['*'], // 모든 경로 사전 렌더링
},
},
};
각 페이지를 SSG로 설정하려면:
// src/routes/blog/+page.js
export const prerender = true; // 이 페이지 사전 렌더링
export const ssr = true; // SSR 활성화 (기본값)
export const csr = true; // CSR 활성화 (기본값)
실전 예제: 블로그 사이트
// src/routes/blog/+page.js
export async function load({ fetch }) {
const res = await fetch('/api/posts?limit=10');
return await res.json();
}
<!-- src/routes/blog/+page.svelte -->
<script>
import { formatDate } from '$lib/utils/date.js';
let { data } = $props();
</script>
<svelte:head>
<title>블로그</title>
</svelte:head>
<h1>블로그</h1>
<div class="posts-grid">
{#each data.posts as post (post.id)}
<article class="post-card">
{#if post.coverImage}
<img src={post.coverImage} alt={post.title} />
{/if}
<div class="content">
<div class="tags">
{#each post.tags as tag}
<span class="tag">{tag}</span>
{/each}
</div>
<h2><a href="/blog/{post.slug}">{post.title}</a></h2>
<p>{post.excerpt}</p>
<footer>
<span>{post.author.name}</span>
<time>{formatDate(post.publishedAt)}</time>
</footer>
</div>
</article>
{/each}
</div>
<style>
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.post-card {
border: 1px solid #eee;
border-radius: 12px;
overflow: hidden;
transition: box-shadow 0.2s;
}
.post-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
.post-card img { width: 100%; height: 200px; object-fit: cover; }
.content { padding: 1.25rem; }
.tags { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.tag {
background: #f0f0f0;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
}
h2 a { text-decoration: none; color: inherit; }
h2 a:hover { color: #ff3e00; }
footer {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: #666;
margin-top: 1rem;
}
</style>
고수 팁
팁 1: hooks.server.js — 서버 미들웨어
// src/hooks.server.js
import { db } from '$lib/server/db.js';
export async function handle({ event, resolve }) {
// 모든 요청에서 세션 체크
const sessionId = event.cookies.get('session');
if (sessionId) {
event.locals.user = await db.session.findUnique({
where: { id: sessionId },
include: { user: true },
}).then(s => s?.user ?? null);
}
const response = await resolve(event);
return response;
}
팁 2: 에러 페이지 커스터마이징
<!-- src/routes/+error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<svelte:head>
<title>에러 {$page.status}</title>
</svelte:head>
<div class="error-page">
<h1>{$page.status}</h1>
<p>{$page.error?.message ?? '알 수 없는 오류가 발생했습니다.'}</p>
<a href="/">홈으로 돌아가기</a>
</div>
팁 3: 내비게이션 상태
<script>
import { navigating, page } from '$app/stores';
</script>
<!-- 내비게이션 중 로딩 표시 -->
{#if $navigating}
<div class="loading-bar"></div>
{/if}
<!-- 현재 경로 기반 활성 링크 -->
<nav>
{#each [['/', '홈'], ['/blog', '블로그'], ['/about', '소개']] as [href, label]}
<a {href} class:active={$page.url.pathname === href}>{label}</a>
{/each}
</nav>
정리
| 개념 | 설명 |
|---|---|
| 파일 기반 라우팅 | src/routes/ 구조가 URL 구조 결정 |
+page.svelte | 페이지 UI 컴포넌트 |
+layout.svelte | 공통 레이아웃 |
load 함수 | 페이지 렌더링 전 데이터 준비 |
| Form Actions | 서버에서 폼 처리 |
+server.js | REST API 엔드포인트 |
[slug] | 동적 URL 파라미터 |
| 어댑터 | 배포 환경별 최적화 |
다음 장에서는 Svelte와 SvelteKit의 실전 고수 팁을 알아봅니다.