Skip to main content
Summary

페이지는 데이터를 기다릴 줄 알아야 한다

이전 장에서는 /products/[id]처럼 URL에 따라 다른 페이지를 보여 주는 방법을 배웠다. 하지만 실제 앱에서 URL만 바뀌는 것으로는 부족하다. /products/1에 들어오면 1번 상품 데이터를 가져와야 하고, 없는 상품이면 404 화면을 보여 줘야 한다.

SvelteKit에서는 이 일을 load 함수가 맡는다. 페이지 컴포넌트가 화면을 그리기 전에 필요한 데이터를 먼저 준비하고, 그 결과를 data라는 props로 넘겨준다.

예를 들어 상품 상세 페이지는 이런 구조로 만들 수 있다.

src/routes/products/[id]/+page.server.ts
src/routes/products/[id]/+page.svelte

먼저 서버에서 상품을 찾는다.

// src/routes/products/[id]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

const products = [
  { id: '1', name: 'Svelte 머그컵', price: 15000 },
  { id: '2', name: 'Svelte 티셔츠', price: 29000 }
];

export const load: PageServerLoad = async ({ params }) => {
  const product = products.find((item) => item.id === params.id);

  if (!product) {
    throw error(404, '상품을 찾을 수 없습니다.');
  }

  return { product };
};

그리고 페이지에서는 data.product를 사용한다.

<!-- src/routes/products/[id]/+page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<h2>{data.product.name}</h2>
<p>{data.product.price.toLocaleString()}원</p>

중요한 점은 페이지 컴포넌트가 직접 상품을 찾지 않는다는 것이다. 페이지는 받은 데이터를 화면에 보여 주는 데 집중한다. 데이터 준비는 load 함수가 담당한다. 상품이 없을 때 throw error(404, ...)를 호출하면 SvelteKit은 해당 요청을 404 응답으로 처리하고 에러 화면을 보여 준다.

기존 SvelteKit 예제에서는 export let data를 사용하는 코드를 볼 수 있다. Svelte 5 runes 문법에서는 위처럼 let { data } = $props();로 읽으면 된다.

데이터 로딩과 서버 기능 익히기AI 생성 이미지

+page.ts와 +page.server.ts의 차이

SvelteKit에는 페이지 데이터를 준비하는 파일이 크게 두 가지 있다.

+page.ts
+page.server.ts

둘 다 load 함수를 만들 수 있지만 실행 위치가 다르다.

+page.ts의 load는 유니버설 load라고 부른다. 처음 페이지를 서버에서 만들 때도 실행될 수 있고, 사용자가 앱 안에서 다른 페이지로 이동할 때는 브라우저에서도 실행될 수 있다. 그래서 브라우저에 노출되어도 괜찮은 코드만 넣어야 한다.

// src/routes/weather/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch }) => {
  const response = await fetch('/api/weather?city=Seoul');
  const weather = await response.json();

  return { weather };
};

반대로 +page.server.ts의 load는 서버에서만 실행된다. 브라우저로 코드가 전달되지 않는다. 데이터베이스 접근, 파일 시스템 사용, 비공개 API 키 사용, 쿠키나 헤더를 이용한 사용자 확인처럼 서버에서만 해야 하는 작업은 여기에 둔다.1

// src/routes/account/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  return {
    user: locals.user
  };
};

초보자에게 가장 안전한 기준은 단순하다.

  • 공개 API를 읽고, 비밀 값이 필요 없다면 +page.ts를 사용할 수 있다.

  • 데이터베이스, 비공개 API 키, 인증 정보가 필요하다면 +page.server.ts를 사용한다.

  • 여러 화면에서 호출할 자체 API가 필요하다면 +server.ts를 만든다.

특히 이전 장에서 말한 로그인 사용자 정보처럼 요청마다 달라지는 데이터는 전역 store에 넣으면 위험하다. 서버 요청은 사용자마다 분리되어야 한다. 이런 정보는 서버 load, 쿠키, 세션, locals 같은 SvelteKit의 서버 흐름 안에서 다루는 편이 안전하다.

fetch로 외부 데이터 가져오기

load 함수 안에서는 SvelteKit이 넘겨주는 fetch를 사용하는 것이 좋다. 이 fetch는 브라우저의 일반 fetch와 비슷하지만, SvelteKit 앱 안의 API를 호출할 때 더 잘 맞게 동작한다. 예를 들어 서버 렌더링 중 상대 경로 요청을 처리하거나, 처음 렌더링한 데이터를 브라우저에서 다시 중복 요청하지 않도록 도와준다.2

영화 검색 페이지를 만든다고 생각해 보자. 처음에는 기본 검색어로 영화 목록을 가져온다.

// src/routes/movies/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch }) => {
  const response = await fetch('/api/movies?q=svelte');

  if (!response.ok) {
    return {
      movies: [],
      message: '영화 목록을 불러오지 못했습니다.'
    };
  }

  const movies = await response.json();

  return {
    movies,
    message: movies.length === 0 ? '검색 결과가 없습니다.' : ''
  };
};

페이지에서는 성공, 실패, 빈 결과를 구분해서 보여 준다.

<!-- src/routes/movies/+page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

{#if data.message}
  <p>{data.message}</p>
{:else if data.movies.length > 0}
  <ul>
    {#each data.movies as movie}
      <li>{movie.title} ({movie.year})</li>
    {/each}
  </ul>
{:else}
  <p>보여 줄 영화가 없습니다.</p>
{/if}

여기에는 별도의 로딩 화면이 없다. load는 페이지가 렌더링되기 전에 데이터를 준비하기 때문이다. 위 예제처럼 페이지 안에서 메시지를 보여 줄 수도 있고, 아예 에러 페이지로 보내고 싶다면 앞에서 본 것처럼 error()를 던지는 방식도 사용할 수 있다.

사용자가 검색 버튼을 눌러 새 데이터를 가져오는 경우에는 컴포넌트 안에서 loading, errorMessage, results 같은 상태를 직접 둘 수 있다. 이때는 첫 검색 전 상태도 따로 생각해 두는 편이 좋다.

<script lang="ts">
  let query = $state('svelte');
  let results = $state<{ title: string; year: string }[]>([]);
  let loading = $state(false);
  let errorMessage = $state('');
  let hasSearched = $state(false);

  async function search() {
    loading = true;
    errorMessage = '';
    results = [];
    hasSearched = true;

    try {
      const response = await fetch(`/api/movies?q=${encodeURIComponent(query)}`);

      if (!response.ok) {
        throw new Error('검색에 실패했습니다.');
      }

      results = await response.json();
    } catch (error) {
      errorMessage = '잠시 후 다시 시도해 주세요.';
    } finally {
      loading = false;
    }
  }
</script>

<form onsubmit={(event) => { event.preventDefault(); search(); }}>
  <input bind:value={query} placeholder="영화 제목" />
  <button disabled={loading}>검색</button>
</form>

{#if loading}
  <p>불러오는 중...</p>
{:else if errorMessage}
  <p>{errorMessage}</p>
{:else if !hasSearched}
  <p>검색어를 입력하고 버튼을 눌러 보세요.</p>
{:else if results.length === 0}
  <p>검색 결과가 없습니다.</p>
{:else}
  <ul>
    {#each results as movie}
      <li>{movie.title} ({movie.year})</li>
    {/each}
  </ul>
{/if}

이 패턴은 영화 검색뿐 아니라 날씨 위젯, 레시피 검색, 뉴스 목록에도 그대로 적용된다. 핵심은 다섯 가지 상태를 구분하는 것이다.

  • 아직 검색을 시작하지 않았는가?

  • 지금 요청 중인가?

  • 요청이 실패했는가?

  • 성공했지만 결과가 비어 있는가?

  • 성공했고 보여 줄 데이터가 있는가?

이 상태를 나누면 화면이 훨씬 예측 가능해진다. 사용자는 지금 기다려야 하는지, 다시 시도해야 하는지, 아니면 정말 결과가 없는지 쉽게 이해할 수 있다.

+server.ts로 API 엔드포인트 만들기

SvelteKit에서는 +server.ts 파일로 API 엔드포인트를 만들 수 있다. 엔드포인트는 화면을 반환하는 페이지가 아니라 JSON, 텍스트, 상태 코드 같은 응답을 반환하는 라우트다.

예를 들어 다음 파일은 /api/movies 주소를 만든다.

src/routes/api/movies/+server.ts

GET 요청을 처리해 보자.

// src/routes/api/movies/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

const movies = [
  { title: 'Svelte Begins', year: '2025' },
  { title: 'The Reactive App', year: '2026' },
  { title: 'Compile Time', year: '2024' }
];

export const GET: RequestHandler = ({ url }) => {
  const query = url.searchParams.get('q')?.toLowerCase() ?? '';

  const results = movies.filter((movie) =>
    movie.title.toLowerCase().includes(query)
  );

  return json(results);
};

GET 함수 이름이 HTTP 메서드와 연결된다. 사용자가 /api/movies?q=svelte로 요청하면 url.searchParams에서 검색어를 읽고, json()으로 JSON 응답을 돌려준다.3

POST 요청도 같은 파일에서 처리할 수 있다.

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();

  if (!body.title) {
    return json({ message: 'title이 필요합니다.' }, { status: 400 });
  }

  const movie = {
    title: body.title,
    year: body.year ?? 'unknown'
  };

  return json(movie, { status: 201 });
};

실제 서비스라면 POST에서 데이터베이스에 저장하거나 외부 API에 요청을 보낼 수 있다. 여기서 중요한 것은 +server.ts가 서버 코드라는 점이다. 브라우저에서 직접 실행되지 않기 때문에 비공개 API 키를 사용해야 하는 중간 서버 역할에 잘 맞는다.

다만 +server.ts로 만든 주소 자체는 사용자가 요청할 수 있는 엔드포인트다. 그래서 비밀 값을 코드 안에서 안전하게 사용하는 것과 별개로, 요청 데이터 검증, 권한 확인, 필요한 응답만 반환하기는 항상 신경 써야 한다. 서버 코드라고 해서 아무 사용자에게나 모든 데이터를 돌려줘도 된다는 뜻은 아니다.

예를 들어 날씨 API 키를 브라우저에 노출하고 싶지 않다면, 페이지에서 외부 날씨 API를 직접 호출하지 말고 /api/weather를 호출하게 만든다. /api/weather의 +server.ts가 비공개 키로 외부 API에 요청한 뒤 필요한 결과만 JSON으로 반환한다.

환경 변수: 공개할 값과 숨길 값 구분하기

API 기반 앱을 만들다 보면 주소, API 키, 데이터베이스 URL 같은 설정값이 필요하다. 이런 값은 코드에 직접 적지 않고 환경 변수로 관리한다.

SvelteKit에서는 공개 환경 변수와 비공개 환경 변수를 구분한다.

공개해도 되는 값은 PUBLIC_ 접두사를 붙인다.

PUBLIC_API_BASE_URL=https://api.example.com

이 값은 브라우저 코드에서도 읽을 수 있다.

import { PUBLIC_API_BASE_URL } from '$env/static/public';

반대로 API 키, 데이터베이스 주소, 서버 비밀 값은 PUBLIC_를 붙이면 안 된다.

WEATHER_API_KEY=secret-key
DATABASE_URL=postgres://user:[email protected]/db

서버 전용 코드에서는 이렇게 읽는다.

import { WEATHER_API_KEY } from '$env/static/private';

비공개 환경 변수는 +page.server.ts, +server.ts 같은 서버 코드에서 사용한다. .svelte 컴포넌트나 +page.ts처럼 브라우저에서 실행될 수 있는 코드에 넣으면 안 된다.4

날씨 API 엔드포인트는 다음처럼 만들 수 있다.

// src/routes/api/weather/+server.ts
import { json } from '@sveltejs/kit';
import { WEATHER_API_KEY } from '$env/static/private';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url, fetch }) => {
  const city = url.searchParams.get('city') ?? 'Seoul';

  const response = await fetch(
    `https://api.example.com/weather?city=${encodeURIComponent(city)}&key=${WEATHER_API_KEY}`
  );

  if (!response.ok) {
    return json({ message: '날씨 정보를 가져오지 못했습니다.' }, { status: 502 });
  }

  const data = await response.json();

  return json({
    city,
    temperature: data.temperature,
    description: data.description
  });
};

페이지는 이제 비밀 키를 몰라도 된다.

const response = await fetch('/api/weather?city=Seoul');

이 구조는 보안뿐 아니라 유지보수에도 좋다. 외부 API 응답 구조가 바뀌어도 페이지 전체를 고칠 필요 없이 서버 엔드포인트에서 필요한 모양으로 정리해 반환하면 된다.

API 기반 미니 앱으로 연습하기

이 장의 내용을 연습할 때는 큰 프로젝트보다 작은 앱이 좋다. 데이터 흐름이 보이는 예제를 선택하자.

영화 검색 앱은 검색어를 입력하고 /api/movies?q=검색어를 호출한다. 빈 결과와 오류 메시지를 연습하기 좋다.

날씨 위젯은 /api/weather?city=Seoul처럼 도시 이름을 쿼리로 넘긴다. 비공개 API 키를 +server.ts 안에서만 사용하는 연습에 적합하다.

레시피 검색 앱은 재료 목록을 입력하고 결과를 카드 목록으로 보여 준다. 검색 결과가 많을 수 있으므로 {#each}와 빈 목록 처리를 함께 복습할 수 있다.

세 앱 모두 구조는 비슷하다.

사용자 입력
→ 페이지 또는 컴포넌트의 fetch
→ SvelteKit +server.ts
→ 외부 API 또는 데이터베이스
→ JSON 응답
→ 화면 갱신

이 흐름을 이해하면 SvelteKit이 단순히 화면을 만드는 도구가 아니라는 점이 보인다. SvelteKit은 페이지, 서버 코드, API 엔드포인트를 한 프로젝트 안에서 연결해 준다. 화면은 데이터를 보여 주고, load는 페이지에 필요한 데이터를 준비하며, +server.ts는 브라우저에 노출하면 안 되는 서버 작업을 안전하게 감싼다.

다음 장에서는 이 서버 흐름을 폼 제출과 인증으로 확장한다. 사용자가 입력한 데이터를 서버에 보내고, 로그인 상태에 따라 다른 화면을 보여 주는 구조를 만들 것이다.

참고

1SvelteKit routing documentation

2SvelteKit Load Functions: Server vs Universal Data Loading

3SvelteKit API Endpoints And Loading Data For Pages

4Understanding environment variables in SvelteKit

책 목록

쉽게 배우는 Svelte 개발: Svelte 5와 SvelteKit으로 시작하는 프론트엔드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기