hobokai 님의 블로그

OAuth2 & JWT 고급 활용 가이드 본문

Backend

OAuth2 & JWT 고급 활용 가이드

hobokai 2025. 8. 7. 13:04

실무에서 바로 써먹는 OAuth2와 JWT 완전 정복

기본 인증을 넘어선 고급 보안 패턴과 실전 구현 전략을 다룹니다.


🔐 OAuth2 고급 패턴

Authorization Code Flow with PKCE

PKCE(Proof Key for Code Exchange)는 SPA와 모바일 앱의 보안을 강화하는 OAuth2 확장입니다.

// 프론트엔드 - PKCE 구현
class OAuth2PKCEClient {
    constructor(clientId, redirectUri, authUrl, tokenUrl) {
        this.clientId = clientId;
        this.redirectUri = redirectUri;
        this.authUrl = authUrl;
        this.tokenUrl = tokenUrl;
    }

    // PKCE Code Verifier 생성
    generateCodeVerifier() {
        const array = new Uint8Array(32);
        crypto.getRandomValues(array);
        return this.base64URLEncode(array);
    }

    // Code Challenge 생성
    async generateCodeChallenge(verifier) {
        const encoder = new TextEncoder();
        const data = encoder.encode(verifier);
        const digest = await crypto.subtle.digest('SHA-256', data);
        return this.base64URLEncode(new Uint8Array(digest));
    }

    base64URLEncode(array) {
        return btoa(String.fromCharCode(...array))
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
            .replace(/=/g, '');
    }

    // 인증 URL 생성
    async buildAuthUrl() {
        const codeVerifier = this.generateCodeVerifier();
        const codeChallenge = await this.generateCodeChallenge(codeVerifier);
        
        // 세션 스토리지에 code_verifier 저장
        sessionStorage.setItem('pkce_code_verifier', codeVerifier);
        
        const params = new URLSearchParams({
            response_type: 'code',
            client_id: this.clientId,
            redirect_uri: this.redirectUri,
            scope: 'openid profile email',
            state: this.generateState(),
            code_challenge: codeChallenge,
            code_challenge_method: 'S256'
        });

        return `${this.authUrl}?${params.toString()}`;
    }

    // 토큰 교환
    async exchangeCodeForToken(code) {
        const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
        
        const response = await fetch(this.tokenUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                grant_type: 'authorization_code',
                client_id: this.clientId,
                code: code,
                redirect_uri: this.redirectUri,
                code_verifier: codeVerifier
            })
        });

        if (!response.ok) {
            throw new Error('Token exchange failed');
        }

        return await response.json();
    }

    generateState() {
        return Math.random().toString(36).substring(2, 15);
    }
}

OAuth2 Resource Server 구현 (Spring Boot)

@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfig {
    
    @Bean
    public JwtDecoder jwtDecoder() {
        // JWT 서명 검증을 위한 RSA 공개키 설정
        RSAKey rsaKey = loadRSAKey();
        return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
    }
    
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter authoritiesConverter = 
            new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthorityPrefix("ROLE_");
        authoritiesConverter.setAuthoritiesClaimName("roles");

        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        jwtConverter.setPrincipalClaimName("sub");
        return jwtConverter;
    }
    
    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
        return http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/users/me").hasRole("USER")
                .requestMatchers(HttpMethod.POST, "/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .build();
    }
}

다중 테넌트 OAuth2 구현

@Component
public class MultiTenantJwtDecoder implements JwtDecoder {
    
    private final Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>();
    private final TenantResolver tenantResolver;
    
    public MultiTenantJwtDecoder(TenantResolver tenantResolver) {
        this.tenantResolver = tenantResolver;
    }
    
    @Override
    public Jwt decode(String token) throws JwtException {
        String tenantId = tenantResolver.resolve(token);
        JwtDecoder decoder = jwtDecoders.computeIfAbsent(tenantId, this::createJwtDecoder);
        return decoder.decode(token);
    }
    
    private JwtDecoder createJwtDecoder(String tenantId) {
        TenantConfig config = getTenantConfig(tenantId);
        return NimbusJwtDecoder.withJwkSetUri(config.getJwksUri())
                .cache(Duration.ofMinutes(5))
                .build();
    }
    
    private TenantConfig getTenantConfig(String tenantId) {
        // 테넌트별 설정 조회 (데이터베이스, 캐시 등)
        return tenantConfigRepository.findByTenantId(tenantId)
                .orElseThrow(() -> new IllegalArgumentException("Unknown tenant: " + tenantId));
    }
}

@Component
public class TenantResolver {
    
    public String resolve(String token) {
        try {
            // JWT 헤더에서 테넌트 정보 추출
            String[] chunks = token.split("\\.");
            Base64.Decoder decoder = Base64.getUrlDecoder();
            String header = new String(decoder.decode(chunks[0]));
            
            JsonNode headerNode = objectMapper.readTree(header);
            return headerNode.get("tenant").asText();
            
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid token format", e);
        }
    }
}

🎫 JWT 고급 활용 패턴

JWT Refresh Token 로테이션

보안을 강화하기 위해 Refresh Token을 한 번 사용하면 새로운 토큰으로 교체하는 패턴입니다.

@Service
@RequiredArgsConstructor
public class RefreshTokenService {
    
    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtTokenProvider jwtTokenProvider;
    
    @Transactional
    public TokenResponse refreshTokens(String refreshToken) {
        // 기존 Refresh Token 검증
        RefreshToken existingToken = refreshTokenRepository
            .findByToken(refreshToken)
            .filter(token -> !token.isExpired())
            .filter(token -> !token.isRevoked())
            .orElseThrow(() -> new InvalidTokenException("Invalid refresh token"));
        
        // 기존 토큰 무효화
        existingToken.revoke();
        
        // 토큰 재사용 감지 (Refresh Token Rotation)
        if (existingToken.isUsed()) {
            // 보안 침해 가능성 - 해당 사용자의 모든 토큰 무효화
            refreshTokenRepository.revokeAllByUserId(existingToken.getUserId());
            throw new SecurityException("Refresh token reuse detected");
        }
        
        existingToken.markAsUsed();
        
        // 새로운 토큰 쌍 생성
        String newAccessToken = jwtTokenProvider.createAccessToken(existingToken.getUserId());
        String newRefreshToken = jwtTokenProvider.createRefreshToken(existingToken.getUserId());
        
        // 새로운 Refresh Token 저장
        RefreshToken newRefreshTokenEntity = RefreshToken.builder()
            .token(newRefreshToken)
            .userId(existingToken.getUserId())
            .expiresAt(LocalDateTime.now().plusDays(30))
            .build();
        
        refreshTokenRepository.save(newRefreshTokenEntity);
        
        return TokenResponse.builder()
            .accessToken(newAccessToken)
            .refreshToken(newRefreshToken)
            .tokenType("Bearer")
            .expiresIn(3600L)
            .build();
    }
    
    @Scheduled(fixedRate = 3600000) // 1시간마다 실행
    public void cleanupExpiredTokens() {
        refreshTokenRepository.deleteAllExpired();
    }
}

JWT Claims 기반 동적 권한 관리

@Component
public class DynamicJwtTokenProvider extends JwtTokenProvider {
    
    private final UserPermissionService permissionService;
    private final RoleHierarchyService roleHierarchyService;
    
    @Override
    public String createAccessToken(String userId) {
        User user = userService.findById(userId);
        
        // 동적 권한 조회
        Set<Permission> permissions = permissionService.getUserPermissions(userId);
        Set<Role> roles = roleHierarchyService.expandRoles(user.getRoles());
        
        // 컨텍스트 기반 클레임 추가
        Map<String, Object> claims = new HashMap<>();
        claims.put("sub", userId);
        claims.put("email", user.getEmail());
        claims.put("roles", roles.stream().map(Role::getName).collect(Collectors.toList()));
        claims.put("permissions", permissions.stream().map(Permission::getName).collect(Collectors.toList()));
        
        // 조직 및 부서 정보
        if (user.getOrganization() != null) {
            claims.put("org_id", user.getOrganization().getId());
            claims.put("dept_id", user.getDepartment().getId());
        }
        
        // 지역 제한 (Multi-region)
        claims.put("allowed_regions", user.getAllowedRegions());
        
        // 데이터 접근 레벨
        claims.put("data_classification_level", user.getDataClassificationLevel());
        
        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
            .signWith(SignatureAlgorithm.RS256, privateKey)
            .compact();
    }
}

@Component
public class ClaimsBasedSecurityExpressionRoot {
    
    public boolean hasDataAccess(Authentication auth, String dataClassification) {
        if (!(auth.getPrincipal() instanceof JwtAuthenticationToken)) {
            return false;
        }
        
        JwtAuthenticationToken jwt = (JwtAuthenticationToken) auth.getPrincipal();
        String userLevel = jwt.getToken().getClaimAsString("data_classification_level");
        
        return isAuthorizedForDataLevel(userLevel, dataClassification);
    }
    
    public boolean inRegion(Authentication auth, String regionCode) {
        if (!(auth.getPrincipal() instanceof JwtAuthenticationToken)) {
            return false;
        }
        
        JwtAuthenticationToken jwt = (JwtAuthenticationToken) auth.getPrincipal();
        List<String> allowedRegions = jwt.getToken().getClaimAsStringList("allowed_regions");
        
        return allowedRegions.contains(regionCode);
    }
    
    private boolean isAuthorizedForDataLevel(String userLevel, String requiredLevel) {
        // PUBLIC < INTERNAL < CONFIDENTIAL < RESTRICTED
        Map<String, Integer> levels = Map.of(
            "PUBLIC", 1,
            "INTERNAL", 2, 
            "CONFIDENTIAL", 3,
            "RESTRICTED", 4
        );
        
        return levels.getOrDefault(userLevel, 0) >= levels.getOrDefault(requiredLevel, 999);
    }
}

// 사용 예시
@RestController
public class DataController {
    
    @GetMapping("/api/financial-reports/{id}")
    @PreAuthorize("hasDataAccess(authentication, 'CONFIDENTIAL') and inRegion(authentication, 'KR')")
    public FinancialReport getFinancialReport(@PathVariable String id) {
        return financialReportService.findById(id);
    }
}

JWT 토큰 블랙리스트 관리

@Service
@RequiredArgsConstructor
public class JwtBlacklistService {
    
    private final RedisTemplate<String, String> redisTemplate;
    private final JwtTokenProvider jwtTokenProvider;
    
    private static final String BLACKLIST_KEY_PREFIX = "jwt:blacklist:";
    
    public void blacklistToken(String token) {
        try {
            Claims claims = jwtTokenProvider.parseClaims(token);
            String jti = claims.getId(); // JWT ID
            Date expiration = claims.getExpiration();
            
            if (jti != null && expiration.after(new Date())) {
                String key = BLACKLIST_KEY_PREFIX + jti;
                long ttl = expiration.getTime() - System.currentTimeMillis();
                
                // Redis에 블랙리스트 등록 (만료시간까지만)
                redisTemplate.opsForValue().set(key, token, Duration.ofMillis(ttl));
            }
        } catch (Exception e) {
            log.error("Failed to blacklist token", e);
        }
    }
    
    public boolean isBlacklisted(String token) {
        try {
            Claims claims = jwtTokenProvider.parseClaims(token);
            String jti = claims.getId();
            
            if (jti == null) return false;
            
            String key = BLACKLIST_KEY_PREFIX + jti;
            return redisTemplate.hasKey(key);
            
        } catch (Exception e) {
            // 파싱 실패한 토큰은 유효하지 않은 것으로 간주
            return true;
        }
    }
    
    // 사용자의 모든 토큰 무효화
    public void blacklistAllUserTokens(String userId) {
        // 활성 세션에서 해당 사용자의 토큰들을 찾아서 블랙리스트에 추가
        Set<String> activeTokens = getActiveTokensByUser(userId);
        activeTokens.forEach(this::blacklistToken);
    }
    
    private Set<String> getActiveTokensByUser(String userId) {
        // Redis나 데이터베이스에서 활성 토큰 조회
        String pattern = "user:session:" + userId + ":*";
        return redisTemplate.keys(pattern).stream()
            .map(key -> redisTemplate.opsForValue().get(key))
            .collect(Collectors.toSet());
    }
}

// JWT 검증 필터에서 블랙리스트 체크
@Component
public class JwtBlacklistFilter extends OncePerRequestFilter {
    
    private final JwtBlacklistService blacklistService;
    private final JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) throws ServletException, IOException {
        
        String token = jwtTokenProvider.resolveToken(request);
        
        if (token != null && blacklistService.isBlacklisted(token)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write("{\"error\":\"Token has been revoked\"}");
            return;
        }
        
        filterChain.doFilter(request, response);
    }
}

🔒 고급 보안 패턴

JWT 암호화 (JWE) 구현

민감한 정보를 포함한 JWT는 암호화해야 합니다.

@Service
public class JWETokenService {
    
    private final RSAPrivateKey privateKey;
    private final RSAPublicKey publicKey;
    
    public String createEncryptedToken(Map<String, Object> claims) {
        try {
            // JWE 헤더 생성
            JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
                .contentType("JWT")
                .build();
            
            // JWT Claims 생성
            JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
                .subject((String) claims.get("sub"))
                .issuer("your-issuer")
                .audience("your-audience")
                .expirationTime(new Date(System.currentTimeMillis() + 3600000))
                .issueTime(new Date())
                .jwtID(UUID.randomUUID().toString())
                .build();
            
            // 추가 클레임 설정
            claims.forEach((key, value) -> {
                if (!"sub".equals(key)) {
                    claimsSet.setClaim(key, value);
                }
            });
            
            // JWE 객체 생성
            JWEObject jweObject = new JWEObject(header, new Payload(claimsSet.toJSONObject()));
            
            // RSA 암호화
            RSAEncrypter encrypter = new RSAEncrypter(publicKey);
            jweObject.encrypt(encrypter);
            
            return jweObject.serialize();
            
        } catch (Exception e) {
            throw new RuntimeException("Failed to create encrypted token", e);
        }
    }
    
    public JWTClaimsSet decryptToken(String encryptedToken) {
        try {
            // JWE 파싱
            JWEObject jweObject = JWEObject.parse(encryptedToken);
            
            // RSA 복호화
            RSADecrypter decrypter = new RSADecrypter(privateKey);
            jweObject.decrypt(decrypter);
            
            // Claims 추출
            return JWTClaimsSet.parse(jweObject.getPayload().toJSONObject());
            
        } catch (Exception e) {
            throw new RuntimeException("Failed to decrypt token", e);
        }
    }
}

토큰 바인딩 (Token Binding)

토큰을 특정 클라이언트나 세션에 바인딩하여 보안을 강화합니다.

@Service
public class TokenBindingService {
    
    public String createBoundToken(String userId, HttpServletRequest request) {
        // 클라이언트 지문 생성
        String clientFingerprint = generateClientFingerprint(request);
        
        Map<String, Object> claims = new HashMap<>();
        claims.put("sub", userId);
        claims.put("client_fp", clientFingerprint);
        claims.put("ip", getClientIP(request));
        claims.put("user_agent_hash", hashUserAgent(request.getHeader("User-Agent")));
        
        return jwtTokenProvider.createToken(claims);
    }
    
    public boolean validateTokenBinding(String token, HttpServletRequest request) {
        try {
            Claims claims = jwtTokenProvider.parseClaims(token);
            
            String tokenFingerprint = claims.get("client_fp", String.class);
            String tokenIP = claims.get("ip", String.class);
            String tokenUserAgentHash = claims.get("user_agent_hash", String.class);
            
            String currentFingerprint = generateClientFingerprint(request);
            String currentIP = getClientIP(request);
            String currentUserAgentHash = hashUserAgent(request.getHeader("User-Agent"));
            
            return Objects.equals(tokenFingerprint, currentFingerprint) &&
                   Objects.equals(tokenIP, currentIP) &&
                   Objects.equals(tokenUserAgentHash, currentUserAgentHash);
                   
        } catch (Exception e) {
            return false;
        }
    }
    
    private String generateClientFingerprint(HttpServletRequest request) {
        StringBuilder fingerprint = new StringBuilder();
        fingerprint.append(request.getHeader("User-Agent"));
        fingerprint.append(request.getHeader("Accept-Language"));
        fingerprint.append(request.getHeader("Accept-Encoding"));
        fingerprint.append(getClientIP(request));
        
        return DigestUtils.sha256Hex(fingerprint.toString());
    }
    
    private String getClientIP(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
    
    private String hashUserAgent(String userAgent) {
        return userAgent != null ? DigestUtils.sha256Hex(userAgent) : "";
    }
}

적응형 인증 (Adaptive Authentication)

@Service
@RequiredArgsConstructor
public class AdaptiveAuthService {
    
    private final UserBehaviorService behaviorService;
    private final RiskAssessmentService riskService;
    private final MFAService mfaService;
    
    public AuthenticationDecision authenticate(LoginRequest request, HttpServletRequest httpRequest) {
        // 위험도 평가
        RiskScore riskScore = riskService.calculateRisk(request, httpRequest);
        
        AuthenticationDecision.Builder decision = AuthenticationDecision.builder()
            .userId(request.getUserId())
            .riskScore(riskScore);
        
        if (riskScore.getScore() < 30) {
            // 낮은 위험도 - 일반 인증
            return decision
                .authenticationLevel(AuthLevel.BASIC)
                .requiresMFA(false)
                .tokenValidity(Duration.ofHours(8))
                .build();
                
        } else if (riskScore.getScore() < 70) {
            // 중간 위험도 - 추가 인증 필요
            return decision
                .authenticationLevel(AuthLevel.ENHANCED)
                .requiresMFA(true)
                .mfaMethod(MFAMethod.SMS_OR_APP)
                .tokenValidity(Duration.ofHours(2))
                .build();
                
        } else {
            // 높은 위험도 - 강화된 인증
            return decision
                .authenticationLevel(AuthLevel.HIGH_ASSURANCE)
                .requiresMFA(true)
                .mfaMethod(MFAMethod.HARDWARE_TOKEN)
                .requiresDeviceVerification(true)
                .tokenValidity(Duration.ofMinutes(30))
                .additionalVerification(List.of("EMAIL_VERIFICATION", "SECURITY_QUESTIONS"))
                .build();
        }
    }
}

@Service
public class RiskAssessmentService {
    
    public RiskScore calculateRisk(LoginRequest request, HttpServletRequest httpRequest) {
        RiskCalculator calculator = new RiskCalculator();
        
        // 지리적 위치 위험도
        calculator.addFactor("location", assessLocationRisk(request.getUserId(), httpRequest));
        
        // 디바이스 위험도  
        calculator.addFactor("device", assessDeviceRisk(request.getUserId(), httpRequest));
        
        // 시간 패턴 위험도
        calculator.addFactor("time_pattern", assessTimePatternRisk(request.getUserId()));
        
        // 행동 패턴 위험도
        calculator.addFactor("behavior", assessBehaviorRisk(request.getUserId(), request));
        
        // 네트워크 위험도
        calculator.addFactor("network", assessNetworkRisk(httpRequest));
        
        return calculator.calculate();
    }
    
    private int assessLocationRisk(String userId, HttpServletRequest request) {
        String clientIP = getClientIP(request);
        Location currentLocation = geoLocationService.getLocation(clientIP);
        List<Location> knownLocations = userLocationService.getKnownLocations(userId);
        
        if (knownLocations.isEmpty()) {
            return 30; // 새 사용자
        }
        
        double minDistance = knownLocations.stream()
            .mapToDouble(loc -> calculateDistance(currentLocation, loc))
            .min()
            .orElse(Double.MAX_VALUE);
        
        if (minDistance < 50) return 0;      // 50km 이내
        if (minDistance < 500) return 20;    // 500km 이내
        if (minDistance < 2000) return 40;   // 2000km 이내
        return 80; // 매우 먼 거리
    }
    
    private int assessDeviceRisk(String userId, HttpServletRequest request) {
        String deviceFingerprint = deviceFingerprintService.generate(request);
        boolean isKnownDevice = deviceService.isKnownDevice(userId, deviceFingerprint);
        
        if (isKnownDevice) {
            return 0;
        } else {
            // 새로운 디바이스
            return 50;
        }
    }
}

🔄 토큰 생명주기 관리

토큰 갱신 전략

@Service
public class TokenLifecycleService {
    
    @Scheduled(fixedRate = 300000) // 5분마다
    public void refreshActiveTokens() {
        List<ActiveSession> expiringSessions = sessionService
            .findSessionsExpiringWithin(Duration.ofMinutes(10));
        
        expiringSessions.parallelStream().forEach(session -> {
            try {
                if (shouldRefreshToken(session)) {
                    refreshSessionToken(session);
                }
            } catch (Exception e) {
                log.error("Failed to refresh token for session: {}", session.getId(), e);
            }
        });
    }
    
    private boolean shouldRefreshToken(ActiveSession session) {
        // 토큰 갱신 조건
        return session.getLastActivity().isAfter(LocalDateTime.now().minusMinutes(30)) &&
               session.isActive() &&
               !session.isHighRisk();
    }
    
    private void refreshSessionToken(ActiveSession session) {
        String newAccessToken = jwtTokenProvider.createAccessToken(
            session.getUserId(), 
            session.getAuthenticationLevel()
        );
        
        session.updateAccessToken(newAccessToken);
        sessionService.save(session);
        
        // 클라이언트에 새 토큰 푸시 (WebSocket, Server-Sent Events 등)
        tokenPushService.pushNewToken(session.getSessionId(), newAccessToken);
    }
}

@Component
public class TokenValidityAdjuster {
    
    public Duration adjustTokenValidity(String userId, AuthenticationContext context) {
        UserProfile profile = userService.getUserProfile(userId);
        
        Duration baseValidity = Duration.ofHours(1);
        
        // 사용자 역할에 따른 조정
        if (profile.hasRole("ADMIN")) {
            baseValidity = Duration.ofMinutes(30); // 관리자는 짧게
        }
        
        // 접근하는 리소스에 따른 조정
        if (context.isHighSensitiveResource()) {
            baseValidity = Duration.ofMinutes(15);
        }
        
        // 네트워크 환경에 따른 조정
        if (context.isFromPublicNetwork()) {
            baseValidity = baseValidity.dividedBy(2);
        }
        
        // 사용자 행동 패턴에 따른 조정
        if (behaviorService.isRegularUser(userId)) {
            baseValidity = baseValidity.multipliedBy(2); // 신뢰할 수 있는 사용자는 길게
        }
        
        return baseValidity;
    }
}

🚀 성능 최적화

JWT 캐싱 전략

@Service
public class OptimizedJwtValidationService {
    
    private final RedisTemplate<String, String> redisTemplate;
    private final LoadingCache<String, ValidationResult> validationCache;
    
    public OptimizedJwtValidationService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.validationCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build(this::validateTokenFromSource);
    }
    
    public ValidationResult validateToken(String token) {
        try {
            return validationCache.get(token);
        } catch (Exception e) {
            log.error("Token validation failed", e);
            return ValidationResult.invalid("Validation error");
        }
    }
    
    private ValidationResult validateTokenFromSource(String token) {
        // 1. 기본 JWT 구조 검증
        if (!isValidJwtStructure(token)) {
            return ValidationResult.invalid("Invalid JWT structure");
        }
        
        // 2. 블랙리스트 체크 (Redis)
        if (isBlacklisted(token)) {
            return ValidationResult.invalid("Token is blacklisted");
        }
        
        // 3. 서명 검증
        if (!verifySignature(token)) {
            return ValidationResult.invalid("Invalid signature");
        }
        
        // 4. 클레임 검증
        Claims claims = extractClaims(token);
        if (claims.getExpiration().before(new Date())) {
            return ValidationResult.invalid("Token expired");
        }
        
        return ValidationResult.valid(claims);
    }
    
    // 토큰 무효화 시 캐시 삭제
    public void invalidateToken(String token) {
        validationCache.invalidate(token);
        blacklistService.blacklistToken(token);
    }
}

@Component
public class JwtCompressionService {
    
    public String compressToken(String token) {
        try {
            byte[] compressed = compress(token.getBytes(StandardCharsets.UTF_8));
            return Base64.getUrlEncoder().withoutPadding().encodeToString(compressed);
        } catch (Exception e) {
            throw new RuntimeException("Failed to compress token", e);
        }
    }
    
    public String decompressToken(String compressedToken) {
        try {
            byte[] compressed = Base64.getUrlDecoder().decode(compressedToken);
            byte[] decompressed = decompress(compressed);
            return new String(decompressed, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("Failed to decompress token", e);
        }
    }
    
    private byte[] compress(byte[] data) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
            gzip.write(data);
        }
        return bos.toByteArray();
    }
    
    private byte[] decompress(byte[] compressed) throws IOException {
        ByteArrayInputStream bis = new ByteArrayInputStream(compressed);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try (GZIPInputStream gzip = new GZIPInputStream(bis)) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = gzip.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
        }
        return bos.toByteArray();
    }
}

🎯 실무 적용 가이드

마이크로서비스에서의 JWT 전파

@Component
public class JwtPropagationInterceptor implements ClientHttpRequestInterceptor {
    
    @Override
    public ClientHttpResponse intercept(
            HttpRequest request, 
            byte[] body, 
            ClientHttpRequestExecution execution) throws IOException {
        
        // 현재 요청의 JWT 토큰 획득
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication instanceof JwtAuthenticationToken) {
            JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
            String token = jwtAuth.getToken().getTokenValue();
            
            // 다운스트림 서비스 호출 시 JWT 전달
            request.getHeaders().setBearerAuth(token);
        }
        
        return execution.execute(request, body);
    }
}

// Feign Client에서 JWT 전파
@Component
public class FeignJwtInterceptor implements RequestInterceptor {
    
    @Override
    public void apply(RequestTemplate template) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication instanceof JwtAuthenticationToken) {
            JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
            String token = jwtAuth.getToken().getTokenValue();
            template.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
        }
    }
}

API Gateway에서의 JWT 처리

@Component
public class JwtGatewayFilter implements GlobalFilter, Ordered {
    
    private final JwtTokenProvider jwtTokenProvider;
    private final RouteValidator routeValidator;
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        
        if (routeValidator.isSecured(request)) {
            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                return onError(exchange, "Missing authorization header", HttpStatus.UNAUTHORIZED);
            }
            
            String authHeader = request.getHeaders().getOrEmpty(HttpHeaders.AUTHORIZATION).get(0);
            if (!authHeader.startsWith("Bearer ")) {
                return onError(exchange, "Invalid authorization header", HttpStatus.UNAUTHORIZED);
            }
            
            String token = authHeader.substring(7);
            
            return validateTokenAsync(token)
                .flatMap(isValid -> {
                    if (!isValid) {
                        return onError(exchange, "Invalid token", HttpStatus.UNAUTHORIZED);
                    }
                    
                    // JWT 클레임을 헤더에 추가하여 다운스트림으로 전달
                    return addJwtClaimsToRequest(exchange, token, chain);
                })
                .onErrorResume(throwable -> 
                    onError(exchange, "Token validation failed", HttpStatus.UNAUTHORIZED)
                );
        }
        
        return chain.filter(exchange);
    }
    
    private Mono<Void> addJwtClaimsToRequest(ServerWebExchange exchange, String token, GatewayFilterChain chain) {
        try {
            Claims claims = jwtTokenProvider.parseClaims(token);
            
            ServerHttpRequest modifiedRequest = exchange.getRequest()
                .mutate()
                .header("X-User-Id", claims.getSubject())
                .header("X-User-Roles", String.join(",", getUserRoles(claims)))
                .header("X-User-Email", claims.get("email", String.class))
                .build();
            
            ServerWebExchange modifiedExchange = exchange.mutate()
                .request(modifiedRequest)
                .build();
            
            return chain.filter(modifiedExchange);
        } catch (Exception e) {
            return onError(exchange, "Failed to process token", HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
    
    private Mono<Boolean> validateTokenAsync(String token) {
        return Mono.fromCallable(() -> jwtTokenProvider.validateToken(token))
            .subscribeOn(Schedulers.boundedElastic());
    }
    
    private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(status);
        response.getHeaders().add("Content-Type", "application/json");
        
        String errorResponse = String.format("{\"error\":\"%s\"}", message);
        DataBuffer buffer = response.bufferFactory().wrap(errorResponse.getBytes());
        
        return response.writeWith(Mono.just(buffer));
    }
    
    @Override
    public int getOrder() {
        return -100; // 높은 우선순위
    }
}

🛡️ 보안 모니터링

토큰 이상 행위 탐지

@Service
@RequiredArgsConstructor
public class TokenAnomalyDetectionService {
    
    private final RedisTemplate<String, String> redisTemplate;
    private final AlertService alertService;
    
    @EventListener
    public void handleTokenUsage(TokenUsageEvent event) {
        // 토큰 사용 패턴 분석
        analyzeTokenUsagePattern(event);
        
        // 지리적 이상 탐지
        detectGeographicalAnomaly(event);
        
        // 시간 패턴 이상 탐지
        detectTimePatternAnomaly(event);
        
        // 동시 접속 탐지
        detectConcurrentAccess(event);
    }
    
    private void analyzeTokenUsagePattern(TokenUsageEvent event) {
        String key = "token:usage:" + event.getUserId();
        String currentHour = String.valueOf(System.currentTimeMillis() / 3600000);
        
        Long requestCount = redisTemplate.opsForHash()
            .increment(key, currentHour, 1);
        
        redisTemplate.expire(key, Duration.ofDays(1));
        
        // 비정상적으로 많은 요청 탐지
        if (requestCount > 1000) { // 시간당 1000회 초과
            SecurityAlert alert = SecurityAlert.builder()
                .type(AlertType.EXCESSIVE_TOKEN_USAGE)
                .userId(event.getUserId())
                .details(Map.of(
                    "requestCount", requestCount,
                    "timeWindow", currentHour,
                    "token", maskToken(event.getToken())
                ))
                .severity(AlertSeverity.HIGH)
                .build();
                
            alertService.sendAlert(alert);
        }
    }
    
    private void detectConcurrentAccess(TokenUsageEvent event) {
        String key = "token:concurrent:" + event.getTokenId();
        String sessionInfo = event.getSessionId() + ":" + event.getClientIP();
        
        // 현재 활성 세션들 추가
        redisTemplate.opsForSet().add(key, sessionInfo);
        redisTemplate.expire(key, Duration.ofMinutes(5));
        
        // 동시 세션 수 확인
        Long concurrentSessions = redisTemplate.opsForSet().size(key);
        
        if (concurrentSessions > 3) { // 3개 이상 동시 접속
            SecurityAlert alert = SecurityAlert.builder()
                .type(AlertType.CONCURRENT_TOKEN_USAGE)
                .userId(event.getUserId())
                .details(Map.of(
                    "concurrentSessions", concurrentSessions,
                    "sessions", redisTemplate.opsForSet().members(key)
                ))
                .severity(AlertSeverity.MEDIUM)
                .build();
                
            alertService.sendAlert(alert);
        }
    }
    
    @Scheduled(fixedRate = 3600000) // 1시간마다
    public void generateSecurityReport() {
        SecurityReport report = SecurityReport.builder()
            .reportTime(LocalDateTime.now())
            .activeTokens(getActiveTokenCount())
            .blacklistedTokens(getBlacklistedTokenCount())
            .anomalousActivities(getAnomalousActivities())
            .build();
            
        securityReportService.save(report);
    }
}

📋 체크리스트 & 베스트 프랙티스

보안 체크리스트

  •  JWT 서명 알고리즘: RS256 또는 ES256 사용 (HS256 지양)
  •  토큰 만료 시간: 적절한 만료 시간 설정 (보안 vs 사용성)
  •  Refresh Token: 안전한 저장 및 로테이션 구현
  •  토큰 블랙리스트: 로그아웃 시 토큰 무효화
  •  HTTPS 전용: 토큰 전송은 반드시 HTTPS
  •  민감 정보 제외: JWT에 비밀번호 등 민감 정보 포함 금지
  •  토큰 바인딩: 클라이언트/세션 바인딩으로 보안 강화
  •  이상 행위 모니터링: 토큰 남용 탐지 시스템 구축

성능 최적화 체크리스트

  •  토큰 캐싱: 검증 결과 캐시로 성능 향상
  •  토큰 압축: 큰 페이로드의 경우 압축 적용
  •  비동기 검증: 블랙리스트 체크 등 비동기 처리
  •  Connection Pool: 외부 검증 서비스 연결 풀 최적화
  •  지연 로딩: 필요시에만 추가 사용자 정보 조회

이 가이드를 통해 OAuth2와 JWT를 실무에서 안전하고 효율적으로 활용할 수 있을 것입니다! 🚀