본문으로 건너뛰기
Advertisement

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.jsAPI 엔드포인트서버만
+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.jsREST API 엔드포인트
[slug]동적 URL 파라미터
어댑터배포 환경별 최적화

다음 장에서는 Svelte와 SvelteKit의 실전 고수 팁을 알아봅니다.

Advertisement