[2026] JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제
이 글의 핵심
JWT 인증을 실무에 적용하는 완벽 가이드입니다. JWT 원리부터 Access Token, Refresh Token, 보안 모범 사례, Next.js/Express 구현까지 실전 예제로 정리했습니다.
실무 경험 공유: 동시 접속자 10만 명 규모의 웹 서비스에서 세션 기반 인증을 JWT로 전환하면서, 서버 메모리 사용량을 60% 줄이고 인증 속도를 3배 향상시킨 경험을 공유합니다.
들어가며: “로그인 상태를 어떻게 유지하죠?”
실무 문제 시나리오
시나리오 1: 세션 서버 부하
세션을 서버 메모리에 저장하니 사용자가 늘어날수록 메모리가 부족합니다. JWT는 서버에 상태를 저장하지 않아 확장성이 좋습니다.
시나리오 2: 마이크로서비스 인증
여러 서비스에서 세션을 공유하기 어렵습니다. JWT는 토큰만으로 인증할 수 있어 마이크로서비스에 적합합니다.
시나리오 3: 모바일 앱 인증
쿠키가 작동하지 않는 모바일 앱에서 인증이 필요합니다. JWT는 HTTP 헤더로 전송할 수 있습니다.
아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 (헤더)
다음은 간단한 json 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
{
"alg": "HS256",
"typ": "JWT"
}
alg: 서명 알고리즘 (HS256, RS256 등)typ: 토큰 타입 (JWT)
2. Payload (페이로드)
아래 코드는 json를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
{
"userId": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
iat(Issued At): 발급 시간exp(Expiration): 만료 시간- 커스텀 클레임:
userId,name등
3. Signature (서명)
다음은 간단한 javascript 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
2. JWT 생성 및 검증
Node.js (jsonwebtoken)
npm install jsonwebtoken
다음은 javascript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 함수를 통해 로직을 구현합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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');
다음은 javascript를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 패턴
왜 두 개의 토큰?
아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
구현
다음은 javascript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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
다음은 typescript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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;
}
다음은 typescript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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)
다음은 typescript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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;
}
다음은 typescript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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
다음은 간단한 javascript 코드 예제입니다. 필요한 모듈을 import하고. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 비밀 키 생성
import crypto from 'crypto';
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret);
2. HttpOnly 쿠키
아래 코드는 javascript를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 좋은 예: 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. 짧은 만료 시간
아래 코드는 javascript를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// Access Token: 15분
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh Token: 7일
const refreshToken = jwt.sign(payload, secret, { expiresIn: '7d' });
4. 토큰 무효화 (Blacklist)
다음은 javascript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 방어
다음은 javascript를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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에 저장
다음은 javascript를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 코드
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에 저장
아래 코드는 javascript를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 코드 - XSS 취약
localStorage.setItem('accessToken', token);
// ✅ 올바른 코드 - 메모리에 저장
let accessToken = null;
// 또는 HttpOnly 쿠키 (Refresh Token)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
});
문제 3: 비밀 키를 코드에 하드코딩
아래 코드는 javascript를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 코드
const token = jwt.sign(payload, 'my-secret-key');
// ✅ 올바른 코드
const token = jwt.sign(payload, process.env.JWT_SECRET);
문제 4: 만료 시간 확인 안 함
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 코드
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를 토큰으로 사용할 수 있습니다.