[Svelte 시리즈 13편] 인증 흐름과 가드된 UI

English version

이제 앱에 로그인과 권한 제어를 붙여 봅니다.

시작 전에 정리하기

  • 무엇을 만든다: 세션 쿠키를 읽어 locals.user에 심고, 보호된 (app) 레이아웃에서 인증 여부를 검사하는 흐름.
  • 왜 중요한가: API 통합 이후부터는 사용자별 데이터를 다루게 되므로, 인증 상태를 중앙에서 공유해야 실수가 줄어듭니다.
  • 주의할 점: handle 훅에서 비동기 작업이 실패하면 전체 요청이 막히므로, 토큰 검증 오류를 예외 대신 null 처리로 흡수해야 합니다.

이번 글에서 새로 나오는 용어

  1. 세션: 서버가 발급하는 로그인 토큰으로, 쿠키에 저장해 두면 새로고침 후에도 사용자 정보를 기억할 수 있습니다.
  2. 쿠키: 브라우저가 보관하는 작은 데이터 조각으로, cookies.set으로 세션 토큰을 남길 수 있습니다.
  3. 라우트 가드: 로그인 여부나 권한을 확인해 특정 페이지나 레이아웃 접근을 막는 규칙입니다.
  4. event.locals: 서버 훅과 로드 함수 사이에서 공유되는 객체로, 여기에 user 정보를 넣어 전역으로 전달합니다.

개념

세션(session)은 서버가 사용자 상태를 기억하기 위해 발급하는 토큰입니다. 쿠키에 세션 토큰을 저장하면 페이지를 새로고침해도 인증 상태가 유지됩니다. 라우트 가드(route guard, 경로 보호 규칙)는 인증 여부를 확인해 허용된 사용자만 특정 레이아웃이나 페이지를 보게 만드는 장치입니다. event.locals는 SvelteKit이 서버 훅에서 저장한 데이터를 모든 로드 함수에서 공유하게 해 줍니다. 세션 정보를 전달하기에 알맞은 자리입니다.

D2로 보는 인증 흐름

Login formhooks.server handleevent.locals.user(app)/+layout.server(app)/+layoutdashboard routes/loginAuth service request + session cookieverify tokenuser|nullevent.locals.userload()data.user or redirectPOST credentialsauthenticatetokenSet-Cookiesubsequent requests

👉 미니 요약: 모든 요청은 먼저 hooks.server.ts를 거쳐 쿠키에서 사용자를 찾습니다. 찾은 결과는 event.locals.user로 넘어가고, 보호된 레이아웃이 없으면 로그인 화면으로 돌려보냅니다. 로그인 액션은 쿠키를 새로 써 준 뒤 원래 가려던 페이지로 다시 이동시킵니다.

코드

서버 훅, 레이아웃 가드, 로그인 액션 순서로 살펴봅니다.

// src/hooks.server.ts

const attachUser = async ({ event, resolve }) => {
  const token = event.cookies.get('session');
  if (!token) {
    event.locals.user = null;
    return resolve(event);
  }
  try {
    event.locals.user = await getUserByToken(token);
  } catch (error) {
    console.error('토큰 검증 실패', error);
    event.locals.user = null;
    event.cookies.delete('session', { path: '/' });
  }
  return resolve(event);
};

export const handle = sequence(attachUser);
// src/routes/(app)/+layout.server.ts

export const load = async ({ locals, url }) => {
  if (!locals.user) {
    throw redirect(303, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
  }
  return { user: locals.user };
};
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
  export let data;
</script>

<AppShell user={data.user}>
  <slot />
</AppShell>
// src/routes/login/+page.server.ts

export const load = ({ locals }) => {
  if (locals.user) {
    throw redirect(303, '/dashboard');
  }
  return {};
};

export const actions = {
  default: async ({ request, cookies, url }) => {
    const form = await request.formData();
    const email = form.get('email');
    const password = form.get('password');
    const token = await authenticate(String(email), String(password));
    if (!token) {
      return fail(400, { message: '이메일 또는 비밀번호를 확인하세요.' });
    }
    cookies.set('session', token, { path: '/', httpOnly: true, maxAge: 60 * 60 * 24 * 7 });
    const redirectTo = url.searchParams.get('redirectTo') ?? '/dashboard';
    throw redirect(303, redirectTo);
  }
};
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { page } from '$app/stores';
  import { derived } from 'svelte/store';

  const errors = derived(page, ($page) => $page.form?.message);
</script>

<form method="POST" use:enhance>
  <label>
    이메일
    <input name="email" type="email" autocomplete="email" required />
  </label>
  <label>
    비밀번호
    <input name="password" type="password" autocomplete="current-password" required />
  </label>
  {#if $errors}
    <p role="alert">{$errors}</p>
  {/if}
  <button type="submit">로그인</button>
</form>

<form method="POST" action="?/logout" class="logout">
  <button type="submit">로그아웃</button>
</form>
// src/routes/(app)/+layout.server.ts (logout action 추가)
export const actions = {
  logout: async ({ cookies }) => {
    cookies.delete('session', { path: '/' });
    return { success: true };
  }
};
http://localhost:5173/dashboard
DEV

UI Preview

로그인 후에만 대시보드 접근

로그인하면 즉시 AppShell이 열리고, 미로그인 사용자는 안내 문구 대신 로그인 화면으로 리디렉션됩니다.

session cookielayout guard

Login

POST /login

쿠키에 session을 저장.

Redirect

redirectTo 파라미터

로그인 후 원래 페이지 복귀.

Guard

layout.server.ts

locals.user 없으면 303 이동.

👉 미니 요약: “Login” 탭은 쿠키를 쓰는 서버 액션, “Redirect” 탭은 redirectTo 쿼리, “Guard” 탭은 레이아웃의 조건문을 보여 줍니다. 세 탭 모두 같은 쿠키 값에 의존한다는 점을 기억하세요.

(선택) 로그아웃/역할 제어 심화

로그아웃 액션이나 역할별 분기(user.role === 'admin')는 인프라 연동이 필요한 경우만 구현하세요. 학교 프로젝트에서 필수는 아니므로 시간이 없으면 “로그아웃 버튼과 역할 가드는 뒤에 붙인다”라고 문서에 표시만 남겨도 됩니다.

로그인 폼은 use:enhance로 제출 상태를 다루고, 로그인 후 쿠키가 설정되면 보호된 레이아웃이 AppShell을 렌더링합니다. 로그아웃 액션은 서버에서 쿠키를 삭제해 세션을 깔끔하게 종료합니다.

왜 중요한가

학교 프로젝트라도 개인정보가 들어가면 인증이 필수입니다. 세션을 중앙에서 로드하면 모든 페이지가 동일한 사용자 정보를 공유해 실수할 여지가 줄어듭니다. 또한 가드된 레이아웃을 사용하면 미인증 사용자가 민감한 정보를 잠깐이라도 보지 못하게 막을 수 있습니다.

실습

  • 따라 하기: hooks.server.ts에서 세션 쿠키를 읽어 locals.user를 설정하고, 가드된 레이아웃에서 로그인 여부를 분기한다.
  • 확장하기: user.role에 따라 헤더 버튼이나 관리자 기능을 조건부로 노출한다.
  • 디버깅: 인증이 풀리면 쿠키 옵션(path, httpOnly)과 parent() 호출 순서를 다시 확인한다.
  • 완료 기준: 로그인/로그아웃 시 UI가 즉시 전환되고 미인증 사용자는 안내 화면만 본다.

마무리

세션과 가드 구조를 정리하면 앱이 안전한 기반을 갖추게 됩니다. 다음 편에서는 파일 업로드와 미디어 흐름처럼 데이터 양이 많은 기능을 다뤄 보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요