🌐 웹개발

SvelteKit

SvelteKit

Svelte 기반 풀스택 프레임워크. SSR, SSG, API 라우트 지원.

📖 상세 설명

SvelteKit(스벨트킷)은 Svelte를 기반으로 한 풀스택 웹 애플리케이션 프레임워크입니다. Next.js가 React에게, Nuxt가 Vue에게 하는 것처럼, SvelteKit은 Svelte 위에서 라우팅, 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG), API 엔드포인트 등 프로덕션 웹앱에 필요한 기능을 제공합니다. 2022년 12월 1.0 버전이 출시되었으며, Svelte 팀이 공식으로 개발합니다.

SvelteKit의 핵심 특징은 파일 기반 라우팅입니다. src/routes 디렉토리 구조가 곧 URL 경로가 됩니다. +page.svelte는 페이지 컴포넌트, +page.server.ts는 서버에서만 실행되는 데이터 로딩(load 함수) 및 폼 처리(actions)를 담당합니다. +server.ts로 REST API 엔드포인트를 만들 수 있어, 별도 백엔드 없이 풀스택 앱을 구축할 수 있습니다.

렌더링 전략을 페이지별로 유연하게 설정할 수 있습니다. 기본적으로 SSR(서버 사이드 렌더링)을 사용하지만, +page.ts에서 export const prerender = true를 설정하면 빌드 타임에 정적 HTML을 생성(SSG)합니다. export const ssr = false로 클라이언트 사이드 렌더링만 사용하거나, export const csr = false로 JavaScript 번들 없는 순수 HTML 페이지도 만들 수 있습니다.

SvelteKit은 어댑터(Adapter) 시스템으로 다양한 플랫폼에 배포할 수 있습니다. adapter-vercel, adapter-cloudflare, adapter-netlify, adapter-node 등 공식 및 커뮤니티 어댑터가 있어, 서버리스 엣지부터 전통적인 Node.js 서버까지 유연하게 배포 가능합니다. Svelte의 컴파일러 기반 최적화와 결합하여 매우 작은 번들 크기와 빠른 초기 로딩을 달성합니다.

💻 코드 예제

프로젝트 구조

my-sveltekit-app/
├── src/
│   ├── routes/
│   │   ├── +page.svelte           # / (홈페이지)
│   │   ├── +page.server.ts        # 서버 로직
│   │   ├── +layout.svelte         # 공통 레이아웃
│   │   ├── about/
│   │   │   └── +page.svelte       # /about
│   │   ├── blog/
│   │   │   ├── +page.svelte       # /blog (목록)
│   │   │   ├── +page.server.ts
│   │   │   └── [slug]/
│   │   │       ├── +page.svelte   # /blog/:slug (상세)
│   │   │       └── +page.server.ts
│   │   └── api/
│   │       └── posts/
│   │           └── +server.ts     # /api/posts (API)
│   ├── lib/
│   │   ├── components/            # 재사용 컴포넌트
│   │   └── server/                # 서버 전용 코드
│   ├── app.html                   # HTML 템플릿
│   └── app.d.ts                   # 타입 정의
├── static/                        # 정적 파일
├── svelte.config.js
├── vite.config.ts
└── package.json

페이지 컴포넌트와 데이터 로딩

<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  export let data: PageData;

  // data는 +page.server.ts의 load 함수에서 반환
</script>

<svelte:head>
  <title>{data.post.title} | My Blog</title>
  <meta name="description" content={data.post.excerpt} />
</svelte:head>

<article>
  <h1>{data.post.title}</h1>
  <p class="meta">
    {new Date(data.post.createdAt).toLocaleDateString('ko-KR')}
    · {data.post.author.name}
  </p>
  <div class="content">
    {@html data.post.content}
  </div>
</article>

<style>
  article {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }
  .meta {
    color: #666;
    margin-bottom: 2rem;
  }
</style>

서버 사이드 데이터 로딩

// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/database';

export const load: PageServerLoad = async ({ params, locals }) => {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
    include: { author: true }
  });

  if (!post) {
    throw error(404, {
      message: '포스트를 찾을 수 없습니다.'
    });
  }

  // 조회수 증가 (비동기, 응답 대기 안 함)
  db.post.update({
    where: { id: post.id },
    data: { views: { increment: 1 } }
  });

  return {
    post,
    user: locals.user  // hooks.server.ts에서 설정
  };
};

// 정적 생성 옵션
export const prerender = false;  // 동적 페이지

Form Actions (서버 사이드 폼 처리)

// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { sendEmail } from '$lib/server/email';

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const formData = await request.formData();
    const name = formData.get('name')?.toString();
    const email = formData.get('email')?.toString();
    const message = formData.get('message')?.toString();

    // 유효성 검사
    const errors: Record<string, string> = {};
    if (!name || name.length < 2) {
      errors.name = '이름은 2자 이상이어야 합니다.';
    }
    if (!email || !email.includes('@')) {
      errors.email = '올바른 이메일을 입력하세요.';
    }
    if (!message || message.length < 10) {
      errors.message = '메시지는 10자 이상이어야 합니다.';
    }

    if (Object.keys(errors).length > 0) {
      return fail(400, { errors, name, email, message });
    }

    try {
      await sendEmail({
        to: 'contact@example.com',
        subject: `[문의] ${name}님의 메시지`,
        body: `이름: ${name}\n이메일: ${email}\n\n${message}`
      });

      // 성공 시 감사 페이지로 리다이렉트
      throw redirect(303, '/contact/thanks');
    } catch (e) {
      if (e instanceof Response) throw e;  // redirect는 throw
      return fail(500, { error: '전송에 실패했습니다.' });
    }
  }
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';

  export let form: ActionData;
</script>

<h1>문의하기</h1>

<form method="POST" use:enhance>
  <div class="field">
    <label for="name">이름</label>
    <input
      type="text"
      id="name"
      name="name"
      value={form?.name ?? ''}
    />
    {#if form?.errors?.name}
      <p class="error">{form.errors.name}</p>
    {/if}
  </div>

  <div class="field">
    <label for="email">이메일</label>
    <input
      type="email"
      id="email"
      name="email"
      value={form?.email ?? ''}
    />
    {#if form?.errors?.email}
      <p class="error">{form.errors.email}</p>
    {/if}
  </div>

  <div class="field">
    <label for="message">메시지</label>
    <textarea
      id="message"
      name="message"
      rows="5"
    >{form?.message ?? ''}</textarea>
    {#if form?.errors?.message}
      <p class="error">{form.errors.message}</p>
    {/if}
  </div>

  <button type="submit">전송</button>

  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}
</form>

API 엔드포인트

// src/routes/api/posts/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/database';

// GET /api/posts
export const GET: RequestHandler = async ({ url }) => {
  const page = parseInt(url.searchParams.get('page') ?? '1');
  const limit = parseInt(url.searchParams.get('limit') ?? '10');

  const posts = await db.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
    select: {
      id: true,
      slug: true,
      title: true,
      excerpt: true,
      createdAt: true
    }
  });

  const total = await db.post.count();

  return json({
    posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  });
};

// POST /api/posts
export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) {
    throw error(401, '로그인이 필요합니다.');
  }

  const body = await request.json();

  const post = await db.post.create({
    data: {
      title: body.title,
      slug: body.slug,
      content: body.content,
      authorId: locals.user.id
    }
  });

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

svelte.config.js 설정

// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';  // 또는 adapter-node
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),

  kit: {
    adapter: adapter({
      runtime: 'edge',  // Vercel Edge Functions
      regions: ['icn1']  // 서울 리전
    }),

    alias: {
      $components: 'src/lib/components',
      $utils: 'src/lib/utils'
    },

    // CSP 설정
    csp: {
      mode: 'auto',
      directives: {
        'script-src': ['self']
      }
    },

    // 환경 변수 접두사 (기본: PUBLIC_)
    env: {
      publicPrefix: 'PUBLIC_'
    }
  }
};

export default config;

🗣️ 실무에서 이렇게 말해요

  • "SvelteKit으로 만들면 번들 크기가 React 대비 절반 이하라서 모바일 성능이 확실히 좋아요."
  • "+page.server.ts에서 load 함수로 데이터 가져오면 SSR이 자동으로 되니까 SEO 걱정 없어요."
  • "폼 처리는 actions로 하면 JavaScript 없이도 동작하고, progressive enhancement 되니까 접근성도 좋습니다."
  • "Vercel Edge에 배포하면 서울 리전에서 Cold Start 없이 빠르게 응답해요. adapter-vercel 쓰면 됩니다."
  • "SvelteKit의 load 함수가 실행되는 환경(서버/클라이언트)을 설명하고, +page.ts와 +page.server.ts의 차이점은 무엇인가요?"
  • "SvelteKit의 렌더링 전략(SSR, SSG, CSR)을 어떻게 설정하고, 각각 언제 사용하나요?"
  • "SvelteKit의 어댑터 시스템이 무엇이고, 왜 유용한가요?"
  • "form actions와 use:enhance의 역할은 무엇인가요?"
  • "이 데이터는 클라이언트에 노출되면 안 돼요. +page.ts 대신 +page.server.ts로 옮기세요."
  • "폼에 use:enhance 추가하면 페이지 새로고침 없이 제출되고 로딩 상태 처리도 쉬워져요."
  • "이 페이지는 정적이니까 export const prerender = true 추가해서 빌드 타임에 생성하면 성능이 좋아집니다."

⚠️ 주의사항

서버/클라이언트 코드 구분

+page.server.ts의 코드는 서버에서만 실행되고, +page.ts는 서버와 클라이언트 모두에서 실행될 수 있습니다. DB 커넥션, API 키 등 민감한 코드는 반드시 +page.server.ts나 $lib/server/ 내에 작성하세요.

생태계 크기

Svelte/SvelteKit 생태계는 React/Next.js에 비해 작습니다. 서드파티 라이브러리 선택지가 제한적일 수 있으며, 커뮤니티 지원이나 채용 시장에서 고려해야 할 사항입니다.

Hydration 불일치

SSR된 HTML과 클라이언트 Hydration 결과가 다르면 경고가 발생합니다. 브라우저 전용 API(window, localStorage)는 onMount 내에서 또는 browser 체크 후 사용하세요.

🔗 관련 용어

📚 더 배우기