Skip to main content
Summary

폼 제출은 서버와의 대화다

지금까지는 주로 브라우저 안에서 상태를 바꾸고 화면을 갱신했다. 입력값을 $state에 담고, 버튼을 누르면 배열에 항목을 추가했다. 하지만 실제 웹 앱에서는 여기서 한 단계 더 나아가야 한다.

회원가입, 로그인, 글 작성, 결제 요청처럼 중요한 작업은 서버가 최종 판단해야 한다. 브라우저의 상태는 사용자가 언제든 바꿀 수 있다. 그래서 입력값이 올바른지 확인하고, 데이터베이스에 저장하고, 로그인 세션을 만드는 일은 서버에서 처리해야 한다.

SvelteKit의 form action은 이 흐름을 단순하게 만든다. 별도의 API 주소를 직접 만들지 않아도, 페이지와 가까운 +page.server.ts에서 폼 제출을 받을 수 있다.

흐름은 다음과 같다.

  1. 사용자가 <form>에 값을 입력한다.

  2. 폼이 POST 요청으로 서버에 제출된다.

  3. 같은 라우트의 +page.server.ts에 있는 action이 실행된다.

  4. 서버가 값을 검증한다.

  5. 실패하면 오류와 기존 입력값을 돌려준다.

  6. 성공하면 저장, 쿠키 설정, 리다이렉트 같은 작업을 한다.

핵심은 폼이 HTML의 기본 동작 위에 있다는 점이다. JavaScript가 꺼져 있어도 제출은 동작하고, 필요하면 use:enhance로 새로고침 없는 제출 경험을 추가할 수 있다.1

폼 액션, 인증 흐름, 실전 앱 구조AI 생성 이미지

서버 action으로 입력값 받기

예를 들어 사용자가 간단한 메모를 작성하는 페이지를 만든다고 하자.

// src/routes/notes/new/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();

    const title = String(formData.get('title') ?? '').trim();
    const body = String(formData.get('body') ?? '').trim();

    const errors: Record<string, string> = {};

    if (!title) errors.title = '제목을 입력하세요.';
    if (title.length > 50) errors.title = '제목은 50자 이하로 입력하세요.';
    if (!body) errors.body = '내용을 입력하세요.';

    if (Object.keys(errors).length > 0) {
      return fail(422, {
        errors,
        values: { title, body }
      });
    }

    // 실제 앱에서는 여기서 데이터베이스에 저장한다.
    // await db.note.create({ data: { title, body } });

    return {
      message: '메모를 저장했습니다.'
    };
  }
} satisfies Actions;

request.formData()는 제출된 폼 값을 읽는다. formData.get('title')의 이름은 입력 요소의 name='title'과 연결된다. 그래서 폼을 다룰 때는 입력 요소마다 서버에서 읽을 수 있는 name을 빠뜨리지 않는 것이 중요하다.

검증에 실패하면 fail(422, ...)를 반환한다. 422는 요청은 도착했지만 입력값이 규칙에 맞지 않는다는 뜻으로 자주 사용한다. 상황에 따라 400을 쓰기도 하지만, 중요한 것은 실패 이유를 사용자에게 다시 보여 줄 수 있게 구조화해서 돌려주는 것이다. 이때 errors만 돌려주면 사용자가 다시 처음부터 입력해야 하므로, values에 기존 입력값도 함께 담는다.

form props로 오류와 성공 메시지 보여주기

이제 페이지 컴포넌트에서 action 결과를 읽어 보자. SvelteKit은 action이 반환한 값을 페이지의 form props로 전달한다. 기존 Svelte 4 예제에서는 export let form을 자주 볼 수 있지만, Svelte 5에서는 다음처럼 $props()로 받으면 된다.

<!-- src/routes/notes/new/+page.svelte -->
<script lang='ts'>
  import { enhance } from '$app/forms';
  import type { PageProps } from './$types';

  let { form }: PageProps = $props();
  let submitting = $state(false);
</script>

<form
  method='POST'
  use:enhance={() => {
    submitting = true;

    return async ({ update }) => {
      await update();
      submitting = false;
    };
  }}
>
  <label>
    제목
    <input
      name='title'
      value={form?.values?.title ?? ''}
      aria-invalid={!!form?.errors?.title}
    />
  </label>

  {#if form?.errors?.title}
    <p class='error'>{form.errors.title}</p>
  {/if}

  <label>
    내용
    <textarea name='body' value={form?.values?.body ?? ''}></textarea>
  </label>

  {#if form?.errors?.body}
    <p class='error'>{form.errors.body}</p>
  {/if}

  <button disabled={submitting}>
    {submitting ? '저장 중...' : '저장'}
  </button>
</form>

{#if form?.message}
  <p class='success'>{form.message}</p>
{/if}

여기서 중요한 패턴은 세 가지다.

첫째, 필드별 오류는 form.errors.title처럼 입력 이름과 같은 키로 다룬다. 그러면 어떤 입력창 아래에 어떤 메시지를 보여 줄지 명확해진다.

둘째, 실패했을 때 기존 입력값을 form.values로 다시 넣는다. 사용자는 한 글자 때문에 전체 폼을 다시 작성하지 않아도 된다. 다만 비밀번호 같은 민감한 값은 다시 내려보내지 않는 편이 안전하다.

셋째, use:enhance는 선택 사항이다. 이 문법을 추가하면 JavaScript가 켜진 환경에서 전체 페이지 새로고침 없이 action 결과를 반영한다. 하지만 기본 폼 동작을 깨뜨리지 않으므로, JavaScript가 실패해도 서버 제출은 그대로 동작한다. 이것이 점진적 향상이다.

로그인은 named action으로 나누기

한 페이지에 여러 서버 동작이 필요할 때는 named action을 쓴다. 예를 들어 로그인 페이지의 폼은 action='?/login'으로 특정 action을 호출할 수 있다.

<form method='POST' action='?/login'>
  <input name='email' type='email' autocomplete='email' />
  <input name='password' type='password' autocomplete='current-password' />
  <button>로그인</button>
</form>

서버 파일에서는 login action을 만든다.

// src/routes/login/+page.server.ts
import { dev } from '$app/environment';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { createSession, verifyUser } from '$lib/server/auth';

export const actions = {
  login: async ({ request, cookies }) => {
    const formData = await request.formData();

    const email = String(formData.get('email') ?? '').trim();
    const password = String(formData.get('password') ?? '');

    if (!email || !password) {
      return fail(400, {
        errors: { email: '이메일과 비밀번호를 입력하세요.' },
        values: { email }
      });
    }

    const user = await verifyUser(email, password);

    if (!user) {
      return fail(400, {
        errors: { email: '이메일 또는 비밀번호를 확인하세요.' },
        values: { email }
      });
    }

    const session = await createSession(user.id);

    cookies.set('session', session.id, {
      path: '/',
      httpOnly: true,
      sameSite: 'lax',
      secure: !dev,
      maxAge: 60 * 60 * 24 * 7
    });

    redirect(303, '/dashboard');
  }
} satisfies Actions;

verifyUser는 이메일과 비밀번호가 맞는지 확인하는 서버 함수다. 실제 앱에서는 비밀번호를 그대로 비교하지 않고, 저장된 해시와 비교해야 한다. createSession은 로그인한 사용자를 나타내는 임의의 세션 ID를 만들고 서버 저장소에 기록한다.

여기서 쿠키에는 사용자 정보 전체를 넣지 않는다. 쿠키에는 추측하기 어려운 세션 ID만 넣고, 실제 사용자 정보는 서버에서 찾는다. 쿠키 기반 인증의 일반적인 흐름은 로그인 성공 시 세션 쿠키를 설정하고, 이후 요청마다 그 쿠키를 읽어 사용자를 확인하는 방식이다.2

쿠키 옵션도 중요하다.

  • httpOnly: true: 브라우저 JavaScript에서 쿠키를 읽지 못하게 한다.

  • secure: !dev: 배포 환경에서는 HTTPS에서만 쿠키를 보내게 한다.

  • sameSite: 'lax': 다른 사이트에서 온 요청에 쿠키가 무분별하게 붙는 일을 줄인다.

  • path: '/': 앱 전체 경로에서 쿠키를 사용할 수 있게 한다.

  • maxAge: 세션 유지 시간을 정한다. SvelteKit의 cookies.set에서 maxAge는 초 단위로 지정한다.

로그인 성공 후에는 redirect(303, '/dashboard')로 이동시켰다. 303은 POST 요청 뒤에 다른 페이지로 이동할 때 자주 쓰는 상태 코드다. 브라우저가 다음 페이지를 GET 요청으로 다시 열게 해 주기 때문이다.

named action을 사용할 때 한 가지 더 기억할 점이 있다. 같은 actions 객체 안에서 default action과 named action을 함께 두지 않는 편이 아니라, SvelteKit에서는 함께 둘 수 없다. 한 페이지에 여러 동작이 필요하다면 login, register, logout처럼 이름 있는 action으로 통일하자.

로그아웃은 반대로 서버의 세션을 삭제하고 쿠키를 지우면 된다.

// 예: src/routes/logout/+page.server.ts 또는 관련 페이지의 named action
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { deleteSession } from '$lib/server/auth';

export const actions = {
  logout: async ({ cookies }) => {
    const sessionId = cookies.get('session');

    if (sessionId) {
      await deleteSession(sessionId);
    }

    cookies.delete('session', { path: '/' });
    redirect(303, '/login');
  }
} satisfies Actions;

쿠키만 지워도 브라우저에서는 로그아웃된 것처럼 보이지만, 서버 저장소에 남아 있는 세션도 함께 삭제해야 더 깔끔하다.

hooks와 locals로 로그인 상태 유지하기

로그인 후 모든 페이지에서 매번 쿠키를 직접 읽으면 코드가 반복된다. SvelteKit에서는 src/hooks.server.ts에서 요청을 가로채 공통 처리를 할 수 있다.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { findUserBySession } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get('session');

  event.locals.user = null;

  if (sessionId) {
    const user = await findUserBySession(sessionId);

    if (user) {
      event.locals.user = {
        id: user.id,
        email: user.email,
        name: user.name
      };
    }
  }

  return resolve(event);
};

locals는 요청 하나에만 붙어 있는 서버 측 임시 공간이다. 전역 상태처럼 모든 사용자에게 공유되지 않는다. 그래서 로그인 사용자처럼 요청마다 달라지는 정보를 담기에 적합하다.3

TypeScript를 사용한다면 event.locals.user를 바로 쓰기 위해 타입도 알려 줘야 한다. 보통 src/app.d.ts에 다음처럼 추가한다. 이미 파일이 있다면 namespace App 안에 Locals만 합치면 된다.

// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: {
        id: string;
        email: string;
        name: string;
      } | null;
    }
  }
}

export {};

프로젝트에 따라 id가 숫자라면 id: number로 맞추면 된다. 중요한 것은 hooks.server.ts, 서버 load, action에서 같은 locals.user 구조를 공유하도록 타입을 맞춰 두는 것이다.

이제 루트 레이아웃 서버 파일에서 안전한 사용자 정보만 페이지로 내려보낼 수 있다.

// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

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

페이지나 레이아웃 컴포넌트는 data.user를 보고 로그인 여부를 표시한다. 단, 서버의 세션 ID, 비밀번호 해시, 내부 권한 토큰 같은 값은 절대 내려보내지 않는다.

보호된 페이지에서는 서버 load에서 접근을 막는다.

// src/routes/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = ({ locals }) => {
  if (!locals.user) {
    redirect(303, '/login');
  }

  return {
    user: locals.user
  };
};

이 검사는 컴포넌트에서만 하면 부족하다. 화면에서 버튼을 숨기는 것은 사용자 경험일 뿐이고, 실제 접근 제어는 서버에서 해야 한다. 사용자가 주소를 직접 입력하거나 요청을 직접 보내도 서버 load와 action에서 막을 수 있어야 한다.

비밀 값은 서버 전용 파일에 둔다

SvelteKit에는 서버에서만 실행되는 파일이 있다. 대표적으로 +page.server.ts, +layout.server.ts, +server.ts, hooks.server.ts, 그리고 $lib/server 아래 파일이다.

데이터베이스 연결 정보, 비공개 API 키, 세션 생성 로직, 비밀번호 검증 로직은 이런 서버 전용 위치에 둬야 한다. 반대로 +page.svelte, 브라우저에서도 실행될 수 있는 +page.ts, 클라이언트 컴포넌트에서 import되는 일반 모듈에 비밀 값을 넣으면 안 된다.

브라우저로 전달된 코드는 사용자가 볼 수 있다. 난독화되어 있어도 안전하다고 볼 수 없다. 비밀 값은 숨기는 것이 아니라 애초에 클라이언트 번들에 포함되지 않게 해야 한다.

실전 앱 폴더 구조 예시

앱이 커질수록 파일을 역할별로 나누는 것이 중요하다. 다음 구조는 작은 인증 앱을 만들 때 출발점으로 삼기 좋다.

src/
  app.d.ts
  hooks.server.ts
  lib/
    components/
      LoginForm.svelte
      UserMenu.svelte
    schemas/
      login.ts
      note.ts
    utils/
      formatDate.ts
    server/
      auth.ts
      db.ts
      session.ts
  routes/
    +layout.svelte
    +layout.server.ts
    login/
      +page.svelte
      +page.server.ts
    dashboard/
      +page.svelte
      +page.server.ts
    notes/
      new/
        +page.svelte
        +page.server.ts

구분 기준은 단순하다.

프론트엔드 컴포넌트는 화면을 보여 주고 폼을 제출한다. 서버 action은 입력값을 검증하고 데이터를 변경한다. hooks.server.ts는 요청마다 세션을 확인한다. $lib/server는 데이터베이스, 인증, 세션처럼 서버에서만 필요한 로직을 담는다. app.d.ts는 locals처럼 앱 전체에서 공유되는 타입을 선언한다. 공통 검증 규칙이나 포맷 함수는 필요에 따라 $lib/schemas, $lib/utils로 분리한다.

처음부터 완벽한 구조를 만들 필요는 없다. 하지만 비밀 값과 서버 로직을 클라이언트 코드와 섞지 않는 원칙은 초반부터 지켜야 한다.

이번 장의 기준

폼 action은 서버와 협력하는 SvelteKit 앱의 기본 단위다. 입력값은 서버에서 검증하고, 실패하면 필드별 오류와 기존 값을 돌려준다. 성공하면 저장, 쿠키 설정, 리다이렉트처럼 서버가 책임져야 할 일을 수행한다.

인증 흐름은 쿠키 하나로 끝나지 않는다. 쿠키에는 세션 ID를 담고, 서버는 그 세션 ID로 사용자를 찾고, locals에 현재 요청의 사용자 정보를 넣는다. 페이지는 서버 load를 통해 안전한 정보만 받는다.

이 구조를 이해하면 클라이언트 상태만 다루는 앱에서 벗어나 실제 서비스에 가까운 SvelteKit 앱을 만들 수 있다.

참고

1Forms in SvelteKit — Actions, Validation & Progressive Enhancement

2SvelteKit Authentication Using Cookies

3Understanding locals in SvelteKit

책 목록

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

전체 목차 보기

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

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

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

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

참여하기