JSON Web Token (JWT): 현대 웹 애플리케이션의 안전한 인증과 정보 교환

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘은 현대 웹 애플리케이션 개발에서 필수적인 요소로 자리 잡은 **JSON Web Token (JWT)**에 대해 깊이 있게 알아보는 시간을 갖겠습니다. JWT는 단순히 인증에만 사용되는 기술이 아니라, 분산 시스템 환경에서 사용자 정보나 특정 데이터를 안전하게 주고받는 데 핵심적인 역할을 합니다. 초중급 개발자라면 반드시 이해하고 실무에 적용할 수 있어야 할 중요한 개념이죠.
1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

1.1. JWT란 무엇인가?
JSON Web Token, 줄여서 **JWT(젭트)**는 클라이언트와 서버, 또는 서비스와 서비스 간에 정보를 안전하게 주고받기 위해 사용되는 간결하고 URL-safe한(URL로 전송 가능한) 토큰입니다. 이 토큰은 JSON 객체 형태로 정보를 담고 있으며, 디지털 서명되어 있어 정보의 위변조 여부를 확인할 수 있습니다.
1.2. 탄생 배경: 전통적인 세션 기반 인증의 한계
JWT가 등장하기 전, 웹 애플리케이션의 인증 방식은 주로 세션(Session) 기반이었습니다. 사용자가 로그인하면 서버는 세션 ID를 생성하고, 이 세션 ID를 클라이언트(브라우저)에 쿠키 형태로 저장합니다. 이후 클라이언트의 요청이 올 때마다 세션 ID를 통해 서버에 저장된 사용자 정보를 참조하여 인증 상태를 확인했죠.
하지만 세션 기반 인증은 다음과 같은 한계점을 가집니다:
- 확장성 문제 (Scalability): 서버가 여러 대인 분산 환경에서는 사용자의 세션 정보가 특정 서버에만 저장되어 있을 경우, 다른 서버로 요청이 전달되면 인증에 실패할 수 있습니다. 이를 해결하기 위해 세션 정보를 공유하는 복잡한 메커니즘(Sticky Session, Redis와 같은 중앙 세션 저장소)이 필요했습니다.
- CORS (Cross-Origin Resource Sharing) 문제: 웹 브라우저의 Same-Origin Policy로 인해 다른 도메인 간의 세션 쿠키 공유가 어렵습니다. 이는 REST API나 모바일 앱, SPA(Single Page Application)와 같이 백엔드와 프론트엔드가 다른 도메인에 위치하는 현대적인 아키텍처에서 큰 제약이 됩니다.
- 모바일 앱/SPA 친화적이지 않음: 모바일 앱은 쿠키를 직접 관리하기 어렵고, SPA는 백엔드와 분리되어 독립적으로 동작하므로 세션 기반 인증이 부적합한 경우가 많습니다.
이러한 문제들을 해결하기 위해 무상태(Stateless) 인증 방식이 필요했고, 그 결과 JWT가 각광받게 되었습니다.
1.3. 왜 중요한가?
JWT가 현대 웹 애플리케이션에서 중요한 이유는 다음과 같습니다:
- 무상태(Stateless) 아키텍처 지원: 서버는 클라이언트의 상태를 저장할 필요 없이, 오직 토큰 자체만으로 인증 및 권한 부여를 수행할 수 있습니다. 이는 서버의 확장성을 크게 높여줍니다.
- 분산 시스템에 적합: 여러 서버가 독립적으로 동작하는 마이크로서비스 아키텍처에서, 각 서비스는 공유된 비밀 키만으로 토큰을 검증할 수 있어 중앙 인증 서버 없이도 인증 정보를 공유할 수 있습니다.
- 모바일 앱/SPA 친화적: HTTP 헤더를 통해 토큰을 전달하므로, 쿠키 제약 없이 다양한 클라이언트 환경에서 쉽게 사용할 수 있습니다.
- 보안성: 디지털 서명 덕분에 토큰의 내용이 위변조되었는지 쉽게 확인할 수 있습니다. (단, 암호화된 것은 아니므로 민감 정보는 담지 않아야 합니다.)
- 간결성: 토큰 자체가 필요한 정보를 담고 있어, 추가적인 데이터베이스 조회가 줄어들어 성능 향상에 기여합니다.
2. 핵심 원리 설명: Header, Payload, Signature

JWT는 점(.)으로 구분된 세 부분으로 구성됩니다. 각 부분은 Base64Url로 인코딩되어 있으며, 이는 암호화가 아니라 단순히 데이터를 URL-safe 문자열로 변환하는 과정입니다.
Header.Payload.Signature
2.1. Header (헤더)
헤더는 토큰의 타입과 서명에 사용된 해싱 알고리즘 정보를 담고 있는 JSON 객체입니다.
예시:
{
"alg": "HS256", // 서명 알고리즘 (예: HMAC SHA256)
"typ": "JWT" // 토큰 타입
}
이 JSON 객체는 Base64Url로 인코딩되어 JWT의 첫 번째 부분이 됩니다.
2.2. Payload (페이로드)
페이로드에는 클라이언트와 서버 간에 주고받는 실제 정보(클레임, Claims)가 담겨 있습니다. 클레임은 key:value 형태로 구성되며, 세 가지 유형으로 나뉩니다.
- 등록된 클레임 (Registered Claims): 미리 정의된 클레임으로, JWT의 표준에 따라 사용됩니다.
iss(발행자),exp(만료 시간), ``sub(주제),aud`(수신자) 등이 있습니다. 이 클레임들은 선택 사항이지만, 사용하는 것을 권장합니다. - 공개 클레임 (Public Claims): 충돌 방지를 위해 IANA JWT Registry에 등록되거나, URI 형식으로 이름을 지정하는 클레임입니다.
- 개인 클레임 (Private Claims): 클라이언트와 서버 간에 협의하여 사용하는 사용자 정의 클레임입니다. 예를 들어,
userId,role등이 있습니다.
예시:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022, // 발행 시간
"exp": 1516242622 // 만료 시간 (Unix Timestamp)
}
이 JSON 객체도 Base64Url로 인코딩되어 JWT의 두 번째 부분이 됩니다. 주의할 점은 페이로드는 Base64Url 인코딩만 되어 있을 뿐 암호화된 것이 아니므로, 민감한 정보(예: 비밀번호, 주민등록번호)를 직접 저장해서는 안 됩니다.
2.3. Signature (서명)
서명은 토큰의 위변조 여부를 확인하는 데 사용됩니다. 시그니처는 다음과 같이 생성됩니다:
- 헤더와 페이로드를 Base64Url 인코딩한 문자열을 가져옵니다.
- 이 두 문자열을 점(.)으로 연결합니다. (
Base64Url(Header) + "." + Base64Url(Payload)) - 서명 알고리즘(헤더의
alg에 명시된 알고리즘, 예: HS256)과 **서버만 아는 비밀 키(Secret Key)**를 사용하여 2단계에서 생성된 문자열을 해싱합니다.
수식으로 표현하면 다음과 같습니다:
Signature = HMACSHA256(Base64Url(Header) + "." + Base64Url(Payload), Secret Key)
이 서명 문자열이 JWT의 마지막 부분이 됩니다. 클라이언트로부터 JWT를 받은 서버는 동일한 과정을 거쳐 서명을 다시 생성하고, 토큰에 포함된 서명과 일치하는지 비교하여 토큰의 유효성을 검증합니다. 만약 헤더나 페이로드의 내용이 변경되었다면, 서명이 달라지므로 서버는 이를 감지하고 해당 토큰을 거부합니다.
2.4. 비유와 다이어그램 (말로 설명)
JWT의 핵심 원리를 이해하기 위해 **'신분증'**에 비유해 봅시다.
-
헤더 (Header) = 신분증의 종류와 발급 기관 로고:
- "이것은 JWT 신분증이야. 그리고 위조 방지를 위해 SHA256 암호화 방식으로 서명했어!" 와 같은 정보를 담고 있습니다.
-
페이로드 (Payload) = 신분증의 개인 정보:
- "이 신분증의 주인은 '김철수'이고, 생년월일은 'xxxx년 x월 x일', 관리자 권한을 가지고 있어. 이 신분증은 2026년 3월 19일 23시 59분에 만료돼." 와 같은 실제 사용자 정보나 권한 정보가 담겨 있습니다. 이 정보는 누구나 볼 수 있지만, 위조는 어렵게 만듭니다.
-
서명 (Signature) = 위조 방지 홀로그램 또는 숨겨진 패턴:
- 이 신분증이 진짜인지 위조된 것인지 확인하는 가장 중요한 부분입니다. 신분증의 종류와 개인 정보를 조합하고, 정부(서버)만 아는 **비밀 도장(Secret Key)**으로 찍은 특수한 홀로그램과 같습니다.
- 누군가가 신분증의 개인 정보(페이로드)를 조금이라도 수정하면, 홀로그램(서명)이 원래의 것과 달라지게 됩니다. 신분증을 검사하는 사람(서버)은 이 홀로그램이 진짜 도장으로 찍힌 것인지 확인하여 위조 여부를 판단합니다.
JWT 동작 흐름 다이어그램 (말로 설명):
+------------+ POST /login +-------------------+
| 클라이언트 | ---------------------> | 인증 서버 |
| (브라우저/앱) | | (ID/PW 검증, JWT 발급) |
+------------+ +-------------------+
^ |
| | (1) 로그인 정보 전송
| (4) 토큰 저장 후, |
| 다음 요청 시 토큰 첨부 | (2) JWT 생성 및 서명 (Secret Key 사용)
| |
+ <------------------------------------| (3) JWT 응답
200 OK, { "token": "Header.Payload.Signature" }
--------------------------------------------------------------------------------------
+------------+ GET /api/profile +-------------------+
| 클라이언트 | ---------------------> | 리소스 서버 |
| (브라우저/앱) | (Authorization: Bearer JWT) | (JWT 검증, 정보 제공) |
+------------+ +-------------------+
^ |
| | (1) JWT 토큰과 함께 요청
| (4) 응답 처리 |
| | (2) 토큰 서명 검증 (Secret Key 사용)
| | (3) Payload 정보 추출
+ <------------------------------------| (4) 요청 처리 및 응답
200 OK, { "user": "John Doe", ... }
이 흐름에서 중요한 것은 인증 서버와 리소스 서버가 JWT를 검증할 때 동일한 Secret Key를 사용해야 한다는 점입니다.
3. 코드 예제 2개 (Python, JavaScript)
JWT를 생성하고 검증하는 코드를 Python과 JavaScript(Node.js)로 살펴보겠습니다. 실제 라이브러리들은 복잡한 서명 과정을 추상화하여 쉽게 사용할 수 있도록 돕습니다.
3.1. Python 예제: JWT 생성 및 검증
PyJWT 라이브러리를 사용합니다.
설치: pip install PyJWT
import jwt
import datetime
from datetime import timezone, timedelta
# 1. 비밀 키 설정
SECRET_KEY = "your-very-secret-key-that-no-one-should-know"
# 2. JWT 생성 함수
def create_jwt_token(user_id: str, is_admin: bool = False) -> str:
"""
주어진 사용자 ID와 관리자 여부로 JWT 토큰을 생성합니다.
토큰은 1시간 동안 유효합니다.
"""
# 현재 시간 (UTC)
now = datetime.datetime.now(timezone.utc)
# 토큰 만료 시간: 현재 시간 + 1시간
expiration = now + timedelta(hours=1)
payload = {
"user_id": user_id,
"is_admin": is_admin,
"exp": expiration, # 만료 시간 (Registered Claim)
"iat": now, # 발행 시간 (Registered Claim)
"iss": "my-awesome-app" # 발행자 (Registered Claim)
}
# jwt.encode(payload, secret_key, algorithm)
# alg="HS256"은 기본값이므로 명시하지 않아도 됩니다.
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
return token
# 3. JWT 검증 함수
def verify_jwt_token(token: str) -> dict:
"""
JWT 토큰을 검증하고, 유효하면 페이로드 데이터를 반환합니다.
"""
try:
# jwt.decode(token, secret_key, algorithms)
# algorithms는 토큰을 서명할 때 사용된 알고리즘 리스트를 지정합니다.
# 이 리스트에 포함된 알고리즘으로만 토큰을 디코딩합니다.
decoded_payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return decoded_payload
except jwt.ExpiredSignatureError:
print("토큰이 만료되었습니다.")
return None
except jwt.InvalidTokenError as e:
print(f"유효하지 않은 토큰입니다: {e}")
return None
# --- 예제 실행 ---
if __name__ == "__main__":
# 토큰 생성
print("--- 토큰 생성 ---")
user_token = create_jwt_token("user123", is_admin=False)
admin_token = create_jwt_token("admin_user", is_admin=True)
print(f"생성된 사용자 토큰: {user_token}")
print(f"생성된 관리자 토큰: {admin_token}")
print("\n--- 토큰 검증 ---")
# 유효한 사용자 토큰 검증
print(f"사용자 토큰 검증 결과: {verify_jwt_token(user_token)}")
# 유효한 관리자 토큰 검증
print(f"관리자 토큰 검증 결과: {verify_jwt_token(admin_token)}")
# 만료된 토큰 시뮬레이션 (실제로는 시간이 지나야 만료됩니다)
# 간단한 테스트를 위해 만료 시간을 짧게 설정하여 토큰을 다시 생성해봅니다.
print("\n--- 만료된 토큰 검증 시뮬레이션 ---")
# 짧은 유효 기간을 가진 토큰 생성 (예: 1초)
now_short = datetime.datetime.now(timezone.utc)
expiration_short = now_short + timedelta(seconds=1)
payload_short = {
"user_id": "temp_user",
"exp": expiration_short,
"iat": now_short
}
short_lived_token = jwt.encode(payload_short, SECRET_KEY, algorithm="HS256")
print(f"짧은 유효 기간 토큰: {short_lived_token}")
# 1초 대기 후 검증
import time
time.sleep(1.1) # 1.1초 대기하여 토큰 만료
print(f"짧은 유효 기간 토큰 검증 결과 (만료 후): {verify_jwt_token(short_lived_token)}")
# 위변조된 토큰 시뮬레이션
print("\n--- 위변조된 토큰 검증 시뮬레이션 ---")
parts = user_token.split('.')
# 페이로드 부분만 살짝 변경 (예: user_id를 admin으로)
# 실제로는 Base64Url 디코딩 -> JSON 파싱 -> 수정 -> JSON 문자열화 -> Base64Url 인코딩 과정을 거쳐야 합니다.
# 여기서는 간단히 페이로드의 일부를 변경하여 서명 불일치를 유도합니다.
# 이 방식은 잘못된 서명을 만들기 때문에 InvalidTokenError를 발생시킵니다.
# 주의: 실제 위변조는 페이로드 내용을 디코딩하여 수정하고 인코딩하는 과정이 필요합니다.
# 여기서는 서명이 유효하지 않게 되는 가장 간단한 방법을 보여줍니다.
invalid_payload_token = parts[0] + '.' + 'eyJ1c2VyX2lkIjoibmV3X3VzZXIiLCJpc19hZG1pbiI6ZmFsc2V9' + '.' + parts[2]
print(f"위변조된 토큰 검증 결과: {verify_jwt_token(invalid_payload_token)}")
3.2. JavaScript (Node.js) 예제: JWT 생성 및 검증
jsonwebtoken 라이브러리를 사용합니다.
설치: npm install jsonwebtoken
const jwt = require('jsonwebtoken');
// 1. 비밀 키 설정
const SECRET_KEY = "your-very-secret-key-that-no-one-should-know";
// 2. JWT 생성 함수
function createJwtToken(userId, isAdmin = false) {
const payload = {
userId: userId,
isAdmin: isAdmin,
};
// jwt.sign(payload, secret_key, options)
// expiresIn: 토큰 유효 기간 (예: '1h', '7d', '1y')
// algorithm: 서명 알고리즘 (기본값은 HS256)
const token = jwt.sign(payload, SECRET_KEY, { expiresIn: '1h', algorithm: 'HS256' });
return token;
}
// 3. JWT 검증 함수
function verifyJwtToken(token) {
try {
// jwt.verify(token, secret_key, options, callback)
// 비동기 함수이므로 Promise를 반환하도록 래핑합니다.
return new Promise((resolve, reject) => {
jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] }, (err, decoded) => {
if (err) {
// err.name으로 오류 종류 확인 가능: 'TokenExpiredError', 'JsonWebTokenError' 등
if (err.name === 'TokenExpiredError') {
console.log("토큰이 만료되었습니다.");
} else {
console.log(`유효하지 않은 토큰입니다: ${err.message}`);
}
reject(null); // 실패 시 null 반환
} else {
resolve(decoded); // 성공 시 페이로드 반환
}
});
});
} catch (e) {
console.error("토큰 검증 중 예외 발생:", e.message);
return Promise.resolve(null);
}
}
// --- 예제 실행 ---
(async () => { // 비동기 함수 실행을 위한 IIFE (즉시 실행 함수)
// 토큰 생성
console.log("--- 토큰 생성 ---");
const userToken = createJwtToken("user123", false);
const adminToken = createJwtToken("admin_user", true);
console.log(`생성된 사용자 토큰: ${userToken}`);
console.log(`생성된 관리자 토큰: ${adminToken}`);
console.log("\n--- 토큰 검증 ---");
// 유효한 사용자 토큰 검증
console.log("사용자 토큰 검증 결과:", await verifyJwtToken(userToken));
// 유효한 관리자 토큰 검증
console.log("관리자 토큰 검증 결과:", await verifyJwtToken(adminToken));
// 만료된 토큰 시뮬레이션 (실제로는 시간이 지나야 만료됩니다)
console.log("\n--- 만료된 토큰 검증 시뮬레이션 ---");
const shortLivedToken = jwt.sign({ userId: "temp_user" }, SECRET_KEY, { expiresIn: '1s' });
console.log(`짧은 유효 기간 토큰: ${shortLivedToken}`);
// 1.1초 대기 후 검증
await new Promise(resolve => setTimeout(resolve, 1100)); // 1.1초 대기
console.log("짧은 유효 기간 토큰 검증 결과 (만료 후):", await verifyJwtToken(shortLivedToken));
// 위변조된 토큰 시뮬레이션
console.log("\n--- 위변조된 토큰 검증 시뮬레이션 ---");
const parts = userToken.split('.');
// 페이로드 부분만 살짝 변경하여 서명 불일치를 유도
// 이 방식은 잘못된 서명을 만들기 때문에 JsonWebTokenError를 발생시킵니다.
const invalidPayloadToken = parts[0] + '.' + 'eyJ1c2VyX2lkIjoibmV3X3VzZXIiLCJpc19hZG1pbiI6ZmFsc2V9' + '.' + parts[2];
console.log("위변조된 토큰 검증 결과:", await verifyJwtToken(invalidPayloadToken));
})();
4. 실무 적용 사례
JWT는 다양한 현대 웹 애플리케이션 시나리오에서 활용됩니다.
-
API 인증 및 권한 부여: 가장 일반적인 사용 사례입니다. 사용자가 로그인하면 서버는 JWT를 발행하고, 클라이언트는 이 토큰을 HTTP 요청의
Authorization헤더(예:Authorization: Bearer <token>)에 담아 보냅니다. 서버는 요청이 올 때마다 토큰을 검증하여 사용자를 인증하고, 페이로드에 포함된role등의 정보로 권한을 확인합니다. 이는 RESTful API, 마이크로서비스 간 통신에 특히 유용합니다. -
싱글 사인 온 (Single Sign-On, SSO): 여러 서비스가 존재하는 환경에서 사용자가 한 번의 로그인으로 모든 서비스에 접근할 수 있도록 할 때 JWT를 활용할 수 있습니다. 인증 서버가 JWT를 발행하면, 다른 서비스들은 이 JWT를 검증하여 사용자를 인증합니다.
-
정보 교환: 두 당사자(예: 클라이언트와 서버, 또는 두 개의 마이크로서비스) 사이에 안전하게 정보를 전송해야 할 때 JWT를 사용할 수 있습니다. 서명 덕분에 정보의 무결성과 신뢰성을 보장할 수 있습니다. 예를 들어, 특정 작업을 수행하기 위한 일회성 토큰이나, 사용자 상태 정보를 전달하는 데 사용될 수 있습니다.
-
OAuth 2.0의 Access Token: OAuth 2.0 프로토콜에서 리소스 서버에 접근하는 데 사용되는 Access Token의 구현체로 JWT가 자주 사용됩니다. 이를 통해 리소스 서버는 별도의 인증 서버에 질의하지 않고도 토큰 자체만으로 유효성을 검증하고 사용자 정보를 얻을 수 있습니다.
5. 자주 하는 실수와 해결법
JWT는 강력하지만, 잘못 사용하면 심각한 보안 취약점을 초래할 수 있습니다.
5.1. 비밀 키(Secret Key) 관리 소홀
- 문제: 비밀 키가 외부에 노출되면 공격자가 유효한 JWT를 위조할 수 있습니다. 코드 내에 하드코딩하거나, 버전 관리 시스템(Git)에 포함시키는 것은 매우 위험합니다.
- 해결법:
- 환경 변수 사용:
SECRET_KEY=your_secret_key와 같이 환경 변수로 설정하고, 애플리케이션은 환경 변수에서 읽어오도록 합니다. - 안전한 키 관리 시스템: AWS KMS, Azure Key Vault, Google Cloud Key Management Service와 같은 서비스를 사용하여 키를 안전하게 저장하고 관리합니다.
- 강력한 키 사용: 예측하기 어려운 길고 복잡한 문자열을 사용합니다.
- 환경 변수 사용:
5.2. 토큰 탈취 후 재사용 (Replay Attack)
- 문제: 공격자가 네트워크 스니핑 등으로 JWT를 탈취하면, 만료되기 전까지 탈취된 토큰으로 인증된 사용자 행세를 할 수 있습니다.
- 해결법:
- 짧은 유효 기간 설정: 토큰의
exp(만료 시간)을 짧게(예: 15분 ~ 1시간) 설정하여 토큰이 탈취되더라도 공격자가 사용할 수 있는 시간을 최소화합니다. - Refresh Token 사용: Access Token의 유효 기간을 짧게 하고, 유효 기간이 긴 Refresh Token을 사용하여 Access Token을 갱신하는 방식을 사용합니다. Refresh Token은 한 번만 사용되도록 하거나, 별도의 데이터베이스에 저장하여 관리하고, 탈취 시 무효화할 수 있도록 합니다.
- HTTPS 사용: 모든 통신에 HTTPS를 사용하여 전송 과정에서 토큰이 암호화되도록 합니다.
- 짧은 유효 기간 설정: 토큰의
5.3. 민감 정보 Payload에 저장
- 문제: 페이로드는 Base64Url 인코딩될 뿐, 암호화되는 것이 아닙니다. 따라서 누구나 디코딩하여 내용을 볼 수 있습니다. 여기에 비밀번호, 개인 식별 정보(PII) 등 민감한 정보를 담으면 심각한 정보 유출로 이어집니다.
- 해결법:
- 최소한의 정보만 포함: 인증 및 권한 부여에 필요한 최소한의 정보(예:
user_id,role)만 포함합니다. - 민감 정보는 서버에서 관리: 민감한 사용자 정보는 서버의 데이터베이스에 저장하고, JWT의
user_id를 통해 DB에서 조회하여 사용합니다. - 암호화된 JWT (JWE) 고려: 정말 민감한 정보를 JWT에 담아야 한다면, JWT 표준의 확장인 JWE(JSON Web Encryption)를 고려할 수 있습니다. 하지만 JWE는 복잡성이 증가하므로, 일반적으로는 최소한의 정보만 담는 것을 권장합니다.
- 최소한의 정보만 포함: 인증 및 권한 부여에 필요한 최소한의 정보(예:
5.4. 서명 검증 누락
- 문제: 클라이언트로부터 받은 JWT의 서명을 서버에서 검증하지 않으면, 공격자가 위변조한 토큰을 사용하여 시스템에 접근할 수 있습니다.
- 해결법:
- 모든 요청에 서명 검증 필수: JWT를 사용하는 모든 API 엔드포인트에서 토큰의
