Spring Security 7.0 + jjwt 0.12.6๋กœ JWT ์ธ์ฆ ๊ตฌํ˜„ํ•˜๊ธฐ

Spring Security 7.0 + jjwt 0.12.6 JWT ์ธ์ฆ Spring Boot 4.0 · Spring Security 7.0 ํ™˜๊ฒฝ์—์„œ jjwt 0.12.x๋กœ JWT ์ธ์ฆ์„ ์ฒ˜์Œ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•˜๋Š” ์ „์ฒด ๊ณผ์ •์„ ๊ธฐ๋กํ•œ๋‹ค.


๋“ค์–ด๊ฐ€๋ฉฐ

Spring Boot 4.0.3 ๊ธฐ๋ฐ˜ ํ”„๋กœ์ ํŠธ๋ฅผ ์„ธํŒ…ํ•˜๋ฉด์„œ JWT ์ธ์ฆ ๊ตฌ์กฐ๋ฅผ ์ฒ˜์Œ๋ถ€ํ„ฐ ์žก์•˜๋‹ค.
์˜ˆ์ „์— ์“ฐ๋˜ jjwt 0.11.x ์ฝ”๋“œ๋ฅผ ๊ทธ๋Œ€๋กœ ๊ฐ€์ ธ์™”๋‹ค๊ฐ€ ์ปดํŒŒ์ผ ์—๋Ÿฌ๋ฅผ ๊ฝค ๋งŒ๋‚ฌ๋Š”๋ฐ, 0.12.x์—์„œ API๊ฐ€ ์ƒ๋‹นํžˆ ์ •๋ฆฌ๋๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.
Spring Security 7.0๋„ WebSecurityConfigurerAdapter๊ฐ€ ์™„์ „ํžˆ ์ œ๊ฑฐ๋๊ณ , Jackson 3.0 ์ „ํ™˜์— ๋”ฐ๋ฅธ ๋ณ€ํ™”๋„ ์žˆ์–ด์„œ ๊ตฌ๋ฒ„์ „ ์˜ˆ์ œ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ฐ๋ฉด ์•ˆ ๋˜๋Š” ๋ถ€๋ถ„์ด ๋งŽ์•˜๋‹ค.

์ด ๊ธ€์€ JWT ์ธ์ฆ ์ „์ฒด ํ๋ฆ„์„ ๊ตฌํ˜„ํ•œ ๊ณผ์ •์„ ๊ธฐ๋กํ•œ๋‹ค.


JWT ๊ตฌ์กฐ ๋ณต์Šต

JWT(JSON Web Token)๋Š” RFC 7519๋กœ ์ •์˜๋œ ํ† ํฐ ํ˜•์‹์ด๋‹ค.
Header.Payload.Signature ์„ธ ํŒŒํŠธ๋ฅผ ์ (.)์œผ๋กœ ์ด์–ด ๋ถ™์ธ Base64URL ์ธ์ฝ”๋”ฉ ๋ฌธ์ž์—ด๋กœ ๊ตฌ์„ฑ๋œ๋‹ค.

eyJhbGciOiJIUzI1NiJ9
.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
ํŒŒํŠธ ๋‚ด์šฉ
Header ์„œ๋ช… ์•Œ๊ณ ๋ฆฌ์ฆ˜ (alg: HS256), ํ† ํฐ ํƒ€์ž… (typ: JWT)
Payload ํด๋ ˆ์ž„(Claims) — sub, iat, exp ๋“ฑ ํ‘œ์ค€ ํด๋ ˆ์ž„ + ์ปค์Šคํ…€ ํด๋ ˆ์ž„
Signature HMAC-SHA256(base64url(header) + "." + base64url(payload), secretKey)

Payload๋Š” Base64URL๋กœ ์ธ์ฝ”๋”ฉ๋œ ๊ฒƒ์ด์ง€ ์•”ํ˜ธํ™”๋œ ๊ฒŒ ์•„๋‹ˆ๋‹ค.
๋ˆ„๊ตฌ๋‚˜ ๋””์ฝ”๋”ฉํ•ด์„œ ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ๋น„๋ฐ€๋ฒˆํ˜ธ๋‚˜ ๋ฏผ๊ฐํ•œ ๊ฐœ์ธ์ •๋ณด๋Š” ์ ˆ๋Œ€ ๋„ฃ์œผ๋ฉด ์•ˆ ๋œ๋‹ค.
Signature๊ฐ€ ํ•˜๋Š” ์—ญํ• ์€ "์ด ํ† ํฐ์ด ์šฐ๋ฆฌ ์„œ๋ฒ„์—์„œ ๋ฐœ๊ธ‰๋๊ณ  ๋ณ€์กฐ๋˜์ง€ ์•Š์•˜๋‹ค"๋Š” ๊ฒƒ์„ ๋ณด์žฅํ•˜๋Š” ๊ฒƒ์ด๋‹ค.


Access Token๊ณผ Refresh Token ์ „๋žต

Access Token๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ๊ธธ๊ฒŒ ์žก์„์ˆ˜๋ก ํƒˆ์ทจ ์œ„ํ—˜์ด ์ปค์ง€๊ณ , ์งง๊ฒŒ ์žก์œผ๋ฉด ์‚ฌ์šฉ์ž๊ฐ€ ์ž์ฃผ ์žฌ๋กœ๊ทธ์ธํ•ด์•ผ ํ•˜๋Š” UX ๋ฌธ์ œ๊ฐ€ ์ƒ๊ธด๋‹ค. ๊ทธ๋ž˜์„œ ์ผ๋ฐ˜์ ์œผ๋กœ ๋‘ ์ข…๋ฅ˜์˜ ํ† ํฐ์„ ํ•จ๊ป˜ ์šด์šฉํ•œ๋‹ค.

๊ตฌ๋ถ„ Access Token Refresh Token
์šฉ๋„ API ์š”์ฒญ ์ธ์ฆ Access Token ์žฌ๋ฐœ๊ธ‰
๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์งง๊ฒŒ (1์‹œ๊ฐ„ ๋‚ด์™ธ) ๊ธธ๊ฒŒ (7์ผ ~ 30์ผ)
์ €์žฅ ์œ„์น˜ ๋ฉ”๋ชจ๋ฆฌ ๋˜๋Š” ํด๋ผ์ด์–ธํŠธ DB ๋˜๋Š” Redis
ํƒˆ์ทจ ์‹œ ๋Œ€์‘ ๋งŒ๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ ์„œ๋ฒ„์—์„œ ์ฆ‰์‹œ ํ๊ธฐ ๊ฐ€๋Šฅ

Refresh Token์€ ์„œ๋ฒ„ DB์— ์ €์žฅํ•ด๋‘๊ณ , ์žฌ๋ฐœ๊ธ‰ ์š”์ฒญ์ด ๋“ค์–ด์˜ค๋ฉด DB์— ์ €์žฅ๋œ ๊ฐ’๊ณผ ๋น„๊ต ๊ฒ€์ฆํ•œ๋‹ค.
์—ฌ๊ธฐ์— Refresh Token Rotation ์ „๋žต์„ ๋”ํ•˜๋ฉด ๋ณด์•ˆ์ด ๊ฐ•ํ™”๋œ๋‹ค.
์žฌ๋ฐœ๊ธ‰ํ•  ๋•Œ๋งˆ๋‹ค ๊ธฐ์กด Refresh Token์„ ํ๊ธฐํ•˜๊ณ  ์ƒˆ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•˜๋Š” ๋ฐฉ์‹์ธ๋ฐ,
ํƒˆ์ทจ๋œ Refresh Token์ด ์‚ฌ์šฉ๋˜๋Š” ์ˆœ๊ฐ„ ์ •์ƒ ์‚ฌ์šฉ์ž์˜ ํ† ํฐ๋„ ๋ฌดํšจํ™”๋˜์–ด ์ด์ƒ ์ง•ํ›„๋ฅผ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค.


์˜์กด์„ฑ ์ถ”๊ฐ€

jjwt๋Š” 0.12.x๋ถ€ํ„ฐ API / ๊ตฌํ˜„์ฒด / JSON ํŒŒ์„œ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ์„ ์–ธํ•ด์•ผ ํ•œ๋‹ค.
jjwt-impl์€ ๋ฐ˜๋“œ์‹œ runtimeOnly๋กœ ์„ ์–ธํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๋‚ด๋ถ€ ๊ตฌํ˜„์ฒด์— ์ปดํŒŒ์ผ ํƒ€์ž„ ์˜์กด์„ฑ์ด ์ƒ๊ธฐ๋ฉด ๋ฒ„์ „ ์—…๊ทธ๋ ˆ์ด๋“œ ์‹œ ํ˜ธํ™˜์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

// build.gradle.kts
dependencies {
    implementation("io.jsonwebtoken:jjwt-api:0.12.6")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
}

Spring Boot 4.0์€ Jackson 3.0์„ ๊ธฐ๋ณธ์œผ๋กœ ์‚ฌ์šฉํ•˜์ง€๋งŒ, jjwt-jackson์€ Jackson 2 ๊ธฐ๋ฐ˜์ด๋‹ค.
์ด ํ”„๋กœ์ ํŠธ์—์„œ๋Š” jjwt-jackson์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•˜๊ณ  Jackson 2 ํ˜ธํ™˜ ์˜์กด์„ฑ์„ ์œ ์ง€ํ–ˆ๋‹ค.
Jackson 3๋งŒ ์“ฐ๊ณ  ์‹ถ๋‹ค๋ฉด jjwt-gson์œผ๋กœ ๋Œ€์ฒดํ•˜๊ฑฐ๋‚˜ ์ปค์Šคํ…€ JSON ํŒŒ์„œ๋ฅผ ๋“ฑ๋กํ•˜๋ฉด ๋œ๋‹ค.


JwtProvider ๊ตฌํ˜„

jjwt 0.12.x์—์„œ ๋‹ฌ๋ผ์ง„ ํ•ต์‹ฌ API๋ฅผ ์ •๋ฆฌํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

0.11.x ๋ฒ„์ „ 0.12.x ๋ฒ„์ „
Keys.secretKeyFor(SignatureAlgorithm.HS256) Jwts.SIG.HS256.key().build() ๋˜๋Š” Keys.hmacShaKeyFor(bytes)
.signWith(key, SignatureAlgorithm.HS256) .signWith(key) (์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ž๋™ ์ถ”๋ก )
.parseClaimsJws(token) .parseSignedClaims(token)
.setSubject(), .setExpiration() .subject(), .expiration()
// src/main/java/com/example/security/jwt/JwtProvider.java
package com.example.security.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtProvider {

    private final SecretKey secretKey;
    private final long accessTokenExpiry;
    private final long refreshTokenExpiry;

    public JwtProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.access-token-expiry:3600000}") long accessTokenExpiry,
            @Value("${jwt.refresh-token-expiry:604800000}") long refreshTokenExpiry
    ) {
        // HS256 ๊ธฐ์ค€ ์ตœ์†Œ 256๋น„ํŠธ(32๋ฐ”์ดํŠธ) ์ด์ƒ์˜ ํ‚ค๊ฐ€ ํ•„์š”ํ•˜๋‹ค
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessTokenExpiry = accessTokenExpiry;
        this.refreshTokenExpiry = refreshTokenExpiry;
    }

    public String generateAccessToken(String email, String role) {
        return Jwts.builder()
                .subject(email)
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiry))
                .signWith(secretKey)   // 0.12.x: ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ž๋™ ์ถ”๋ก , ๋ณ„๋„ ๋ช…์‹œ ๋ถˆํ•„์š”
                .compact();
    }

    public String generateRefreshToken(String email) {
        return Jwts.builder()
                .subject(email)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + refreshTokenExpiry))
                .signWith(secretKey)
                .compact();
    }

    public Claims parseToken(String token) {
        // 0.12.x: parseClaimsJws() ๋Œ€์‹  parseSignedClaims() ์‚ฌ์šฉ
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public String extractEmail(String token) {
        return parseToken(token).getSubject();
    }

    public boolean isTokenValid(String token) {
        try {
            parseToken(token);
            return true;
        } catch (ExpiredJwtException e) {
            // ๋งŒ๋ฃŒ๋œ ํ† ํฐ — ์žฌ๋ฐœ๊ธ‰ ํ๋ฆ„์œผ๋กœ ์œ ๋„
            return false;
        } catch (JwtException | IllegalArgumentException e) {
            // ์„œ๋ช… ๋ถˆ์ผ์น˜, ํ˜•์‹ ์˜ค๋ฅ˜ ๋“ฑ
            return false;
        }
    }
}

jwt.secret์€ application.yml์—์„œ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ์ฃผ์ž…๋ฐ›๋Š”๋‹ค.
์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์ ˆ๋Œ€ ์†Œ์Šค ์ฝ”๋“œ์— ํ•˜๋“œ์ฝ”๋”ฉํ•˜์ง€ ์•Š๋Š”๋‹ค.

# application.yml
jwt:
  secret: ${JWT_SECRET}           # ์ตœ์†Œ 32๋ฐ”์ดํŠธ ์ด์ƒ์˜ ๋žœ๋ค ๋ฌธ์ž์—ด
  access-token-expiry: 3600000    # 1์‹œ๊ฐ„ (ms)
  refresh-token-expiry: 604800000 # 7์ผ (ms)

JwtAuthenticationFilter ๊ตฌํ˜„

Spring Security ํ•„ํ„ฐ ์ฒด์ธ์— JWT ๊ฒ€์ฆ ๋กœ์ง์„ ๋ผ์›Œ ๋„ฃ๋Š”๋‹ค.
OncePerRequestFilter๋ฅผ ์ƒ์†ํ•˜๋ฉด ๊ฐ™์€ ์š”์ฒญ์—์„œ ํ•„ํ„ฐ๊ฐ€ ์ค‘๋ณต ์‹คํ–‰๋˜๋Š” ๊ฒƒ์„ ๋ง‰์•„์ค€๋‹ค.
Authorization ํ—ค๋”์—์„œ Bearer ์ ‘๋‘์‚ฌ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ํ† ํฐ์„ ์ถ”์ถœํ•œ ๋’ค, ์œ ํšจํ•˜๋ฉด SecurityContextHolder์— ์ธ์ฆ ์ •๋ณด๋ฅผ ์„ค์ •ํ•œ๋‹ค.

// src/main/java/com/example/security/jwt/JwtAuthenticationFilter.java
package com.example.security.jwt;

import com.example.domain.user.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final UserRepository userRepository;

    public JwtAuthenticationFilter(JwtProvider jwtProvider, UserRepository userRepository) {
        this.jwtProvider = jwtProvider;
        this.userRepository = userRepository;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        String token = extractToken(request);

        if (StringUtils.hasText(token) && jwtProvider.isTokenValid(token)) {
            String email = jwtProvider.extractEmail(token);

            userRepository.findByEmail(email).ifPresent(user -> {
                var authToken = new UsernamePasswordAuthenticationToken(
                        user, null, user.getAuthorities()
                );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                SecurityContextHolder.getContext().setAuthentication(authToken);
            });
        }

        filterChain.doFilter(request, response);
    }

    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

SecurityConfig ์„ค์ •

Spring Security 7.0์—์„œ๋Š” WebSecurityConfigurerAdapter๊ฐ€ ์™„์ „ํžˆ ์ œ๊ฑฐ๋๋‹ค.
SecurityFilterChain ๋นˆ์„ ์ง์ ‘ ๋“ฑ๋กํ•˜๋Š” ๋ฐฉ์‹๋งŒ ์ง€์›ํ•˜๋ฉฐ, JWT ๊ธฐ๋ฐ˜ Stateless ์ธ์ฆ์ด๋ฏ€๋กœ ์„ธ์…˜ ์ƒ์„ฑ ์ •์ฑ…์„ STATELESS๋กœ ์„ค์ •ํ•˜๊ณ  CSRF๋„ ๋น„ํ™œ์„ฑํ™”ํ•œ๋‹ค.

// src/main/java/com/example/security/SecurityConfig.java
package com.example.security;

import com.example.security.jwt.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserDetailsService userDetailsService;

    public SecurityConfig(
            JwtAuthenticationFilter jwtAuthenticationFilter,
            UserDetailsService userDetailsService
    ) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(
                        jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class
                )
                .build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config
    ) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

์ธ์ฆ API ๊ตฌํ˜„

๋กœ๊ทธ์ธ / ์žฌ๋ฐœ๊ธ‰ / ๋กœ๊ทธ์•„์›ƒ

DTO๋Š” Java 16+์˜ Record๋กœ ๊ฐ„๊ฒฐํ•˜๊ฒŒ ์ •์˜ํ–ˆ๋‹ค.

public record LoginRequest(String email, String password) {}
public record RefreshRequest(String refreshToken) {}
public record TokenResponse(String accessToken, String refreshToken) {}
// src/main/java/com/example/api/auth/AuthController.java
package com.example.api.auth;

import com.example.security.jwt.JwtProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;

    public AuthController(
            AuthenticationManager authenticationManager,
            JwtProvider jwtProvider,
            RefreshTokenService refreshTokenService
    ) {
        this.authenticationManager = authenticationManager;
        this.jwtProvider = jwtProvider;
        this.refreshTokenService = refreshTokenService;
    }

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.email(), request.password()
                )
        );

        var user = (com.example.domain.user.User) auth.getPrincipal();
        String accessToken  = jwtProvider.generateAccessToken(user.getEmail(), user.getRole().name());
        String refreshToken = jwtProvider.generateRefreshToken(user.getEmail());

        refreshTokenService.save(user.getEmail(), refreshToken, user.getRole().name());

        return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
    }

    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refresh(@RequestBody RefreshRequest request) {
        String oldRefreshToken = request.refreshToken();

        if (!jwtProvider.isTokenValid(oldRefreshToken)) {
            return ResponseEntity.status(401).build();
        }

        String email = jwtProvider.extractEmail(oldRefreshToken);

        // DB์— ์ €์žฅ๋œ ํ† ํฐ๊ณผ ๋น„๊ต — ๋ถˆ์ผ์น˜ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ
        refreshTokenService.validate(email, oldRefreshToken);

        // Rotation: ๊ธฐ์กด ํ† ํฐ ํ๊ธฐ ํ›„ ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰
        String newRefreshToken = jwtProvider.generateRefreshToken(email);
        String role = refreshTokenService.getRole(email);
        refreshTokenService.rotate(email, newRefreshToken);

        String newAccessToken = jwtProvider.generateAccessToken(email, role);

        return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestBody RefreshRequest request) {
        String email = jwtProvider.extractEmail(request.refreshToken());
        refreshTokenService.delete(email);
        return ResponseEntity.noContent().build();
    }
}

RefreshTokenService

// src/main/java/com/example/api/auth/RefreshTokenService.java
package com.example.api.auth;

import com.example.domain.token.RefreshToken;
import com.example.domain.token.RefreshTokenRepository;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class RefreshTokenService {

    private final RefreshTokenRepository repository;

    public RefreshTokenService(RefreshTokenRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void save(String email, String token, String role) {
        repository.findById(email)
                .ifPresentOrElse(
                        rt -> rt.rotate(token),
                        () -> repository.save(new RefreshToken(email, token, role))
                );
    }

    public void validate(String email, String token) {
        RefreshToken stored = repository.findById(email)
                .orElseThrow(() -> new InvalidTokenException("Refresh token not found"));

        if (!stored.getToken().equals(token)) {
            // ํ† ํฐ ๋ถˆ์ผ์น˜ — ํƒˆ์ทจ ๊ฐ€๋Šฅ์„ฑ, ์ €์žฅ๋œ ํ† ํฐ ์ „์ฒด ํ๊ธฐ
            repository.deleteById(email);
            throw new InvalidTokenException("Refresh token mismatch. Possible token theft.");
        }
    }

    @Transactional
    public void rotate(String email, String newToken) {
        repository.findById(email).ifPresent(rt -> rt.rotate(newToken));
    }

    public String getRole(String email) {
        return repository.findById(email)
                .map(RefreshToken::getRole)
                .orElseThrow(() -> new InvalidTokenException("Refresh token not found"));
    }

    @Transactional
    public void delete(String email) {
        repository.deleteById(email);
    }
}

์ „์ฒด ์ธ์ฆ ํ๋ฆ„

๊ตฌํ˜„ํ•œ JWT ์ธ์ฆ ํ๋ฆ„์„ ์‹œํ€€์Šค๋กœ ์ •๋ฆฌํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

ํด๋ผ์ด์–ธํŠธ                           ์„œ๋ฒ„
   โ”‚                                   โ”‚
   โ”‚โ”€โ”€ POST /api/auth/login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
   โ”‚   { email, password }             โ”‚
   โ”‚                                   โ”‚โ”€โ”€ AuthenticationManager.authenticate()
   โ”‚                                   โ”‚โ”€โ”€ BCrypt ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ
   โ”‚                                   โ”‚โ”€โ”€ Access Token ์ƒ์„ฑ (1h)
   โ”‚                                   โ”‚โ”€โ”€ Refresh Token ์ƒ์„ฑ (7d) → DB ์ €์žฅ
   โ”‚<โ”€โ”€ { accessToken, refreshToken } โ”€โ”€โ”‚
   โ”‚                                   โ”‚
   โ”‚โ”€โ”€ GET /api/resource โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
   โ”‚   Authorization: Bearer {AT}      โ”‚
   โ”‚                                   โ”‚โ”€โ”€ JwtAuthenticationFilter
   โ”‚                                   โ”‚โ”€โ”€ ์„œ๋ช… ๊ฒ€์ฆ + ๋งŒ๋ฃŒ ํ™•์ธ
   โ”‚                                   โ”‚โ”€โ”€ SecurityContextHolder ์„ค์ •
   โ”‚<โ”€โ”€ 200 OK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚
   โ”‚                                   โ”‚
   โ”‚  (Access Token ๋งŒ๋ฃŒ ํ›„)            โ”‚
   โ”‚โ”€โ”€ POST /api/auth/refresh โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
   โ”‚   { refreshToken }                โ”‚
   โ”‚                                   โ”‚โ”€โ”€ DB ์ €์žฅ ํ† ํฐ๊ณผ ๋น„๊ต ๊ฒ€์ฆ
   โ”‚                                   โ”‚โ”€โ”€ ๊ธฐ์กด RT ํ๊ธฐ + ์ƒˆ RT ๋ฐœ๊ธ‰ (Rotation)
   โ”‚                                   โ”‚โ”€โ”€ ์ƒˆ Access Token ๋ฐœ๊ธ‰
   โ”‚<โ”€โ”€ { newAccessToken, newRefreshToken } โ”‚
   โ”‚                                   โ”‚
   โ”‚โ”€โ”€ POST /api/auth/logout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
   โ”‚   { refreshToken }                โ”‚
   โ”‚                                   โ”‚โ”€โ”€ DB์—์„œ RT ์‚ญ์ œ
   โ”‚<โ”€โ”€ 204 No Content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚

๋งˆ์น˜๋ฉฐ

jjwt 0.12.x๋กœ ๋„˜์–ด์˜ค๋ฉด์„œ API๊ฐ€ ๊ฝค ์ •๋ฆฌ๋๋‹ค.
๊ตฌ๋ฒ„์ „ ์˜ˆ์ œ ์ฝ”๋“œ์—์„œ ์ž์ฃผ ๋ณด์ด๋Š” parseClaimsJws(), setSubject(), Keys.secretKeyFor(SignatureAlgorithm.HS256) ๊ฐ™์€ ๋ฉ”์„œ๋“œ๋“ค์ด deprecated๋˜๊ฑฐ๋‚˜ ์ œ๊ฑฐ๋์œผ๋‹ˆ, ์˜ˆ์ „ ์ฝ”๋“œ๋ฅผ ๊ทธ๋Œ€๋กœ ๊ฐ€์ ธ๋‹ค ์“ฐ๋ฉด ์ปดํŒŒ์ผ ์—๋Ÿฌ๊ฐ€ ๋‚œ๋‹ค.

Refresh Token Rotation์€ ๊ตฌํ˜„ ๋น„์šฉ ๋Œ€๋น„ ๋ณด์•ˆ ํšจ๊ณผ๊ฐ€ ํฌ๋‹ค.
ํƒˆ์ทจ๋œ ํ† ํฐ์ด ์‚ฌ์šฉ๋˜๋Š” ์ˆœ๊ฐ„ ์ •์ƒ ์‚ฌ์šฉ์ž์˜ ํ† ํฐ๋„ ๋ฌดํšจํ™”๋˜๊ธฐ ๋•Œ๋ฌธ์—, ์ด์ƒ ์ง•ํ›„๋ฅผ ๋น ๋ฅด๊ฒŒ ๊ฐ์ง€ํ•˜๊ณ  ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ๋‹ค.


์ฐธ๊ณ  ์ถœ์ฒ˜

Spring Boot 4.0 Release Notes — https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Release-Notes
Spring Security 7.0 Migration Guide — https://docs.spring.io/spring-security/reference/migration/index.html
RFC 7519: JSON Web Token (JWT) — https://datatracker.ietf.org/doc/html/rfc7519
jjwt GitHub Repository — https://github.com/jwtk/jjwt