마스터하기: JWT (JSON Web Tokens) - 현대 웹 인증의 핵심 열쇠

개념 소개

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 여러분이 웹 서비스를 개발하거나 이용하면서 로그인 기능을 접할 때, 아마도 눈에 보이지 않는 어떤 "열쇠"가 여러분의 신원을 확인하고 특정 리소스에 접근할 권한을 부여하는 과정을 거치고 있을 겁니다. 오늘 우리가 마스터할 주제인 JWT(JSON Web Tokens)는 바로 이 "열쇠" 역할을 하는 가장 널리 사용되는 표준 중 하나입니다.
JWT란 무엇인가?
JWT는 웹 표준(RFC 7519)으로, 당사자 간에 정보를 JSON 객체 형태로 안전하게 전송하기 위한 간결하고 자체 포함적인(self-contained) 방법을 정의합니다. 여기서 "안전하게"라는 말은 정보가 위조되지 않았음을 보증하며, "자체 포함적"이라는 말은 토큰 자체에 사용자 인증에 필요한 모든 정보가 들어있다는 뜻입니다. 토큰은 일반적으로 서버에서 생성되어 클라이언트(웹 브라우저, 모바일 앱 등)에 전송되고, 클라이언트는 이후 서버에 요청을 보낼 때 이 토큰을 함께 보내 자신의 신원을 증명합니다.
JWT의 탄생 배경
JWT가 등장하기 전, 웹 인증은 주로 세션(Session) 기반으로 이루어졌습니다. 사용자가 로그인하면 서버는 세션 ID를 생성하고 이를 서버 메모리나 데이터베이스에 저장한 뒤, 세션 ID를 클라이언트의 쿠키에 전달하는 방식이었죠. 클라이언트는 이후 요청마다 이 세션 ID를 담은 쿠키를 서버로 보내 신원을 확인받았습니다.
하지만 이 방식은 몇 가지 한계를 가지고 있었습니다:
- 확장성 (Scalability) 문제: 여러 서버가 분산되어 있는 환경(로드 밸런싱된 서버 팜)에서는 특정 사용자의 세션 정보가 어느 서버에 저장되어 있는지 모든 서버가 공유해야 하는 문제가 발생했습니다. 이를 위해 세션 스토리지를 외부(Redis 등)로 분리해야 했고, 이는 아키텍처를 복잡하게 만들었습니다.
- CORS (Cross-Origin Resource Sharing) 문제: 다른 도메인 간에 요청을 보낼 때 쿠키 기반의 세션 인증은 복잡한 CORS 설정을 필요로 했습니다.
- 모바일 앱 및 마이크로서비스 환경 부적합: 웹 브라우저가 아닌 모바일 앱에서는 쿠키를 직접 다루기 어렵고, 여러 개의 작은 서비스로 구성된 마이크로서비스 아키텍처에서는 각 서비스가 독립적으로 인증 정보를 검증하기 어려웠습니다.
JWT는 이러한 문제들을 해결하기 위해 등장했습니다. 서버는 사용자 정보를 담은 토큰을 생성하고, 이 토큰을 서명(Signature)하여 클라이언트에게 전달합니다. 클라이언트는 이 토큰을 저장하고, 다음 요청부터는 이 토큰을 Authorization 헤더에 담아 보냅니다. 서버는 요청이 올 때마다 토큰의 유효성(서명 검증)만 확인하면 되므로, 별도의 세션 정보를 서버에 저장할 필요가 없어집니다. 이를 무상태(Stateless) 인증이라고 부르며, 분산 시스템과 마이크로서비스 환경에 매우 적합합니다.
왜 중요한가?
JWT는 현대 웹 개발에서 다음과 같은 이유로 매우 중요한 역할을 합니다.
- 확장성 및 무상태성: 서버가 사용자 세션 상태를 저장할 필요가 없어 수평 확장이 용이합니다. 이는 클라우드 환경에서 무한에 가까운 트래픽을 처리하는 데 큰 장점입니다.
- 다양한 클라이언트 지원: 웹 브라우저는 물론, 모바일 앱, 데스크톱 앱 등 모든 종류의 클라이언트에서 일관된 인증 방식을 제공할 수 있습니다.
- 마이크로서비스 친화적: 각 마이크로서비스가 독립적으로 토큰의 유효성을 검증할 수 있어, 서비스 간의 의존성을 줄이고 분산 시스템을 구축하는 데 유리합니다.
- 보안: 토큰이 서명되어 있어 위변조를 방지할 수 있으며, HTTPS와 함께 사용하면 중간자 공격(Man-in-the-Middle Attack)으로부터 안전합니다.
- 정보 교환의 편리성: 토큰 내부에 필요한 정보를 포함할 수 있어, 별도의 데이터베이스 조회를 줄여 성능 향상에 기여할 수 있습니다.
이러한 장점들 덕분에 JWT는 RESTful API, OAuth 2.0, OpenID Connect 등 다양한 현대 웹 기술에서 핵심적인 인증 및 인가 메커니즘으로 활용되고 있습니다.
핵심 원리 설명

JWT는 크게 세 부분으로 나뉘며, 각 부분은 . (점)으로 구분됩니다.
Header.Payload.Signature
각 부분은 Base64 URL-safe 인코딩되어 있습니다. Base64 인코딩은 데이터를 텍스트 형태로 변환하는 방식일 뿐, 암호화가 아님을 명심해야 합니다. 즉, 누구나 인코딩된 내용을 디코딩하여 볼 수 있습니다.
1. Header (헤더)
헤더는 토큰의 종류(typ)와 서명에 사용될 알고리즘(alg)을 명시합니다. 일반적으로 다음과 같은 JSON 형태로 구성됩니다.
{
"alg": "HS256", // 서명 알고리즘 (HMAC SHA256)
"typ": "JWT" // 토큰 타입
}
이 JSON 객체는 Base64 URL-safe로 인코딩되어 JWT의 첫 번째 부분이 됩니다.
2. Payload (페이로드)
페이로드는 실제 전달하고자 하는 정보, 즉 **클레임(Claims)**을 담고 있습니다. 클레임은 사용자 이름, 사용자 ID, 권한 등 토큰과 관련된 속성들을 나타냅니다. 클레임은 세 가지 유형으로 나뉩니다.
- 등록된 클레임 (Registered Claims): JWT 자체에 대한 정보들을 담기 위해 미리 정의된 클레임입니다.
iss(발급자),exp(만료 시간),sub(주제),aud(수신자) 등이 있습니다. 이들은 선택 사항이지만, 사용하는 것을 권장합니다. - 공개 클레임 (Public Claims): 충돌 방지를 위해 URI 형태로 이름을 짓는 클레임입니다. 예를 들어,
https://example.com/jwt/claims/is_admin: true와 같은 형태입니다. - 비공개 클레임 (Private Claims): 클라이언트와 서버 간에 협의된 정보를 담기 위한 클레임입니다. 사용자 ID, 사용자 이름, 역할 등 애플리케이션에 특화된 정보를 여기에 넣습니다.
예시 페이로드:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"exp": 1783456789 // 2026-06-20 10:00:00 (timestamp)
}
이 JSON 객체 또한 Base64 URL-safe로 인코딩되어 JWT의 두 번째 부분이 됩니다. 다시 강조하지만, 페이로드 내용은 누구나 디코딩하여 볼 수 있으므로 민감한 정보(비밀번호, 주민등록번호 등)를 직접 넣어서는 안 됩니다.
3. Signature (서명)
서명은 JWT의 무결성(Integrity)을 보장하는 핵심 부분입니다. 서명은 다음 세 가지를 Base64 URL-safe 인코딩된 형태로 합친 후, 서버에만 알려진 **비밀 키(Secret Key)**를 사용하여 헤더에 지정된 알고리즘으로 암호화하여 생성됩니다.
HMACSHA256( Base64UrlEncode(header) + "." + Base64UrlEncode(payload), secret_key)
서명의 역할은 다음과 같습니다:
- 토큰 위변조 방지: 토큰의 헤더나 페이로드 내용이 변경되면, 서버가 동일한 비밀 키로 다시 서명을 계산했을 때 기존 서명과 일치하지 않게 됩니다. 이를 통해 서버는 토큰이 변조되었음을 감지하고 요청을 거부할 수 있습니다.
- 토큰의 유효성 검증: 서버는 토큰을 받은 후 자체적으로 서명을 다시 계산하고, 토큰에 포함된 서명 값과 비교하여 토큰이 올바른 출처에서 왔는지 확인합니다.
JWT 작동 방식 (비유 및 다이어그램)
JWT의 작동 방식을 이해하기 위해 **'신분증'**에 비유해봅시다.
- 로그인 요청 (신분증 발급 요청): 사용자가 웹사이트에 ID와 비밀번호를 제출하여 로그인합니다.
- JWT 생성 (신분증 발급):
- 서버는 사용자의 ID, 이름, 권한 등의 정보를 담아 페이로드(신분증의 사진, 이름, 주소 등)를 만듭니다.
- 어떤 방식으로 이 신분증을 만들었는지(헤더)를 기록합니다.
- 그리고 서버만이 아는 **비밀 도장(Secret Key)**으로 이 신분증에 **위조 방지 스티커(Signature)**를 붙입니다. 이 스티커는 신분증 내용이 변경되면 즉시 위조 여부를 알 수 있도록 합니다.
- 이렇게 만들어진 Header.Payload.Signature 형태의 JWT(신분증)를 클라이언트에게 발급합니다.
- JWT 저장 (신분증 소지): 클라이언트는 서버로부터 받은 JWT를 안전한 곳(예: Local Storage, Cookie)에 보관합니다.
- API 요청 (신분증 제시): 클라이언트가 보호된 리소스(예: 내 정보 보기, 게시글 작성)에 접근하고 싶을 때마다, HTTP 요청의
Authorization헤더에 JWT(신분증)를 담아 서버에 보냅니다. - JWT 검증 (신분증 확인):
- 서버는 클라이언트로부터 받은 JWT를 받습니다.
- 서버는 자신의 **비밀 도장(Secret Key)**을 사용하여 JWT의 서명(위조 방지 스티커)이 올바른지 확인합니다. 만약 스티커가 훼손되었거나 내용이 바뀌어 위조된 것으로 판명되면, 요청을 거부합니다.
- 서명이 유효하면, 페이로드에서 사용자 정보를 추출하여 해당 요청을 처리할 권한이 있는지 확인합니다.
- 별도의 데이터베이스 조회가 필요 없이 토큰 자체만으로 검증이 완료됩니다.
아래 다이어그램은 이 과정을 시각적으로 보여줍니다.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: 1. 로그인 요청 (ID, PW)
activate S
S-->>S: 2. ID/PW 검증
S-->>S: 3. JWT 생성 (Header, Payload, Secret Key로 Signature 생성)
S->>C: 4. JWT 응답 (Access Token)
deactivate S
C-->>C: 5. JWT 저장 (Local Storage / HttpOnly Cookie)
loop 보호된 리소스 접근
C->>S: 6. API 요청 (Authorization: Bearer <JWT>)
activate S
S-->>S: 7. JWT Signature 검증 (Secret Key로)
alt Signature 유효 && 만료되지 않음
S-->>S: 8. Payload에서 사용자 정보 추출 및 권한 확인
S->>C: 9. 요청 처리 및 응답
else Signature 무효 || 만료됨
S->>C: 9. 인증 실패 (401 Unauthorized)
end
deactivate S
end
코드 예제
여기서는 Python (서버)과 JavaScript (클라이언트)를 사용하여 JWT 생성 및 검증, 그리고 클라이언트에서의 사용법을 보여드리겠습니다.
예제 1: Python으로 JWT 생성 및 검증 (서버 측)
PyJWT 라이브러리를 사용합니다.
pip install PyJWT
# jwt_server.py
import jwt
import datetime
import time
# 실제 서비스에서는 이 키를 환경 변수나 보안 저장소에 보관해야 합니다.
SECRET_KEY = "your-very-secret-key-that-no-one-should-know"
def create_jwt_token(user_id: str, username: str, is_admin: bool = False) -> str:
"""
주어진 사용자 정보를 바탕으로 JWT를 생성합니다.
"""
# Payload (클레임) 정의
payload = {
"user_id": user_id,
"username": username,
"is_admin": is_admin,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30), # 30분 후 만료
"iat": datetime.datetime.now(datetime.timezone.utc), # 발행 시간
"iss": "my-awesome-service" # 발행자
}
# Header 정의 (기본값 사용 시 생략 가능)
# header = {
# "alg": "HS256",
# "typ": "JWT"
# }
# JWT 생성
token = jwt.encode(
payload,
SECRET_KEY,
algorithm="HS256" # Header에 명시된 알고리즘과 동일해야 합니다.
)
return token
def verify_jwt_token(token: str) -> dict | None:
"""
JWT의 유효성을 검증하고, 유효하다면 Payload를 반환합니다.
"""
try:
# JWT 디코딩 및 검증
# algorithms: 어떤 알고리즘으로 서명되었는지 명시
# audience, issuer 등 추가 검증 가능
decoded_payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
issuer="my-awesome-service" # 발행자 검증
)
return decoded_payload
except jwt.ExpiredSignatureError:
print("토큰이 만료되었습니다.")
return None
except jwt.InvalidTokenError as e:
print(f"유효하지 않은 토큰입니다: {e}")
return None
if __name__ == "__main__":
# 1. JWT 생성 예시
print("--- JWT 생성 ---")
user_token = create_jwt_token("user123", "Alice", is_admin=False)
admin_token = create_jwt_token("admin456", "Bob", is_admin=True)
print(f"생성된 사용자 토큰: {user_token}")
print(f"생성된 관리자 토큰: {admin_token}")
# 2. JWT 검증 예시
print("\n--- JWT 검증 ---")
print("사용자 토큰 검증:")
decoded_user_payload = verify_jwt_token(user_token)
if decoded_user_payload:
print(f"디코딩된 사용자 페이로드: {decoded_user_payload}")
print(f"사용자 ID: {decoded_user_payload.get('user_id')}")
print(f"관리자 여부: {decoded_user_payload.get('is_admin')}")
print("\n관리자 토큰 검증:")
decoded_admin_payload = verify_jwt_token(admin_token)
if decoded_admin_payload:
print(f"디코딩된 관리자 페이로드: {decoded_admin_payload}")
print(f"사용자 ID: {decoded_admin_payload.get('user_id')}")
print(f"관리자 여부: {decoded_admin_payload.get('is_admin')}")
# 3. 만료된 토큰 검증 예시
print("\n--- 만료된 토큰 검증 (실제로는 시간이 지나야 만료) ---")
# 임의로 만료 시간을 과거로 설정하여 만료된 토큰 생성
expired_payload = {
"user_id": "expired_user",
"username": "Expired User",
"exp": datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=1), # 1분 전 만료
"iat": datetime.datetime.now(datetime.timezone.utc),
"iss": "my-awesome-service"
}
expired_token = jwt.encode(expired_payload, SECRET_KEY, algorithm="HS256")
print(f"생성된 만료 토큰: {expired_token}")
print("만료 토큰 검증:")
verify_jwt_token(expired_token)
# 4. 변조된 토큰 검증 예시 (서명 불일치)
print("\n--- 변조된 토큰 검증 ---")
# 사용자 토큰의 페이로드 일부를 임의로 변경
parts = user_token.split('.')
original_payload_decoded = jwt.decode(user_token, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": False})
original_payload_decoded['is_admin'] = True # 관리자 권한으로 위조 시도
# 변경된 페이로드를 다시 Base64 인코딩
import json
import base64
modified_payload_encoded = base64.urlsafe_b64encode(json.dumps(original_payload_decoded).encode()).decode().rstrip("=")
# 변조된 토큰 (서명은 그대로)
tampered_token = f"{parts[0]}.{modified_payload_encoded}.{parts[2]}"
print(f"변조된 토큰 (서명 불일치): {tampered_token}")
print("변조된 토큰 검증:")
verify_jwt_token(tampered_token) # InvalidSignatureError 발생 예상
예제 2: JavaScript로 JWT 저장 및 전송 (클라이언트 측)
클라이언트에서 JWT를 저장하고 서버로 전송하는 기본적인 방법입니다.
// jwt_client.js
// 가상의 로그인 함수 (실제로는 서버 API 호출)
async function login(username, password) {
console.log(`로그인 시도: ${username}`);
try {
// 실제 API 호출 (예: fetch('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }))
// 여기서는 임의의 JWT를 반환한다고 가정합니다.
// 이 JWT는 위의 Python 스크립트에서 생성된 토큰일 수 있습니다.
const response = await fetch('http://localhost:5000/login', { // 가상의 로그인 엔드포인트
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const jwtToken = data.access_token; // 서버에서 받은 JWT
if (jwtToken) {
// JWT를 Local Storage에 저장
localStorage.setItem('accessToken', jwtToken);
console.log('로그인 성공! JWT 저장됨:', jwtToken);
document.getElementById('status').innerText = '로그인 성공!';
document.getElementById('tokenDisplay').innerText = jwtToken;
document.getElementById('loginSection').style.display = 'none';
document.getElementById('appSection').style.display = 'block';
} else {
console.error('로그인 실패: 토큰을 받지 못했습니다.');
document.getElementById('status').innerText = '로그인 실패!';
}
} catch (error) {
console.error('로그인 중 오류 발생:', error);
document.getElementById('status').innerText = `로그인 오류: ${error.message}`;
}
}
// 보호된 리소스에 접근하는 함수
async function getProtectedResource() {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
console.error('접근 토큰이 없습니다. 먼저 로그인해주세요.');
document.getElementById('resourceStatus').innerText = '접근 토큰이 없습니다. 먼저 로그인해주세요.';
return;
}
try {
// JWT를 Authorization 헤더에 담아 서버로 전송
const response = await fetch('http://localhost:5000/protected', { // 가상의 보호된 엔드포인트
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
// 만료된 토큰이거나 유효하지 않은 토큰일 경우 401 응답이 올 수 있습니다.
if (response.status === 401) {
console.warn('인증 실패: 토큰이 유효하지 않거나 만료되었습니다.');
document.getElementById('resourceStatus').innerText = '인증 실패! 다시 로그인해주세요.';
// 토큰이 만료되었을 경우, Local Storage에서 삭제하고 재로그인 유도
localStorage.removeItem('accessToken');
document.getElementById('loginSection').style.display = 'block';
document.getElementById('appSection').style.display = 'none';
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('보호된 리소스 응답:', data);
document.getElementById('resourceStatus').innerText = `리소스 로드 성공: ${JSON.stringify(data)}`;
} catch (error) {
console.error('보호된 리소스 요청 중 오류 발생:', error);
document.getElementById('resourceStatus').innerText = `리소스 로드 오류: ${error.message}`;
}
}
// 로그아웃 함수
function logout() {
localStorage.removeItem('accessToken');
console.log('로그아웃 성공! JWT 삭제됨.');
document.getElementById('status').innerText = '로그아웃되었습니다.';
document.getElementById('tokenDisplay').innerText = '';
document.getElementById('loginSection').style.display = 'block';
document.getElementById('appSection').style.display = 'none';
document.getElementById('resourceStatus').innerText = '';
}
// DOM 로드 후 이벤트 리스너 연결
document.addEventListener('DOMContentLoaded', () => {
// 로그인 버튼 클릭 이벤트
document.getElementById('loginBtn').addEventListener('click', () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
login(username, password);
});
// 리소스 요청 버튼 클릭 이벤트
document.getElementById('getResourceBtn').addEventListener('click', getProtectedResource);
// 로그아웃 버튼 클릭 이벤트
document.getElementById('logoutBtn').addEventListener('click', logout);
// 페이지 로드 시 JWT 존재 여부에 따라 UI 변경
if (localStorage.getItem('accessToken')) {
document.getElementById('loginSection').style.display = 'none';
document.getElementById('appSection').style.display = 'block';
document.getElementById('tokenDisplay').innerText = localStorage.getItem('accessToken');
document.getElementById('status').innerText = '이미 로그인되어 있습니다.';
} else {
document.getElementById('loginSection').style.display = 'block';
document.getElementById('appSection').style.display = 'none';
}
});
// 이 스크립트가 동작하려면, 간단한 Node.js + Express 서버가 필요합니다.
// 다음은 예시 서버 코드입니다 (server.js):
/*
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors'); // CORS 미들웨어 추가
const jwt = require('jsonwebtoken');
const app = express();
const port = 5000;
const SECRET_KEY = "your-very-secret-key-that-no-one-should-know"; // Python 코드와 동일해야 함
app.use(bodyParser.json());
app.use(cors()); // 모든 origin 허용 (개발용, 실제 서비스에서는 특정 origin만 허용)
// 로그인 엔드포인트
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 실제로는 DB에서 사용자 검증
if (username === 'testuser' && password === 'password123') {
const payload = {
user_id: 'testuser_id',
username: 'testuser',
is_admin: false,
iss: "my-awesome-service"
};
const token = jwt.sign(payload, SECRET_KEY, { expiresIn: '30m' }); // 30분 유효
return res.json({ access_token: token });
}
res.status(401).json({ message: 'Invalid credentials' });
});
// 보호된 엔드포인트
app.get('/protected', (req, res) => {
const authHeader = req.headers['authorization'];
if (!authHeader) {
return res.status(401).json({ message: 'No token provided' });
}
const token = authHeader.split(' ')[1]; // "Bearer <token>" 에서 <token> 부분 추출
if (!token) {
return res.status(401).json({ message: 'Token format is "Bearer <token>"' });
}
try {
const decoded = jwt.verify(token, SECRET_KEY, { issuer: "my-awesome-service" });
req.user = decoded; // 요청 객체에 사용자 정보 추가
return res.json({ message: `Hello, ${req.user.username}! This is protected data.`, user: req.user });
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({ message: 'Token expired' });
}
return res.status(401).json({ message: 'Invalid token', error: error.message });
}
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
*/
이 JavaScript 코드는 HTML 파일에 포함되어 웹 브라우저에서 실행됩니다. 로그인 시 서버로부터 JWT를 받아 localStorage에 저장하고, 이후 보호된 API 요청 시 Authorization 헤더에 이 토큰을 포함하여 보냅니다.
실무 적용 사례
JWT는 그 유연성과 효율성 덕분에 다양한 실무 환경에서 활용됩니다.
- API 인증 (RESTful API 및 마이크로서비스): 가장 일반적인 사용 사례입니다. 사용자가 로그인하면 JWT를 발급하고, 이후 모든
