
@ConfigurationProperties(record)로 설정 바인딩 + SecretKey 초기화 방식 + JJWT 버전 업 + UserDetailsService “username” 딜레마
JWT를 적용하면서 부딪히는 지점은 크게 4가지다.
설정값(Secret, 만료시간 등)을 어디서/어떻게 관리할지
Base64 문자열 secret → SecretKey 객체로 언제/어디서 만들지
JJWT 라이브러리 버전 업으로 API가 바뀐 부분
UserDetailsService가 username 기반인데, 나는 userId 기반으로 로드하고 싶은 문제
이번 글은 이 4가지를 한 흐름으로 정리한다.
결론: 가능하고 흔한 방식
다만 jwtParser가 환경변수를 직접 읽는 게 아니라,
Spring이 @Value("${jwt.secret.key}")로 값을 주입해주고
그 값을 가지고 내가 JwtParser/Jwts.builder()에 넣는 구조다.
그리고 실무에서는 .yml에서 환경변수로 연결해 두는 패턴이 일반적이다.
jwt:
secret:
key: ${JWT_SECRET_KEY}
환경변수 이름 JWT_SECRET_KEY는 운영 환경에서만 주입하고, 레포에는 노출하지 않는 방식으로 관리한다.
@Validated
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
@NotBlank
private String issuer;
@Valid
private final Secret secret = new Secret();
@Valid
private final AccessToken accessToken = new AccessToken();
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public Secret getSecret() { return secret; }
public AccessToken getAccessToken() { return accessToken; }
public static class Secret {
@NotBlank
private String key; // base64 string
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
}
public static class AccessToken {
@Min(1)
private int expireMinutes;
public int getExpireMinutes() { return expireMinutes; }
public void setExpireMinutes(int expireMinutes) { this.expireMinutes = expireMinutes; }
}
}
@ConfigurationProperties(prefix = "jwt") 의미는 간단하다.
설정 파일에서 jwt.로 시작하는 값들을 찾아서 이 클래스에 바인딩(매핑)한다.
예를 들어 yml:
jwt:
issuer: my-app
secret:
key: ${JWT_SECRET_KEY}
access-token:
expire-minutes: 30
refresh-token:
expire-days: 14
이런 매핑이 일어난다.
jwt.issuer → issuer
jwt.secret.key → secret.key
jwt.access-token.expire-minutes → accessToken.expireMinutes
access-token(케밥케이스) ↔ accessToken(카멜케이스) 변환은 Spring이 자동으로 해준다.
2번 제목에서 나온 @ConfigurationProperties로 묶는 방식에서 setter가 많길래 궁금해서 찾아본 것이다.
@ConfigurationProperties는 “설정값을 객체로 채운다”인데, 채우는 방식이 둘 다 가능하다.
그래서 setter가 있으면 “바인딩 통로”로 사용된다.
객체를 만들 때 생성자 인자로 값을 넣는다.
setter가 필요 없다.
불변 구조라 안전하고 깔끔하다.
오늘 정리한 record 예시는 B에 해당한다.
예:
@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(
@NotBlank String issuer,
@Valid Secret secret,
@Valid AccessToken accessToken
) {
public record Secret(@NotBlank String key) {}
public record AccessToken(@Min(1) int expireMinutes){}
}
바인딩은 이렇게 이해하면 된다.
Spring이 yml을 읽고
new JwtProperties(issuer, new Secret(key), new AccessToken(expireMinutes))
형태로 생성자를 호출해서 객체를 만든다.
즉,
Secret / AccessToken도 각각 record 생성자를 통해 바인딩되고
생성된 Secret, AccessToken 인스턴스가 JwtProperties 안에 포함되어 사용된다.
여기서 불변의 핵심은 “static final” 같은 키워드가 아니라,
record 컴포넌트가 사실상 final이고
setter가 없어서
생성 이후 값 변경 경로가 없다
라는 점에서 나온다.
(중첩 record는 바깥 인스턴스를 붙잡지 않는 형태로 동작해서 구조가 더 단순해진다.)
자바에서 non-static 내부 클래스(inner class) 특성 하나:
즉, 내부 객체가 “바깥 객체를 붙잡고” 있는 상태가 되는 거야.
class Outer {
class Inner {
void hello() {}
}
}
Outer o = new Outer();
Outer.Inner i = o.new Inner(); // 바깥 o를 참조하면서 생성됨
여기서 i는 내부적으로 o를 참조하고 있음
이게 “바깥 인스턴스를 붙잡는다”는 뜻
중첩 record(또는 중첩 static class)는 기본적으로 static처럼 동작해.
즉, 바깥 인스턴스 참조를 들고 있지 않아.
코드처럼:
public record JwtProperties(String issuer, Secret secret) {
public record Secret(String key) {}
}
이 Secret은 내부적으로 JwtProperties 인스턴스를 붙잡지 않아.
그래서 이렇게 단독 생성이 가능해:
JwtProperties.Secret s = new JwtProperties.Secret("abc");
바깥 JwtProperties 객체가 없어도 만들 수 있음
non-static inner class는 항상 “바깥 참조”를 들고 있어서:
메모리적으로 불필요한 연결이 생길 수 있고
직렬화/프록시/리플렉션 같은 상황에서 복잡도가 올라가고
무엇보다 “이 타입은 Outer 없이는 존재할 수 없다”는 제약이 생겨.
반면 중첩 record는:
바깥 참조가 없고
독립적인 값 객체(value object)로 존재하고
생성/사용이 단순해.
즉, JwtProperties.Secret은 “JwtProperties 안에 이름만 묶어둔 타입”에 가깝고,
“JwtProperties 인스턴스와 생명주기적으로 연결된 내부 객체”가 아니야.
issuer는 JWT 표준 클레임 중 iss(issuer, 발급자)를 의미한다.
“이 토큰을 누가 발급했는지”를 표시하는 값
여러 서비스/환경(DEV/PROD/마이크로서비스)이 섞일 때 유용
필수는 아니지만, 검증 시 iss가 기대값인지 체크하는 방어막이 된다
JwtProperties: 설정 값을 담는 데이터 객체
JwtConfig: 스프링 설정 클래스
@EnableConfigurationProperties(JwtProperties.class)는
JwtProperties를 스프링 빈으로 등록하고, @ConfigurationProperties 바인딩을 활성화하라
라는 의미다.
그런데 앱 실행 클래스에 @ConfigurationPropertiesScan을 이미 달아뒀다면?
그럼 JwtProperties는 이미 스캔되어 빈 등록된다. 즉,
@EnableConfigurationProperties(JwtProperties.class)는 JwtConfig에 굳이 없어도 된다.
JwtConfig는 그 대신 SecretKey, JwtParser 같은 JWT 관련 Bean을 “모아두는 장소”로 쓰는 편이 자연스럽다.
여기서 헷갈리기 쉬운 포인트가 있다.
props.secret().key()는 문자열(Base64) 이다. (원재료)
JWT 서명/검증에 쓰는 건 javax.crypto.SecretKey 객체다. (가공 결과물)
record는 “상태(state)”가 헤더의 컴포넌트로 고정되는 구조라서,
컴포넌트 외에 별도 인스턴스 필드를 추가할 수 없다.
즉, 이런 “캐시 필드”를 record 안에 둘 수 없다.
public record JwtProperties(...) {
private SecretKey key; // ❌ record는 추가 인스턴스 필드 불가
}
그래서 SecretKey는 보통 record 밖에서 만든다.
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
스프링이 주입/바인딩을 끝낸 뒤 호출됨
필드 주입(@Value)을 쓰는 구조에서 편함
key를 final로 만들기 어려움
객체 생성 시점에 key가 완성됨 (초기 상태가 명확)
final로 만들기 쉬워 안정적
키 생성 책임이 해당 클래스(JwtUtil 등)에 들어감
위의 8번 제목에서 나온 궁금증인데 생성자에서 key를 만들 때 책임이 JwtParser에 들어가는데 이걸 빼는 게 맞나 아닌가 생각하다 나온 질문이다.
@Configuration
public class JwtConfig {
@Bean
public SecretKey jwtSigningKey(JwtProperties props) {
byte[] bytes = Base64.getDecoder().decode(props.secret().key());
return Keys.hmacShaKeyFor(bytes);
}
}
@Component
public class JwtParser {
private final SecretKey key;
public JwtParser(SecretKey key) {
this.key = key;
}
}
public JwtUtil(JwtProperties props) {
byte[] bytes = Base64.getDecoder().decode(props.secret().key());
this.key = Keys.hmacShaKeyFor(bytes);
}
둘 다 “한 번만 만들어 재사용”할 수 있지만, Bean 분리의 이점은 “한 줄”이 아니라 아래에 있다.
SecretKey를 쓰는 컴포넌트가 늘면(필터/파서/서비스 등)
복붙 없이 주입만 하면 된다.
HS256 → RS256 전환, 키 롤링, KMS/Vault에서 키 로드 등 변경이 생기면
JwtConfig만 바꾸고 JwtParser은 거의 건드리지 않을 수 있다.
JwtUtil은 “토큰 로직”에 집중하고,
키 생성 같은 인프라 작업은 Config가 맡는다.
JwtUtil 테스트 시 SecretKey만 주입하면 되어서 환경설정 의존이 줄어든다.
단, “JwtUtil 하나만 키를 쓰고 변경 가능성이 거의 없다”면 JwtUtil 생성자에서 만드는 방식도 충분히 괜찮다.
sub: 사용자 식별자(보통 userId)
exp: 만료
iat: 발급 시간
(선택) iss: issuer
(선택) aud: audience
(선택) jti: 토큰 고유 ID (블랙리스트/추적/단건폐기 시 유용)
roles / authorities / scope 중 하나로 권한 최소화해서 담기
(선택) ver(token_version): 강제 로그아웃/권한 변경 즉시 반영용(서버 DB와 비교)
name, email 같은 PII는 가능하면 토큰에 넣지 않는 편이 많다.
필요하면 userId(sub)로 서버에서 조회하는 방식이 안전하다.
return BEARER_PREFIX + Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("userRole", userRole)
.setExpiration(...)
.setIssuedAt(...)
.signWith(key, signatureAlgorithm)
.compact();
setSubject, setIssuedAt, setExpiration 같은 setXxx 메서드가 deprecate되고
subject(...), issuedAt(...), expiration(...) 스타일로 이동하는 경우가 많다.
SignatureAlgorithm 및 signWith(key, signatureAlgorithm)도 버전에 따라 권장 방식이 바뀐다.
String jwt = Jwts.builder()
.subject(String.valueOf(userId))
.claim("userRole", userRole.name())
.issuedAt(...)
.expiration(...)
.signWith(key, Jwts.SIG.HS256)
.compact();
UserDetailsService를 구현하면 계약(인터페이스) 때문에 아래 메서드는 반드시 구현해야 한다.
UserDetails loadUserByUsername(String username)
그래서 결론은?
메서드 이름/시그니처는 바꿀 수 없다.
하지만 “username 파라미터”에 userId 문자열을 넣어서 id 조회하는 건 가능하다.
@Override
public UserDetails loadUserByUsername(String username) {
Long userId = Long.valueOf(username); // username 자리에 userId 문자열이 들어온다고 가정
User user = userRepository.findById(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user);
}
loadUserById(Long id) 같은 메서드를 별도로 만들어 JWT 흐름에서는 그걸 호출하는 방식도 많이 쓴다.
(폼로그인 등 username 기반이 필요한 경우와 역할 분리가 된다.)