티스토리 뷰

반응형

세션이 좋을까, JWT가 좋을까? 로그인 이후 인증 상태를 유지하는 두 가지 방식 제대로 이해하기 — FastAPI · Spring Boot · Node.js

비밀번호 해시까지 붙이고 나면, 이제 진짜로 다음 질문이 나옵니다.

“로그인 성공한 사용자를 이제 어떻게 기억하지?”

여기서 거의 무조건 나오는 단어가 두 개예요.

  • 세션(Session)
  • JWT

근데 이 주제는 늘 좀 이상하게 흘러갑니다.
누군가는 “요즘은 무조건 JWT죠”라고 하고,
또 누군가는 “아니, 세션이 더 안전해요”라고 하죠.

솔직히 말하면… 둘 다 반쯤 맞고, 반쯤은 맥락이 빠진 말입니다.

저도 예전엔 JWT가 더 최신이고 멋진 방식처럼 느껴졌어요.
토큰 하나 들고 다니면 되니까 뭔가 더 현대적이고, 마이크로서비스랑도 잘 맞고, 모바일 앱에도 잘 어울려 보였거든요.
그런데 서비스 구조를 조금 더 경험하고 나니까,
무조건 JWT가 정답은 아니고, 세션이 훨씬 편하고 안전한 경우도 많다는 걸 알게 됐습니다.

이번 글에서는 그 얘기를 제대로 해보겠습니다.

  • 세션이 정확히 뭔지
  • JWT가 정확히 뭔지
  • 각각 장단점이 뭔지
  • 언제 어떤 쪽이 더 맞는지
  • FastAPI / Spring Boot / Node.js에서 최소 구현은 어떻게 시작하는지

FastAPI 공식 문서는 OAuth2 password flow와 Bearer 토큰, JWT를 이용한 보안 튜토리얼을 제공합니다. (FastAPI)
Spring Security는 세션 관리와 OAuth2 Resource Server JWT를 각각 공식 문서로 다루고, Spring Session은 클러스터 환경의 세션 관리까지 지원합니다. (Home)
Node.js/Express 쪽에서는 express-session이 대표적인 세션 미들웨어이고, 이 미들웨어는 쿠키에 세션 데이터 자체가 아니라 세션 ID만 저장한다고 명시합니다. (NPM)


먼저 아주 짧게 정리하면

세션

서버가 로그인 상태를 기억합니다.
브라우저/클라이언트는 보통 세션 ID만 들고 다닙니다.
express-session 문서도 쿠키에는 세션 데이터가 아니라 세션 ID만 저장된다고 설명합니다. (NPM)

JWT

서버가 서명한 토큰 자체에 사용자 정보나 클레임을 담아 클라이언트가 들고 다닙니다.
FastAPI 보안 튜토리얼도 Bearer JWT 토큰 흐름을 예제로 보여줍니다. (FastAPI)

이 차이가 진짜 핵심이에요.

세션은 서버 저장형,
JWT는 토큰 전달형에 가깝습니다.


세션은 어떻게 동작할까

흐름은 단순합니다.

  1. 사용자가 로그인
  2. 서버가 로그인 성공을 확인
  3. 서버가 세션 저장소에 사용자 상태를 기록
  4. 클라이언트에는 세션 ID를 쿠키로 전달
  5. 이후 요청마다 세션 ID를 보고 서버가 사용자 상태를 조회

즉, 로그인 상태의 진짜 본체는 서버에 있어요.

이 구조의 장점은 꽤 명확합니다.

  • 강제 로그아웃이 쉬움
  • 서버에서 세션 만료/차단 관리가 쉬움
  • 민감한 상태를 토큰에 많이 담지 않아도 됨
  • 브라우저 기반 웹앱에서 다루기 편함

Spring Security는 세션 관리 기능을 공식적으로 제공하고, Spring Session은 여러 서버에서 세션을 공유하는 clustered session 지원까지 제공합니다. (Home)


JWT는 어떻게 동작할까

반응형

흐름은 보통 이렇습니다.

  1. 사용자가 로그인
  2. 서버가 로그인 성공을 확인
  3. 서버가 JWT를 발급
  4. 클라이언트가 이 토큰을 저장
  5. 이후 요청마다 Authorization: Bearer <token> 형태로 보냄
  6. 서버는 토큰 서명과 만료를 검증해 사용자 식별

즉, 인증 판단에 필요한 정보가 서버 세션 저장소가 아니라 토큰 안쪽에 들어 있습니다.

FastAPI 공식 보안 튜토리얼은 바로 이 Bearer JWT 흐름을 실제로 구현 가능한 코드로 설명합니다. (FastAPI)
Spring Security의 OAuth2 Resource Server JWT 문서도 JWT 기반 보호 엔드포인트 구성을 공식적으로 설명합니다. (Home)


그럼 뭐가 더 좋을까

이 질문에 저는 이제 이렇게 답합니다.

좋고 나쁜 게 아니라, 어디에 더 맞느냐가 다르다.


세션이 더 잘 맞는 경우

이런 상황이면 세션이 꽤 좋습니다.

  • 브라우저 기반 웹 서비스
  • 서버 렌더링 또는 전통적인 웹앱
  • 관리자 페이지
  • 강제 로그아웃, 동시 세션 제한이 중요함
  • 서버 쪽에서 상태 제어를 강하게 하고 싶음

Spring Security는 동시 세션 제어, 만료 관리 같은 세션 관리 기능을 공식적으로 제공합니다. (Home)

제가 느끼기엔 세션은
“웹 서비스 운영자가 제어하기 편한 방식” 입니다.


JWT가 더 잘 맞는 경우

이런 상황이면 JWT가 잘 맞습니다.

  • 순수 API 서버
  • 모바일 앱 + 웹 + 외부 클라이언트가 함께 붙음
  • Authorization 헤더 기반 인증이 자연스러움
  • 별도 인증 서버/리소스 서버 구조
  • 마이크로서비스 또는 API 게이트웨이 구조

FastAPI는 JWT 기반 API 인증 예제를 공식 튜토리얼로 제공하고, Spring Security는 Resource Server JWT를 별도 문서로 다룹니다. (FastAPI)

제가 느끼기엔 JWT는
“API 중심 서비스에서 연결성이 좋은 방식” 에 더 가깝습니다.


그런데 JWT가 무조건 더 현대적인 건 아니다

이건 꼭 말하고 싶어요.

JWT가 요즘 많이 쓰인다고 해서 항상 더 낫진 않습니다.

왜냐하면 JWT는 편한 만큼 트레이드오프가 분명하거든요.

예를 들면,

  • 발급된 access token을 강제로 즉시 무효화하기가 세션보다 불편함
  • 토큰 만료, 재발급, refresh token 관리가 필요해짐
  • 클라이언트 저장 위치를 잘못 잡으면 보안 문제가 생길 수 있음
  • 토큰 자체가 커지면 네트워크 오버헤드가 늘어남

반대로 세션은 서버 저장소가 필요하고, 서버가 여러 대면 세션 공유 전략이 필요합니다. Spring Session이 바로 그 문제를 풀기 위한 공식 솔루션 중 하나입니다. (Home)


제가 실무에서 보는 아주 현실적인 기준

저는 보통 이렇게 추천합니다.

1. 브라우저 중심 웹 서비스면 세션부터 고려

특히 관리자 페이지, 사내 시스템, 백오피스, 전통적 웹앱은 세션이 더 단순하고 관리가 쉬운 경우가 많습니다. Spring Security의 세션 관리 기능도 이쪽에 강합니다. (Home)

2. 모바일/SPA/API 중심이면 JWT가 자연스러움

FastAPI의 보안 튜토리얼도 Bearer JWT 흐름을 중심으로 설명합니다. (FastAPI)

3. “무상태니까 무조건 JWT”는 위험한 단순화

실제로는 refresh token, revoke 전략, 토큰 만료 정책까지 같이 설계해야 합니다. Spring의 Resource Server 문서도 JWT 보호 엔드포인트 쪽에 집중하지, “JWT면 모든 문제가 해결된다”는 식으로 말하지 않습니다. (Home)


1) FastAPI에서 JWT 방식으로 시작하기

FastAPI는 현재 공식 보안 튜토리얼이 JWT Bearer 방식을 가장 직접적으로 보여줍니다. (FastAPI)
그래서 FastAPI 쪽은 JWT 기준으로 시작하는 게 가장 자연스럽습니다.

이번 예제는 “로그인 성공 시 access token 발급”의 최소 구조만 보여드릴게요.

추천 구조

fastapi-backend/
├── app/
│   ├── api/
│   │   └── auth.py
│   ├── repositories/
│   │   └── user_repository.py
│   ├── schemas/
│   │   └── auth.py
│   ├── security/
│   │   ├── jwt.py
│   │   └── password.py
│   ├── services/
│   │   └── auth_service.py
│   ├── dependencies.py
│   └── main.py
└── requirements.txt

requirements.txt

fastapi
uvicorn[standard]
pydantic
email-validator
pwdlib[argon2]
pyjwt
python-multipart

FastAPI 보안 튜토리얼은 JWT 예제에서 python-jose 계열을 사용해 왔지만, JWT 라이브러리 선택 자체보다 핵심은 서명된 access token을 발급하고 검증하는 구조입니다. 공식 문서도 Bearer JWT 토큰 흐름 자체를 강조합니다. (FastAPI)

app/security/password.py

from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()


def hash_password(raw_password: str) -> str:
    return password_hash.hash(raw_password)


def verify_password(raw_password: str, hashed_password: str) -> bool:
    return password_hash.verify(raw_password, hashed_password)

app/security/jwt.py

from datetime import datetime, timedelta, timezone
import jwt

SECRET_KEY = "change-this-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30


def create_access_token(subject: str) -> str:
    now = datetime.now(timezone.utc)
    expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    payload = {
        "sub": subject,
        "iat": int(now.timestamp()),
        "exp": int(expire.timestamp()),
    }

    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

app/schemas/auth.py

from pydantic import BaseModel, EmailStr, Field


class SignupRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=100)
    nickname: str = Field(min_length=2, max_length=20)


class LoginRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=100)


class UserRecord(BaseModel):
    id: int
    email: EmailStr
    password_hash: str
    nickname: str


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

app/repositories/user_repository.py

from typing import Optional
from app.schemas.auth import UserRecord


class UserRepository:
    def __init__(self) -> None:
        self._users: list[UserRecord] = []
        self._next_id = 1

    def find_by_email(self, email: str) -> Optional[UserRecord]:
        return next((u for u in self._users if str(u.email) == email), None)

    def save(self, email: str, password_hash: str, nickname: str) -> UserRecord:
        user = UserRecord(
            id=self._next_id,
            email=email,
            password_hash=password_hash,
            nickname=nickname,
        )
        self._users.append(user)
        self._next_id += 1
        return user

app/services/auth_service.py

from app.repositories.user_repository import UserRepository
from app.schemas.auth import LoginRequest, SignupRequest, TokenResponse
from app.security.jwt import create_access_token
from app.security.password import hash_password, verify_password


class AuthService:
    def __init__(self, user_repository: UserRepository) -> None:
        self.user_repository = user_repository

    def signup(self, request: SignupRequest) -> None:
        if self.user_repository.find_by_email(str(request.email)) is not None:
            raise ValueError("이미 가입된 이메일입니다.")

        hashed = hash_password(request.password)

        self.user_repository.save(
            email=str(request.email),
            password_hash=hashed,
            nickname=request.nickname,
        )

    def login(self, request: LoginRequest) -> TokenResponse:
        user = self.user_repository.find_by_email(str(request.email))
        if user is None:
            raise ValueError("이메일 또는 비밀번호가 올바르지 않습니다.")

        if not verify_password(request.password, user.password_hash):
            raise ValueError("이메일 또는 비밀번호가 올바르지 않습니다.")

        token = create_access_token(subject=str(user.email))
        return TokenResponse(access_token=token)

app/dependencies.py

from app.repositories.user_repository import UserRepository
from app.services.auth_service import AuthService

user_repository = UserRepository()


def get_auth_service() -> AuthService:
    return AuthService(user_repository)

app/api/auth.py

from fastapi import APIRouter, Depends, HTTPException, status

from app.dependencies import get_auth_service
from app.schemas.auth import LoginRequest, SignupRequest, TokenResponse
from app.services.auth_service import AuthService

router = APIRouter(prefix="/api/auth", tags=["auth"])


@router.post("/signup", status_code=status.HTTP_201_CREATED)
def signup(
    request: SignupRequest,
    auth_service: AuthService = Depends(get_auth_service),
):
    try:
        auth_service.signup(request)
        return {"message": "회원가입 성공"}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@router.post("/login", response_model=TokenResponse)
def login(
    request: LoginRequest,
    auth_service: AuthService = Depends(get_auth_service),
) -> TokenResponse:
    try:
        return auth_service.login(request)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

app/main.py

from fastapi import FastAPI
from app.api.auth import router as auth_router

app = FastAPI(title="backend-series-fastapi-jwt")
app.include_router(auth_router)

FastAPI에서 기억할 포인트

FastAPI는 공식 튜토리얼 자체가 OAuth2 password flow + Bearer JWT 흐름을 기준으로 설명하기 때문에,
API 서버를 만들 때 JWT 접근이 꽤 자연스럽습니다. (FastAPI)

제가 추천하는 건 이거예요.

  • 비밀번호 검증은 security/password.py
  • 토큰 발급은 security/jwt.py
  • 로그인 흐름은 AuthService
  • 라우터는 최대한 얇게

이렇게 끊어두면 나중에 refresh token이나 현재 사용자 조회 의존성을 붙이기도 편합니다.


2) Spring Boot에서 세션 방식으로 시작하기

Spring Security는 세션 관리 기능이 매우 강합니다. 공식 문서에도 세션 관리가 큰 축으로 따로 있고, 동시 세션 제어 같은 기능까지 다룹니다. (Home)
그래서 Spring Boot 쪽은 “브라우저 중심 웹 서비스”라면 세션 방식으로 시작하는 게 꽤 자연스럽습니다.

여기서는 가장 단순한 “로그인 성공 시 세션에 사용자 식별값 저장” 흐름으로 보여드릴게요.

추천 구조

springboot-backend/
├── src/main/java/com/example/backend/
│   ├── BackendApplication.java
│   ├── controller/
│   │   └── AuthController.java
│   ├── dto/
│   │   ├── LoginRequest.java
│   │   ├── SignupRequest.java
│   │   └── UserRecord.java
│   ├── repository/
│   │   └── UserRepository.java
│   ├── service/
│   │   └── AuthService.java
│   └── config/
│       └── SecurityBeansConfig.java
└── build.gradle

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.3'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

config/SecurityBeansConfig.java

package com.example.backend.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityBeansConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

dto/SignupRequest.java

package com.example.backend.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record SignupRequest(
        @NotBlank @Email String email,
        @NotBlank @Size(min = 8, max = 100) String password,
        @NotBlank @Size(min = 2, max = 20) String nickname
) {
}

dto/LoginRequest.java

package com.example.backend.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record LoginRequest(
        @NotBlank @Email String email,
        @NotBlank @Size(min = 8, max = 100) String password
) {
}

dto/UserRecord.java

package com.example.backend.dto;

public record UserRecord(
        Long id,
        String email,
        String passwordHash,
        String nickname
) {
}

repository/UserRepository.java

package com.example.backend.repository;

import com.example.backend.dto.UserRecord;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Repository
public class UserRepository {

    private final List<UserRecord> users = new ArrayList<>();
    private long nextId = 1L;

    public Optional<UserRecord> findByEmail(String email) {
        return users.stream()
                .filter(user -> user.email().equals(email))
                .findFirst();
    }

    public UserRecord save(String email, String passwordHash, String nickname) {
        UserRecord user = new UserRecord(nextId++, email, passwordHash, nickname);
        users.add(user);
        return user;
    }
}

service/AuthService.java

package com.example.backend.service;

import com.example.backend.dto.LoginRequest;
import com.example.backend.dto.SignupRequest;
import com.example.backend.dto.UserRecord;
import com.example.backend.repository.UserRepository;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public void signup(SignupRequest request) {
        userRepository.findByEmail(request.email()).ifPresent(user -> {
            throw new IllegalArgumentException("이미 가입된 이메일입니다.");
        });

        String encodedPassword = passwordEncoder.encode(request.password());
        userRepository.save(request.email(), encodedPassword, request.nickname());
    }

    public void login(LoginRequest request, HttpSession session) {
        UserRecord user = userRepository.findByEmail(request.email())
                .orElseThrow(() -> new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."));

        if (!passwordEncoder.matches(request.password(), user.passwordHash())) {
            throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.");
        }

        session.setAttribute("LOGIN_USER_EMAIL", user.email());
        session.setAttribute("LOGIN_USER_ID", user.id());
    }

    public void logout(HttpSession session) {
        session.invalidate();
    }
}

controller/AuthController.java

package com.example.backend.controller;

import com.example.backend.dto.LoginRequest;
import com.example.backend.dto.SignupRequest;
import com.example.backend.service.AuthService;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/signup")
    @ResponseStatus(HttpStatus.CREATED)
    public Object signup(@Valid @RequestBody SignupRequest request) {
        authService.signup(request);
        return java.util.Map.of("message", "회원가입 성공");
    }

    @PostMapping("/login")
    public Object login(@Valid @RequestBody LoginRequest request, HttpSession session) {
        authService.login(request, session);
        return java.util.Map.of("message", "로그인 성공");
    }

    @PostMapping("/logout")
    public Object logout(HttpSession session) {
        authService.logout(session);
        return java.util.Map.of("message", "로그아웃 성공");
    }
}

Spring Boot에서 기억할 포인트

여기서는 Spring Security의 전체 로그인 필터 체인을 다 쓴 건 아니고,
세션 개념을 이해하기 위한 최소 예제로 HttpSession을 직접 사용했습니다.

핵심은 이거예요.

  • 로그인 성공 시 세션에 사용자 식별값 저장
  • 이후 요청은 세션 ID 기반으로 서버가 사용자 상태 확인
  • 로그아웃은 session.invalidate()

Spring Security는 이 세션 관리를 훨씬 정교하게 확장해줍니다. 동시 세션 제한, 세션 만료 정책, 클러스터 환경 세션 공유는 공식 기능과 Spring Session으로 확장 가능합니다. (Home)

개인적으로 Spring 쪽은
웹앱/백오피스/사내시스템 같은 쪽에서 세션이 정말 편합니다.


3) Node.js(Express)에서 세션 방식으로 시작하기

Express는 세션을 프레임워크 내장으로 제공하지 않고, 보통 express-session 미들웨어를 씁니다. npm 공식 페이지는 이 미들웨어가 세션 데이터는 쿠키에 저장하지 않고, 세션 ID만 저장한다고 설명합니다. (NPM)

이 부분이 되게 중요해요.
많이들 “세션이면 쿠키에 다 들어가는 거 아닌가요?”라고 오해하는데, 기본 개념은 그게 아닙니다.

추천 구조

node-backend/
├── src/
│   ├── repositories/
│   │   └── user.repository.js
│   ├── routes/
│   │   └── auth.route.js
│   ├── security/
│   │   └── password.js
│   ├── services/
│   │   └── auth.service.js
│   └── server.js
└── package.json

package.json

{
  "name": "backend-series-node-session",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node src/server.js"
  },
  "dependencies": {
    "bcrypt": "^5.1.1",
    "express": "^5.1.0",
    "express-session": "^1.18.2"
  }
}

src/security/password.js

import bcrypt from "bcrypt";

const SALT_ROUNDS = 10;

export async function hashPassword(rawPassword) {
  return bcrypt.hash(rawPassword, SALT_ROUNDS);
}

export async function verifyPassword(rawPassword, hashedPassword) {
  return bcrypt.compare(rawPassword, hashedPassword);
}

src/repositories/user.repository.js

export class UserRepository {
  constructor() {
    this.users = [];
    this.nextId = 1;
  }

  findByEmail(email) {
    return this.users.find((user) => user.email === email) ?? null;
  }

  save(email, passwordHash, nickname) {
    const user = {
      id: this.nextId++,
      email,
      passwordHash,
      nickname,
    };

    this.users.push(user);
    return user;
  }
}

src/services/auth.service.js

import { hashPassword, verifyPassword } from "../security/password.js";

export class AuthService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async signup({ email, password, nickname }) {
    const existingUser = this.userRepository.findByEmail(email);
    if (existingUser) {
      throw new Error("이미 가입된 이메일입니다.");
    }

    const passwordHash = await hashPassword(password);
    this.userRepository.save(email, passwordHash, nickname);
  }

  async login({ email, password }) {
    const user = this.userRepository.findByEmail(email);
    if (!user) {
      throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
    }

    const matched = await verifyPassword(password, user.passwordHash);
    if (!matched) {
      throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
    }

    return user;
  }
}

src/routes/auth.route.js

import { Router } from "express";

export function createAuthRouter(authService) {
  const router = Router();

  router.post("/api/auth/signup", async (req, res) => {
    const { email, password, nickname } = req.body;

    if (!email || !password || !nickname) {
      return res.status(400).json({ message: "필수값이 누락되었습니다." });
    }

    try {
      await authService.signup({ email, password, nickname });
      return res.status(201).json({ message: "회원가입 성공" });
    } catch (error) {
      return res.status(400).json({ message: error.message });
    }
  });

  router.post("/api/auth/login", async (req, res) => {
    const { email, password } = req.body;

    if (!email || !password) {
      return res.status(400).json({ message: "필수값이 누락되었습니다." });
    }

    try {
      const user = await authService.login({ email, password });

      req.session.user = {
        id: user.id,
        email: user.email,
        nickname: user.nickname,
      };

      return res.json({ message: "로그인 성공" });
    } catch (error) {
      return res.status(400).json({ message: error.message });
    }
  });

  router.post("/api/auth/logout", (req, res) => {
    req.session.destroy((err) => {
      if (err) {
        return res.status(500).json({ message: "로그아웃 실패" });
      }
      return res.json({ message: "로그아웃 성공" });
    });
  });

  return router;
}

src/server.js

import express from "express";
import session from "express-session";
import { UserRepository } from "./repositories/user.repository.js";
import { createAuthRouter } from "./routes/auth.route.js";
import { AuthService } from "./services/auth.service.js";

const app = express();
const PORT = 3000;

const userRepository = new UserRepository();
const authService = new AuthService(userRepository);

app.use(express.json());

app.use(
  session({
    secret: "change-this-session-secret",
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
    },
  })
);

app.use(createAuthRouter(authService));

app.listen(PORT, () => {
  console.log(`server running on http://localhost:${PORT}`);
});

Node.js에서 기억할 포인트

express-session은 정말 많이 쓰이지만, npm 문서도 기본 MemoryStore는 프로덕션용으로 권장하지 않습니다. 실제 운영에서는 별도 세션 스토어를 써야 합니다. (NPM)

이건 진짜 중요해요.

개발용 데모에선 괜찮아도 운영에서는 보통 Redis 같은 외부 저장소로 세션을 빼는 편이 낫습니다.
그리고 httpOnly 쿠키 설정 같은 기본 쿠키 보안 옵션도 꼭 챙겨야 합니다. express-session 자체도 쿠키 옵션을 제공합니다. (NPM)


세션 vs JWT를 진짜 현실적으로 비교해보면

세션의 장점

  • 서버가 상태를 통제하기 쉽다
  • 강제 로그아웃이 쉽다
  • 토큰 폐기 전략 고민이 덜하다
  • 웹 브라우저 기반 앱에 자연스럽다

세션의 단점

  • 서버 저장소가 필요하다
  • 서버가 여러 대면 세션 공유가 필요하다
  • 완전한 무상태 구조는 아니다

Spring Session은 바로 이 다중 서버 세션 문제를 풀기 위한 공식 확장입니다. (Home)

JWT의 장점

  • API 중심 구조와 잘 맞는다
  • 모바일/SPA/외부 클라이언트에서 쓰기 좋다
  • Authorization 헤더 기반으로 다루기 편하다
  • Resource Server 구조와 잘 맞는다

Spring Security와 FastAPI 모두 JWT Bearer 흐름을 공식 문서에서 강조합니다. (FastAPI)

JWT의 단점

  • access token 강제 무효화가 까다롭다
  • refresh token 설계가 필요해진다
  • 저장 위치에 따라 보안 이슈가 생길 수 있다
  • 토큰 수명 정책을 잘못 잡으면 운영이 꼬인다

주니어 때 자주 하는 실수

이건 진짜 많이 봤어요.

1. JWT면 서버가 아무 상태도 안 가져도 된다고 생각

실무에서는 refresh token 저장, revoke 목록, 디바이스 관리 등이 붙으면 완전 무상태가 아닌 경우가 많습니다.

2. 세션이면 쿠키에 사용자 정보가 전부 들어간다고 오해

기본 개념상 세션 쿠키에는 세션 ID만 두고, 실제 상태는 서버 쪽에 둡니다. express-session도 그 점을 명시합니다. (NPM)

3. JWT payload에 너무 많은 정보 넣기

토큰은 “필요 최소한”만 넣는 게 낫습니다. Spring Security의 Resource Server JWT 문서도 리소스 서버가 토큰 검증과 클레임 해석을 한다는 흐름을 보여주지, 토큰을 사용자 프로필 저장소처럼 쓰라고 하진 않습니다. (Home)

4. access token 만료를 너무 길게 잡기

토큰이 오래 살수록 탈취 시 피해가 커집니다.

5. 세션/ JWT를 기술 유행으로만 고르기

이건 제일 위험합니다.
서비스 구조, 클라이언트 종류, 운영 요구사항을 보고 골라야 해요.


제가 추천하는 아주 현실적인 선택 가이드

브라우저 기반 웹앱, 관리자 페이지, 사내 시스템

세션 먼저 고려하세요.

모바일 앱, SPA, API 서버, 외부 연동

JWT 먼저 고려하세요.

인증 서버와 리소스 서버 분리, OAuth2 체계

JWT가 더 자연스럽습니다. Spring Security Resource Server가 바로 그 흐름을 지원합니다. (Home)

“지금 당장 빨리 만들고 싶은 작은 서비스”

브라우저 중심이면 세션이 오히려 단순할 수 있어요.
API 중심이면 FastAPI 스타일 JWT가 더 빠를 수 있고요. (FastAPI)


이번 글 핵심 정리

이번 글의 핵심은 이겁니다.

세션과 JWT는 경쟁 기술이라기보다, 인증 상태를 유지하는 두 가지 다른 전략이다.

정리하면 이렇게 보면 됩니다.

  • 세션: 서버가 로그인 상태를 기억한다
  • JWT: 토큰이 인증 정보를 담아 다닌다
  • 브라우저 중심 웹앱은 세션이 편한 경우가 많다
  • API 중심 서비스는 JWT가 자연스러운 경우가 많다
  • 무조건 JWT가 더 좋은 건 아니다
  • 강제 로그아웃, 세션 통제, 다중 서버 구조까지 같이 보고 선택해야 한다

다음 글 예고

다음 글에서는 여기서 한 단계 더 들어가서
JWT를 실제로 어떻게 검증하고 보호 API를 만들 것인가 를 다뤄보겠습니다.

즉,

  • access token을 어떻게 읽는지
  • 현재 로그인 사용자를 어떻게 꺼내는지
  • 보호된 API는 어떻게 만드는지
  • FastAPI / Spring Boot / Node.js에서 인증 미들웨어나 필터를 어떻게 붙이는지

이걸 실제 코드 흐름으로 이어가 보겠습니다.


출처


백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 세션인증, JWT, 인증설계, 로그인구현, 백엔드시리즈

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
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
글 보관함
반응형