SvelteKit으로 풀스택 앱 만들기: load 함수, Form Actions, API 라우트, Hooks, 배포 어댑터
이 글의 핵심
SvelteKit의 파일 라우팅과 load 함수가 서버·클라이언트 중 어디서 도는지, Form Actions로 JS 없이도 동작하는 폼 만들기, API 라우트와 Hooks, 배포 어댑터 선택을 다룹니다.
SvelteKit을 처음 써 보면 가장 먼저 눈에 들어오는 것은 src/routes 폴더만 열어도 URL 구조가 그대로 보인다는 점입니다. Next.js Pages Router 시절에 getServerSideProps와 pages/api 파일을 오가며 머릿속으로 그리던 지도가 “이 URL은 이 폴더, 이 폴더의 서버 로직은 이 파일”로 단순해집니다. Svelte가 컴파일러 방식이라 런타임 코드가 적게 번들된다는 장점도 있지만, 실제로 개발 경험에 가장 큰 차이를 만드는 것은 파일 이름이 코드가 어디서 실행되는지를 알려 준다는 규칙입니다.
SvelteKit은 Svelte 컴포넌트 위에 라우팅, 서버 사이드 렌더링(SSR), 데이터 로딩, 폼 처리, API 엔드포인트, 배포 어댑터를 얹은 공식 풀스택 프레임워크입니다. Vite 기반이라 개발 서버가 빠르고, 한 프로젝트 안에서 화면과 서버 로직을 같이 다룹니다. 이 글은 SvelteKit 2와 Svelte 5 기준으로 설명하며, 오래된 튜토리얼과 달라진 부분은 본문에서 따로 짚습니다.
프로젝트 생성과 라우팅 기본
새 프로젝트는 공식 CLI로 만듭니다. 예전 튜토리얼에 나오는 npm create svelte@latest는 폐기되었고, 현재는 sv CLI를 씁니다.
npx sv create my-app
cd my-app
npm install
npm run dev
CLI가 TypeScript, ESLint, Prettier, Playwright 같은 도구를 설정할지 물어보며, 나중에 npx sv add로 추가할 수도 있습니다. 라우팅은 폴더 구조가 곧 규칙입니다. 루트 화면은 src/routes/+page.svelte, 하위 경로는 폴더 이름이 URL 세그먼트가 되고, [slug]처럼 대괄호로 감싼 폴더는 동적 파라미터가 됩니다. HTML이 아닌 JSON 응답이나 웹훅 엔드포인트는 +server.ts에 둡니다.
src/routes/
├── +page.svelte
├── about/
│ └── +page.svelte
├── blog/
│ ├── +page.svelte
│ └── [slug]/
│ └── +page.svelte
└── api/
└── users/
└── +server.ts
+ 접두사가 붙은 파일만 라우팅에 쓰이므로, 같은 폴더에 Card.svelte 같은 일반 컴포넌트를 함께 두어도 URL이 생기지 않습니다. 선택적 파라미터([[lang]]), 나머지 파라미터([...path]), URL에 영향을 주지 않는 그룹 폴더((marketing))도 같은 규칙으로 표현합니다.
Load 함수: +page.ts와 +page.server.ts
+page.svelte는 해당 경로의 화면이고, +layout.svelte는 그 아래 모든 경로를 감싸는 공통 껍데기입니다. 화면에 필요한 데이터는 load 함수가 준비하는데, 이 함수를 어느 파일에 두느냐에 따라 실행 위치가 달라집니다. +page.server.ts의 load는 항상 서버에서만 실행되므로 DB 접근, 비밀 키 사용, 쿠키 읽기가 안전합니다. +page.ts의 load는 유니버설 로드라서 첫 요청 때는 서버에서, 이후 클라이언트 내비게이션 때는 브라우저에서 실행됩니다.
저는 판단이 애매하면 +page.server.ts부터 씁니다. 파일 이름만 보고도 “이 코드는 서버에서만 돈다”는 것이 확실하기 때문입니다. +page.ts에 실수로 DB 클라이언트나 $env/static/private를 import하면 SvelteKit이 빌드 단계에서 “Cannot import $env/static/private into client-side code” 같은 에러로 막아 주긴 하지만, 외부 API 키를 문자열로 직접 넣는 실수까지 막아 주지는 못합니다. 반대로 공개 API를 호출하기만 하는 로드라면 +page.ts가 유리합니다. 클라이언트 내비게이션 때 서버를 한 번 더 거치지 않고 브라우저가 API를 직접 호출하기 때문입니다.
첫 방문에서는 서버가 HTML과 함께 직렬화된 load 결과를 내려보내고, 브라우저는 이 데이터로 하이드레이션합니다. 그 뒤 페이지를 이동할 때는 클라이언트 라우터가 필요한 load만 다시 실행합니다. 로드 함수가 params, url, 또는 depends('custom:key')로 선언한 의존성 중 무엇을 읽었는지 SvelteKit이 추적하기 때문에, 관련 값이 바뀌거나 invalidate('custom:key')를 호출했을 때만 다시 실행됩니다.
load가 받는 fetch는 브라우저 기본 fetch와 다르게 동작한다는 점도 알아 두어야 합니다. 서버에서 실행될 때 /api/posts 같은 상대 경로를 쓸 수 있고, 같은 앱의 +server.ts는 실제 HTTP 요청 없이 내부 함수 호출로 처리됩니다. 또 서버 렌더링 중에 받은 응답이 HTML에 인라인되므로, 하이드레이션 때 브라우저가 같은 요청을 다시 보내지 않습니다. 전역 fetch를 쓰면 이 이점을 모두 잃고, 서버에서 상대 경로를 쓰면 에러가 납니다.
// 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">
let { data } = $props();
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post}
<li>
<a href="/blog/{post.slug}">{post.title}</a>
</li>
{/each}
</ul>
load가 반환한 객체는 페이지 컴포넌트의 data prop으로 들어옵니다. Svelte 5에서는 $props() 룬으로 받고, Svelte 4 이하 코드에서는 export let data;로 받습니다. 두 문법이 섞인 예제가 인터넷에 많으니 프로젝트의 Svelte 버전을 먼저 확인하는 것이 좋습니다.
DB를 조회하는 로드는 서버 파일에 둡니다. Next.js에서 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) {
error(404, 'Post not found');
}
return { post };
}
SvelteKit 1에서는 throw error(404, ...)처럼 직접 throw했지만, SvelteKit 2부터 error()와 redirect()는 스스로 예외를 던지므로 throw를 붙이지 않습니다. 주의할 점은 try/catch 블록 안에서 redirect()를 호출하면 그 예외가 catch에 잡혀 리다이렉트가 동작하지 않는다는 것입니다. 이 함수들은 try 바깥에서 호출하거나, catch에서 isRedirect()로 걸러 다시 던져야 합니다.
타입은 ./$types에서 생성되는 PageServerLoad 같은 타입으로 맞추면 params.slug의 존재와 data의 형태가 컴포넌트까지 자동으로 추론됩니다. 그리고 +page.server.ts의 반환값은 네트워크를 거쳐 브라우저로 가므로 직렬화 가능한 값이어야 합니다. SvelteKit은 devalue로 직렬화해서 Date, Map, Set, BigInt까지는 지원하지만 함수나 클래스 인스턴스를 반환하면 “Data returned from load … is not serializable” 에러가 납니다. ORM이 반환한 모델 객체를 그대로 넘기다 이 에러를 만나는 경우가 많으므로, 필요한 필드만 골라 평범한 객체로 반환하는 습관을 들이면 좋습니다.
Form Actions로 폼 처리하기
Form Actions는 +page.server.ts에 actions를 내보내 HTML <form method="POST"> 제출을 서버에서 처리하는 기능입니다. 별도의 API 엔드포인트와 클라이언트 fetch 코드를 만들 필요가 없고, 무엇보다 JavaScript가 로드되지 않아도 동작합니다. 브라우저의 기본 폼 제출이 그대로 서버 액션을 호출하기 때문입니다.
// 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: '/' });
redirect(303, '/dashboard');
},
};
fail(400, {...})은 검증 실패를 알리는 응답으로, 두 번째 인자가 페이지의 form prop으로 돌아옵니다. 예제처럼 사용자가 입력한 email을 돌려주면 폼을 다시 그릴 때 값이 남아 있어 사용자 경험이 좋아지지만, 비밀번호는 절대 돌려주면 안 됩니다. 성공 후 redirect(303, ...)을 쓰는 이유는 POST 응답 뒤에 새로고침했을 때 폼이 다시 제출되는 문제를 피하고 브라우저가 GET으로 이동하게 하기 위해서입니다. cookies.set에서 SvelteKit은 기본적으로 httpOnly와 sameSite: 'lax'를 적용하고, path는 SvelteKit 2부터 필수 인자입니다.
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</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>
use:enhance는 JavaScript가 로드된 경우에만 폼 제출을 가로채 fetch로 보내고, 페이지 전체를 새로고침하지 않고 form 값과 데이터를 갱신합니다. JavaScript가 없으면 일반 폼 제출로 동작하므로, 같은 코드가 두 환경에서 모두 작동하는 점진적 향상(progressive enhancement)이 됩니다. 한 페이지에 폼이 여러 개라면 default 대신 login, register처럼 이름 있는 액션을 정의하고 action="?/register"로 지정합니다. 이름 있는 액션과 default는 같은 파일에 함께 둘 수 없다는 제약이 있습니다.
SSR과 하이드레이션
SvelteKit은 기본적으로 첫 요청을 서버에서 렌더링해 완성된 HTML을 보내고, 브라우저에서 JavaScript가 로드되면 같은 컴포넌트 트리를 기존 DOM에 연결하는 하이드레이션을 수행합니다. 이후 내비게이션은 클라이언트 라우터가 처리하므로 SPA처럼 빠르게 동작합니다. 페이지나 레이아웃 파일에서 export const ssr = false로 SSR을 끄거나, export const prerender = true로 빌드 시점에 정적 HTML을 만들 수 있고, csr = false로 클라이언트 JavaScript를 아예 보내지 않게 할 수도 있습니다.
하이드레이션 단계에서 자주 만나는 문제는 서버와 브라우저의 렌더링 결과가 달라지는 경우입니다. Date.now(), Math.random(), new Date().toLocaleString()처럼 실행 시점이나 환경에 따라 값이 달라지는 코드를 렌더링에 직접 쓰면 서버 HTML과 클라이언트 결과가 어긋납니다. 이런 값은 load에서 한 번 계산해 data로 넘기거나, 브라우저 전용 값이라면 onMount 안에서 설정합니다. window나 localStorage를 컴포넌트 최상위에서 참조하면 서버 렌더링 중에 “window is not defined”가 나는 것도 같은 맥락이며, $app/environment의 browser 플래그로 분기하면 됩니다.
API 라우트: +server.ts
HTML 대신 JSON을 돌려주는 엔드포인트가 필요하면 +server.ts에서 HTTP 메서드 이름으로 함수를 내보냅니다. 모바일 앱이나 외부 서비스가 호출하는 API, 웹훅 수신, 파일 다운로드 같은 경우에 적합합니다.
// 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 });
}
각 함수는 웹 표준 Request를 받고 Response를 반환하므로 SvelteKit에 종속된 API가 거의 없습니다. 같은 앱의 화면에서만 쓰는 데이터라면 +server.ts보다 load와 Form Actions가 낫습니다. 타입이 자동으로 이어지고, 앞에서 설명한 CSRF Origin 검사도 폼 제출에 기본 적용되기 때문입니다. 반대로 +server.ts의 POST는 JSON 요청이라 Origin 검사 대상이 아니므로, 쿠키 인증을 쓰는 API라면 CORS와 인증 토큰 검증을 직접 챙겨야 합니다. 예제의 POST도 data.name과 data.email을 검증 없이 저장하고 있으므로, 실무에서는 Zod 같은 스키마 검증을 앞에 두는 것이 좋습니다.
Hooks로 요청 가로채기
src/hooks.server.ts의 handle 함수는 페이지, 액션, API를 가리지 않고 모든 서버 요청 앞에서 실행되는 미들웨어입니다. 세션 쿠키를 해석해 event.locals에 사용자 정보를 넣어 두면, 이후의 모든 load와 액션에서 locals.user로 꺼내 쓸 수 있습니다.
// 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) {
redirect(303, '/login');
}
return {
user: locals.user,
};
}
TypeScript에서 locals.user를 쓰려면 src/app.d.ts의 App.Locals 인터페이스에 타입을 선언해야 하며, 선언하지 않으면 “Property ‘user’ does not exist on type ‘Locals’” 에러가 납니다. 권한 검사를 레이아웃 load에만 두는 것은 흔한 함정입니다. 레이아웃 load는 자식 페이지의 load나 Form Actions 실행을 막지 않으므로, 보호가 필요한 액션과 API에서는 각각 locals.user를 다시 확인하거나 handle에서 경로 단위로 막아야 합니다. handle은 요청 ID를 부여해 로그에 남기거나 응답 헤더를 일괄로 붙이는 관측 용도로도 편리합니다.
배포 어댑터 고르기
SvelteKit은 빌드 결과를 특정 플랫폼 형식으로 바꿔 주는 어댑터를 통해 배포합니다. 기본 템플릿의 adapter-auto는 Vercel, Netlify, Cloudflare Pages 같은 환경을 자동 감지하지만, 배포 대상이 정해졌다면 전용 어댑터를 명시하는 편이 설정을 세밀하게 조정할 수 있어 좋습니다. 자체 서버나 Docker 컨테이너라면 adapter-node, 서버 로직이 전혀 없는 사이트라면 adapter-static을 씁니다.
npm install -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter(),
},
};
어댑터를 고를 때 가장 먼저 확인할 것은 런타임 제약입니다. Cloudflare Workers나 Vercel Edge 같은 엣지 런타임은 Node.js의 fs, 일부 crypto API, 네이티브 모듈을 지원하지 않을 수 있고 실행 시간과 메모리 한도도 작습니다. 로컬 npm run dev는 Node.js에서 돌기 때문에 개발 중에는 멀쩡하다가 배포 후에만 “No such module” 류의 에러가 나는 경우가 이것입니다. WebSocket처럼 연결을 오래 유지하는 기능은 서버리스 환경에서 지원되지 않는 경우가 많으므로 adapter-node로 상시 서버를 띄우는 편이 맞습니다. adapter-static을 쓸 때는 모든 페이지가 prerender 가능해야 하고, 그렇지 않으면 빌드가 “Encountered dynamic routes” 에러로 실패합니다.
마무리: Next.js와 비교, 실전 팁
SvelteKit과 Next.js 중 무엇이 나은지는 팀 상황에 달려 있습니다. 번들 크기와 초기 로딩 성능에 민감하고 팀이 새 문법을 배우는 데 부담이 적다면 SvelteKit이 잘 맞고, 사내에 React 컴포넌트 자산이 많거나 서드파티 라이브러리 생태계를 최대한 활용해야 한다면 Next.js가 선택지가 넓습니다. Svelte 문법 자체는 HTML·CSS·JS에 가까워 학습 기간이 짧은 편이지만, Svelte 5의 룬($state, $derived, $props)은 이전 버전과 사고방식이 달라서 예제 코드의 버전을 확인하는 습관이 필요합니다.
운영 단계에서 챙길 것도 몇 가지 있습니다. src/routes/+error.svelte로 에러 화면을 만들어 두고, use:enhance를 쓰더라도 입력 검증과 권한 확인은 반드시 서버 액션에서 수행합니다. 환경 변수는 $env/static/public(브라우저에 노출됨, PUBLIC_ 접두사 필요)과 $env/static/private(서버 전용)를 구분하는데, 비밀 키를 PUBLIC_ 변수로 선언하면 번들에 그대로 들어가므로 가장 큰 사고로 이어집니다. load가 예상보다 자주 실행된다면 url 객체 전체를 읽어서 쿼리 문자열 변경에도 반응하도록 의존성이 잡혔는지, 레이아웃과 페이지 양쪽에 같은 요청이 있는지를 먼저 확인해 보세요. 데이터를 바꾸는 부작용은 load가 아니라 Form Actions나 +server.ts에 두어야 재실행에 안전합니다.