SvelteKit 실전 인증 시스템 — JWT + Refresh Token - 코드픽 블로그
SvelteKit 실전 인증 시스템 — JWT + Refresh Token
기술 가이드

SvelteKit 실전 인증 시스템 — JWT + Refresh Token

2026년 3월 7일 36 views by 코드벤터

SvelteKit 실전 인증 시스템 — JWT + Refresh Token

안녕하세요, 코드픽 독자 여러분!

현대의 웹 애플리케이션에서 사용자 인증은 필수적인 요소입니다. 단순히 로그인/로그아웃 기능을 제공하는 것을 넘어, 보안, 확장성, 그리고 사용자 경험까지 고려한 견고한 인증 시스템을 구축하는 것은 개발자에게 중요한 과제입니다. 특히 SvelteKit과 같은 풀스택 프레임워크를 사용할 때는 프론트엔드와 백엔드 로직을 아우르는 통합적인 접근 방식이 필요합니다.

오늘은 SvelteKit 환경에서 JWT(JSON Web Token)와 Refresh Token을 활용하여 안전하고 효율적인 인증 시스템을 구축하는 방법에 대해 깊이 있게 다뤄보겠습니다. 백엔드 API 라우트부터 SvelteKit의 강력한 Hooks 기능을 활용한 미들웨어 구현, 그리고 프론트엔드에서의 토큰 관리까지, 실제 프로젝트에 바로 적용할 수 있는 실전적인 코드 예제와 함께 설명해 드릴 예정입니다.

이 가이드를 통해 여러분은 다음을 배우게 될 것입니다:

  • 세션 기반 인증과 토큰 기반 인증의 차이점 및 JWT의 원리
  • Refresh Token을 활용하여 JWT의 보안 취약점을 보완하는 방법
  • SvelteKit API 라우트에서 JWT 발행 및 검증 로직 구현
  • SvelteKit hooks.server.js를 이용한 전역 인증 미들웨어 구축
  • 프론트엔드에서 인증 상태 관리 및 보호된 라우트 접근 제어

그럼, SvelteKit과 함께 강력한 인증 시스템을 만들어 나가는 여정을 시작해볼까요?


1. 인증 시스템의 이해: 왜 JWT + Refresh Token인가?

웹 인증 방식은 오랜 시간 발전해왔으며, 각 방식마다 장단점이 명확합니다. SvelteKit과 같은 현대적인 프레임워크에서 토큰 기반 인증이 각광받는 이유를 먼저 살펴보겠습니다.

1.1. 세션 기반 인증 vs. 토큰 기반 인증

전통적인 웹 애플리케이션에서는 주로 세션 기반 인증을 사용했습니다. 사용자가 로그인하면 서버는 세션 ID를 생성하고 이를 서버 메모리나 데이터베이스에 저장합니다. 이 세션 ID는 쿠키를 통해 클라이언트에게 전달되며, 클라이언트는 이후 요청마다 이 세션 ID를 포함하여 서버에 전송합니다.

특징세션 기반 인증토큰 기반 인증 (JWT)
**상태 관리**서버에 세션 상태 저장 (Stateful)서버에 상태 저장 안 함 (Stateless)
**확장성**세션 공유를 위한 추가 작업 필요 (Sticky Session)서버 간 상태 공유 불필요, 수평 확장 용이
**모바일 친화성**쿠키 사용으로 모바일 앱에서 구현 어려움헤더를 통한 토큰 전송으로 모바일 앱/SPA에 적합
**CSRF 공격**취약 (쿠키 자동 전송)상대적으로 안전 (HttpOnly 쿠키 사용 시)
**보안**세션 하이재킹 위험, 서버 자원 소모토큰 탈취 위험, 토큰 만료 관리 중요

토큰 기반 인증, 특히 JWT는 서버가 사용자 상태를 저장할 필요가 없는(Stateless) 방식으로, MSA(Microservices Architecture)나 SPA(Single Page Application), 모바일 앱 환경에서 높은 확장성과 유연성을 제공합니다.

1.2. JWT(JSON Web Token) 심층 분석

JWT는 정보를 안전하게 전송하기 위한 간결하고 자체 포함적인(self-contained) 방법입니다. 세 부분으로 구성됩니다:

  1. Header (헤더): 토큰의 타입(JWT)과 서명에 사용된 알고리즘(예: HMAC SHA256 또는 RSA)을 포함합니다.
    json
    {
      "alg": "HS256",
      "typ": "JWT"
    }
  2. Payload (페이로드): 클레임(Claim)이라고 불리는 실제 정보가 담깁니다. 사용자 ID, 권한, 만료 시간 등 필요한 데이터를 JSON 형태로 저장할 수 있습니다.
    json
    {
      "userId": "user123",
      "role": "admin",
      "exp": 1678886400 // 만료 시간 (Unix timestamp)
    }
  3. Signature (서명): 인코딩된 헤더와 페이로드, 그리고 서버의 비밀 키(Secret Key)를 사용하여 생성됩니다. 이 서명 덕분에 토큰이 변조되지 않았음을 확인할 수 있습니다.

JWT는 Header.Payload.Signature 형태로 점(.)으로 연결된 문자열입니다. 클라이언트는 이 토큰을 요청 헤더(Authorization: Bearer )에 담아 서버로 전송하고, 서버는 토큰의 서명을 검증하여 유효성을 확인한 후 페이로드의 정보를 활용합니다.

장점:

  • Stateless: 서버가 사용자 상태를 유지할 필요가 없어 확장성이 좋습니다.
  • Self-contained: 토큰 자체에 필요한 정보가 담겨 있어, 데이터베이스 조회 없이 빠르게 인증 가능합니다.

단점:

  • 탈취 시 위험: 토큰이 탈취되면 만료될 때까지 악용될 수 있습니다.
  • 토큰 무효화 어려움: 이미 발행된 토큰을 서버에서 강제로 무효화하기 어렵습니다 (블랙리스트 관리 필요).
  • 페이로드 노출: 페이로드는 암호화되지 않고 인코딩만 되므로, 민감한 정보는 담지 않아야 합니다.

이러한 단점, 특히 토큰 탈취 시 위험을 줄이기 위해 등장한 것이 바로 Refresh Token입니다.

1.3. Refresh Token의 필요성

JWT의 가장 큰 단점 중 하나는 탈취 시 보안 취약점입니다. 만약 Access Token이 탈취되면, 공격자는 해당 토큰의 만료 시간까지 사용자 행세를 할 수 있습니다. 이를 방지하기 위해 Access Token의 만료 시간을 짧게 설정하는 것이 일반적입니다. 하지만 Access Token이 너무 자주 만료되면 사용자 경험이 저하됩니다 (자주 로그인해야 함).

이러한 문제를 해결하기 위해 Refresh Token이 도입됩니다.

  • Access Token (접근 토큰): 만료 시간이 짧습니다 (예: 15분 ~ 1시간). 실제 리소스 접근에 사용됩니다. 탈취되어도 짧은 시간만 유효하므로 피해를 최소화할 수 있습니다.
  • Refresh Token (갱신 토큰): 만료 시간이 깁니다 (예: 1일 ~ 1년). Access Token이 만료되었을 때 새로운 Access Token을 발급받는 용도로만 사용됩니다. 이 토큰은 일반적으로 HttpOnly 쿠키에 저장되어 XSS 공격으로부터 비교적 안전하게 보호됩니다.

JWT + Refresh Token 동작 흐름:

  1. 사용자가 로그인 요청을 보냅니다.
  2. 서버는 인증 성공 시 Access TokenRefresh Token을 모두 발행합니다.
  3. Access Token은 클라이언트 메모리(혹은 HttpOnly 쿠키)에 저장되어 이후 모든 API 요청의 Authorization 헤더에 포함됩니다.
  4. Refresh TokenHttpOnly 쿠키에 저장되어 XSS 공격으로부터 보호됩니다.
  5. 클라이언트는 API 요청 시 Access Token을 사용합니다.
  6. Access Token이 만료되면, 클라이언트는 Refresh Token을 포함한 요청을 서버의 refresh 엔드포인트로 보냅니다.
  7. 서버는 Refresh Token의 유효성을 검증하고, 유효하다면 새로운 Access Token을 발행하여 클라이언트에게 전송합니다.
  8. 만약 Refresh Token마저 만료되거나 유효하지 않다면, 사용자는 다시 로그인해야 합니다.

이 방식은 보안과 사용자 경험 사이의 균형을 효과적으로 맞출 수 있게 해줍니다.


2. SvelteKit 환경 설정 및 기본 구조

이제 본격적으로 SvelteKit 프로젝트를 설정하고 인증 시스템을 위한 기본 구조를 잡아보겠습니다.

2.1. 프로젝트 초기화 및 의존성 설치

먼저 새로운 SvelteKit 프로젝트를 생성합니다.

bash
npm create svelte@latest my-sveltekit-auth
cd my-sveltekit-auth
npm install

다음으로 JWT 및 쿠키 처리에 필요한 라이브러리를 설치합니다.

  • jsonwebtoken: JWT를 생성하고 검증하는 데 사용됩니다.
  • cookie-parser: SvelteKit hooks.server.js에서 요청 헤더의 쿠키를 쉽게 파싱할 수 있도록 돕습니다.
  • dotenv: 환경 변수를 관리하는 데 사용됩니다.
bash
npm install jsonwebtoken cookie-parser dotenv
npm install -D @types/cookie-parser # TypeScript 사용 시

2.2. 백엔드 (API 라우트) 설정

SvelteKit은 src/routes 디렉토리 내에 +server.js 파일을 생성하여 API 엔드포인트를 쉽게 만들 수 있습니다. 인증 시스템을 위해 다음과 같은 API 라우트를 구성할 예정입니다.

  • src/routes/api/auth/+server.js: 로그인 및 회원가입 처리를 담당합니다. POST 요청을 받아 사용자 인증 후 JWT 및 Refresh Token을 발급합니다.
  • src/routes/api/auth/refresh/+server.js: 만료된 Access Token을 갱신하기 위해 Refresh Token을 사용하여 새로운 Access Token을 발급합니다.
  • src/routes/api/protected/+server.js: 인증된 사용자만 접근할 수 있는 보호된 리소스의 예시입니다.

SvelteKit의 파일 기반 라우팅은 백엔드 API를 구축하는 데 매우 직관적입니다.


3. JWT 및 Refresh Token 구현: 백엔드 로직

이제 JWT와 Refresh Token을 생성하고 관리하는 핵심 백엔드 로직을 구현해 보겠습니다.

3.1. 환경 변수 설정 (.env)

보안을 위해 JWT 서명에 사용될 비밀 키와 토큰 만료 시간은 환경 변수로 관리해야 합니다. 프로젝트 루트에 .env 파일을 생성하고 다음 내용을 추가합니다.

ini
# .env
ACCESS_TOKEN_SECRET="your_access_token_secret_key"
REFRESH_TOKEN_SECRET="your_refresh_token_secret_key"
ACCESS_TOKEN_EXPIRY="15m" # 예: 15분
REFRESH_TOKEN_EXPIRY="7d"  # 예: 7일

주의: 실제 배포 환경에서는 이 비밀 키를 절대 공개 저장소에 올리지 마십시오. 강력하고 무작위적인 문자열을 사용해야 합니다.

3.2. 유틸리티 함수 (Token 생성/검증)

JWT를 생성하고 검증하는 로직을 별도의 유틸리티 파일로 분리하여 관리하면 코드를 깔끔하게 유지할 수 있습니다. src/lib/server/jwt.js 파일을 생성합니다. src/lib/server는 서버에서만 사용되는 코드를 저장하는 관례적인 위치입니다.

javascript
// src/lib/server/jwt.js
import jwt from jsonwebtoken;
import { env } from $env/dynamic/private; // SvelteKit에서 환경 변수 접근

const ACCESS_TOKEN_SECRET = env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = env.ACCESS_TOKEN_EXPIRY || 15m;
const REFRESH_TOKEN_EXPIRY = env.REFRESH_TOKEN_EXPIRY || 7d;

/**
 * Access Token을 생성합니다.
 * @param {object} payload - 토큰에 포함될 페이로드 (예: userId)
 * @returns {string} 생성된 Access Token
 */
export function generateAccessToken(payload) {
    return jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY });
}

/**
 * Refresh Token을 생성합니다.
 * @param {object} payload - 토큰에 포함될 페이로드 (예: userId)
 * @returns {string} 생성된 Refresh Token
 */
export function generateRefreshToken(payload) {
    return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
}

/**
 * 토큰의 유효성을 검증합니다.
 * @param {string} token - 검증할 토큰
 * @param {string} type - access 또는 refresh
 * @returns {object|null} 유효한 경우 페이로드, 유효하지 않은 경우 null
 */
export function verifyToken(token, type) {
    const secret = type === access ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
    try {
        return jwt.verify(token, secret);
    } catch (error) {
        return null;
    }
}

3.3. 로그인 및 회원가입 API 구현

src/routes/api/auth/+server.js 파일을 생성하여 로그인 및 회원가입 로직을 처리합니다. 여기서는 간단하게 인메모리 배열로 사용자 정보를 관리하지만, 실제 애플리케이션에서는 데이터베이스를 사용해야 합니다.

javascript
// src/routes/api/auth/+server.js
import { json } from @sveltejs/kit;
import { generateAccessToken, generateRefreshToken } from $lib/server/jwt;

// 실제 앱에서는 데이터베이스를 사용해야 합니다.
const users = [
    { id: user123, username: testuser, password: password123 }
];

/**
 * 사용자 로그인 및 회원가입 처리
 * @param {Request} request
 * @returns {Response}
 */
export async function POST({ request }) {
    const { username, password, type } = await request.json();

    if (type === register) {
        // 간단한 회원가입 로직 (실제 앱에서는 비밀번호 해싱 필수)
        const existingUser = users.find(u => u.username === username);
        if (existingUser) {
            return json({ message: Username already exists }, { status: 409 });
        }
        const newUser = { id: `user${users.length + 1}`, username, password };
        users.push(newUser);
        return json({ message: User registered successfully }, { status: 201 });
    }

    if (type === login) {
        const user = users.find(u => u.username === username && u.password === password);

        if (!user) {
            return json({ message: Invalid credentials }, { status: 401 });
        }

        const accessToken = generateAccessToken({ userId: user.id });
        const refreshToken = generateRefreshToken({ userId: user.id });

        // HttpOnly 쿠키에 Refresh Token 설정
        // Secure: HTTPS에서만 전송 (배포 시 필수)
        // SameSite: CSRF 보호 (Lax는 대부분의 경우 충분)
        const headers = {
            Set-Cookie: [
                `accessToken=${accessToken}; Path=/; HttpOnly; Max-Age=${60 * 15}; SameSite=Lax${process.env.NODE_ENV === production ? ; Secure : }`, // 15분
                `refreshToken=${refreshToken}; Path=/; HttpOnly; Max-Age=${60 * 60 * 24 * 7}; SameSite=Lax${process.env.NODE_ENV === production ? ; Secure : }` // 7일
            ]
        };

        return json({ message: Login successful, accessToken }, { headers, status: 200 });
    }

    return json({ message: Invalid request type }, { status: 400 });
}

보안 주의사항:

  • 실제 프로덕션 환경에서는 사용자 비밀번호를 반드시 해싱(예: bcrypt)하여 저장해야 합니다.
  • Secure 플래그는 HTTPS 환경에서만 쿠키를 전송하도록 합니다. 개발 환경에서는 process.env.NODE_ENV === production과 같은 조건부로 설정하는 것이 편리합니다.
  • SameSite=Lax는 대부분의 CSRF 공격을 방지하지만, 더 강력한 보호가 필요하다면 Strict를 고려하거나 추가적인 CSRF 토큰을 사용할 수 있습니다.

3.4. Refresh Token API 구현

Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위한 refresh 엔드포인트를 구현합니다. src/routes/api/auth/refresh/+server.js 파일을 생성합니다.

javascript
// src/routes/api/auth/refresh/+server.js
import { json } from @sveltejs/kit;
import { generateAccessToken, verifyToken } from $lib/server/jwt;
import cookieParser from cookie-parser; // 쿠키 파싱을 위해 임포트

export async function POST({ request }) {
    // SvelteKit request 객체에서 쿠키 헤더를 가져옵니다.
    const cookieHeader = request.headers.get(cookie);
    const cookies = cookieParser.parse(cookieHeader || ); // cookie-parser로 쿠키 파싱

    const refreshToken = cookies.refreshToken;

    if (!refreshToken) {
        return json({ message: Refresh token not found }, { status: 401 });
    }

    const decoded = verifyToken(refreshToken, refresh);

    if (!decoded) {
        // 유효하지 않거나 만료된 Refresh Token인 경우
        // 기존 쿠키를 삭제하고 재로그인을 유도합니다.
        const headers = {
            Set-Cookie: [
                `accessToken=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax${process.env.NODE_ENV === production ? ; Secure : }`,
                `refreshToken=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax${process.env.NODE_ENV === production ? ; Secure : }`
            ]
        };
        return json({ message: Invalid or expired refresh token. Please log in again. }, { headers, status: 403 });
    }

    // 새로운 Access Token 발급
    const newAccessToken = generateAccessToken({ userId: decoded.userId });

    const headers = {
        Set-Cookie: `accessToken=${newAccessToken}; Path=/; HttpOnly; Max-Age=${60 * 15}; SameSite=Lax${process.env.NODE_ENV === production ? ; Secure : }`
    };

    return json({ message: Access token refreshed successfully, accessToken: newAccessToken }, { headers, status: 200 });
}

여기서 cookieParser를 사용하여 request.headers.get(cookie)에서 문자열로 가져온 쿠키를 객체 형태로 편리하게 파싱합니다.


4. SvelteKit 미들웨어 및 보호된 라우트

SvelteKit의 hooks.server.js 파일은 모든 서버 요청 전에 실행되는 미들웨어 역할을 합니다. 이를 활용하여 전역적인 인증 로직을 구현하고 보호된 라우트에 접근을 제어할 수 있습니다.

4.1. SvelteKit Hooks (src/hooks.server.js)

src/hooks.server.js 파일을 생성하여 모든 서버 요청에 대한 인증 검사를 수행합니다.

javascript
// src/hooks.server.js
import { verifyToken } from $lib/server/jwt;
import { redirect } from @sveltejs/kit;
import cookieParser from cookie-parser;

/** @type {import(@sveltejs/kit).Handle} */
export async function handle({ event, resolve }) {
    // 1. 쿠키 파싱
    const cookieHeader = event.request.headers.get(cookie);
    const cookies = cookieParser.parse(cookieHeader || );
    event.locals.cookies = cookies; // event.locals에 파싱된 쿠키 저장 (선택 사항)

    const accessToken = cookies.accessToken;

    if (accessToken) {
        // 2. Access Token 검증
        const decoded = verifyToken(accessToken, access);
        if (decoded) {
            // 3. 유효한 경우 사용자 정보 저장
            event.locals.user = decoded;
        } else {
            // 4. Access Token이 유효하지 않은 경우 (만료 등)
            // Refresh Token을 사용하여 재발급 시도 (클라이언트 측에서 처리)
            // 여기서는 단순히 user 정보를 null로 설정
            event.locals.user = null;
        }
    } else {
        event.locals.user = null;
    }

    // 5. 보호된 라우트 처리 (예: /protected 라우트)
    const protectedRoutes = [/protected, /profile]; // 보호할 라우트 목록
    const currentRoute = event.url.pathname;

    if (protectedRoutes.some(route => currentRoute.startsWith(route)) && !event.locals.user) {
        // 인증되지 않은 사용자가 보호된 라우트에 접근 시 로그인 페이지로 리다이렉트
        throw redirect(302, /login);
    }

    // 요청 처리 계속
    const response = await resolve(event);
    return response;
}

event.locals 활용:
event.locals 객체는 hooks.server.js에서 설정한 데이터를 +page.server.js+layout.server.js, 그리고 API 라우트의 event 객체에서 접근할 수 있도록 해줍니다. 이는 SvelteKit의 강력한 기능 중 하나입니다.

src/app.d.ts 파일을 업데이트하여 event.locals의 타입 정의를 확장해야 TypeScript 오류를 방지할 수 있습니다.

typescript
// src/app.d.ts
import cookie-parser; // cookie-parser의 타입 정의를 가져옵니다.

declare global {
	namespace App {
		// interface Error {}
		interface Locals {
			user: { userId: string } | null;
            cookies: Record<string, string>; // cookie-parser가 반환하는 타입
		}
		// interface PageData {}
		// interface Platform {}
	}
}

export {};

4.2. 보호된 API 라우트 예시

src/routes/api/protected/+server.js 파일을 생성하여 인증된 사용자만 접근할 수 있는 API 엔드포인트를 만듭니다.

javascript
// src/routes/api/protected/+server.js
import { json } from @sveltejs/kit;

/**
 * 인증된 사용자만 접근 가능한 API
 * @param {import(@sveltejs/kit).RequestEvent} event
 * @

개발 의뢰 상담

AI 서비스나 플랫폼 개발을
고민 중이신가요?

CodePick에서는
기획 → 개발 → 운영까지 함께합니다.
아이디어만 있어도 상담 가능합니다.

CodeVenter 개발팀이 직접 담당 · 1~2 영업일 내 회신

✓ 스타트업 MVP 개발✓ AI 서비스 개발✓ 웹 플랫폼 개발✓ 기업 시스템 구축✓ 모바일 앱 개발

AI Development Studio

코드픽 by 코드벤터

  • 대표: 윤승환 · 사업자등록번호: 121-57-64983
  • 대구광역시 중구 국채보상로 586, 16층 · info@codeventer.com

© 2025 코드벤터. All rights reserved.