본문으로 건너뛰기

🎫 JWT 토큰 완전 이해

📖 정의

JWT(JSON Web Token)는 클라이언트와 서버 간에 정보를 안전하게 전송하기 위한 JSON 기반의 토큰입니다. 세 부분(Header, Payload, Signature)으로 구성되며, 서버에서 발급하고 클라이언트가 요청 시 함께 전송하여 인증을 처리합니다. 세션과 달리 서버에 상태를 저장하지 않아(stateless) 확장성이 뛰어납니다.

🎯 비유로 이해하기

놀이공원 입장권

JWT를 놀이공원 입장권에 비유하면:

일반 입장권 (세션 방식)
├─ 입장 시 팔찌 받음
├─ 탈 때마다 직원이 팔찌 확인
└─ 직원이 모든 방문객 기억 필요 (서버 부담)

VIP 입장권 (JWT 방식)
├─ 입장 시 위조 불가능한 도장 찍힌 티켓
├─ 티켓에 이름, 유효기간, 등급 기록
├─ 탈 때마다 티켓만 보여주면 됨
└─ 직원은 도장만 확인 (서버 부담 적음)

JWT = 위조 방지 도장이 찍힌 정보 카드

⚙️ 작동 원리

1. JWT 구조

JWT = Header.Payload.Signature

예시:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywidXNlcm5hbWUiOiJraW0ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
Header (헤더) Payload (페이로드) Signature (서명)

Header (헤더)

{
"alg": "HS256", // 서명 알고리즘
"typ": "JWT" // 토큰 타입
}
// Base64로 인코딩됨

Payload (페이로드) - 실제 데이터

{
"userId": 123,
"username": "kim",
"email": "kim@example.com",
"role": "admin",
"iat": 1640000000, // Issued At (발급 시간)
"exp": 1640086400 // Expiration (만료 시간)
}
// Base64로 인코딩됨

Signature (서명) - 위조 방지

HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret // 비밀 키 (서버만 알고 있음)
)

2. JWT 인증 흐름

1. 로그인
클라이언트 → 서버: username, password
서버: 인증 성공 → JWT 생성 및 반환

2. 요청 시 JWT 포함
클라이언트 → 서버: Authorization: Bearer <JWT>

3. 서버에서 JWT 검증
- 서명 확인 (위조 여부)
- 만료 시간 확인
- Payload에서 사용자 정보 추출

4. 응답
서버 → 클라이언트: 요청한 데이터

💡 실제 예시

JWT 생성 및 검증 (Node.js)

const jwt = require('jsonwebtoken');

// 비밀 키 (환경변수로 관리 권장)
const SECRET_KEY = 'your-secret-key-keep-it-safe';

// ========== JWT 생성 ==========
function generateToken(user) {
// Payload 정의
const payload = {
userId: user.id,
username: user.username,
email: user.email,
role: user.role
};

// 옵션
const options = {
expiresIn: '1h', // 1시간 후 만료
// expiresIn: '7d', // 7일
// expiresIn: '30m', // 30분
issuer: 'my-app', // 발급자
subject: 'user-auth' // 용도
};

// JWT 생성
const token = jwt.sign(payload, SECRET_KEY, options);
return token;
}

// ========== JWT 검증 ==========
function verifyToken(token) {
try {
const decoded = jwt.verify(token, SECRET_KEY);
console.log('검증 성공:', decoded);
/*
{
userId: 123,
username: 'kim',
email: 'kim@example.com',
role: 'admin',
iat: 1640000000,
exp: 1640003600
}
*/
return decoded;
} catch (error) {
if (error.name === 'TokenExpiredError') {
console.error('토큰 만료');
} else if (error.name === 'JsonWebTokenError') {
console.error('유효하지 않은 토큰');
}
return null;
}
}

// ========== 사용 예시 ==========
const user = {
id: 123,
username: 'kim',
email: 'kim@example.com',
role: 'admin'
};

const token = generateToken(user);
console.log('생성된 JWT:', token);

const decoded = verifyToken(token);
console.log('디코딩:', decoded);

로그인 API 구현

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

const app = express();
app.use(express.json());

const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key';
const users = []; // 실제로는 데이터베이스 사용

// ========== 회원가입 ==========
app.post('/api/register', async (req, res) => {
const { username, password, email } = req.body;

// 비밀번호 해시화
const hashedPassword = await bcrypt.hash(password, 10);

const user = {
id: users.length + 1,
username,
email,
password: hashedPassword
};

users.push(user);

res.json({ message: '회원가입 성공', userId: user.id });
});

// ========== 로그인 ==========
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;

// 사용자 찾기
const user = users.find(u => u.username === username);
if (!user) {
return res.status(401).json({ error: '사용자를 찾을 수 없습니다' });
}

// 비밀번호 확인
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: '비밀번호가 틀렸습니다' });
}

// JWT 생성
const token = jwt.sign(
{
userId: user.id,
username: user.username,
email: user.email
},
SECRET_KEY,
{ expiresIn: '1h' }
);

// Refresh Token도 함께 발급 (선택사항)
const refreshToken = jwt.sign(
{ userId: user.id },
SECRET_KEY,
{ expiresIn: '7d' }
);

res.json({
message: '로그인 성공',
accessToken: token,
refreshToken: refreshToken
});
});

// ========== 인증 미들웨어 ==========
function authenticateToken(req, res, next) {
// Authorization 헤더에서 토큰 추출
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

if (!token) {
return res.status(401).json({ error: '토큰이 없습니다' });
}

jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: '토큰이 만료되었습니다' });
}
return res.status(403).json({ error: '유효하지 않은 토큰입니다' });
}

// 검증 성공 - 사용자 정보를 req에 저장
req.user = user;
next();
});
}

// ========== 보호된 라우트 ==========
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user는 JWT에서 추출한 정보
res.json({
message: '프로필 정보',
user: req.user
});
});

app.get('/api/admin', authenticateToken, (req, res) => {
// 권한 확인
if (req.user.role !== 'admin') {
return res.status(403).json({ error: '관리자 권한이 필요합니다' });
}

res.json({ message: '관리자 페이지' });
});

app.listen(3000, () => {
console.log('서버 실행 중: http://localhost:3000');
});

프론트엔드에서 JWT 사용

// ========== 로그인 ==========
async function login(username, password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});

const data = await response.json();

if (response.ok) {
// JWT를 로컬스토리지에 저장
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);

console.log('로그인 성공!');
}
}

// ========== API 요청 시 JWT 포함 ==========
async function fetchProfile() {
const token = localStorage.getItem('accessToken');

const response = await fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});

if (response.status === 401) {
// 토큰 만료 → 로그인 페이지로
window.location.href = '/login';
return;
}

const data = await response.json();
console.log('프로필:', data);
}

// ========== Axios 인터셉터로 자동화 ==========
import axios from 'axios';

// 요청 인터셉터: 모든 요청에 JWT 자동 추가
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);

// 응답 인터셉터: 401 에러 시 자동 로그아웃
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);

// 이제 모든 요청에 자동으로 JWT 포함!
axios.get('/api/profile').then(response => {
console.log(response.data);
});

Refresh Token 구현

// ========== Access Token + Refresh Token 패턴 ==========

// Access Token: 짧은 수명 (15분)
// Refresh Token: 긴 수명 (7일)

app.post('/api/login', async (req, res) => {
// ... 로그인 검증 ...

const accessToken = jwt.sign(
{ userId: user.id, username: user.username },
SECRET_KEY,
{ expiresIn: '15m' } // 15분
);

const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET_KEY,
{ expiresIn: '7d' } // 7일
);

// Refresh Token은 DB에 저장 (선택사항)
// await saveRefreshToken(user.id, refreshToken);

res.json({ accessToken, refreshToken });
});

// ========== Access Token 갱신 ==========
app.post('/api/refresh', (req, res) => {
const { refreshToken } = req.body;

if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token이 없습니다' });
}

jwt.verify(refreshToken, REFRESH_SECRET_KEY, (err, user) => {
if (err) {
return res.status(403).json({ error: '유효하지 않은 Refresh Token' });
}

// 새 Access Token 발급
const accessToken = jwt.sign(
{ userId: user.userId },
SECRET_KEY,
{ expiresIn: '15m' }
);

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

// ========== 프론트엔드: 자동 토큰 갱신 ==========
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;

if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
// Refresh Token으로 새 Access Token 받기
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post('/api/refresh', { refreshToken });

const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);

// 원래 요청 재시도
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh Token도 만료 → 로그아웃
localStorage.clear();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);

🤔 자주 묻는 질문

Q1. JWT vs 세션의 차이는?

A:

// ========== 세션 방식 ==========
// 서버에 사용자 정보 저장

로그인 → 서버에 세션 저장
{
sessionId: 'abc123',
userId: 123,
username: 'kim'
}

클라이언트는 sessionId만 저장
Cookie: sessionId=abc123

요청 시 sessionId로 서버에서 세션 조회

장점: 서버에서 즉시 무효화 가능
단점: 서버 메모리/스토리지 필요, 확장성 낮음

// ========== JWT 방식 ==========
// 클라이언트가 토큰 저장

로그인 → JWT 생성 및 반환
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

클라이언트가 JWT 전체 저장
localStorage.setItem('token', jwt)

요청 시 JWT 포함, 서버는 서명만 검증

장점: 서버 부담 없음, 확장성 좋음
단점: 토큰 무효화 어려움, 크기 큼

Q2. JWT는 안전한가요?

A: 올바르게 사용하면 안전합니다:

// ✅ 안전한 사용법
1. HTTPS 사용 필수
- HTTPJWT 가로채기 가능

2. 비밀 키 보안
- 환경변수로 관리
- 절대 코드에 하드코딩 금지
const SECRET = process.env.JWT_SECRET;

3. 짧은 만료 시간
- Access Token: 15~1시간
- Refresh Token으로 갱신

4. 민감 정보 저장 금지
{ password: '1234', ssn: '123-45-6789' }
{ userId: 123, role: 'user' }

5. XSS 방지
- 로컬스토리지보다 HttpOnly 쿠키 권장
res.cookie('token', jwt, {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS only
sameSite: 'strict'
});

// ❌ 위험한 사용법
1. 만료 시간 없음
2. HTTP로 전송
3. localStorage저장 (XSS 취약)
4. 비밀번호 같은 민감 정보 포함

Q3. JWT를 무효화하려면?

A: 여러 방법이 있습니다:

// 1. Blacklist (블랙리스트)
// - 로그아웃 시 토큰을 DB/Redis에 저장
// - 요청 시 블랙리스트 확인

const blacklist = new Set();

app.post('/api/logout', authenticateToken, (req, res) => {
const token = req.headers.authorization.split(' ')[1];
blacklist.add(token);
res.json({ message: '로그아웃 성공' });
});

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

if (blacklist.has(token)) {
return res.status(401).json({ error: '무효화된 토큰' });
}

// ... JWT 검증 ...
}

// 2. Redis에 저장 (만료 시간 설정)
const redis = require('redis');
const client = redis.createClient();

app.post('/api/logout', authenticateToken, async (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token);

// 토큰 만료 시간까지만 블랙리스트에 유지
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
await client.setEx(`blacklist:${token}`, ttl, 'true');

res.json({ message: '로그아웃 성공' });
});

// 3. 짧은 수명 + Refresh Token
// - Access Token: 15분 (무효화 필요 없음)
// - Refresh Token만 DB에 저장 및 무효화

// 4. 버전 관리
// - 사용자별 토큰 버전을 DB에 저장
// - 로그아웃 시 버전 증가

const payload = {
userId: user.id,
tokenVersion: user.tokenVersion // DB에서 가져옴
};

// 로그아웃
await db.users.update(
{ id: userId },
{ $inc: { tokenVersion: 1 } } // 버전 증가
);

Q4. JWT를 어디에 저장해야 하나요?

A:

// 옵션 1: LocalStorage (간편하지만 XSS 취약)
localStorage.setItem('token', jwt);
// ❌ XSS 공격으로 토큰 탈취 가능

// 옵션 2: SessionStorage (탭 닫으면 삭제)
sessionStorage.setItem('token', jwt);
// ❌ XSS 취약

// 옵션 3: HttpOnly Cookie (가장 안전, 권장)
// 서버에서 설정
res.cookie('token', jwt, {
httpOnly: true, // JavaScript 접근 불가 (XSS 방지)
secure: true, // HTTPS only
sameSite: 'strict',// CSRF 방지
maxAge: 3600000 // 1시간
});

// 클라이언트에서는 자동으로 전송됨
fetch('/api/profile', {
credentials: 'include' // 쿠키 포함
});

// 옵션 4: 메모리 (가장 안전하지만 새로고침 시 로그아웃)
let token = null;

function login() {
// ... 로그인 ...
token = response.accessToken; // 변수에만 저장
}

// 장단점 비교
LocalStorage: 간편하지만 XSS 취약
Cookie: 안전하지만 설정 복잡
Memory: 가장 안전하지만 UX 나쁨

Q5. Access Token vs Refresh Token?

A:

// Access Token
// - 짧은 수명 (15분~1시간)
// - API 요청마다 전송
// - 탈취되어도 빨리 만료

// Refresh Token
// - 긴 수명 (7일~30일)
// - Access Token 갱신 시에만 사용
// - DB에 저장하여 관리

// 흐름
1. 로그인
Access Token (15) + Refresh Token (7) 발급

2. API 요청
Access Token 사용

3. Access Token 만료 (15분 후)
Refresh Token으로Access Token 받기

4. Refresh Token도 만료 (7일 후)
→ 재로그인 필요

// 코드 예시
// 토큰 발급
const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' });

// 자동 갱신
if (accessTokenExpired) {
const newAccessToken = await refreshAccessToken(refreshToken);
}

🎓 다음 단계

JWT를 이해했다면, 다음을 학습해보세요:

  1. HTTPS란? (문서 작성 예정) - 안전한 토큰 전송
  2. CORS란? (문서 작성 예정) - API 보안
  3. 쿠키란? - 세션 vs JWT

유용한 도구

// JWT 디버거
// https://jwt.io
// - JWT 디코딩 및 검증

// 라이브러리
// Node.js: jsonwebtoken
// Python: PyJWT
// Java: jjwt
// Go: jwt-go

// 테스트
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const decoded = jwt.decode(token, { complete: true });
console.log(decoded);
/*
{
header: { alg: 'HS256', typ: 'JWT' },
payload: { userId: 123, ... },
signature: '...'
}
*/

🎬 마무리

JWT는 현대 웹의 표준 인증 방식입니다:

  • 구조: Header.Payload.Signature
  • 장점: Stateless, 확장성, 다양한 플랫폼 지원
  • 보안: HTTPS, HttpOnly Cookie, 짧은 수명
  • 패턴: Access Token + Refresh Token

올바르게 사용하면 안전하고 효율적인 인증 시스템을 구축할 수 있습니다! 🎫✨