Spring 7기 프로젝트/뉴스피드 팀 프로젝트
[트러블슈팅] JWT의 본질
JuNo_12
2025. 6. 2. 19:58
"토큰의 본질적인 의미는 클라이언트가 가지고 있는 것이다. 서버와는 완전 관계가 없는 형태여야 하는데, DB를 조회해야 하는 것 자체가 토큰의 의미가 퇴화된다고 생각해."
이 한 마디에서 시작된 JWT 설계 기준에 대해 얘기해보겠습니다.
문제의 발견: "이것이 정말 JWT인가?"
흔히 볼 수 있는 JWT 구현
대부분의 JWT 예시들과 실제 구현들을 보면 이런 패턴을 볼 수 있습니다:
// 일반적인 JWT 구현
@Component
public class JwtTokenProvider {
private final UserRepository userRepository; // ❌ DB 의존성
public Authentication getAuthentication(String token) {
String userId = extractUserId(token);
// ❌ 매 요청마다 DB 조회
User user = userRepository.findById(userId).orElse(null);
UserDetails userDetails = new CustomUserDetails(user);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
}
이런 의문이 들었습니다:
- JWT를 쓰는데 왜 매번 DB를 조회하지?
- 이거면 세션과 뭐가 다른가?
- 토큰의 본래 목적을 제대로 활용하고 있는 건가?
토큰 의미의 퇴화 과정
JWT가 어떻게 단순한 "세션 ID + 만료시간"으로 전락하는지 살펴보겠습니다:
1단계: 완전한 토큰 (원래 의도)
// 토큰에 모든 정보 포함
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"roles": ["user", "editor"],
"permissions": ["read:posts", "write:posts"]
}
// 서버는 토큰만 믿고 사용
public User getCurrentUser(String token) {
return User.fromToken(token.getClaims()); // DB 조회 없음
}
2단계: 부분적 퇴화
// 토큰 정보가 부족하다고 느껴서...
{
"sub": "1234567890" // userId만
}
// DB에서 추가 정보 조회
public User getCurrentUser(String token) {
Long userId = token.getUserId();
return userRepository.findById(userId); // 부분적 DB 의존
}
3단계: 완전한 퇴화
// 토큰은 단순 인증 증명, 모든 정보는 DB에서
public User getCurrentUser(String token) {
if (isValidToken(token)) { // 토큰은 단순 검증용
Long userId = token.getUserId();
return userRepository.findById(userId); // 완전한 DB 의존
}
}
3단계에 도달하면 사실상 "세션 ID + 만료시간"과 다를 바가 없습니다.
토큰의 본질 재발견
JWT의 본질
JWT는 "클라이언트가 스스로 자신의 신원을 증명할 수 있는 self-contained 증명서"입니다.
마치 여권과 같은 개념:
- 여권에는 필요한 모든 정보가 적혀있음
- 공항에서 여권만 보고 신원 확인 가능
- 본국 정부에 매번 전화로 확인하지 않음
이전 코드에서 일어난 "토큰 본질의 왜곡"
1. 접근 방식의 문제
// 대부분의 JWT 튜토리얼
"JWT 토큰에서 userId를 추출해서 DB에서 사용자 정보를 조회하세요"
// ❌ 이미 JWT의 본질을 놓친 접근법
2. 기존 시스템과의 호환성
// 기존 세션 기반 → JWT 전환하면서 똑같은 패턴 적용
public User getCurrentUser(HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return userRepository.findById(userId); // 세션 방식
}
public User getCurrentUser(String token) {
Long userId = extractUserId(token); // 토큰으로만 바뀜
return userRepository.findById(userId); // 똑같은 DB 조회
}
3. 실시간성에 대한 과도한 우려
// 개발자들의 걱정
"사용자가 닉네임을 바꾸면 토큰에는 이전 정보가 남아있을 텐데..."
"권한이 변경되면 즉시 반영되어야 하는데..."
// 결과: DB 조회로 회귀
진정한 JWT 시스템 설계
핵심 설계 원칙
- 토큰 중심 사고: "사용자 정보는 토큰에 있고, DB는 토큰 발급 시점에만 필요"
- 완전한 Self-contained: 토큰에 필요한 모든 정보 포함
- DB는 토큰 발급 시에만: 로그인할 때만 DB 조회, 이후 완전 독립
1. 완전한 Self-contained 토큰 매니저
@Component
public class JwtTokenManager {
// ✅ DB 의존성 없음!
/**
* 완전한 Self-contained 토큰 생성
*/
public String createAccessToken(TokenUserInfo userInfo) {
return Jwts.builder()
.setSubject(userInfo.getUserId().toString())
.claim("email", userInfo.getEmail())
.claim("userName", userInfo.getUserName())
.claim("roles", userInfo.getRoles())
.claim("permissions", userInfo.getPermissions())
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiration))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 토큰에서 완전한 사용자 정보 추출 (DB 조회 없음!)
*/
public TokenUserInfo extractUserInfo(String token) {
Claims claims = getClaims(token);
return TokenUserInfo.builder()
.userId(Long.valueOf(claims.getSubject()))
.email(claims.get("email", String.class))
.userName(claims.get("userName", String.class))
.roles(claims.get("roles", List.class))
.permissions(claims.get("permissions", List.class))
.build();
}
}
2. 토큰 기반 사용자 정보 모델
/**
* 토큰에서 추출된 완전한 사용자 정보
* DB 조회 없이 토큰만으로 모든 인증/인가 처리 가능
*/
@Getter
@Builder
public class TokenUserInfo implements UserDetails {
private final Long userId;
private final String email;
private final String userName;
private final List<String> roles;
private final List<String> permissions;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 토큰의 역할과 권한을 모두 GrantedAuthority로 변환
return Stream.concat(
roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)),
permissions.stream().map(permission -> new SimpleGrantedAuthority("PERM_" + permission))
).toList();
}
/**
* 특정 역할/권한 확인도 토큰 정보만으로
*/
public boolean hasRole(String role) {
return roles.contains(role);
}
public boolean hasPermission(String permission) {
return permissions.contains(permission);
}
}
3. 완전 무상태 인증 필터
/**
* 완전한 Stateless JWT 인증 필터
* DB 조회 없이 토큰만으로 인증 처리
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenManager jwtTokenManager;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
String token = jwtTokenManager.extractToken(request);
if (token != null && jwtTokenManager.validateToken(token)) {
// ✅ 토큰에서 완전한 사용자 정보 추출 (DB 조회 없음!)
TokenUserInfo userInfo = jwtTokenManager.extractUserInfo(token);
// Spring Security 인증 객체 생성
Authentication authentication = new UsernamePasswordAuthenticationToken(
userInfo, null, userInfo.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
4. 토큰 중심 인증 서비스
/**
* 토큰 중심 인증 서비스
* DB는 토큰 발급 시에만 사용, 이후 완전 무상태
*/
@Service
public class StatelessAuthService {
/**
* 로그인 - 유일하게 DB를 조회하는 메서드
*/
public TokenPair login(String email, String password) {
// 1. 사용자 인증 (이때만 DB 조회)
User user = authenticateUser(email, password);
// 2. 사용자 정보를 토큰에 모두 포함
TokenUserInfo userInfo = TokenUserInfo.fromUser(
user.getId(),
user.getEmail(),
user.getUserName(),
getUserRoles(user), // 사용자 역할 목록
getUserPermissions(user) // 사용자 권한 목록
);
// 3. 완전한 Self-contained 토큰 생성
String accessToken = jwtTokenManager.createAccessToken(userInfo);
String refreshToken = jwtTokenManager.createRefreshToken(user.getId());
return new TokenPair(accessToken, refreshToken);
}
/**
* 현재 사용자 정보 조회 - 완전히 토큰만 사용 (DB 조회 없음!)
*/
public TokenUserInfo getCurrentUser(Authentication authentication) {
return (TokenUserInfo) authentication.getPrincipal();
}
}
실제 사용법: 완전 토큰 기반 서비스
기본 사용법
@RestController
public class PostController {
/**
* DB 조회 없이 토큰 정보만으로 모든 작업 수행
*/
@GetMapping("/posts")
public ResponseEntity<List<String>> getPosts(@AuthenticationPrincipal TokenUserInfo userInfo) {
// 토큰에서 모든 정보 사용 가능
Long userId = userInfo.getUserId();
String userName = userInfo.getUserName();
String email = userInfo.getEmail();
// 비즈니스 로직에서 이 정보들을 그대로 사용
return ResponseEntity.ok(postService.getPostsForUser(userInfo));
}
}
권한 기반 접근 제어
@RestController
public class AdminController {
/**
* 토큰의 역할 정보로 접근 제어
*/
@PostMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> adminOnlyEndpoint(@AuthenticationPrincipal TokenUserInfo userInfo) {
return ResponseEntity.ok("Admin access for: " + userInfo.getUserName());
}
/**
* 토큰의 권한 정보로 세밀한 제어
*/
@PostMapping("/posts")
@PreAuthorize("hasAuthority('PERM_POST_WRITE')")
public ResponseEntity<String> createPost(@AuthenticationPrincipal TokenUserInfo userInfo) {
// 권한 검증은 토큰 정보만으로 완료
return postService.createPost(userInfo);
}
}
동적 권한 검증
@PutMapping("/posts/{postId}")
public ResponseEntity<String> updatePost(
@PathVariable Long postId,
@AuthenticationPrincipal TokenUserInfo userInfo) {
// 토큰의 권한 정보로 직접 검증
if (!userInfo.hasPermission("POST_WRITE") && !userInfo.hasRole("MODERATOR")) {
return ResponseEntity.status(403).body("Permission denied");
}
return ResponseEntity.ok("Post updated by: " + userInfo.getUserName());
}
성능 및 확장성 분석
성능 비교
| 방식 | DB 조회 | 응답시간 | 동시처리 | 확장성 |
| 기존 (매번 DB 조회) | 매 요청마다 1회 | 50-150ms | 커넥션 풀 제한 | DB 병목 |
| 개선 (토큰 기반) | 0회 | 2-5ms | 무제한 | 무제한 |
확장성 장점
1. 무제한 수평 확장
// 기존 방식: DB가 병목점
Server 1 ←→ Database ←→ Server 2
↑ 병목점
// 토큰 방식: 각 서버가 완전 독립
Server 1 (독립적)
Server 2 (독립적)
Server 3 (독립적)
2. 마이크로서비스 친화적
// User Service가 다운되어도 다른 서비스들은 정상 동작
Auth Service: 토큰 발급 ❌ (신규 로그인 불가)
Post Service: 게시물 CRUD ✅ (기존 사용자 정상 이용)
Comment Service: 댓글 CRUD ✅ (기존 사용자 정상 이용)
Like Service: 좋아요 기능 ✅ (기존 사용자 정상 이용)
3. 인프라 비용 절약
// 1000명 동시 사용자, 1분당 10번 API 호출 시나리오
기존 방식:
- DB 쿼리: 10,000회/분
- DB 서버 스펙업 필요
- Read Replica 필요
- 월 인프라 비용: $500+
토큰 방식:
- DB 쿼리: 0회/분 (인증 관련)
- 기존 DB 서버로 충분
- 추가 인프라 불필요
- 월 인프라 비용: $100
비용 절약: 80% 감소
트레이드오프와 실시간성 해결책
토큰 방식의 제약사항
// 1. 실시간성 부족
user.updateProfile("새로운닉네임"); // DB 업데이트
// 하지만 기존 토큰에는 이전 닉네임이 남아있음
// 2. 토큰 크기 증가
// 기존: 150 bytes → 개선: 400 bytes (2.7배 증가)
// 하지만 네트워크 비용 < DB 조회 비용
// 3. 민감한 정보 관리
// 권한 변경 등은 실시간 반영 어려움
현실적 해결책
중요도별 전략
// 1. 즉시 반영 불필요한 정보 → 토큰에 포함
userName, email, profileImage
// 변경 빈도 낮음, 사용자 경험에 큰 영향 없음
// 2. 즉시 반영 필요한 정보 → 토큰 재발급
roles, permissions, accountStatus
// 보안 관련, 즉시 반영 필요
// 3. 하이브리드 접근
일반 API: 토큰 정보 사용 (99%의 경우)
보안 API: 실시간 검증 추가 (1%의 경우)
토큰 재발급 전략
@Service
public class TokenRefreshService {
/**
* 중요 정보 변경 시 토큰 재발급
*/
public TokenPair refreshOnCriticalChange(Long userId) {
User user = userRepository.findById(userId);
// 기존 토큰들을 블랙리스트에 추가 (선택사항)
blacklistService.invalidateUserTokens(userId);
// 새 토큰 발급
return authService.issueNewTokens(user);
}
}
구현 가이드라인
1. 토큰 설계 원칙
// ✅ 토큰에 포함할 정보
{
"sub": "123", // 사용자 ID (필수)
"email": "user@example.com", // 이메일 (자주 사용)
"userName": "홍길동", // 사용자명 (자주 사용)
"roles": ["USER", "PREMIUM"], // 역할 (권한 제어용)
"permissions": ["POST_WRITE"], // 권한 (세밀한 제어용)
"iat": 1640995200, // 발급 시간
"exp": 1640998800 // 만료 시간
}
// ❌ 토큰에 포함하지 말 것
password, sensitive_data, large_objects, frequently_changing_data
2. 점진적 마이그레이션
// Phase 1: 기존 시스템 유지하면서 토큰 정보 확장
{
"sub": "123",
"email": "user@example.com" // 기본 정보만 추가
}
// Phase 2: 권한 정보 추가
{
"sub": "123",
"email": "user@example.com",
"roles": ["USER"]
}
// Phase 3: 완전한 Self-contained 토큰
{
"sub": "123",
"email": "user@example.com",
"userName": "홍길동",
"roles": ["USER"],
"permissions": ["POST_WRITE"]
}
3. 모니터링 및 측정
@Component
public class JwtMetrics {
private final MeterRegistry meterRegistry;
public void recordTokenValidation(boolean dbFree) {
Counter.builder("jwt.validation")
.tag("type", dbFree ? "token_only" : "with_db")
.register(meterRegistry)
.increment();
}
public void recordResponseTime(String endpoint, long duration, boolean dbFree) {
Timer.builder("api.response_time")
.tag("endpoint", endpoint)
.tag("auth_type", dbFree ? "token_only" : "with_db")
.register(meterRegistry)
.record(duration, TimeUnit.MILLISECONDS);
}
}
핵심 교훈
1. 토큰의 본질 이해하기
"토큰은 클라이언트가 스스로 증명하는 자립적 신원증명서"
- 서버는 토큰의 서명만 검증
- 토큰 내용은 그대로 믿고 사용
- DB는 토큰 발급 시에만 필요
2. 성능과 확장성의 혁신
// 기존 사고: "DB에 진실이 있고, 토큰은 그 열쇠"
User user = userRepository.findById(tokenUserId);
// 토큰 중심 사고: "토큰 자체가 진실"
TokenUserInfo user = token.getUserInfo();
3. 실용적 접근의 중요성
완벽한 실시간성보다는:
- 80-20 법칙: 80%는 토큰으로, 20%만 실시간 검증
- 점진적 개선: 한 번에 모든 걸 바꾸려 하지 말기
- 측정 기반 결정: 실제 성능 데이터로 의사결정
마무리
JWT의 본질을 되찾는 여정을 통해 다음을 깨달았습니다:
진정한 JWT의 가치
- 완전한 분산 시스템 가능
- 무한 확장성
- 극도의 성능 최적화
- 인프라 비용 절약
핵심 패러다임 변화
// From: "토큰으로 DB에 접근"
String userId = token.getUserId();
User user = database.findById(userId);
// To: "토큰 자체가 모든 정보"
TokenUserInfo user = token.getAllUserInfo();
JWT는 단순한 인증 수단이 아닙니다.
이는 "클라이언트 중심의 완전 분산 아키텍처"를 가능하게 하는 핵심 기술입니다. 토큰의 본질을 이해하고 올바르게 구현한다면, 성능과 확장성에서 혁신적인 결과를 얻을 수 있다고 생각합니다.