Spring Boot JWT 정리

오병택·2026년 2월 2일
post-thumbnail

@ConfigurationProperties(record)로 설정 바인딩 + SecretKey 초기화 방식 + JJWT 버전 업 + UserDetailsService “username” 딜레마

JWT를 적용하면서 부딪히는 지점은 크게 4가지다.

  1. 설정값(Secret, 만료시간 등)을 어디서/어떻게 관리할지

  2. Base64 문자열 secret → SecretKey 객체로 언제/어디서 만들지

  3. JJWT 라이브러리 버전 업으로 API가 바뀐 부분

  4. UserDetailsService가 username 기반인데, 나는 userId 기반으로 로드하고 싶은 문제

이번 글은 이 4가지를 한 흐름으로 정리한다.

1) @Value("${jwt.secret.key}")로 jwtParser에 환경변수 쓰는 게 맞나?

결론: 가능하고 흔한 방식

다만 jwtParser가 환경변수를 직접 읽는 게 아니라,

  • Spring이 @Value("${jwt.secret.key}")로 값을 주입해주고

  • 그 값을 가지고 내가 JwtParser/Jwts.builder()에 넣는 구조다.

그리고 실무에서는 .yml에서 환경변수로 연결해 두는 패턴이 일반적이다.

jwt:
  secret:
    key: ${JWT_SECRET_KEY}

환경변수 이름 JWT_SECRET_KEY는 운영 환경에서만 주입하고, 레포에는 노출하지 않는 방식으로 관리한다.

2) 실무에서 더 흔한 @ConfigurationProperties로 묶기

@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이 자동으로 해준다.

3) setter는 왜 있나? (setter 바인딩 vs 생성자/record 바인딩)

상황설명

2번 제목에서 나온 @ConfigurationProperties로 묶는 방식에서 setter가 많길래 궁금해서 찾아본 것이다.

@ConfigurationProperties는 “설정값을 객체로 채운다”인데, 채우는 방식이 둘 다 가능하다.

A) setter(자바빈) 방식

  • 객체를 만든 다음 setter로 값을 채운다.

그래서 setter가 있으면 “바인딩 통로”로 사용된다.

B) 생성자/record 방식

  • 객체를 만들 때 생성자 인자로 값을 넣는다.

  • setter가 필요 없다.

  • 불변 구조라 안전하고 깔끔하다.

오늘 정리한 record 예시는 B에 해당한다.

4) record로 JwtProperties 바인딩하면 Secret/AccessToken은 어떻게 채워지나?

예:

@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는 바깥 인스턴스를 붙잡지 않는 형태로 동작해서 구조가 더 단순해진다.)

a) “바깥 인스턴스를 붙잡는다”는 게 뭔 뜻이야?

자바에서 non-static 내부 클래스(inner class) 특성 하나:

  • 내부 클래스 인스턴스가 만들어지면, 그 안에 바깥 클래스 인스턴스를 가리키는 숨은 참조(this$0) 를 같이 들고 있음

즉, 내부 객체가 “바깥 객체를 붙잡고” 있는 상태가 되는 거야.

예시: non-static inner class

class Outer {
    class Inner {
        void hello() {}
    }
}

이 경우 Inner는 단독으로 못 만들고, 반드시 Outer가 있어야 해:

Outer o = new Outer();
Outer.Inner i = o.new Inner(); // 바깥 o를 참조하면서 생성됨

여기서 i는 내부적으로 o를 참조하고 있음
이게 “바깥 인스턴스를 붙잡는다”는 뜻

b) 중첩 record는 왜 다르냐?

중첩 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 객체가 없어도 만들 수 있음

c) 왜 이게 “구조가 더 단순”해지냐?

non-static inner class는 항상 “바깥 참조”를 들고 있어서:

  • 메모리적으로 불필요한 연결이 생길 수 있고

  • 직렬화/프록시/리플렉션 같은 상황에서 복잡도가 올라가고

  • 무엇보다 “이 타입은 Outer 없이는 존재할 수 없다”는 제약이 생겨.

반면 중첩 record는:

  • 바깥 참조가 없고

  • 독립적인 값 객체(value object)로 존재하고

  • 생성/사용이 단순해.

즉, JwtProperties.Secret은 “JwtProperties 안에 이름만 묶어둔 타입”에 가깝고,
“JwtProperties 인스턴스와 생명주기적으로 연결된 내부 객체”가 아니야.

5) issuer는 뭔데 yml에 넣나?

issuer는 JWT 표준 클레임 중 iss(issuer, 발급자)를 의미한다.

  • “이 토큰을 누가 발급했는지”를 표시하는 값

  • 여러 서비스/환경(DEV/PROD/마이크로서비스)이 섞일 때 유용

필수는 아니지만, 검증 시 iss가 기대값인지 체크하는 방어막이 된다

6) JwtConfig는 JwtProperties랑 같은 건가? @EnableConfigurationProperties는 뭔가?

결론

JwtProperties: 설정 값을 담는 데이터 객체

JwtConfig: 스프링 설정 클래스

@EnableConfigurationProperties(JwtProperties.class)

JwtProperties를 스프링 빈으로 등록하고, @ConfigurationProperties 바인딩을 활성화하라

라는 의미다.

그런데 앱 실행 클래스에 @ConfigurationPropertiesScan을 이미 달아뒀다면?

그럼 JwtProperties는 이미 스캔되어 빈 등록된다. 즉,

  • @EnableConfigurationProperties(JwtProperties.class)는 JwtConfig에 굳이 없어도 된다.

  • JwtConfig는 그 대신 SecretKey, JwtParser 같은 JWT 관련 Bean을 “모아두는 장소”로 쓰는 편이 자연스럽다.

7) record에는 왜 SecretKey를 필드로 저장 못 하나?

여기서 헷갈리기 쉬운 포인트가 있다.

props.secret().key()는 문자열(Base64) 이다. (원재료)

JWT 서명/검증에 쓰는 건 javax.crypto.SecretKey 객체다. (가공 결과물)

record는 “상태(state)”가 헤더의 컴포넌트로 고정되는 구조라서,
컴포넌트 외에 별도 인스턴스 필드를 추가할 수 없다.

즉, 이런 “캐시 필드”를 record 안에 둘 수 없다.

public record JwtProperties(...) {
    private SecretKey key; // ❌ record는 추가 인스턴스 필드 불가
}

그래서 SecretKey는 보통 record 밖에서 만든다.

8) @PostConstruct로 key 만들기 vs 생성자에서 만들기 — 차이점

예전 코드에서 흔한 패턴:

@PostConstruct
public void init() {
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    key = Keys.hmacShaKeyFor(bytes);
}

@PostConstruct 특징

  • 스프링이 주입/바인딩을 끝낸 뒤 호출됨

  • 필드 주입(@Value)을 쓰는 구조에서 편함

단점

key를 final로 만들기 어려움

생성자에서 만드는 특징

  • 객체 생성 시점에 key가 완성됨 (초기 상태가 명확)

  • final로 만들기 쉬워 안정적

단점

키 생성 책임이 해당 클래스(JwtUtil 등)에 들어감

9) “키 한 줄 때문에 Bean으로 빼?” → Bean 분리의 진짜 이유

상황설명

위의 8번 제목에서 나온 궁금증인데 생성자에서 key를 만들 때 책임이 JwtParser에 들어가는데 이걸 빼는 게 맞나 아닌가 생각하다 나온 질문이다.

Bean으로 뺀 예:

@Configuration
public class JwtConfig {

    @Bean
    public SecretKey jwtSigningKey(JwtProperties props) {
        byte[] bytes = Base64.getDecoder().decode(props.secret().key());
        return Keys.hmacShaKeyFor(bytes);
    }
}

그리고 JwtParser:

@Component
public class JwtParser {
    private final SecretKey key;

    public JwtParser(SecretKey key) {
        this.key = key;
    }
}

JwtParser에서 직접 만드는 방식과의 차이

JwtUtil에서 직접 만들면:

public JwtUtil(JwtProperties props) {
    byte[] bytes = Base64.getDecoder().decode(props.secret().key());
    this.key = Keys.hmacShaKeyFor(bytes);
}

둘 다 “한 번만 만들어 재사용”할 수 있지만, Bean 분리의 이점은 “한 줄”이 아니라 아래에 있다.

1) 재사용이 쉬움

SecretKey를 쓰는 컴포넌트가 늘면(필터/파서/서비스 등)
복붙 없이 주입만 하면 된다.

2) 변경 포인트가 한 곳

HS256 → RS256 전환, 키 롤링, KMS/Vault에서 키 로드 등 변경이 생기면
JwtConfig만 바꾸고 JwtParser은 거의 건드리지 않을 수 있다.

3) 관심사 분리

JwtUtil은 “토큰 로직”에 집중하고,
키 생성 같은 인프라 작업은 Config가 맡는다.

4) 테스트가 쉬움

JwtUtil 테스트 시 SecretKey만 주입하면 되어서 환경설정 의존이 줄어든다.

단, “JwtUtil 하나만 키를 쓰고 변경 가능성이 거의 없다”면 JwtUtil 생성자에서 만드는 방식도 충분히 괜찮다.

10) claims에 뭘 넣는 게 좋은가? (민감정보 제외 전제)

기본(표준)으로 많이 쓰는 클레임

  • sub: 사용자 식별자(보통 userId)

  • exp: 만료

  • iat: 발급 시간

  • (선택) iss: issuer

  • (선택) aud: audience

  • (선택) jti: 토큰 고유 ID (블랙리스트/추적/단건폐기 시 유용)

인가에 필요한 최소 정보

  • roles / authorities / scope 중 하나로 권한 최소화해서 담기

  • (선택) ver(token_version): 강제 로그아웃/권한 변경 즉시 반영용(서버 DB와 비교)

주의

  • name, email 같은 PII는 가능하면 토큰에 넣지 않는 편이 많다.

  • 필요하면 userId(sub)로 서버에서 조회하는 방식이 안전하다.

11) 예전 토큰 생성 코드가 버전 업되면서 안 되는 이유 (JJWT)

예전 스타일:

return BEARER_PREFIX + Jwts.builder()
    .setSubject(String.valueOf(userId))
    .claim("userRole", userRole)
    .setExpiration(...)
    .setIssuedAt(...)
    .signWith(key, signatureAlgorithm)
    .compact();

최근 JJWT에서 흔히 걸리는 지점

setSubject, setIssuedAt, setExpiration 같은 setXxx 메서드가 deprecate되고
subject(...), issuedAt(...), expiration(...) 스타일로 이동하는 경우가 많다.

SignatureAlgorithmsignWith(key, signatureAlgorithm)도 버전에 따라 권장 방식이 바뀐다.

업데이트 예시(개념)

String jwt = Jwts.builder()
    .subject(String.valueOf(userId))
    .claim("userRole", userRole.name())
    .issuedAt(...)
    .expiration(...)
    .signWith(key, Jwts.SIG.HS256)
    .compact();

12) UserDetailsService는 username인데, userId로 로드하고 싶다

UserDetailsService를 구현하면 계약(인터페이스) 때문에 아래 메서드는 반드시 구현해야 한다.

UserDetails loadUserByUsername(String username)

그래서 결론은?

  • 메서드 이름/시그니처는 바꿀 수 없다.

  • 하지만 “username 파라미터”에 userId 문자열을 넣어서 id 조회하는 건 가능하다.

예: JWT sub를 userId로 쓰는 경우

@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 기반이 필요한 경우와 역할 분리가 된다.)

profile
걱정하지 말고 일단 해봐!

0개의 댓글