
JWT: 쿠키 없는 로그인의 비밀
세션 저장 안 하고도 로그인 유지. 서버가 토큰만 검증하면 끝. Base64 인코딩된 JSON의 정체. 왜 stateless가 확장성에 좋은지.

세션 저장 안 하고도 로그인 유지. 서버가 토큰만 검증하면 끝. Base64 인코딩된 JSON의 정체. 왜 stateless가 확장성에 좋은지.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

API 서버를 처음 만들 때 로그인 기능을 어떻게 구현할지 막막했습니다. "세션을 쓰면 되겠지" 싶어서 서버 메모리에 세션을 저장했는데, 서버를 재시작하면 모든 사용자가 로그아웃되더라고요. Redis를 쓰면 되긴 하지만, 서버가 여러 대로 늘어나면 세션 공유가 복잡해진다는 얘기를 들었습니다.
"JWT 쓰세요"라는 조언을 듣고 찾아보니 "토큰 기반 인증"이라고 하는데, 도대체 어떻게 서버가 아무것도 저장하지 않고도 로그인 상태를 유지할 수 있는지 이해가 안 갔습니다. 토큰을 클라이언트가 들고 있다는데, 그럼 누가 위조하면 어떡하지? 그런데 또 서명이 있어서 안전하다는데, 서명이 뭔지도 몰랐습니다.
처음엔 "JWT는 암호화된 토큰이구나" 하고 받아들였다가, Base64로 디코딩하면 내용이 다 보인다는 걸 알고 충격을 받았습니다. 암호화도 아닌데 어떻게 안전하다는 건지, 왜 이걸 쓰는 건지 의문이 계속 쌓였습니다.
클라이언트가 토큰을 들고 있다는데, 그럼 사용자가 토큰을 조작하면 어떡하지? "나는 관리자야"라고 페이로드를 바꿔서 보내면 서버가 속지 않을까? 세션은 서버가 들고 있으니 안전한데, 클라이언트가 들고 있으면 위험하지 않나?
JWT를 보니까 이상한 문자열이 나열되어 있고, .으로 3개 부분이 나뉘어 있더라고요. 처음엔 "이게 암호화된 거구나" 싶었는데, Base64 디코딩하면 내용이 그대로 보인다는 걸 알고 당황했습니다. 그럼 이건 암호화가 아니잖아? 왜 이렇게 쓰는 거지?
"서명이 있어서 위조할 수 없다"는 말을 들었는데, 서명이 뭔지 몰랐습니다. 그냥 해시 값인가? HMAC이 뭐고 RSA가 뭔지도 몰랐고, 왜 비밀키가 있으면 안전한지 이해가 안 갔습니다.
세션은 서버가 상태를 저장하니까 언제든 무효화할 수 있는데, JWT는 발급하면 만료될 때까지 막을 수 없다는데, 그럼 보안에 더 안 좋은 거 아닌가? 왜 굳이 JWT를 쓰는 건지 이해가 안 갔습니다.
어느 날 동료가 이렇게 설명해줬습니다.
"세션은 놀이공원 입장할 때 번호표를 받는 거예요. 입구에서 '12번입니다' 하고 번호표를 주면, 놀이공원이 '12번은 철수'라고 메모해둡니다. 다음에 철수가 놀이기구 탈 때마다 '12번이요' 하고 번호표를 보여주면, 직원이 메모장에서 '12번은 철수니까 타도 돼' 하고 확인합니다. 문제는 놀이공원이 모든 입장객 명단을 계속 관리해야 한다는 거죠. 메모리 부담이 크고, 놀이공원이 여러 개로 늘어나면 명단 공유가 복잡해집니다.
JWT는 입장권에 '이름: 철수, 나이: 25, 입장 시간: 10시'라고 적고, 놀이공원 도장을 찍어주는 겁니다. 다음 방문 때 입장권을 보여주면, 직원이 도장만 확인하고 '아, 철수구나' 하고 받아들입니다. 놀이공원은 명단을 안 가지고 있어요. 입장권에 정보가 다 있으니까요. 이게 Stateless입니다."
이 비유가 너무 와닿았습니다. 세션은 서버가 '기억'하는 거고, JWT는 토큰 자체에 정보가 있어서 서버가 기억할 필요가 없는 거였습니다. 그럼 도장은 뭐지? 바로 서명이었습니다. 도장이 없거나 위조된 도장이면 들어갈 수 없는 것처럼, 서명이 유효하지 않으면 토큰을 신뢰할 수 없는 겁니다.
JWT를 처음 봤을 때 이런 문자열이었습니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMzQ1LCJuYW1lIjoiQWxpY2UifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
이걸 3부분으로 나누면 (.으로 구분):
{
"alg": "HS256", // HMAC SHA-256
"typ": "JWT"
}
이걸 Base64로 인코딩하면 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9가 됩니다. 여기서 HS256은 HMAC SHA-256 알고리즘을 쓴다는 뜻인데, 나중에 알았지만 이게 대칭키 방식이라서 서버만 비밀키를 알고 있으면 됩니다. RSA 같은 비대칭키 방식도 있는데, 이건 나중에 따로 정리해봐야겠습니다.
{
"userId": 12345,
"name": "Alice",
"exp": 1735689600, // 만료 시간 (타임스탬프)
"iat": 1735603200 // 발급 시간
}
이것도 Base64로 인코딩하면 eyJ1c2VySWQiOjEyMzQ1LCJuYW1lIjoiQWxpY2UifQ가 됩니다. 여기서 중요한 건, Base64는 암호화가 아니라 인코딩이라는 겁니다. 누구나 디코딩해서 내용을 볼 수 있습니다. 그래서 비밀번호나 주민번호 같은 민감한 정보를 넣으면 안 됩니다.
JWT에는 표준 클레임(claim)이 있는데, 이것도 나중에 알게 되었습니다.
iss (issuer): 토큰 발급자sub (subject): 토큰 제목 (보통 사용자 ID)aud (audience): 토큰 대상자exp (expiration): 만료 시간iat (issued at): 발급 시간nbf (not before): 토큰 활성 시간처음엔 이런 게 있는지도 몰랐는데, OAuth2나 OpenID Connect를 공부하다 보니 이런 표준 클레임을 쓰는 게 중요하다는 걸 이해했습니다.
이게 제일 중요합니다. 서명은 이렇게 만듭니다.
HMACSHA256(
base64(header) + "." + base64(payload),
secret_key
)
결과: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
서명을 만들 때 비밀키(secret key)를 씁니다. 이 비밀키는 서버만 알고 있어야 합니다. 누군가 payload를 변조해서 "나는 관리자야"라고 바꾸면, 서명이 맞지 않아서 서버가 검증할 때 거부합니다. 비밀키 없이는 유효한 서명을 만들 수 없기 때문입니다.
// 서버
const jwt = require('jsonwebtoken');
app.post('/login', (req, res) => {
const { email, password } = req.body;
// DB에서 사용자 확인
const user = db.findUser(email, password);
if (!user) {
return res.status(401).send('로그인 실패');
}
// JWT 생성
const token = jwt.sign(
{
userId: user.id,
name: user.name,
role: user.role // 권한 정보도 넣을 수 있음
},
process.env.JWT_SECRET, // 환경 변수로 비밀키 관리
{ expiresIn: '1h' } // 1시간 유효
);
res.json({ token });
});
여기서 중요한 건, 비밀키를 환경 변수로 관리한다는 겁니다. 코드에 하드코딩하면 GitHub에 올렸을 때 유출될 수 있습니다. 실제로 .env 파일에 저장하고 .gitignore에 추가해야 합니다.
// 클라이언트
const token = localStorage.getItem('token');
fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
// 서버
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).send('토큰이 없습니다');
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// decoded = { userId: 12345, name: "Alice", role: "user", ... }
const user = db.getUserById(decoded.userId);
res.json(user);
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).send('토큰이 만료되었습니다');
}
res.status(401).send('유효하지 않은 토큰');
}
});
jwt.verify()가 서명을 검증합니다. 토큰이 변조되었거나 만료되었으면 에러가 발생합니다. 이 과정에서 서버는 어떤 저장소도 조회하지 않습니다. 토큰 자체에 정보가 있고, 서명만 확인하면 되니까요. 이게 Stateless의 핵심입니다.
1. 로그인 성공
2. 서버: 세션 ID 생성 → 메모리/Redis/DB에 저장
sessions = {
"abc123": { userId: 12345, name: "Alice", loginTime: ... }
}
3. 클라이언트: 쿠키에 세션 ID 저장
4. 다음 요청: 쿠키로 세션 ID 전송
5. 서버: 세션 ID로 저장소에서 사용자 정보 조회
문제:
1. 로그인 성공
2. 서버: JWT 생성 (사용자 정보 포함)
3. 클라이언트: JWT 저장 (localStorage 또는 쿠키)
4. 다음 요청: JWT 전송
5. 서버: JWT 검증만 함 (저장소 조회 안 함!)
장점:
단점:
처음엔 "JWT는 암호화된 토큰"이라고 착각했습니다. 하지만 실제로는 인코딩일 뿐입니다.
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMzQ1LCJyb2xlIjoiYWRtaW4ifQ.xxx";
// Base64 디코딩하면 내용 보임!
const parts = token.split('.');
const payload = JSON.parse(atob(parts[1]));
console.log(payload); // { userId: 12345, role: "admin" }
JWT는 인코딩일 뿐, 암호화가 아닙니다! 누구나 내용을 볼 수 있습니다. 그래서 비밀번호, 주민번호, 카드번호 같은 민감한 정보를 절대 넣으면 안 됩니다.
"그럼 어떻게 안전하지?" → 서명 때문입니다.
위조 시도:
1. Eve가 payload 변조: { userId: 99999, role: "admin" }
2. Base64 인코딩해서 토큰 조작
3. 서버로 전송
서버 검증:
HMAC(header + payload, secret_key) != 토큰의 서명
→ ❌ 검증 실패! 위조 탐지
비밀키 없이는 유효한 서명을 만들 수 없습니다. Eve가 payload를 바꾸면 서명이 맞지 않아서 서버가 거부합니다. 이게 JWT의 핵심 보안 원리입니다.
처음엔 HMAC(HS256)만 썼는데, 나중에 RSA(RS256)도 있다는 걸 알았습니다.
마이크로서비스 환경에서는 RSA가 유리합니다. Auth 서버가 private key로 토큰을 발급하고, 다른 서비스들은 public key만 들고 있으면 검증할 수 있으니까요. 비밀키를 공유할 필요가 없어서 보안에 더 좋습니다.
문제: 사용자가 로그아웃해도 토큰은 여전히 유효함
// 로그아웃
localStorage.removeItem('token');
// 하지만 Eve가 토큰 복사해뒀으면?
// 만료 시간까지 계속 사용 가능!
세션은 서버가 세션을 삭제하면 즉시 무효화되는데, JWT는 발급하면 만료 시간까지 막을 방법이 없습니다. 이게 JWT의 가장 큰 단점입니다.
해결책:
세션 ID: "abc123" (6바이트)
JWT: 200+ 바이트
모든 요청마다 전송 → 대역폭 낭비
특히 모바일 환경에서는 문제가 됩니다. 헤더에 200바이트씩 계속 보내니까요. Payload를 최소화하거나, 민감한 요청만 JWT를 쓰는 전략을 고려해야 합니다.
Payload는 누구나 볼 수 있으므로 조심해야 합니다. 실수로 비밀번호나 개인정보를 넣으면 큰일납니다.
JWT의 무효화 문제를 해결하기 위해 Refresh Token 패턴을 씁니다. 이 방법이 가장 현실적이라고 이해했습니다.
// Access Token: 15분 (짧음)
// Refresh Token: 7일 (길음, DB에 저장)
// 로그인
app.post('/login', (req, res) => {
const user = authenticate(req.body);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenType: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token은 DB에 저장 (무효화 가능하게)
db.saveRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// Access Token 만료 시
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
// DB에서 확인 (여기서 무효화 여부 체크)
if (!db.isValidRefreshToken(refreshToken)) {
return res.status(401).send('다시 로그인하세요');
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// 새 Access Token 발급
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).send('유효하지 않은 리프레시 토큰');
}
});
// 로그아웃
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
// Refresh Token을 DB에서 삭제 (무효화)
db.deleteRefreshToken(refreshToken);
res.send('로그아웃 성공');
});
이 방식의 장점:
단점:
결국 완벽한 해결책은 없고, trade-off를 받아들여야 한다고 이해했습니다.
JWT를 어디에 저장할지도 고민이었습니다.
// 로그인
const { token } = await fetch('/login').then(r => r.json());
localStorage.setItem('token', token);
// 요청
fetch('/api/profile', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
장점: 간단함. JavaScript로 접근 가능. 단점: XSS 공격에 취약. 악성 스크립트가 토큰 탈취 가능.
// 서버
res.cookie('token', token, {
httpOnly: true, // JavaScript로 접근 불가
secure: true, // HTTPS만
sameSite: 'strict'
});
// 클라이언트는 자동으로 쿠키 전송 (별도 헤더 설정 불필요)
장점: XSS 공격 방지. JavaScript로 접근 불가. 단점: CSRF 공격 가능 (CSRF 토큰으로 방어 필요).
결론: httpOnly Cookie + CSRF 방어가 더 안전하다고 받아들였습니다.
OAuth2를 공부하다 보니 JWT가 나왔습니다. OAuth2는 인증/인가 프레임워크인데, Access Token 형식으로 JWT를 많이 씁니다.
1. 사용자가 Google 로그인
2. Google이 Authorization Code 발급
3. 서버가 Code로 Access Token 요청
4. Google이 JWT 형식의 Access Token 발급
5. 서버가 JWT 검증 → 사용자 정보 확인
Google, GitHub, Facebook 같은 곳에서 발급하는 Access Token이 대부분 JWT 형식입니다. OpenID Connect는 아예 JWT를 표준으로 씁니다.
// pages/api/protected.js
import jwt from 'jsonwebtoken';
export default function handler(req, res) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
// 권한 체크
if (user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
res.json({ message: `Hello, ${user.name}` });
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
res.status(401).json({ error: 'Invalid token' });
}
}
// 로그인
const login = async (email, password) => {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const { accessToken, refreshToken } = await res.json();
// Access Token: 메모리 (상태 관리)
setAccessToken(accessToken);
// Refresh Token: localStorage (보안 위험 있지만 편의성)
localStorage.setItem('refreshToken', refreshToken);
};
// 인증 요청
const fetchProfile = async () => {
let token = accessToken;
// Access Token 없으면 Refresh Token으로 재발급
if (!token) {
const refreshToken = localStorage.getItem('refreshToken');
const res = await fetch('/api/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken })
});
const { accessToken: newToken } = await res.json();
setAccessToken(newToken);
token = newToken;
}
const res = await fetch('/api/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
};
// ❌ 절대 하지 말 것
const token = jwt.sign(payload, 'my-secret-key');
// ✅ 환경 변수 사용
const token = jwt.sign(payload, process.env.JWT_SECRET);
// ❌ 7일은 너무 김
jwt.sign(payload, secret, { expiresIn: '7d' });
// ✅ 짧게, Refresh Token 활용
jwt.sign(payload, secret, { expiresIn: '15m' });
HTTP로 JWT를 보내면 중간에 가로채기 가능. 반드시 HTTPS 써야 합니다.
// ❌ 비밀번호 넣지 말 것
jwt.sign({ userId: 123, password: 'secret' }, secret);
// ✅ 최소한의 정보만
jwt.sign({ userId: 123, role: 'user' }, secret);
// ❌ 공격자에게 힌트 제공
res.status(401).send('서명이 일치하지 않습니다');
// ✅ 일반적인 메시지
res.status(401).send('유효하지 않은 토큰');
JWT의 핵심을 이렇게 받아들였습니다.
결국 JWT는 "세션의 메모리 부담을 덜고 확장성을 얻은 대신, 토큰 관리의 복잡성을 받아들인 trade-off"였습니다. 완벽한 해결책은 없고, 상황에 맞게 세션과 JWT를 선택하는 게 중요하다고 이해했습니다.
마이크로서비스나 서버가 많은 환경에서는 JWT가 유리하고, 보안이 매우 중요하거나 즉시 무효화가 필요하면 세션이 낫습니다. 둘 다 장단점이 있으니 무조건 JWT를 쓸 필요는 없다는 걸 배웠습니다.