SvelteKit 3: Svelte로 풀스택 웹 앱 만들기

SvelteKit 3: Svelte로 풀스택 웹 앱 만들기

Svelte 5 글에서 룬으로 컴포넌트를 만들어 봤는데요. 컴포넌트만으로는 웹 앱이 완성되지 않습니다. 주소마다 다른 페이지를 보여 줘야 하고, 서버에서 데이터를 가져와야 하고, 폼을 처리하고, 어딘가에 배포도 해야 하죠. 🤔

이런 일을 모두 맡아 주는 것이 Svelte 팀이 직접 만드는 SvelteKit입니다. 마침 2026년 10월 1일에 SvelteKit 3가 정식으로 출시됐는데요. 설정 파일이 바뀌고 $lib 별칭이 #lib로 바뀌는 등 프로젝트 구조부터 달라졌습니다. 이번 글에서는 SvelteKit 3로 작은 블로그 앱을 만들면서 라우팅, 데이터 불러오기, 폼 액션(form action), 환경 변수, 배포까지 차례로 살펴보겠습니다.

SvelteKit이 하는 일

Svelte가 UI 컴포넌트를 만드는 도구라면, SvelteKit은 그 위에 웹 앱에 필요한 나머지를 얹은 메타 프레임워크입니다. React에 Next.js가 있고 Vue.js에 Nuxt가 있듯이, Svelte에는 SvelteKit이 있다고 보시면 됩니다. 메타 프레임워크의 개념은 메타 프레임워크 - 프레임워크를 위한 프레임워크에서 자세히 다뤘습니다.

SvelteKit은 페이지마다 렌더링 방식을 고를 수 있습니다. 기본은 서버에서 HTML을 그려 보내는 서버 사이드 렌더링(SSR)이고, 이후 페이지 이동은 브라우저에서 처리합니다. 설정 한 줄로 특정 페이지를 빌드 시점에 미리 그려 둘 수도 있고, 서버 없이 SPA로 동작하게 만들 수도 있습니다. 각 방식의 차이가 궁금하다면 SPA와 SSG, 그리고 SSR을 먼저 읽어 보세요.

빌드 도구로는 Vite를 씁니다. SvelteKit은 Vite 플러그인 형태로 동작하기 때문에 Vite의 빠른 개발 서버와 플러그인 생태계를 그대로 누립니다.

프로젝트 만들기

SvelteKit 프로젝트는 Svelte 공식 CLI인 sv로 만듭니다. Bun을 쓴다면 다음과 같이 실행합니다.

bunx sv create kit-app --template minimal --types ts --install bun

npm을 쓴다면 다음과 같습니다.

npx sv create kit-app --template minimal --types ts --install npm

옵션을 빼고 bunx sv create만 실행하면 템플릿, 타입 검사, 추가 기능(add-on)을 하나씩 물어봅니다. ESLint, Prettier, Tailwind CSS, Playwright 같은 도구를 이 단계에서 함께 설치하니 편리합니다.

개발 서버를 띄워 보겠습니다.

cd kit-app
bun run dev
결과
$ vite dev
6:00:15 PM [vite] (ssr) connected.
6:00:15 PM [vite] (client) Forced re-optimization of dependencies

  VITE v8.3.2  ready in 512 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

생성된 프로젝트의 구조는 다음과 같습니다.

kit-app/
├── src/
│   ├── app.d.ts
│   ├── app.html
│   ├── lib/
│   │   ├── assets/favicon.svg
│   │   └── index.ts
│   └── routes/
│       ├── +layout.svelte
│       └── +page.svelte
├── static/
│   └── robots.txt
├── package.json
├── tsconfig.json
└── vite.config.ts

SvelteKit 2를 써 본 분이라면 svelte.config.js가 보이지 않는 게 눈에 띌 텐데요. 3.0부터는 모든 설정이 vite.config.ts 안의 sveltekit() 플러그인 옵션으로 들어갑니다.

vite.config.ts
import adapter from "@sveltejs/adapter-auto";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      compilerOptions: {
        // 라이브러리를 제외한 프로젝트 코드는 항상 룬 모드로 컴파일
        runes: ({ filename }) =>
          filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
      },
      adapter: adapter(),
    }),
  ],
});

설정 파일이 둘로 나뉘어 있을 때는 어떤 옵션을 어디에 써야 할지 헷갈렸는데, 이제 한곳에서 관리합니다.

package.json에도 새 필드가 생겼습니다.

package.json
{
  "imports": {
    "#lib": "./src/lib/index.js",
    "#lib/*": "./src/lib/*"
  }
}

src/lib 폴더를 가리키던 $lib 별칭이 #lib로 바뀌었는데요. #으로 시작하는 이 문법은 SvelteKit 전용 기능이 아니라 Node.js 표준인 하위 경로 임포트(subpath imports)입니다. 그래서 TypeScript, Vitest, 에디터 같은 도구가 별도 설정 없이 경로를 이해합니다. 표준을 따르는 만큼 import할 때 #lib/server/posts.js처럼 확장자도 붙여야 합니다.

파일 기반 라우팅

SvelteKit의 라우팅은 파일 기반 라우팅(file-based routing)입니다. src/routes 아래의 폴더 구조가 그대로 URL이 되는데요. 폴더 안에 +page.svelte 파일을 두면 그 경로의 페이지가 됩니다.

src/routes/
├── +layout.svelte          → 모든 페이지를 감싸는 레이아웃
├── +page.svelte            → /
├── guestbook/
│   └── +page.svelte        → /guestbook
└── posts/
    └── [slug]/
        └── +page.svelte    → /posts/hello-sveltekit, /posts/runes, ...

[slug]처럼 대괄호로 감싼 폴더는 동적 경로입니다. /posts/runes로 들어오면 slug 매개변수에 runes가 담깁니다.

파일 이름 앞에 붙은 + 기호는 SvelteKit이 특별하게 다루는 파일이라는 표시입니다. 덕분에 같은 폴더에 PostCard.svelte 같은 일반 컴포넌트를 함께 두어도 라우트로 오해받지 않습니다.

+layout.svelte는 하위 페이지를 모두 감싸는 레이아웃(layout)입니다. 현재 페이지 정보는 $app/state 모듈의 page 객체에 담겨 있어서, 내비게이션에서 현재 메뉴를 표시하기 좋습니다.

src/routes/+layout.svelte
<script lang="ts">
  import { page } from "$app/state";
  import type { LayoutProps } from "./$types";

  let { children }: LayoutProps = $props();
</script>

<nav>
  <a href="/" aria-current={page.url.pathname === "/" ? "page" : undefined}>글</a>
  <a
    href="/guestbook"
    aria-current={page.url.pathname === "/guestbook" ? "page" : undefined}
  >
    방명록
  </a>
</nav>

{@render children()}

children은 Svelte 5 글에서 본 스니펫(snippet)입니다. 각 페이지가 {@render children()} 자리에 그려집니다.

./$types에서 가져오는 LayoutProps는 SvelteKit이 라우트마다 자동으로 만들어 주는 타입입니다. 라우트 구조와 load 함수의 반환값을 분석해서 생성하기 때문에, 직접 타입을 적지 않아도 데이터 구조가 그대로 따라옵니다.

load 함수로 데이터 불러오기

페이지에 필요한 데이터는 load 함수로 불러옵니다. +page.svelte 옆에 +page.server.ts 파일을 만들고 load 함수를 내보내면 됩니다.

먼저 글 데이터를 다루는 모듈을 src/lib/server에 만들어 보겠습니다. 실제 앱이라면 데이터베이스를 조회하겠지만, 예제에서는 배열로 대신합니다.

src/lib/server/posts.ts
export type Post = { slug: string; title: string; content: string };

const posts: Post[] = [
  { slug: "hello-sveltekit", title: "SvelteKit 시작하기", content: "SvelteKit 3로 첫 페이지를 만들었습니다." },
  { slug: "runes", title: "룬 정리", content: "$state, $derived, $effect를 정리했습니다." },
];

export function getPosts() {
  return posts;
}

export function getPost(slug: string) {
  return posts.find((post) => post.slug === slug);
}

src/lib/server 폴더는 서버 전용 모듈을 두는 특별한 위치입니다. 브라우저에서 실행되는 코드가 이 폴더의 모듈을 import하면 SvelteKit이 빌드를 막아서, 데이터베이스 접속 정보 같은 비밀이 클라이언트 번들에 섞여 나가는 사고를 방지합니다.

이제 글 목록 페이지에서 데이터를 불러옵니다.

src/routes/+page.server.ts
import { getPosts } from "#lib/server/posts.js";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = () => {
  return { posts: getPosts() };
};

load가 반환한 객체는 페이지 컴포넌트의 data prop으로 전달됩니다.

src/routes/+page.svelte
<script lang="ts">
  import type { PageProps } from "./$types";

  let { data }: PageProps = $props();
</script>

<h1>글 목록</h1>
<ul>
  {#each data.posts as post (post.slug)}
    <li><a href="/posts/{post.slug}">{post.title}</a></li>
  {/each}
</ul>

data.posts에 마우스를 올려 보면 Post[] 타입이 정확히 잡혀 있는 것을 볼 수 있어요. 서버 코드와 화면 코드 사이에 타입을 직접 맞출 필요가 없습니다.

글 상세 페이지에서는 params로 동적 경로의 값을 받습니다. 글이 없을 때는 error() 함수로 404를 던집니다.

src/routes/posts/[slug]/+page.server.ts
import { error } from "@sveltejs/kit";
import { getPost } from "#lib/server/posts.js";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = ({ params }) => {
  const post = getPost(params.slug);
  if (!post) error(404, "글을 찾을 수 없습니다");
  return { post };
};
src/routes/posts/[slug]/+page.svelte
<script lang="ts">
  import type { PageProps } from "./$types";

  let { data }: PageProps = $props();
</script>

<article>
  <h1>{data.post.title}</h1>
  <p>{data.post.content}</p>
</article>

error()를 호출하면 그 뒤의 코드는 실행되지 않기 때문에, 아래쪽 return { post }에서 post는 undefined가 아닌 것으로 좁혀집니다. 3.0부터 error()의 두 번째 인자는 항상 메시지 문자열이고, 오류 코드 같은 추가 정보는 세 번째 인자로 넘깁니다.

그런데 load 함수를 담는 파일이 +page.server.ts와 +page.ts 두 가지라 헷갈릴 수 있는데요. +page.server.ts의 load는 항상 서버에서만 실행되므로 데이터베이스나 비밀 키에 접근할 수 있습니다. 반면 +page.ts의 load는 첫 요청 때는 서버에서, 이후 페이지 이동 때는 브라우저에서 실행되는 유니버설 load(universal load)입니다. 공개 API를 호출하는 정도라면 +page.ts로 충분하고, 서버 자원이 필요하면 +page.server.ts를 쓰면 됩니다.

폼 액션으로 데이터 쓰기

데이터를 읽었으니 이번에는 써 보겠습니다. SvelteKit에서는 +page.server.ts에서 actions를 내보내 폼 제출을 처리합니다. 방명록 페이지를 만들어 볼게요.

src/routes/guestbook/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";

const messages: string[] = [];

export const load: PageServerLoad = () => {
  return { messages };
};

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const message = formData.get("message")?.toString().trim();

    if (!message) {
      return fail(400, { missing: true });
    }

    messages.push(message);
    return { success: true };
  },
} satisfies Actions;

입력값이 비어 있으면 fail()로 400 상태 코드와 함께 실패 정보를 돌려줍니다. fail()이 반환한 데이터와 액션의 반환값은 페이지 컴포넌트의 form prop으로 전달됩니다.

src/routes/guestbook/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
</script>

<h1>방명록</h1>

<form method="POST" use:enhance>
  <input name="message" placeholder="한마디 남겨 주세요" />
  <button>남기기</button>
</form>

{#if form?.missing}
  <p>내용을 입력해 주세요.</p>
{/if}

<ul>
  {#each data.messages as message}
    <li>{message}</li>
  {/each}
</ul>

이 폼은 평범한 HTML <form method="POST">입니다. 자바스크립트가 꺼져 있어도 브라우저가 폼을 제출하고, 서버가 액션을 실행한 뒤 새 페이지를 돌려줍니다.

여기에 use:enhance를 붙이면 자바스크립트가 켜진 환경에서는 페이지를 새로 고치지 않고 fetch로 제출합니다. 제출이 끝나면 load를 다시 실행해서 목록을 갱신하고 form prop도 업데이트하죠. 기본 HTML 동작 위에 자바스크립트로 경험을 덧입히는 이런 방식을 점진적 향상(progressive enhancement)이라고 합니다.

폼 액션에는 교차 사이트 요청 위조(CSRF) 방어도 기본으로 들어 있습니다. 요청의 Origin 헤더가 앱의 주소와 다르면 SvelteKit이 거부합니다. 실제로 다른 출처를 흉내 내서 POST를 보내 보면 다음과 같은 응답이 옵니다.

curl -X POST -H "Origin: https://evil.example" -d "message=x" http://localhost:3000/guestbook
결과
Cross-site POST form submissions are forbidden

환경 변수 선언하기

3.0에서 가장 크게 좋아진 부분 중 하나가 환경 변수입니다. 이제는 src/env.ts에서 앱이 쓰는 환경 변수를 미리 선언합니다.

src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  SITE_NAME: {
    public: true,
    schema: (value) => value ?? "내 블로그",
  },
  DATABASE_URL: {
    description: "데이터베이스 연결 문자열",
  },
});

public: true로 선언한 변수는 브라우저 코드에서도 쓸 수 있고, 나머지는 서버에서만 쓸 수 있습니다. schema에는 값을 검증하거나 기본값을 채우는 함수를 넘기는데, Zod나 Valibot 같은 Standard Schema 호환 검증기도 그대로 넘길 수 있습니다.

선언한 변수는 공개 여부에 따라 서로 다른 모듈에서 가져옵니다.

src/routes/+layout.svelte
<script lang="ts">
  import { SITE_NAME } from "$app/env/public";
</script>

<strong>{SITE_NAME}</strong>
src/lib/server/db.ts
import { DATABASE_URL } from "$app/env/private";

$app/env/private는 서버 전용 모듈이라 브라우저 코드에서 import하면 빌드가 실패합니다. 그리고 선언만 하고 값을 설정하지 않은 필수 변수가 있으면 빌드 단계에서 바로 알려 줍니다. DATABASE_URL 없이 빌드해 보면 다음과 같은 오류가 납니다.

결과
error during build:
[SvelteKit error: env_invalid
Invalid environment variables:

DATABASE_URL
  - Value is missing. If it is optional, add a validator declaring it as such.

https://svelte.dev/e/kit/env_invalid]

환경 변수가 빠진 채로 배포했다가 운영 중에 터지는 것보다 훨씬 낫죠. 선언한 변수는 타입까지 생성되어서, 에디터에서 description에 적은 설명도 함께 보입니다.

어댑터로 배포하기

SvelteKit은 어댑터(adapter)를 바꿔 끼우는 방식으로 여러 플랫폼에 배포합니다. sv create가 설치해 주는 adapter-auto는 Vercel, Netlify, Cloudflare 같은 플랫폼을 자동으로 감지해서 그에 맞는 어댑터를 고릅니다. 특정 플랫폼이 정해져 있다면 @sveltejs/adapter-cloudflare, @sveltejs/adapter-vercel처럼 해당 어댑터를 직접 쓰는 편이 좋습니다.

직접 서버를 운영한다면 adapter-node를 씁니다.

bun add -d @sveltejs/adapter-node
vite.config.ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      paths: { origin: "https://my-blog.example.com" },
    }),
  ],
});

paths.origin에는 앱이 실제로 서비스되는 주소를 적습니다. SvelteKit 2에서는 ORIGIN 환경 변수로 넘기던 값인데 3.0에서 설정으로 옮겨졌습니다. 이 값을 비워 두면 adapter-node는 요청의 Host 헤더로 주소를 추정하면서 프로토콜을 https로 가정합니다. 그래서 HTTPS를 앞단에서 처리하는 리버스 프록시 없이 http로 띄우면, 앞에서 본 CSRF 검사에 걸려 모든 폼 제출이 403으로 거부됩니다. 처음 배포할 때 가장 당황하기 쉬운 부분이니 꼭 챙겨 두세요.

빌드하면 build 폴더에 실행 가능한 서버가 만들어집니다. 이름은 adapter-node지만 Bun으로도 그대로 실행됩니다.

bun run build
bun build/index.js
결과
Listening on http://0.0.0.0:3000

SvelteKit 2에서 달라진 점

기존 SvelteKit 2 프로젝트를 옮긴다면 다음 변경 사항을 알아 두면 좋습니다.

용도SvelteKit 2SvelteKit 3
설정 파일svelte.config.jsvite.config.ts의 sveltekit() 옵션
src/lib 별칭$lib/foo#lib/foo.js
빌드 환경 정보$app/environment$app/env
환경 변수$env/static/private 등 4개 모듈src/env.ts + $app/env/private 등
페이지 상태$app/stores의 $page$app/state의 page
오류 추가 정보error(404, { message, code })error(404, message, { code })
데이터 다시 불러오기invalidateAll()refreshAll()
adapter-node 주소ORIGIN 환경 변수paths.origin 설정

이 중 $app/stores는 3.0에서 완전히 제거됐습니다. Svelte 5의 룬 기반 반응성이 자리를 잡으면서 스토어 대신 $app/state를 쓰는 방식으로 정리된 것이죠.

변경 사항이 많아 보이지만 대부분은 마이그레이션 도구가 자동으로 처리해 줍니다.

bunx sv migrate sveltekit-3

다만 3.0을 쓰려면 Node.js 22.17 이상, TypeScript 6, Vite 8, Svelte 5.57.1 이상이 필요합니다. Svelte 4 문법으로 작성한 컴포넌트가 남아 있다면 bunx sv migrate svelte-5로 먼저 Svelte 5로 옮긴 뒤에 진행하는 편이 안전합니다.

한편 출시 전부터 화제였던 원격 함수(remote functions)는 3.0에서도 아직 실험 기능입니다. 서버 함수를 클라이언트에서 타입 안전하게 호출하는 기능인데, experimental.remoteFunctions 옵션을 켜야 쓸 수 있습니다. Svelte 팀이 다음 최우선 과제로 꼽은 만큼 안정화되면 별도의 글로 다뤄 보겠습니다.

마치며

지금까지 SvelteKit 3로 작은 블로그 앱을 만들어 보았습니다. src/routes의 파일 구조로 라우팅을 정하고, load 함수로 데이터를 읽고, 폼 액션으로 데이터를 쓰고, 어댑터로 배포하는 흐름은 SvelteKit 1부터 이어져 온 뼈대입니다. 3.0은 그 뼈대는 그대로 두고 설정 파일, import 별칭, 환경 변수를 Vite와 Node.js 표준 쪽으로 정리했습니다.

Svelte의 간결한 컴포넌트 문법이 마음에 들었다면, SvelteKit으로 실제 서비스까지 만들어 보시길 권합니다. 😊

더 자세한 내용은 SvelteKit 공식 문서와 SvelteKit 3 마이그레이션 가이드를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord