SvelteKit 완벽 가이드 | Full Stack·Routing
이 글의 핵심
SvelteKit으로 풀스택 웹 앱을 구축하는 완벽 가이드. 파일 기반 라우팅, Load 함수, Form Actions, Hooks, 배포까지 실전 예제로 정리. SvelteKit·Svelte·Full Stack 중심으로 설명합니다.
처음 SvelteKit 켜봤을 때가 생각난다. 예전 팀에서 Next 쓰다가 사이드 프로젝트만 SvelteKit으로 옮겨봤는데, 라우트 폴더만 열어도 “아, 이 URL은 이 파일이구나”가 바로 보이는 거야. getServerSideProps랑 API Route 파일 나눠 쓰던 머릿속 지도가 한 겹 사라진 느낌이었고, 번들도 가벼워져서 체감이 꽤 컸다. 숫자로 말하면 예전에 React 쪽이 한창 무거울 때랑 비교하면 꽤 줄었지—정확히 몇 퍼센트는 환경마다 달라서 여기선 믿거나 말거나고, 중요한 건 그냥 파일 구조가 말을 해준다는 쪽이다.
SvelteKit은 그냥 Svelte를 풀스택으로 쓰게 해 주는 프레임워크다. 번들이 작고, 문법 보일러플레이트도 덜하고, SSR이랑 API가 같이 온다. 라우팅은 전부 src/routes 아래에서 해결된다.
프로젝트 띄우는 건 이 정도면 된다.
npm create svelte@latest my-app
cd my-app
npm install
npm run dev
폴더만 보면 감이 온다. 루트는 +page.svelte, 하위는 폴더 이름이 URL이고, 동적은 [slug] 같은 식이다. API 느낌은 +server.ts에 두면 된다.
src/routes/
├── +page.svelte
├── about/
│ └── +page.svelte
├── blog/
│ ├── +page.svelte
│ └── [slug]/
│ └── +page.svelte
└── api/
└── users/
└── +server.ts
트리가 곧 매칭 규칙이다. +page.svelte는 그 세그먼트 화면, +layout.svelte는 아래 전부 감싸는 껍데기. 데이터는 +page.ts나 +page.server.ts의 load가 담당하고, REST나 웹훅은 +server.ts. 동적·선택적·rest 파라미터 조합도 다 컨벤션으로 박혀 있어서 문서만 오래 읽을 필요가 덜하다.
여기서 내가 꽤 단정적으로 말하고 싶은 건 하나 있는데, 나한텐 +page.server.ts가 제일 직관적이야. “이 경로에서 서버에서만 돌아가는 일”이 파일 이름에 박혀 있잖아. 범용 +page.ts는 클라이언트에서도 돌 수 있어서 편할 때도 있지만, 시크릿이나 DB 붙일 땐 실수 여지가 있고—그냥 서버 전용이면 +page.server.ts에 몰아넣는 편이 마음이 편하다. 부모 +layout.server.ts가 먼저 돌고 자식으로 내려가는 것도, 파일만 봐도 순서가 그려진다.
첫 방문이면 서버가 HTML이랑 직렬화된 load 결과를 같이 내려주고, 그다음부터는 클라이언트 라우터가 필요한 load만 다시 부른다. depends('custom:key')랑 invalidate로 “이거 바뀌면 다시 불러”를 선언해 둘 수 있다는 것도 실사용에서 꽤 잘 쓴다.
load 안의 fetch는 앱 내부 상대 경로에서 중복을 합쳐 주는 등 일반 fetch랑 다르게 동작한다는 것만 기억해두면 된다.
공개 API만 긁어오는 로드는 +page.ts 예시 정도로 충분하다.
// src/routes/blog/+page.ts
export async function load({ fetch }) {
const response = await fetch('/api/posts');
const posts = await response.json();
return {
posts,
};
}
<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
export let data;
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post}
<li>
<a href="/blog/{post.slug}">{post.title}</a>
</li>
{/each}
</ul>
DB랑 붙는 건 말한 대로 서버 파일에 두는 게 낫다. 나는 이 패턴이 Next에서 API Route + 페이지 곳곳에 흩어지던 걸 한 파일 근처로 끌어당긴 느낌이라고 본다.
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const post = await db.post.findUnique({
where: { slug: params.slug },
});
if (!post) {
throw error(404, 'Post not found');
}
return { post };
}
./$types 쪽에 생성되는 타입으로 시그니처를 맞추고, load가 넘기는 값은 직렬화되는 것들로만 쓰는 습관을 들이자. 클래스 인스턴스나 함수를 밀어 넣었다가 하이드레이션이 꼬이는 경우는 진짜로 있다.
폼은 Form Actions 쓰면 API Route를 따로 파지 않아도 되는 경우가 많다. 로그인 예시는 흔하니 그대로 두되, fail·redirect 흐름이 한 파일에 있다는 점이 편하다.
// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
if (!email || !password) {
return fail(400, { email, missing: true });
}
const user = await authenticateUser(email, password);
if (!user) {
return fail(401, { email, incorrect: true });
}
cookies.set('session', user.sessionId, { path: '/' });
throw redirect(303, '/dashboard');
},
};
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
export let form;
</script>
<form method="POST" use:enhance>
<input name="email" type="email" value={form?.email ?? ''} required />
<input name="password" type="password" required />
{#if form?.missing}
<p class="error">Email and password are required</p>
{/if}
{#if form?.incorrect}
<p class="error">Invalid credentials</p>
{/if}
<button type="submit">Login</button>
</form>
SSR 파이프라인을 장황하게 표로 늘릴 생각은 없다. 대충 서버에서 HTML·데이터 스냅샷을 만들고 → 브라우저에서 JS로 같은 트리를 붙인다고 보면 되고, 스트리밍 켜면 일부 UI를 먼저 흘리기도 하고, 프리렌더/CSR off는 SEO랑 잘 trade-off 보면 된다. 하이드레이션 경고 나오면 Date.now() 같은 비결정적 값을 렌더에 직접 박지 말고 data로 맞추는 쪽으로 가면 된다.
API만 필요하면 +server.ts에서 메서드별로.
// src/routes/api/users/+server.ts
import { json } from '@sveltejs/kit';
export async function GET() {
const users = await db.user.findMany();
return json(users);
}
export async function POST({ request }) {
const data = await request.json();
const user = await db.user.create({
data: {
name: data.name,
email: data.email,
},
});
return json(user, { status: 201 });
}
hooks.server.ts의 handle은 모든 요청에 끼어든다. 세션 풀어서 event.locals에 풀어 두고, 대시보드 load에서 locals.user 없으면 redirect—이 정도 흐름이면 끝.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const session = event.cookies.get('session');
if (session) {
event.locals.user = await getUserFromSession(session);
}
return resolve(event);
};
// src/routes/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit';
export async function load({ locals }) {
if (!locals.user) {
throw redirect(303, '/login');
}
return {
user: locals.user,
};
}
배포는 호스팅에 맞는 어댑터 고르는 문제다. Vercel이면 @sveltejs/adapter-vercel, Cloudflare면 adapter-cloudflare, 아니면 Node 어댑터. 런타임 제한(파일시스템, 실행 시간, WS)이 플랫폼마다 달라서, 어디에 올릴지 먼저 정해두는 게 스트레스가 덜하다.
npm install -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter(),
},
};
Next랑 뭐가 나은가는 결국 팀 상황이다. 가볍고 성능·번들 쪽 민감하면 SvelteKit 쪽이 난 잘 맞았고, npm에 깔린 컴포넌트·예제를 최대로 가져가야 하면 Next 쪽 풀이 더 넓다. Svelte는 배워야 한다—근데 SvelteKit 붙이면 그걸 풀스택으로 쓰는 길이 열리는 거고, 그 학습은 생각보다 짧다. 프로덕션? 1.0도 지났고, 이 글 쓰는 시점엔 쓸 만하다. TypeScript? 당연히 쓰면 된다. load가 자꾸 두 번 도는 느낌이면 레이아웃이랑 페이지 load가 둘 다 있어서일 수도 있고, invalidate나 HMR 때문일 수도—부작용은 +page.server 액션이나 엔드포인트로 빼는 게 안전하다.
프로덕션에서 +error.svelte로 실패 UI 잡고, use:enhance 써도 서버에서 검증·권한은 꼭 하고, 환경 변수는 public/private 헷갈리면 큰 사고다. 관측은 handle에서 요청 ID 박는 식으로 통일해도 잘 먹힌다. 운영 쪽에서 자주 쓰는 사고를 나누자면, 간헐적 실패는 보통 외부 의존이나 타임아웃, 성능은 N+1이나 직렬화 과다, 메모리는 캐시 무한·리스너 누수, 배포만 깨지면 env·lockfile·이미지 버전—대충 이 축에서 보면 빨리 좁힌다. 스테이징은 데이터 양이랑 RTT만 프로덕션에 가깝게 잡혀도 재현이 확 좋아진다. 올릴 땐 git add → commit → push → npm run deploy 정도의 리듬으로.
같이 읽을 만한 건 Svelte 5 쪽, Remix, Next App Router 가이드 정도. 키워드는 그냥 SvelteKit, Svelte, 풀스택, SSR 정도로 찾아보면 된다.