티스토리 뷰

반응형

SvelteKit으로 시작하는 SSR 백엔드-프론트 기초 100단계

11단계 — 세션을 DB로 관리하기 (쿠키 하드코딩 탈출, “서비스 인증”으로 업그레이드)

10단계까지는 “흐름 이해”를 위해 role을 쿠키에 넣는 식으로 단순화했죠.
근데 이 방식은 실제 서비스에서는 위험해요. (쿠키 변조/신뢰 문제)

그래서 이번 단계에서 드디어 이렇게 바꿉니다.

  • ✅ 쿠키에는 session_id(랜덤 토큰) 만 저장
  • ✅ 서버는 DB에서 세션 조회 → 사용자 조회 → locals.user 세팅
  • ✅ 로그아웃하면 DB 세션 삭제
  • ✅ role도 DB에서 가져옴 (이제 쿠키 role 삭제)

이 단계 끝나면 “아, 이게 진짜 로그인이지…” 하는 느낌이 옵니다.


11단계 목표

  • MySQL + Prisma에 User, Session 테이블 추가
  • 로그인 시 Session row 생성 + 쿠키에 session_id 저장
  • hooks.server.ts에서 session_id로 세션/유저 조회 후 locals.user 세팅
  • 로그아웃 시 세션 삭제 + 쿠키 삭제
  • 기존의 requireLogin, requireRole 그대로 동작하게 만들기

0) 전제 조건 (이 글이 그대로 실행되는 기준)

  • 5단계에서 MySQL + Prisma 연결 완료
  • DATABASE_URL="mysql://app:app@localhost:3306/sveltekit"
  • src/lib/server/prisma.ts 존재

1) Prisma 스키마 업데이트 (User / Session 추가)

prisma/schema.prisma를 아래처럼 업데이트하세요.
(기존 Post는 유지하고, 아래 두 모델을 추가합니다.)

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int       @id @default(autoincrement())
  username  String    @unique
  password  String    // 지금은 학습용으로 평문. 다음 단계에서 bcrypt로 바꿉니다.
  role      Role      @default(USER)
  sessions  Session[]
  createdAt DateTime  @default(now())
}

model Session {
  id        String    @id // 랜덤 토큰을 그대로 PK로 씁니다 (session_id)
  userId    Int
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime  @default(now())

  @@index([userId])
  @@index([expiresAt])
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  createdAt DateTime @default(now())
}

enum Role {
  USER
  ADMIN
}

마이그레이션 실행:

npx prisma migrate dev --name add_user_session

2) 유저 시드(초기 데이터) 만들기

일단 로그인 가능한 계정이 있어야 하니까, 간단 시드 스크립트를 하나 만들게요.

2-1) prisma/seed.ts 생성

prisma/seed.ts

import { PrismaClient, Role } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // 중복 생성 방지: username 기준 upsert
  await prisma.user.upsert({
    where: { username: 'admin' },
    update: { password: '1234', role: Role.ADMIN },
    create: { username: 'admin', password: '1234', role: Role.ADMIN }
  });

  await prisma.user.upsert({
    where: { username: 'user' },
    update: { password: '1234', role: Role.USER },
    create: { username: 'user', password: '1234', role: Role.USER }
  });

  console.log('✅ Seed done: admin/1234, user/1234');
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

2-2) package.json에 seed 설정 추가

package.json에 아래를 추가하세요.

{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}

그리고 시드 실행:

npm install -D ts-node
npx prisma db seed

이제 DB에 admin/user 계정이 들어갔습니다.


3) 세션 저장소 만들기 (server 전용)

세션 생성/조회/삭제 로직을 서버에 모아두면 진짜 편합니다.

src/lib/server/session.repo.ts 생성:

import { prisma } from '$lib/server/prisma';
import crypto from 'crypto';

const SESSION_DAYS = 7;

export function createSessionId(): string {
  // URL-safe 토큰
  return crypto.randomBytes(32).toString('hex');
}

export function getSessionExpiry(): Date {
  const expires = new Date();
  expires.setDate(expires.getDate() + SESSION_DAYS);
  return expires;
}

export async function createSession(userId: number) {
  const id = createSessionId();
  const expiresAt = getSessionExpiry();

  await prisma.session.create({
    data: { id, userId, expiresAt }
  });

  return { id, expiresAt };
}

export async function findValidSession(sessionId: string) {
  const now = new Date();

  return prisma.session.findFirst({
    where: {
      id: sessionId,
      expiresAt: { gt: now }
    },
    include: {
      user: true
    }
  });
}

export async function deleteSession(sessionId: string) {
  await prisma.session.delete({ where: { id: sessionId } }).catch(() => {
    // 이미 없으면 무시
  });
}

// (선택) 만료 세션 청소: 나중에 크론/배치로 돌리면 좋아요
export async function deleteExpiredSessions() {
  const now = new Date();
  await prisma.session.deleteMany({
    where: { expiresAt: { lte: now } }
  });
}

4) hooks에서 “쿠키 session_id → DB 조회 → locals.user 세팅”으로 변경

이제 10단계에서 쓰던 user_id, user_role 쿠키는 버립니다.
session_id만 씁니다.

src/hooks.server.ts 수정:

import type { Handle } from '@sveltejs/kit';
import { findValidSession } from '$lib/server/session.repo';

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

  if (!sessionId) {
    event.locals.user = null;
    return resolve(event);
  }

  const session = await findValidSession(sessionId);

  if (!session) {
    // 세션이 없거나 만료면 쿠키도 정리
    event.cookies.delete('session_id', { path: '/' });
    event.locals.user = null;
    return resolve(event);
  }

  // ✅ 이제 role은 DB에서 온다
  event.locals.user = {
    id: String(session.user.id),
    role: session.user.role // 'ADMIN' | 'USER'
  };

  return resolve(event);
};

5) locals 타입 업데이트 (role은 그대로, id는 문자열로 유지)

src/app.d.ts (이미 10단계에서 만들었으면 그대로 유지해도 OK)

declare namespace App {
  interface Locals {
    user: {
      id: string;
      role: 'ADMIN' | 'USER';
    } | null;
  }
}

6) 로그인 로직을 “DB 유저 조회 + 세션 생성”으로 변경

src/routes/login/+page.server.ts를 DB 기반으로 바꿉니다.

import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { prisma } from '$lib/server/prisma';
import { createSession } from '$lib/server/session.repo';

export const load: PageServerLoad = async ({ locals }) => {
  if (locals.user) throw redirect(302, '/');
  return {};
};

export const actions: Actions = {
  default: async ({ request, cookies, url }) => {
    const data = await request.formData();
    const username = String(data.get('username') ?? '').trim();
    const password = String(data.get('password') ?? '').trim();

    if (!username || !password) {
      return fail(400, { error: '아이디/비밀번호를 입력해주세요.' });
    }

    const user = await prisma.user.findUnique({ where: { username } });

    // ⚠️ 학습용: 지금은 평문 비교 (다음 단계에서 bcrypt로 교체)
    if (!user || user.password !== password) {
      return fail(400, { error: '아이디 또는 비밀번호가 틀렸습니다.' });
    }

    const session = await createSession(user.id);

    cookies.set('session_id', session.id, {
      path: '/',
      httpOnly: true,
      sameSite: 'lax',
      secure: false // 로컬 개발이라 false, 배포시 true 권장(HTTPS)
      // maxAge를 주고 싶으면 expiresAt 기반으로 계산해서 넣어도 됩니다
    });

    // (선택) 원래 가려던 곳으로 보내기
    const redirectTo = url.searchParams.get('redirect') ?? '/';
    throw redirect(302, redirectTo);
  }
};

7) 로그아웃을 “세션 삭제 + 쿠키 삭제”로 변경

src/routes/logout/+page.server.ts

import type { Actions } from './$types';
import { redirect } from '@sveltejs/kit';
import { deleteSession } from '$lib/server/session.repo';

export const actions: Actions = {
  default: async ({ cookies }) => {
    const sessionId = cookies.get('session_id');

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

    cookies.delete('session_id', { path: '/' });
    throw redirect(302, '/login');
  }
};

레이아웃에서 로그아웃 버튼이 POST로 /logout을 때리는 구조(8단계) 그대로면, 그대로 작동합니다.


8) 기존 권한 가드(requireRole)는 그대로 쓰면 된다

src/lib/server/auth.ts (10단계에서 만든 거 그대로 OK)

import { redirect, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';

export function requireLogin(event: RequestEvent) {
  if (!event.locals.user) throw redirect(302, '/login');
  return event.locals.user;
}

export function requireRole(event: RequestEvent, role: 'ADMIN' | 'USER') {
  const user = requireLogin(event);
  if (user.role !== role) throw error(403, '권한이 없습니다.');
  return user;
}

/admin 페이지도 수정할 필요 없습니다.
왜냐면 이제 locals.user.role이 DB에서 오니까요.


9) 동작 확인 체크리스트

  1. 마이그레이션 & 시드
npx prisma migrate dev --name add_user_session
npx prisma db seed
  1. 개발 서버 실행
npm run dev
  1. 로그인 테스트
  • /login
  • admin / 1234 → /admin 접근 가능
  • user / 1234 → /admin 접근하면 403
  1. 브라우저 쿠키 확인
  • Application → Cookies
  • 이제 session_id 하나만 있어야 정상
  1. 로그아웃
  • session_id 사라지고 /login으로 이동

오늘 단계에서 주니어가 꼭 가져가야 할 감각 (진짜 중요한 한 문장)

쿠키는 “증거”가 아니라 “열쇠”만 들고 있고,
서버(DB)가 진짜 신원을 판단한다.

이게 서비스 인증의 기본기예요.


 

SvelteKit, SSR, 세션인증, Prisma, MySQL, hooksServer, locals, RBAC, 주니어개발자, 백엔드기초

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함
반응형