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 시스템 설계

핵심 설계 원칙

  1. 토큰 중심 사고: "사용자 정보는 토큰에 있고, DB는 토큰 발급 시점에만 필요"
  2. 완전한 Self-contained: 토큰에 필요한 모든 정보 포함
  3. 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의 가치

  1. 완전한 분산 시스템 가능
  2. 무한 확장성
  3. 극도의 성능 최적화
  4. 인프라 비용 절약

 

핵심 패러다임 변화

// From: "토큰으로 DB에 접근"
String userId = token.getUserId();
User user = database.findById(userId);

// To: "토큰 자체가 모든 정보"
TokenUserInfo user = token.getAllUserInfo();

 

JWT는 단순한 인증 수단이 아닙니다.

이는 "클라이언트 중심의 완전 분산 아키텍처"를 가능하게 하는 핵심 기술입니다. 토큰의 본질을 이해하고 올바르게 구현한다면, 성능과 확장성에서 혁신적인 결과를 얻을 수 있다고 생각합니다.