JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제
이 글의 핵심
JWT 인증을 실무에 적용하는 완벽 가이드입니다. JWT 원리부터 Access Token, Refresh Token, 보안 모범 사례, Next.js/Express 구현까지 실전 예제로 정리했습니다.
실무 경험 공유: 동시 접속자 10만 명 규모의 스트리밍 플랫폼에서 세션 기반 인증을 JWT로 전환하면서, 서버 메모리 사용량을 60% 줄이고 인증 속도를 3배 향상시킨 경험을 공유합니다.
들어가며: “로그인 상태를 어떻게 유지하죠?”
실무 문제 시나리오
시나리오 1: 세션 서버 부하
세션을 서버 메모리에 저장하니 사용자가 늘어날수록 메모리가 부족합니다. JWT는 서버에 상태를 저장하지 않아 확장성이 좋습니다.
시나리오 2: 마이크로서비스 인증
여러 서비스에서 세션을 공유하기 어렵습니다. JWT는 토큰만으로 인증할 수 있어 마이크로서비스에 적합합니다.
시나리오 3: 모바일 앱 인증
쿠키가 작동하지 않는 모바일 앱에서 인증이 필요합니다. JWT는 HTTP 헤더로 전송할 수 있습니다.
sequenceDiagram
participant Client as 클라이언트
participant Server as 서버
participant DB as 데이터베이스
Client->>Server: 로그인 (ID/PW)
Server->>DB: 사용자 확인
DB-->>Server: 사용자 정보
Server->>Server: JWT 생성
Server-->>Client: Access Token + Refresh Token
Client->>Server: API 요청 (+ Access Token)
Server->>Server: 토큰 검증
Server-->>Client: 응답
1. JWT란?
JWT 구조
JWT (JSON Web Token)는 Header.Payload.Signature 형식의 문자열입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1. Header (헤더)
{
"alg": "HS256",
"typ": "JWT"
}
alg: 서명 알고리즘 (HS256, RS256 등)typ: 토큰 타입 (JWT)
2. Payload (페이로드)
{
"userId": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
iat(Issued At): 발급 시간exp(Expiration): 만료 시간- 커스텀 클레임:
userId,name등
3. Signature (서명)
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
2. JWT 생성 및 검증
Node.js (jsonwebtoken)
npm install jsonwebtoken
// JWT 생성
import jwt from 'jsonwebtoken';
const SECRET_KEY = process.env.JWT_SECRET;
function generateAccessToken(userId) {
return jwt.sign(
{ userId },
SECRET_KEY,
{ expiresIn: '15m' } // 15분
);
}
function generateRefreshToken(userId) {
return jwt.sign(
{ userId },
SECRET_KEY,
{ expiresIn: '7d' } // 7일
);
}
// 사용 예시
const accessToken = generateAccessToken('user123');
const refreshToken = generateRefreshToken('user123');
// JWT 검증
function verifyToken(token) {
try {
const decoded = jwt.verify(token, SECRET_KEY);
return { valid: true, decoded };
} catch (error) {
if (error.name === 'TokenExpiredError') {
return { valid: false, error: 'Token expired' };
}
if (error.name === 'JsonWebTokenError') {
return { valid: false, error: 'Invalid token' };
}
return { valid: false, error: 'Token verification failed' };
}
}
// 사용 예시
const result = verifyToken(accessToken);
if (result.valid) {
console.log('User ID:', result.decoded.userId);
} else {
console.error('Error:', result.error);
}
3. Access Token + Refresh Token 패턴
왜 두 개의 토큰?
flowchart TB
subgraph Problem["Access Token만 사용"]
A1[짧은 만료: 자주 로그인]
A2[긴 만료: 보안 위험]
end
subgraph Solution["Access + Refresh Token"]
B1[Access: 짧은 만료 15분]
B2[Refresh: 긴 만료 7일]
B3[Access 만료 시 Refresh로 갱신]
end
Problem --> Solution
구현
// server.js
import express from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const app = express();
app.use(express.json());
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// Refresh Token 저장소 (실제로는 Redis 사용 권장)
const refreshTokens = new Set();
// 로그인
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// 사용자 확인 (실제로는 DB 조회)
const user = await db.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 비밀번호 확인
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 토큰 생성
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token 저장
refreshTokens.add(refreshToken);
res.json({ accessToken, refreshToken });
});
// Access Token 갱신
app.post('/api/auth/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
if (!refreshTokens.has(refreshToken)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
// 새 Access Token 생성
const accessToken = jwt.sign(
{ userId: decoded.userId },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
});
// 로그아웃
app.post('/api/auth/logout', (req, res) => {
const { refreshToken } = req.body;
refreshTokens.delete(refreshToken);
res.json({ message: 'Logged out' });
});
// 인증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
}
// 보호된 라우트
app.get('/api/user/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.userId, email: req.user.email });
});
4. Next.js 구현
API Routes
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
const { email, password } = await request.json();
// 사용자 확인
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// 비밀번호 확인
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// 토큰 생성
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET!,
{ expiresIn: '7d' }
);
// Refresh Token을 HttpOnly 쿠키에 저장
const response = NextResponse.json({ accessToken });
response.cookies.set('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7일
});
return response;
}
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get('refreshToken')?.value;
if (!refreshToken) {
return NextResponse.json(
{ error: 'Refresh token required' },
{ status: 401 }
);
}
try {
const decoded = jwt.verify(
refreshToken,
process.env.REFRESH_TOKEN_SECRET!
) as { userId: string };
// 새 Access Token 생성
const accessToken = jwt.sign(
{ userId: decoded.userId },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: '15m' }
);
return NextResponse.json({ accessToken });
} catch (error) {
return NextResponse.json(
{ error: 'Invalid refresh token' },
{ status: 403 }
);
}
}
클라이언트 (React)
// lib/auth.ts
let accessToken: string | null = null;
export async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
accessToken = data.accessToken;
return data;
}
export async function refreshAccessToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Refresh failed');
}
const data = await response.json();
accessToken = data.accessToken;
return data.accessToken;
}
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
if (!accessToken) {
throw new Error('Not authenticated');
}
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
// Access Token 만료 시 갱신
if (response.status === 403) {
try {
await refreshAccessToken();
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
} catch (error) {
// Refresh 실패 시 로그인 페이지로
window.location.href = '/login';
throw error;
}
}
return response;
}
// components/LoginForm.tsx
'use client';
import { useState } from 'react';
import { login } from '@/lib/auth';
export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login(email, password);
window.location.href = '/dashboard';
} catch (error) {
alert('로그인 실패');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호"
required
/>
<button type="submit">로그인</button>
</form>
);
}
5. 보안 모범 사례
1. 비밀 키 관리
# .env
ACCESS_TOKEN_SECRET=your-very-long-random-secret-key-at-least-256-bits
REFRESH_TOKEN_SECRET=another-different-long-random-secret-key
// 비밀 키 생성
import crypto from 'crypto';
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret);
2. HttpOnly 쿠키
// ✅ 좋은 예: Refresh Token을 HttpOnly 쿠키에
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JavaScript로 접근 불가
secure: true, // HTTPS만
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// ❌ 나쁜 예: localStorage에 저장
localStorage.setItem('refreshToken', refreshToken); // XSS 취약
3. 짧은 만료 시간
// Access Token: 15분
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh Token: 7일
const refreshToken = jwt.sign(payload, secret, { expiresIn: '7d' });
4. 토큰 무효화 (Blacklist)
// Redis를 사용한 블랙리스트
import { createClient } from 'redis';
const redis = createClient();
async function blacklistToken(token) {
const decoded = jwt.decode(token);
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
await redis.setEx(`blacklist:${token}`, expiresIn, 'true');
}
async function isBlacklisted(token) {
const result = await redis.get(`blacklist:${token}`);
return result !== null;
}
// 미들웨어에서 확인
async function authenticateToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (await isBlacklisted(token)) {
return res.status(403).json({ error: 'Token revoked' });
}
// 토큰 검증...
}
5. CSRF 방어
// CSRF 토큰 생성
import crypto from 'crypto';
function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
// 로그인 시 CSRF 토큰 발급
app.post('/api/auth/login', async (req, res) => {
// ... 로그인 로직
const csrfToken = generateCsrfToken();
res.cookie('csrfToken', csrfToken, {
httpOnly: false, // JavaScript로 읽을 수 있어야 함
secure: true,
sameSite: 'strict',
});
res.json({ accessToken, csrfToken });
});
// API 요청 시 CSRF 토큰 확인
function verifyCsrf(req, res, next) {
const csrfToken = req.headers['x-csrf-token'];
const cookieCsrf = req.cookies.csrfToken;
if (!csrfToken || csrfToken !== cookieCsrf) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
6. 자주 하는 실수와 해결법
문제 1: 민감한 정보를 Payload에 저장
// ❌ 잘못된 코드
const token = jwt.sign(
{
userId: user.id,
password: user.password, // 절대 안 됨!
creditCard: user.creditCard, // 절대 안 됨!
},
secret
);
// ✅ 올바른 코드
const token = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
secret
);
문제 2: 토큰을 localStorage에 저장
// ❌ 잘못된 코드 - XSS 취약
localStorage.setItem('accessToken', token);
// ✅ 올바른 코드 - 메모리에 저장
let accessToken = null;
// 또는 HttpOnly 쿠키 (Refresh Token)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
});
문제 3: 비밀 키를 코드에 하드코딩
// ❌ 잘못된 코드
const token = jwt.sign(payload, 'my-secret-key');
// ✅ 올바른 코드
const token = jwt.sign(payload, process.env.JWT_SECRET);
문제 4: 만료 시간 확인 안 함
// ❌ 잘못된 코드
const decoded = jwt.decode(token); // 검증 안 함!
// ✅ 올바른 코드
try {
const decoded = jwt.verify(token, secret); // 만료 시간 자동 확인
} catch (error) {
if (error.name === 'TokenExpiredError') {
// 만료 처리
}
}
7. JWT vs Session
| 항목 | JWT | Session |
|---|---|---|
| 저장 위치 | 클라이언트 (토큰) | 서버 (세션 ID) |
| 확장성 | 우수 (Stateless) | 낮음 (Stateful) |
| 보안 | 탈취 시 무효화 어려움 | 서버에서 즉시 무효화 가능 |
| 크기 | 큼 (수백 바이트) | 작음 (세션 ID만) |
| 마이크로서비스 | 적합 | 세션 공유 필요 |
| 모바일 앱 | 적합 | 쿠키 사용 어려움 |
언제 JWT를 사용할까?
- ✅ 마이크로서비스 아키텍처
- ✅ 모바일 앱 인증
- ✅ 서버 확장성이 중요한 경우
- ✅ 서드파티 API 인증
언제 Session을 사용할까?
- ✅ 단일 서버 애플리케이션
- ✅ 즉시 무효화가 중요한 경우
- ✅ 민감한 정보 다루는 경우
정리 및 체크리스트
핵심 요약
- JWT: Header.Payload.Signature 형식의 토큰
- Access Token: 짧은 만료 (15분), API 요청에 사용
- Refresh Token: 긴 만료 (7일), Access Token 갱신에 사용
- 보안: HttpOnly 쿠키, 짧은 만료, 비밀 키 관리
- 무효화: 블랙리스트 (Redis) 사용
보안 체크리스트
- 비밀 키를 환경 변수로 관리
- Refresh Token을 HttpOnly 쿠키에 저장
- Access Token 만료 시간 15분 이하
- 민감한 정보를 Payload에 저장하지 않음
- HTTPS 사용
- CSRF 방어
- 토큰 블랙리스트 구현 (로그아웃)
같이 보면 좋은 글
- 웹 보안 완벽 가이드 | OWASP Top 10·XSS·CSRF
- Next.js 15 완벽 가이드 | App Router·Server Actions
- Docker Compose 실전 가이드 | 멀티 컨테이너·배포
이 글에서 다루는 키워드
JWT, 인증, Authentication, Access Token, Refresh Token, 보안, Security, OAuth
자주 묻는 질문 (FAQ)
Q. JWT를 localStorage에 저장해도 되나요?
A. 권장하지 않습니다. XSS 공격에 취약합니다. Access Token은 메모리에, Refresh Token은 HttpOnly 쿠키에 저장하세요.
Q. JWT를 무효화할 수 있나요?
A. JWT는 기본적으로 무효화할 수 없습니다. 블랙리스트(Redis)를 사용하거나, 짧은 만료 시간으로 설정하세요.
Q. Access Token 만료 시간은 얼마가 적당한가요?
A. 보안을 위해 15분 이하를 권장합니다. Refresh Token으로 자동 갱신하면 UX 저하 없이 보안을 강화할 수 있습니다.
Q. JWT vs OAuth, 뭐가 다른가요?
A. JWT는 토큰 형식이고, OAuth는 인증 프로토콜입니다. OAuth에서 JWT를 토큰으로 사용할 수 있습니다.