SvelteKit 라우팅과 레이아웃 시작하기
이제는 페이지가 필요하다
지금까지는 주로 하나의 컴포넌트 안에서 상태를 만들고, 조건과 반복으로 화면을 바꾸고, 폼과 이벤트를 다뤘다. 이것만으로도 작은 위젯이나 단일 화면 앱은 만들 수 있다. 하지만 실제 웹사이트에는 보통 여러 페이지가 있다.
예를 들어 쇼핑몰을 만든다고 생각해 보자.
/는 첫 화면/about은 서비스 소개/products는 상품 목록/products/1은 1번 상품 상세/products/2은 2번 상품 상세
이때 필요한 것이 라우팅이다. 라우팅은 "어떤 URL에 어떤 화면을 보여 줄 것인가"를 정하는 규칙이다. SvelteKit은 이 규칙을 JavaScript 객체나 별도 설정 파일에 길게 쓰지 않는다. 대신 src/routes 폴더 구조를 보고 URL을 만든다. 이런 방식을 파일 기반 라우팅이라고 한다.
AI 생성 이미지
src/routes 폴더가 URL이 된다
SvelteKit 프로젝트에서 가장 중요한 라우팅 폴더는 src/routes다. 이 폴더 안의 구조가 그대로 URL 경로가 된다. 파일 기반 라우팅의 기본 규칙은 단순하다. src/routes가 사이트의 루트 주소 /에 해당하고, 그 안의 폴더 이름이 URL 조각이 된다1.
src/routes/
+page.svelte
about/
+page.svelte
products/
+page.svelte위 구조는 다음 URL과 연결된다.
src/routes/+page.svelte -> /
src/routes/about/+page.svelte -> /about
src/routes/products/+page.svelte -> /products여기서 눈여겨볼 파일 이름은 +page.svelte다. SvelteKit에서 +page.svelte는 "이 경로에서 실제로 보여 줄 페이지 컴포넌트"를 뜻한다. 폴더 이름은 URL이 되지만, +page.svelte라는 파일명 자체는 URL에 나타나지 않는다.
처음에는 이렇게 이해하면 충분하다.
폴더 경로 = URL 경로
+page.svelte = 그 URL에서 보이는 화면예를 들어 src/routes/about/+page.svelte를 만들고 다음처럼 작성하면 /about 주소에서 이 화면이 보인다.
<h2>서비스 소개</h2>
<p>Svelte로 만든 작은 쇼핑몰 예제입니다.</p>따로 라우터 설정을 추가하지 않아도 된다. 파일을 올바른 위치에 만들면 SvelteKit이 경로를 알아서 연결한다. 여기서 "알아서"라는 말은 마법이 아니다. SvelteKit이 프로젝트를 빌드할 때 src/routes 폴더를 분석해 URL과 페이지 파일의 관계를 구성하는 것이다.
+page.svelte는 한 주소의 본문이다
+page.svelte는 일반 Svelte 컴포넌트처럼 작성한다. <script>, 마크업, <style>을 사용할 수 있고, $state, $derived, 이벤트, 반복문도 그대로 쓸 수 있다.
첫 화면을 다음처럼 만들어 보자.
<!-- src/routes/+page.svelte -->
<script>
const featured = ['키보드', '마우스', '모니터'];
</script>
<section>
<h2>오늘의 추천 상품</h2>
<ul>
{#each featured as item}
<li>{item}</li>
{/each}
</ul>
</section>이 파일은 / 주소의 페이지다. 이번에는 상품 목록 페이지를 만든다.
<!-- src/routes/products/+page.svelte -->
<script>
const products = [
{ id: 1, name: '기계식 키보드', price: 89000 },
{ id: 2, name: '무선 마우스', price: 42000 },
{ id: 3, name: '27인치 모니터', price: 239000 }
];
</script>
<h2>상품 목록</h2>
<ul class="products">
{#each products as product}
<li>
<a href={`/products/${product.id}`}>
{product.name} - {product.price.toLocaleString()}원
</a>
</li>
{/each}
</ul>여기서도 링크는 특별한 컴포넌트가 아니라 일반 <a> 태그다. SvelteKit에서는 내부 링크를 보통 <a href="/products">처럼 작성한다. 브라우저의 기본 링크 의미를 유지하면서, 앱 안에서는 필요한 경우 빠른 클라이언트 내비게이션으로 처리할 수 있다.
동적 라우트: products/[id]
상품이 3개라면 상세 페이지 파일을 3개 만들 수도 있다.
products/1/+page.svelte
products/2/+page.svelte
products/3/+page.svelte하지만 상품이 1,000개라면 이렇게 만들 수 없다. 이럴 때 동적 라우트를 사용한다. 동적 라우트는 URL의 일부가 고정되어 있지 않고 값으로 들어오는 경로다.
SvelteKit에서는 폴더 이름을 대괄호로 감싸면 동적 경로가 된다.
src/routes/products/[id]/+page.svelte이 파일은 다음 주소들을 모두 처리한다.
/products/1
/products/2
/products/keyboard
/products/anything[id]는 "이 위치에 들어온 값을 id라는 이름으로 다루겠다"는 뜻이다. 페이지 안에서 현재 URL의 파라미터를 읽으려면 SvelteKit이 제공하는 page 객체를 사용할 수 있다.
<!-- src/routes/products/[id]/+page.svelte -->
<script>
import { page } from '$app/state';
let productId = $derived(page.params.id);
</script>
<h2>상품 상세</h2>
<p>현재 상품 ID: {productId}</p>/products/1로 들어오면 productId는 1이 되고, /products/2로 들어오면 2가 된다. 이 값은 숫자가 아니라 URL에서 온 문자열이라는 점에 주의하자. 실제 상품 데이터를 찾을 때는 필요한 경우 숫자로 변환하거나, 문자열 ID 그대로 비교해야 한다.
이 장에서는 동적 경로를 만드는 데 집중한다. 상품 ID로 서버에서 데이터를 가져오거나, 없는 상품일 때 404를 보내는 방식은 다음 장의 데이터 로딩에서 더 정확히 다룬다.
+layout.svelte로 공통 UI 분리하기
페이지가 여러 개 생기면 곧 중복이 생긴다. 모든 페이지 위에 같은 헤더가 있고, 아래에 같은 푸터가 있다면 각 +page.svelte마다 이 코드를 반복하고 싶지 않을 것이다.
나쁜 구조는 다음과 같다.
<header>...</header>
<main>각 페이지 내용</main>
<footer>...</footer>이 코드를 모든 페이지에 복사하면, 메뉴 이름을 하나 바꿀 때마다 여러 파일을 수정해야 한다. 이 문제를 해결하는 파일이 +layout.svelte다.
+layout.svelte는 같은 폴더와 그 아래 경로의 페이지를 감싸는 공통 틀이다. 최상위에 만들면 앱 전체에 적용된다.
src/routes/
+layout.svelte
+page.svelte
about/
+page.svelte
products/
+page.svelte
[id]/
+page.svelte이제 src/routes/+layout.svelte를 만든다.
<!-- src/routes/+layout.svelte -->
<script>
let { children } = $props();
</script>
<header class="site-header">
<a href="/" class="logo">Svelte Shop</a>
<nav>
<a href="/">홈</a>
<a href="/about">소개</a>
<a href="/products">상품</a>
</nav>
</header>
<main class="content">
{@render children()}
</main>
<footer class="site-footer">
<small>© 2026 Svelte Shop</small>
</footer>Svelte 5 기반의 레이아웃에서는 자식 페이지 내용을 children으로 받고, {@render children()} 위치에 렌더링한다2. 즉 위 코드에서 각 페이지의 내용은 <main> 안에 들어간다.
예를 들어 /about으로 이동하면 전체 구조는 개념적으로 이렇게 된다.
<header>공통 헤더</header>
<main>
<!-- about/+page.svelte 내용 -->
</main>
<footer>공통 푸터</footer>/products로 이동하면 <main> 안의 내용만 상품 목록 페이지로 바뀐다. 헤더와 푸터는 +layout.svelte에 한 번만 작성했기 때문에 모든 페이지에서 공유된다.
레이아웃도 폴더별로 나눌 수 있다
최상위 +layout.svelte는 앱 전체를 감싼다. 그런데 특정 구역에만 필요한 레이아웃도 있다. 예를 들어 상품 페이지들에만 왼쪽 카테고리 메뉴를 보여 주고 싶다면 products 폴더 안에 +layout.svelte를 만들 수 있다.
src/routes/
+layout.svelte
products/
+layout.svelte
+page.svelte
[id]/
+page.svelte<!-- src/routes/products/+layout.svelte -->
<script>
let { children } = $props();
</script>
<div class="products-layout">
<aside>
<h3>상품 카테고리</h3>
<a href="/products">전체</a>
</aside>
<section>
{@render children()}
</section>
</div>이 레이아웃은 /products와 /products/1 같은 하위 경로에만 적용된다. /about에는 적용되지 않는다.
레이아웃은 겹쳐서 적용될 수 있다. /products/1 페이지는 먼저 최상위 src/routes/+layout.svelte의 헤더와 푸터 안에 들어가고, 그 안에서 다시 src/routes/products/+layout.svelte의 상품 전용 구조 안에 들어간다.
처음부터 레이아웃을 많이 나눌 필요는 없다. 중복이 눈에 보일 때 분리하면 된다. 기준은 단순하다.
모든 페이지에 필요하면
src/routes/+layout.svelte특정 구역에만 필요하면 그 폴더의
+layout.svelte한 페이지에서만 필요하면 해당
+page.svelte안에 작성
+error.svelte는 라우트의 오류 화면이다
웹사이트에는 정상 화면만 있는 것이 아니다. 없는 주소로 들어올 수도 있고, 데이터를 불러오다가 문제가 생길 수도 있다. 이때 보여 줄 오류 화면을 담당하는 파일이 +error.svelte다.
가장 기본적인 오류 화면은 최상위에 만들 수 있다.
src/routes/
+layout.svelte
+error.svelte
+page.svelte<!-- src/routes/+error.svelte -->
<script>
import { page } from '$app/state';
</script>
<section class="error-page">
<h2>{page.status}</h2>
<p>{page.error?.message ?? '페이지를 표시할 수 없습니다.'}</p>
<a href="/">홈으로 돌아가기</a>
</section>page.status는 404, 500 같은 HTTP 상태 코드다. page.error.message는 사용자에게 보여 줄 오류 메시지다. 사용자가 존재하지 않는 페이지에 들어오거나, 페이지 데이터를 준비하는 과정에서 오류가 발생하면 SvelteKit은 가장 가까운 +error.svelte를 찾아 보여 준다3.
여기서 "가장 가까운"이라는 말도 레이아웃과 비슷하게 폴더 구조를 기준으로 이해하면 된다. 예를 들어 상품 구역에만 다른 오류 화면을 보여 주고 싶다면 src/routes/products/+error.svelte를 둘 수 있다. 그러면 /products 아래에서 발생한 라우트 오류는 상품 구역의 오류 화면을 먼저 사용하고, 그 파일이 없으면 상위 폴더의 오류 화면을 찾아 올라간다.
단, +error.svelte를 모든 작은 실수에 쓰지는 않는다. 입력 폼에서 "이름을 입력하세요" 같은 검증 메시지는 페이지 안의 상태로 보여 주는 편이 자연스럽다. +error.svelte는 페이지 자체를 정상적으로 보여 줄 수 없을 때 사용하는 라우트 단위 오류 화면이라고 이해하면 된다.
$lib alias로 공통 코드를 깔끔하게 가져오기
페이지가 늘어나면 공통 컴포넌트도 늘어난다. 헤더, 푸터, 상품 카드, 가격 포맷 함수 같은 것들은 여러 페이지에서 재사용된다. 이런 파일은 보통 src/lib에 둔다.
src/lib/
components/
SiteHeader.svelte
SiteFooter.svelte
ProductCard.svelte
utils/
formatPrice.jssrc/lib에 있는 파일은 $lib alias로 가져올 수 있다.
<script>
import SiteHeader from '$lib/components/SiteHeader.svelte';
import SiteFooter from '$lib/components/SiteFooter.svelte';
</script>상대 경로로 쓰면 이렇게 길어질 수 있다.
import ProductCard from '../../../lib/components/ProductCard.svelte';파일 위치가 깊어질수록 ../가 많아지고, 나중에 파일을 옮길 때 깨지기 쉽다. $lib를 사용하면 현재 파일이 어디에 있든 src/lib를 기준으로 가져올 수 있다.
예를 들어 가격 포맷 함수를 만든다.
// src/lib/utils/formatPrice.js
export function formatPrice(value) {
return `${value.toLocaleString()}원`;
}상품 목록 페이지에서는 이렇게 사용한다.
<script>
import { formatPrice } from '$lib/utils/formatPrice.js';
const products = [
{ id: 1, name: '기계식 키보드', price: 89000 },
{ id: 2, name: '무선 마우스', price: 42000 }
];
</script>
<ul>
{#each products as product}
<li>
<a href={`/products/${product.id}`}>
{product.name} - {formatPrice(product.price)}
</a>
</li>
{/each}
</ul>$lib는 URL 경로가 아니다. 브라우저 주소창의 /lib와도 관계가 없다. 개발할 때 모듈을 가져오기 쉽게 해 주는 import 별칭이다.
작은 사이트 구조로 정리하기
지금까지 만든 구조를 하나로 모으면 다음과 같다.
src/
lib/
components/
SiteHeader.svelte
SiteFooter.svelte
ProductCard.svelte
utils/
formatPrice.js
routes/
+layout.svelte
+error.svelte
+page.svelte
about/
+page.svelte
products/
+layout.svelte
+page.svelte
[id]/
+page.svelte이 구조만으로도 여러 페이지를 가진 작은 웹사이트를 만들 수 있다.
/는 첫 화면이다./about은 소개 페이지다./products는 상품 목록 페이지다./products/1은 동적 상품 상세 페이지다.+layout.svelte는 공통 헤더와 푸터를 담당한다.products/+layout.svelte는 상품 구역에만 필요한 구조를 담당한다.+error.svelte는 라우트 오류 화면을 담당한다.$lib는 공통 컴포넌트와 유틸 함수를 깔끔하게 가져오게 해 준다.
SvelteKit 라우팅의 핵심은 URL을 코드로 억지로 조립하지 않는다는 점이다. 폴더 구조를 페이지 구조와 비슷하게 만들면 된다. URL이 앱의 뼈대라면, +page.svelte는 각 방의 내용이고, +layout.svelte는 방들을 감싸는 복도와 공통 시설이다.
다음 장에서는 이 페이지들이 실제 데이터를 어떻게 가져오는지 배운다. 지금은 상품 ID를 URL에서 읽기만 했다. 다음에는 그 ID로 데이터를 찾고, 서버에서 필요한 정보를 준비하고, 페이지에 안전하게 전달하는 흐름을 다룰 것이다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.