JJWT 0.12.x 마이그레이션: 모든 변경점 총정리

Daniel·2025년 6월 23일
0

Back-End

목록 보기
56/56

Java 진영 대표 JWT 라이브러리 JJWT가 0.12.x로 메이저 업데이트되면서
JWT 생성/검증, 키 관리, 암호화, 빌더 패턴 등이 대폭 개선되었습니다.

이 글에서는 JJWT 0.11.x 이하 → 0.12.x 이상으로 마이그레이션할 때
실무 개발자가 반드시 알아야 할 모든 변경점을 상세히 다룹니다.


목차

  1. 핵심 변경사항 요약
  2. JWT 생성 방식의 변화
  3. JWT 파싱/검증 방식 변화
  4. 키 생성 및 관리 방식 강화
  5. Builder 패턴 및 API 체계 개선
  6. 암호화(JWE) 및 JWK 기능 강화
  7. 보안 기본값 및 예외 처리 강화
  8. 마이그레이션 실무 체크리스트
  9. 마치며

핵심 변경사항 요약

주요 Breaking Changes

  • signWith(SignatureAlgorithm, key)signWith(key) (알고리즘 자동 추론)
  • setIssuer(), setSubject()issuer(), subject() (set 접두사 제거)
  • Parser 생성 시 verifyWith(key) 또는 keyLocator() 필수
  • 안전하지 않은 키 사용 시 WeakKeyException 발생
  • 새로운 헤더/클레임 빌더 패턴 도입

새로 추가된 기능

  • JWE (암호화된 JWT) 완전 지원
  • JWK (JSON Web Key) 완전 지원
  • 알고리즘별 안전한 키 생성 유틸리티
  • 향상된 압축 지원
  • 타입 안전한 파싱 메서드들

JWT 생성 방식의 변화

이전 방식 (JJWT 0.11.x)

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

private String generateToken(User user) {
    Date now = new Date();
    Date expiry = new Date(now.getTime() + 3600000); // 1시간

    return Jwts.builder()
            .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
            .setIssuer("my-service")
            .setSubject(user.getEmail())
            .setIssuedAt(now)
            .setExpiration(expiry)
            .claim("userId", user.getId())
            .claim("roles", user.getRoles())
            .signWith(SignatureAlgorithm.HS256, "my-secret-key") // ❌ 위험
            .compact();
}

새로운 방식 (JJWT 0.12.x)

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;

public class JwtService {
    
    // 1. 안전한 키 생성 (앱 시작 시 한 번만)
    private final SecretKey secretKey = Jwts.SIG.HS256.key().build();
    
    // 2. 또는 기존 키가 있다면 안전하게 복원
    // private final SecretKey secretKey = Keys.hmacShaKeyFor(
    //     Decoders.BASE64.decode("your-base64-encoded-key")
    // );
    
    public String generateToken(User user) {
        Instant now = Instant.now();
        
        return Jwts.builder()
                .header()                                    // ✅ 헤더 빌더
                    .type("JWT")
                    .keyId("my-key-id")                     // 키 식별자 (옵션)
                    .and()                                  // 헤더 빌더 종료
                
                .issuer("my-service")                       // ✅ set 접두사 제거
                .subject(user.getEmail())
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(3600)))
                .claim("userId", user.getId())
                .claim("roles", user.getRoles())
                
                .signWith(secretKey)                        // ✅ 키만 전달 (알고리즘 자동)
                .compact();
    }
}

주요 변경점

  1. 안전한 키 생성: Jwts.SIG.HS256.key().build()로 충분한 길이의 키 자동 생성
  2. 알고리즘 자동 추론: signWith(key)만 사용, 키 타입에서 알고리즘 자동 결정
  3. 헤더 빌더: .header().type().keyId().and() 체인으로 헤더 설정
  4. 메서드명 단순화: setIssuer()issuer(), setSubject()subject()

JWT 파싱/검증 방식 변화

이전 방식 (JJWT 0.11.x)

public Claims parseToken(String token) {
    try {
        return Jwts.parser()
                .setSigningKey("my-secret-key")  // ❌ 평문 키 사용
                .parseClaimsJws(token)
                .getBody();
    } catch (Exception e) {
        throw new InvalidTokenException("Invalid token", e);
    }
}

새로운 방식 (JJWT 0.12.x)

public class JwtService {
    private final SecretKey secretKey = getSecretKey(); // 위에서 생성한 키
    
    // 1. 기본 파싱 방식
    public Claims parseToken(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(secretKey)                   // ✅ 검증 키 지정
                    .build()                                 // ✅ 파서 빌드
                    .parseSignedClaims(token)               // ✅ 타입 안전한 파싱
                    .getPayload();                          // ✅ getBody() → getPayload()
        } catch (JwtException e) {
            throw new InvalidTokenException("Token validation failed", e);
        }
    }
    
    // 2. 클레임 검증과 함께 파싱
    public Claims parseTokenWithValidation(String token, String expectedIssuer) {
        try {
            return Jwts.parser()
                    .verifyWith(secretKey)
                    .requireIssuer(expectedIssuer)          // ✅ 발급자 검증
                    .clockSkewSeconds(60)                   // ✅ 시간 오차 허용
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new TokenExpiredException("Token expired", e);
        } catch (InvalidClaimException e) {
            throw new InvalidClaimException("Invalid claims", e);
        }
    }
    
    // 3. 동적 키 조회 (멀티 테넌트 환경)
    public Claims parseTokenWithKeyLocator(String token) {
        return Jwts.parser()
                .keyLocator(header -> {                     // ✅ 헤더 기반 키 조회
                    String keyId = header.getKeyId();
                    return keyRepository.findByKeyId(keyId);
                })
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

주요 변경점

  1. 파서 빌드 필수: .build() 메서드로 파서 생성 필요
  2. 타입 안전한 파싱: parseSignedClaims(), parseEncryptedClaims() 등 타입별 메서드
  3. 키 검증 필수: verifyWith() 또는 keyLocator() 반드시 지정
  4. 향상된 검증: requireIssuer(), requireSubject() 등으로 클레임 검증

키 생성 및 관리 방식 강화

알고리즘별 안전한 키 생성

// HMAC 알고리즘용 SecretKey
SecretKey hs256Key = Jwts.SIG.HS256.key().build();  // 256비트 (32바이트)
SecretKey hs384Key = Jwts.SIG.HS384.key().build();  // 384비트 (48바이트)  
SecretKey hs512Key = Jwts.SIG.HS512.key().build();  // 512비트 (64바이트)

// RSA 알고리즘용 KeyPair
KeyPair rs256Pair = Jwts.SIG.RS256.keyPair().build(); // 2048비트 이상
KeyPair rs512Pair = Jwts.SIG.RS512.keyPair().build(); // 4096비트 권장

// ECDSA 알고리즘용 KeyPair  
KeyPair es256Pair = Jwts.SIG.ES256.keyPair().build(); // P-256 곡선
KeyPair es384Pair = Jwts.SIG.ES384.keyPair().build(); // P-384 곡선
KeyPair es512Pair = Jwts.SIG.ES512.keyPair().build(); // P-521 곡선

// EdDSA 알고리즘용 KeyPair (JDK 15+ 또는 BouncyCastle 필요)
KeyPair eddsaPair = Jwts.SIG.EdDSA.keyPair().build(); // Ed25519 또는 Ed448

기존 키 안전하게 처리

import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;

public class KeyManager {
    
    // 키를 안전하게 저장하기 위한 Base64 인코딩
    public String encodeKey(SecretKey key) {
        return Encoders.BASE64.encode(key.getEncoded());
    }
    
    // 저장된 키 복원
    public SecretKey decodeKey(String encodedKey) {
        byte[] keyBytes = Decoders.BASE64.decode(encodedKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
    
    // 평문 패스워드에서 키 생성 (권장하지 않음)
    public SecretKey fromPassword(String password) {
        // ⚠️ 실제 운영환경에서는 PBKDF2 등 키 유도 함수 사용 권장
        return Keys.hmacShaKeyFor(password.getBytes(StandardCharsets.UTF_8));
    }
    
    // 환경변수에서 키 로드
    public SecretKey loadFromEnvironment() {
        String encodedKey = System.getenv("JWT_SECRET_KEY");
        if (encodedKey == null) {
            throw new IllegalStateException("JWT_SECRET_KEY environment variable not set");
        }
        return decodeKey(encodedKey);
    }
}

Builder 패턴 및 API 체계 개선

헤더 커스터마이징

String jwt = Jwts.builder()
    .header()
        .type("JWT")                        // typ 헤더
        .keyId("key-2024-01")              // kid 헤더 (키 식별)
        .add("customHeader", "customValue") // 커스텀 헤더
        .and()                             // 헤더 빌더 종료
    
    // 표준 클레임들
    .issuer("https://auth.example.com")
    .subject("user@example.com")  
    .audience().add("api.example.com").and()   // aud 클레임 (여러 값)
    .expiration(Date.from(Instant.now().plusSeconds(3600)))
    .notBefore(Date.from(Instant.now()))
    .issuedAt(new Date())
    .id(UUID.randomUUID().toString())      // jti 클레임
    
    // 커스텀 클레임들
    .claim("userId", user.getId())
    .claim("roles", Arrays.asList("USER", "ADMIN"))
    .claim("permissions", user.getPermissions())
    
    .signWith(secretKey)
    .compact();

페이로드 타입별 생성

// 1. Claims 기반 JWT (가장 일반적)
String claimsJwt = Jwts.builder()
    .subject("user123")
    .claim("role", "admin")
    .signWith(key)
    .compact();

// 2. 바이너리 컨텐츠 JWT
byte[] imageData = loadImageBytes();
String contentJwt = Jwts.builder()
    .content(imageData, "image/jpeg")      // 컨텐츠 + MIME 타입
    .signWith(key)
    .compact();

// 3. JSON 문자열 컨텐츠
String jsonString = "{\"data\":\"value\"}";
String jsonJwt = Jwts.builder()
    .content(jsonString, "application/json")
    .signWith(key)
    .compact();

암호화(JWE) 및 JWK 기능 강화

JWE (암호화된 JWT) 생성

import io.jsonwebtoken.security.AeadAlgorithm;
import io.jsonwebtoken.security.KeyAlgorithm;

public class JweService {
    
    // 1. 직접 암호화 (동일한 당사자간 사용)
    public String createDirectEncryptedJwt(String subject) {
        SecretKey key = Jwts.ENC.A256GCM.key().build();
        
        return Jwts.builder()
            .subject(subject)
            .claim("sensitive", "confidential-data")
            .encryptWith(key, Jwts.KEY.DIRECT, Jwts.ENC.A256GCM)
            .compact();
    }
    
    // 2. RSA 키 암호화 (수신자의 공개키로 암호화)
    public String createRsaEncryptedJwt(RSAPublicKey recipientPublicKey) {
        return Jwts.builder()
            .subject("alice@example.com")
            .claim("salary", 100000)
            .encryptWith(recipientPublicKey, Jwts.KEY.RSA_OAEP_256, Jwts.ENC.A256GCM)
            .compact();
    }
    
    // 3. 패스워드 기반 암호화
    public String createPasswordEncryptedJwt(char[] password) {
        Password pwd = Keys.password(password);
        
        return Jwts.builder()
            .subject("user123")
            .encryptWith(pwd, Jwts.KEY.PBES2_HS256_A128KW, Jwts.ENC.A256GCM)
            .compact();
    }
    
    // JWE 복호화
    public Claims parseEncryptedJwt(String jwe, SecretKey decryptionKey) {
        return Jwts.parser()
            .decryptWith(decryptionKey)
            .build()
            .parseEncryptedClaims(jwe)
            .getPayload();
    }
}

JWK (JSON Web Key) 활용

import io.jsonwebtoken.security.*;

public class JwkService {
    
    // JWK 생성
    public SecretJwk createSecretJwk(SecretKey key) {
        return Jwks.builder()
            .key(key)
            .idFromThumbprint()                    // 썸프린트를 키 ID로 사용
            .operations().add(KeyOperation.SIGN)   // 허용된 키 연산 지정
            .and()
            .build();
    }
    
    // JWK JSON 문자열로 변환
    public String jwkToJson(Jwk<?> jwk) {
        return new JacksonSerializer().serialize(jwk);
    }
    
    // JSON에서 JWK 파싱
    public Jwk<?> parseJwk(String jwkJson) {
        return Jwks.parser()
            .build()
            .parse(jwkJson);
    }
    
    // JWK에서 Java Key 추출
    public Key jwkToKey(Jwk<?> jwk) {
        return jwk.toKey();
    }
    
    // JWK Set 생성 (여러 키 관리)
    public JwkSet createJwkSet(List<Jwk<?>> jwks) {
        return Jwks.set()
            .add(jwks)
            .build();
    }
}

보안 기본값 및 예외 처리 강화

강화된 키 검증

// ❌ 이제 이런 코드는 WeakKeyException 발생
try {
    String weakKey = "short";  // 너무 짧은 키
    Jwts.builder()
        .subject("test")
        .signWith(Keys.hmacShaKeyFor(weakKey.getBytes()))
        .compact();
} catch (WeakKeyException e) {
    // HMAC-SHA256은 최소 32바이트 필요
    log.error("Key is too weak: {}", e.getMessage());
}

// ✅ 올바른 방식
SecretKey strongKey = Jwts.SIG.HS256.key().build(); // 자동으로 충분한 길이 생성

향상된 예외 처리

public Claims parseTokenSafely(String token) {
    try {
        return Jwts.parser()
            .verifyWith(secretKey)
            .clockSkewSeconds(60)       // 시간 오차 60초 허용
            .build()
            .parseSignedClaims(token)
            .getPayload();
            
    } catch (ExpiredJwtException e) {
        log.warn("Token expired for subject: {}", e.getClaims().getSubject());
        throw new TokenExpiredException("Token has expired");
        
    } catch (UnsupportedJwtException e) {
        log.error("Unsupported JWT format: {}", e.getMessage());
        throw new InvalidTokenException("Unsupported token format");
        
    } catch (MalformedJwtException e) {
        log.error("Malformed JWT: {}", e.getMessage());
        throw new InvalidTokenException("Malformed token");
        
    } catch (SignatureException e) {
        log.error("Invalid JWT signature: {}", e.getMessage());
        throw new InvalidTokenException("Invalid token signature");
        
    } catch (InvalidClaimException e) {
        log.error("Invalid JWT claim: {}", e.getMessage());
        throw new InvalidTokenException("Invalid token claims");
    }
}

보안 기본값 적용

// Unprotected JWT 방지 - 반드시 서명 또는 암호화 필요
try {
    Jwts.builder()
        .subject("test")
        // .signWith() 또는 .encryptWith() 없음
        .compact();  // ❌ 경고: 보안되지 않은 JWT 생성
} catch (Exception e) {
    log.warn("Attempted to create unprotected JWT");
}

// ✅ 올바른 방식 - 반드시 보안 적용
String secureJwt = Jwts.builder()
    .subject("test")
    .signWith(secretKey)  // 또는 .encryptWith(...)
    .compact();

마이그레이션 실무 체크리스트

1. 의존성 업데이트

Maven

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Gradle

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

2. 코드 마이그레이션 체크리스트

  • signWith(SignatureAlgorithm, key)signWith(key) 변경
  • setXxx() 메서드들 → xxx() 메서드로 변경
  • 파서에 .build() 추가 및 verifyWith() 또는 keyLocator() 지정
  • parseClaimsJws()parseSignedClaims() 변경
  • getBody()getPayload() 변경
  • 키 생성 방식을 안전한 방식으로 변경
  • 약한 키 사용 부분 수정 (WeakKeyException 대응)
  • 헤더 설정 부분을 새로운 빌더 패턴으로 변경

3. 테스트 케이스 업데이트

@Test
public void testJwtCreationAndParsing() {
    // Given
    SecretKey key = Jwts.SIG.HS256.key().build();
    String subject = "test-user";
    
    // When - JWT 생성
    String jwt = Jwts.builder()
        .subject(subject)
        .issuedAt(new Date())
        .expiration(Date.from(Instant.now().plusSeconds(3600)))
        .signWith(key)
        .compact();
    
    // Then - JWT 파싱 및 검증
    Claims claims = Jwts.parser()
        .verifyWith(key)
        .build()
        .parseSignedClaims(jwt)
        .getPayload();
    
    assertThat(claims.getSubject()).isEqualTo(subject);
    assertThat(claims.getExpiration()).isAfter(new Date());
}

@Test
public void testInvalidTokenHandling() {
    SecretKey key = Jwts.SIG.HS256.key().build();
    String invalidToken = "invalid.jwt.token";
    
    assertThatThrownBy(() -> {
        Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(invalidToken);
    }).isInstanceOf(JwtException.class);
}

4. 설정 및 환경변수 업데이트

application.yml

jwt:
  secret-key: ${JWT_SECRET_KEY:#{null}}  # 환경변수에서 로드
  expiration-seconds: 3600
  issuer: "https://api.example.com"
  clock-skew-seconds: 60

Configuration 클래스

@Configuration
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {
    
    private String secretKey;
    private long expirationSeconds = 3600;
    private String issuer;
    private long clockSkewSeconds = 60;
    
    @Bean
    public SecretKey jwtSecretKey() {
        if (StringUtils.hasText(secretKey)) {
            return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
        } else {
            // 개발 환경에서만 자동 생성 (운영환경에서는 반드시 설정 필요)
            SecretKey generated = Jwts.SIG.HS256.key().build();
            log.warn("JWT secret key auto-generated. Set JWT_SECRET_KEY in production!");
            return generated;
        }
    }
    
    // getters and setters...
}

마치며

JJWT 0.12.x는 "Secure by Default" 철학을 바탕으로 설계되었습니다.

핵심 원칙

  1. 보안이 기본값: 안전하지 않은 설정은 기본적으로 차단
  2. 타입 안전성: 컴파일 타임에 더 많은 오류 감지
  3. 명확한 API: 빌더 패턴으로 직관적인 코드 작성
  4. 표준 준수: JWT, JWS, JWE, JWK 표준 완전 지원

마이그레이션 시 주의사항

  • 키 관리 방식의 변화가 가장 중요합니다
  • 기존 토큰과의 호환성을 유지하려면 키는 그대로 사용하되, 생성/파싱 코드만 업데이트하세요
  • 테스트 케이스를 충분히 작성하여 마이그레이션 과정의 안전성을 보장하세요

추가 학습 자료


궁금한 점이나 마이그레이션 과정에서 겪은 이슈가 있다면 댓글로 자유롭게 공유해주세요!
실무에서 도움이 되는 추가 팁이나 개선사항도 언제든 환영합니다. 🚀

profile
응애 나 애기 개발자

0개의 댓글