라우팅: 파일 시스템 기반 라우터

라우팅: 파일 시스템 기반 라우터

SvelteKit의 심장은 파일 시스템 기반 라우터(filesystem-based router)예요. 앱의 라우트, 다시 말해 사용자가 접근할 수 있는 URL 경로가 코드베이스의 디렉터리 구조로 그대로 정의돼요. 디렉터리를 하나 만들고 파일을 넣으면 바로 라우트가 생긴다고 생각하면 돼요.

출처: SvelteKit 공식 문서 — Routing

라우트와 라우트 파일

src/routes는 루트 라우트고, 그 아래 디렉터리마다 라우트가 하나씩 생겨요.

  • src/routes — 루트 라우트
  • src/routes/about/about 라우트 생성
  • src/routes/blog/[slug]slug라는 파라미터를 가진 라우트 생성. 사용자가 /blog/hello-world 같은 페이지를 요청하면 이 파라미터로 데이터를 동적으로 불러올 수 있어요.

라우트 디렉터리 하나는 하나 이상의 라우트 파일을 담으며, 이 파일들은 + 접두사로 구분해요. 라우팅이 어떻게 동작하는지 기억하기 위한 간단한 규칙 몇 가지만 정리하면 이래요.

  • 모든 파일은 서버에서 실행돼요.
  • +server 파일을 제외한 모든 파일은 클라이언트에서도 실행돼요.
  • +layout+error 파일은 자신이 있는 디렉터리뿐 아니라 그 하위 디렉터리에도 적용돼요.

src/routes를 다른 디렉터리로 바꾸고 싶다면 프로젝트 설정을 수정하면 돼요.

+page

+page.svelte

+page.svelte 컴포넌트가 앱의 페이지 하나를 정의해요. 기본적으로 페이지는 최초 요청 때는 서버(SSR)에서, 이후 네비게이션 때는 브라우저(CSR)에서 렌더링돼요.

<!--- file: src/routes/+page.svelte --->
<h1>Hello and welcome to my site!</h1>
<a href="/about">About my site</a>
<!--- file: src/routes/about/+page.svelte --->
<h1>About this site</h1>
<p>TODO...</p>
<a href="/">Home</a>

SvelteKit은 라우트 사이를 이동할 때 프레임워크 전용 <Link> 컴포넌트가 아니라 <a> 요소를 사용해요.

페이지는 load 함수가 돌려준 데이터를 data prop으로 받아요.

<!--- file: src/routes/blog/[slug]/+page.svelte --->
<script>
	/** @type {import('./$types').PageProps} */
	let { data } = $props();
</script>

<h1>{data.title}</h1>
<div>{@html data.content}</div>

2.24 버전부터 페이지는 라우트 파라미터를 기반으로 타입이 정해지는 params prop도 받아요. 특히 remote functions와 함께 쓸 때 유용해요.

<!--- file: src/routes/blog/[slug]/+page.svelte --->
<script>
	import { getPost } from '../blog.remote';

	/** @type {import('./$types').PageProps} */
	let { params } = $props();

	const post = $derived(await getPost(params.slug));
</script>

<h1>{post.title}</h1>
<div>{@html post.content}</div>

PageProps는 2.16.0에서 추가됐어요. 그보다 이전 버전에서는 data prop을 직접 PageData로 타입을 달아야 했어요($types 참고). Svelte 4에서는 export let data를 썼죠.

+page.js

페이지가 렌더링되기 전에 데이터를 불러와야 하는 경우가 많아요. 이때 +page.js 모듈을 추가하고 load 함수를 export하면 돼요.

/// file: src/routes/blog/[slug]/+page.js
import { error } from '@sveltejs/kit';

/** @type {import('./$types').PageLoad} */
export function load({ params }) {
	if (params.slug === 'hello-world') {
		return {
			title: 'Hello world!',
			content: 'Welcome to our blog. Lorem ipsum dolor sit amet...'
		};
	}

	error(404, 'Not found');
}

이 함수는 +page.svelte와 함께 실행돼요. 즉 서버 사이드 렌더링 중에는 서버에서, 클라이언트 사이드 네비게이션 중에는 브라우저에서 실행돼요. API 전체 내용은 load에서 확인할 수 있어요.

load 외에도 +page.js는 페이지 동작을 설정하는 값을 export할 수 있어요.

  • export const prerender = true 또는 false 또는 'auto'
  • export const ssr = true 또는 false
  • export const csr = true 또는 false

이 값들에 대한 자세한 내용은 page options에서 볼 수 있어요.

+page.server.js

load 함수가 서버에서만 실행될 수 있을 때 — 예를 들어 데이터베이스에서 데이터를 가져오거나 API 키 같은 비공개 환경 변수에 접근해야 할 때 — +page.js+page.server.js로 이름을 바꾸고 PageLoad 타입을 PageServerLoad로 바꾸면 돼요.

/// file: src/routes/blog/[slug]/+page.server.js

// @filename: ambient.d.ts
declare global {
	const getPostFromDatabase: (slug: string) => {
		title: string;
		content: string;
	}
}

export {};

// @filename: index.js
// ---cut---
import { error } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
	const post = await getPostFromDatabase(params.slug);

	if (post) {
		return post;
	}

	error(404, 'Not found');
}

클라이언트 사이드 네비게이션 중에 SvelteKit은 이 데이터를 서버에서 불러와요. 즉 반환값은 devalue로 직렬화 가능해야 해요. API 전체 내용은 load에서 확인할 수 있어요.

+page.js처럼 +page.server.jspage optionsprerender, ssr, csr — 을 export할 수 있어요.

+page.server.js 파일은 actions 도 export할 수 있어요. load가 서버에서 데이터를 읽어 온다면, actions<form> 요소로 데이터를 서버에 수 있게 해줘요. 사용법은 form actions 섹션에서 볼 수 있어요.

+error

load 중에 오류가 나면 SvelteKit은 기본 오류 페이지를 렌더링해요. 라우트별로 오류 페이지를 바꾸고 싶다면 +error.svelte 파일을 추가하면 돼요.

<!--- file: src/routes/blog/[slug]/+error.svelte --->
<script>
	import { page } from '$app/state';
</script>

<h1>{page.status}: {page.error.message}</h1>

$app/state는 SvelteKit 2.12에서 추가됐어요. 더 이전 버전을 쓰거나 Svelte 4를 쓰고 있다면 $app/stores를 사용하세요.

SvelteKit은 가장 가까운 오류 경계를 찾기 위해 트리를 위로 올라가요. 위 파일이 없으면 src/routes/blog/+error.svelte, 그다음 src/routes/+error.svelte를 시도한 뒤에 기본 오류 페이지를 렌더링해요. 그것마저 실패하면(또는 루트 +error 위에 있는 루트 +layoutload 함수에서 오류가 나면) SvelteKit은 정적 폴백 오류 페이지를 렌더링하는데, src/error.html 파일을 만들어 직접 꾸밀 수 있어요.

오류가 +layout(.server).jsload 함수 안에서 나면, 트리에서 가장 가까운 오류 경계는 그 레이아웃 옆이 아니라 위에 있는 +error.svelte 파일이에요.

라우트를 찾을 수 없으면(404) src/routes/+error.svelte가 사용되고, 그 파일이 없으면 기본 오류 페이지가 사용돼요.

+error.sveltehandle이나 +server.js 요청 핸들러 안에서 오류가 날 때는 사용되지 않아요. 오류 처리에 대한 더 자세한 내용은 errors에서 확인할 수 있어요.

+layout

지금까지 페이지를 완전히 독립된 컴포넌트로 다뤘어요. 네비게이션하면 기존 +page.svelte 컴포넌트는 파괴되고 새 컴포넌트가 그 자리를 차지해요.

하지만 많은 앱에는 최상위 내비게이션이나 푸터처럼 모든 페이지에 보여야 하는 요소가 있어요. 이런 것들을 매 +page.svelte마다 반복하는 대신 레이아웃 에 넣을 수 있어요.

+layout.svelte

모든 페이지에 적용되는 레이아웃을 만들려면 src/routes/+layout.svelte 파일을 만들면 돼요. 직접 만들지 않을 때 SvelteKit이 사용하는 기본 레이아웃은 이렇게 생겼어요.

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

{@render children()}

...여기에 마크업, 스타일, 동작을 원하는 대로 더할 수 있어요. 유일한 요구사항은 컴포넌트가 페이지 콘텐츠를 위한 @render 태그를 포함한다는 것뿐이에요. 예를 들어 내비 바를 추가해 볼게요.

<!--- file: src/routes/+layout.svelte --->
<script>
	let { children } = $props();
</script>

<nav>
	<a href="/">Home</a>
	<a href="/about">About</a>
	<a href="/settings">Settings</a>
</nav>

{@render children()}

/, /about, /settings 페이지를 각각 만들면...

/// file: src/routes/+page.svelte
<h1>Home</h1>
/// file: src/routes/about/+page.svelte
<h1>About</h1>
/// file: src/routes/settings/+page.svelte
<h1>Settings</h1>

...내비는 항상 보이고, 세 페이지 사이를 클릭해 이동하면 <h1>만 바뀌게 돼요.

레이아웃은 중첩될 수 있어요. /settings 페이지 하나만 있는 게 아니라 /settings/profile, /settings/notifications처럼 공유되는 서브메뉴를 가진 중첩 페이지가 있다고 해볼게요(실제 예시로는 github.com/settings를 보면 돼요).

루트 레이아웃(최상위 내비)을 상속하면서 /settings 아래 페이지만 적용되는 레이아웃을 만들 수 있어요.

<!--- file: src/routes/settings/+layout.svelte --->
<script>
	/** @type {import('./$types').LayoutProps} */
	let { data, children } = $props();
</script>

<h1>Settings</h1>

<div class="submenu">
	{#each data.sections as section}
		<a href="/settings/{section.slug}">{section.title}</a>
	{/each}
</div>

{@render children()}

LayoutProps는 2.16.0에서 추가됐어요. 더 이전 버전에서는 속성을 직접 타입으로 달아야 했어요($types 참고). data가 어떻게 채워지는지는 바로 다음 섹션의 +layout.js 예시를 보면 알 수 있어요.

기본적으로 각 레이아웃은 자기 위의 레이아웃을 상속해요. 때로는 그것이 원하는 게 아닐 수 있는데, 그럴 때는 advanced layouts가 도와줘요.

+layout.js

+page.svelte+page.js에서 데이터를 얻는 것처럼, +layout.svelte 컴포넌트도 +layout.jsload 함수에서 데이터를 얻을 수 있어요.

/// file: src/routes/settings/+layout.js
/** @type {import('./$types').LayoutLoad} */
export function load() {
	return {
		sections: [
			{ slug: 'profile', title: 'Profile' },
			{ slug: 'notifications', title: 'Notifications' }
		]
	};
}

+layout.jspage optionsprerender, ssr, csr — 을 export하면 자식 페이지의 기본값으로 사용돼요.

레이아웃의 load 함수가 돌려준 데이터는 모든 자식 페이지에서도 사용할 수 있어요.

<!--- file: src/routes/settings/profile/+page.svelte --->
<script>
	/** @type {import('./$types').PageProps} */
	let { data } = $props();

	console.log(data.sections); // [{ slug: 'profile', title: 'Profile' }, ...]
</script>

페이지 사이를 이동할 때 레이아웃 데이터가 변하지 않는 경우가 많아요. SvelteKit은 필요할 때만 load 함수를 지능적으로 다시 실행해요.

+layout.server.js

레이아웃의 load 함수를 서버에서 실행하려면 +layout.server.js로 옮기고 LayoutLoad 타입을 LayoutServerLoad로 바꾸면 돼요.

+layout.js처럼 +layout.server.jspage optionsprerender, ssr, csr — 을 export할 수 있어요.

+server

페이지뿐 아니라 +server.js 파일로 라우트를 정의할 수도 있어요. 이 파일은 'API 라우트' 또는 '엔드포인트'라고도 불리는데, 응답을 완전히 제어할 수 있어요. +server.js 파일은 GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD 같은 HTTP 동사에 대응하는 함수를 export하며, 각 함수는 RequestEvent 인자를 받고 Response 객체를 반환해요.

예를 들어 GET 핸들러를 가진 /api/random-number 라우트를 만들 수 있어요.

/// file: src/routes/api/random-number/+server.js
import { error } from '@sveltejs/kit';

/** @type {import('./$types').RequestHandler} */
export function GET({ url }) {
	const min = Number(url.searchParams.get('min') ?? '0');
	const max = Number(url.searchParams.get('max') ?? '1');

	const d = max - min;

	if (isNaN(d) || d < 0) {
		error(400, 'min and max must be numbers, and min must be less than max');
	}

	const random = min + Math.random() * d;

	return new Response(String(random));
}

Response의 첫 번째 인자는 ReadableStream일 수 있어서, 대용량 데이터를 스트리밍하거나 server-sent events를 만들 수 있어요(AWS Lambda처럼 응답을 버퍼링하는 플랫폼에 배포하지 않는다면).

편의를 위해 @sveltejs/kiterror, redirect, json 메서드를 사용할 수 있어요(꼭 써야 하는 건 아니에요).

오류가 발생하면(error(...) 또는 예상치 못한 오류) Accept 헤더에 따라 응답은 오류의 JSON 표현 또는 폴백 오류 페이지(— src/error.html로 커스터마이즈 가능)가 돼요. 이 경우 +error.svelte 컴포넌트는 렌더링되지 않아요. 자세한 내용은 errors에서 볼 수 있어요.

OPTIONS 핸들러를 만들 때는 주의할 점이 있어요. Vite가 Access-Control-Allow-OriginAccess-Control-Allow-Methods 헤더를 주입하지만, 이 헤더들은 직접 추가하지 않으면 프로덕션에는 없어요.

+layout 파일은 +server.js 파일에는 아무런 영향을 주지 않아요. 매 요청 전에 어떤 로직을 실행하고 싶다면 서버 handle 훅에 추가하면 돼요.

데이터 받기

POST/PUT/PATCH/DELETE/OPTIONS/HEAD 핸들러를 export하면 +server.js 파일로 완전한 API를 만들 수 있어요.

<!--- file: src/routes/add/+page.svelte --->
<script>
	let a = $state(0);
	let b = $state(0);
	let total = $state(0);

	async function add() {
		const response = await fetch('/api/add', {
			method: 'POST',
			body: JSON.stringify({ a, b }),
			headers: {
				'content-type': 'application/json'
			}
		});

		total = await response.json();
	}
</script>

<input type="number" bind:value={a}> +
<input type="number" bind:value={b}> =
{total}

<button onclick={add}>Calculate</button>
/// file: src/routes/api/add/+server.js
import { json } from '@sveltejs/kit';

/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
	const { a, b } = await request.json();
	return json(a + b);
}

일반적으로 브라우저에서 서버로 데이터를 제출할 때는 form actions가 더 나은 방법이에요.

GET 핸들러를 export하면 HEAD 요청은 GET 핸들러 응답 본문의 content-length를 반환해요.

폴백 메서드 핸들러

fallback 핸들러를 export하면 처리가 없는 모든 요청 메서드를 매칭해요. +server.js에 전용 export가 없는 MOVE 같은 메서드도 포함돼요.

/// file: src/routes/api/add/+server.js
import { json, text } from '@sveltejs/kit';

/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
	const { a, b } = await request.json();
	return json(a + b);
}

// This handler will respond to PUT, PATCH, DELETE, etc.
/** @type {import('./$types').RequestHandler} */
export async function fallback({ request }) {
	return text(`I caught your ${request.method} request!`);
}

HEAD 요청에서는 GET 핸들러가 fallback 핸들러보다 우선해요.

콘텐츠 협상

+server.js 파일은 +page 파일과 같은 디렉터리에 둘 수 있어서, 같은 라우트가 페이지가 될 수도 API 엔드포인트가 될 수도 있어요. 둘 중 어느 쪽인지 결정하기 위해 SvelteKit은 다음 규칙을 적용해요.

  • PUT/PATCH/DELETE/OPTIONS 요청은 페이지에 적용되지 않으므로 항상 +server.js가 처리해요.
  • GET/POST/HEAD 요청은 accept 헤더가 text/html을 우선시하면(즉 브라우저 페이지 요청이면) 페이지 요청으로, 그렇지 않으면 +server.js로 처리돼요.
  • GET 요청에 대한 응답에는 Vary: Accept 헤더가 포함돼서, 프록시와 브라우저가 HTML과 JSON 응답을 별도로 캐시해요.

$types

지금까지 예시에서 $types.d.ts 파일에서 타입을 import했어요. TypeScript(또는 JSDoc 타입 주석을 쓰는 JavaScript)로 작업할 때 SvelteKit이 숨김 디렉터리에 만들어 주는 파일로, 루트 파일 작업 시 타입 안전성을 보장해줘요.

예를 들어 let { data } = $props()PageProps로(+layout.svelte 파일이면 LayoutProps로) 주석 처리하면 TypeScript가 data의 타입이 load에서 반환된 것임을 알게 돼요.

<!--- file: src/routes/blog/[slug]/+page.svelte --->
<script>
	/** @type {import('./$types').PageProps} */
	let { data } = $props();
</script>

PagePropsLayoutProps 타입은 2.16.0에서 추가됐어요. data prop을 PageDataLayoutData로 타입을 다는 것과, 페이지의 form이나 레이아웃의 children 같은 다른 prop까지 타입을 다는 단축 표기예요. 더 이전 버전에서는 이런 속성을 직접 타입으로 달아야 했어요. 예를 들어 페이지라면:

/// file: +page.svelte
/** @type {{ data: import('./$types').PageData, form: import('./$types').ActionData }} */
let { data, form } = $props();

또는 레이아웃이라면:

/// file: +layout.svelte
/** @type {{ data: import('./$types').LayoutData, children: Snippet }} */
let { data, children } = $props();

거꾸로 load 함수를 PageLoad, PageServerLoad, LayoutLoad, LayoutServerLoad(+page.js, +page.server.js, +layout.js, +layout.server.js 각각에 대응)로 주석 처리하면 params와 반환값이 올바르게 타입이 정해져요.

VS Code나 언어 서버 프로토콜과 TypeScript 플러그인을 지원하는 IDE를 쓴다면 이 타입들을 아예 생략할 수 있어요! Svelte의 IDE 도구가 올바른 타입을 자동으로 넣어줘서, 직접 쓰지 않아도 타입 체크를 받을 수 있어요. 명령줄 도구 svelte-check에서도 동작해요. $types 생략에 대한 자세한 내용은 블로그 포스트에서 읽을 수 있어요.

기타 파일

라우트 디렉터리 안의 다른 모든 파일은 SvelteKit이 무시해요. 즉 컴포넌트와 유틸리티 모듈을 그것들이 필요한 라우트와 함께(colocate) 놓을 수 있어요.

컴포넌트와 모듈이 여러 라우트에서 필요하다면 $lib에 두는 게 좋아요.

더 알아보기