Spring Security 6 (Spring Boot 3)์—์„œ Spring Security JWT ์ธ์ฆ

Spring Boot 3์™€ Spring Security 6.x๊ฐ€ ์ •์‹ ๋ฆด๋ฆฌ์Šค๋˜๋ฉด์„œ JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ๊ตฌํ˜„ ๋ฐฉ์‹๋„ ์ƒ๋‹นํžˆ ๋‹ฌ๋ผ์กŒ๋‹ค. ๊ธฐ์กด WebSecurityConfigurerAdapter๋ฅผ ์ƒ์†ํ•˜๋Š” ๋ฐฉ์‹์€ ์™„์ „ํžˆ ์ œ๊ฑฐ๋˜์—ˆ๊ณ , ๋žŒ๋‹ค DSL ๋ฐฉ์‹์˜ SecurityFilterChain ๋นˆ ๋“ฑ๋ก์œผ๋กœ ์ „ํ™˜๋˜์—ˆ๋‹ค. ์ด๋ฒˆ ํฌ์ŠคํŒ…์—์„œ๋Š” Spring Boot 3์™€ Spring Security 6.x ํ™˜๊ฒฝ์—์„œ Spring Security JWT ์ธ์ฆ์„ ์˜์กด์„ฑ ์ถ”๊ฐ€๋ถ€ํ„ฐ JwtService, JwtAuthenticationFilter, ํšŒ์›๊ฐ€์ž…ยท๋กœ๊ทธ์ธ REST API๊นŒ์ง€ ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ์ฝ”๋“œ ๊ธฐ์ค€์œผ๋กœ ๊ตฌํ˜„ํ•œ๋‹ค.


ํ† ํฐ์ด ์š”์ฒญ์„ ํƒ€๊ณ  ํ๋ฅด๋Š” ๋ฐฉ์‹

์ฝ”๋“œ๋ฅผ ๋ณด๊ธฐ ์ „์— ์š”์ฒญ์ด ์–ด๋–ค ์ˆœ์„œ๋กœ ์ฒ˜๋ฆฌ๋˜๋Š”์ง€ ์งš๊ณ  ๋„˜์–ด๊ฐ€์ž.

  1. ํด๋ผ์ด์–ธํŠธ๊ฐ€ /api/v1/auth/login ์—”๋“œํฌ์ธํŠธ๋กœ ์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ „์†กํ•œ๋‹ค.
  2. ์„œ๋ฒ„๋Š” AuthenticationManager๋ฅผ ํ†ตํ•ด ์ž๊ฒฉ ์ฆ๋ช…์„ ๊ฒ€์ฆํ•˜๊ณ , ์„ฑ๊ณต ์‹œ JWT ์•ก์„ธ์Šค ํ† ํฐ์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
  3. ์ดํ›„ ํด๋ผ์ด์–ธํŠธ๋Š” ๋ณดํ˜ธ๋œ API๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ๋งˆ๋‹ค HTTP ํ—ค๋”์— Authorization: Bearer {token}์„ ํฌํ•จํ•˜์—ฌ ์š”์ฒญํ•œ๋‹ค.
  4. JwtAuthenticationFilter๊ฐ€ ๋ชจ๋“  ์š”์ฒญ์— ์•ž์„œ ์‹คํ–‰๋˜์–ด ํ† ํฐ์˜ ์„œ๋ช…๊ณผ ๋งŒ๋ฃŒ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค.
  5. ๊ฒ€์ฆ์— ์„ฑ๊ณตํ•˜๋ฉด SecurityContextHolder์— ์ธ์ฆ ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๊ณ  ์ดํ›„ ํ•„ํ„ฐ ์ฒด์ธ๊ณผ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ •์ƒ ๋™์ž‘ํ•œ๋‹ค.

์ด ํ๋ฆ„์„ ๊ธฐ์–ตํ•ด๋‘๋ฉด ์•„๋ž˜์—์„œ ์„ค๋ช…ํ•  ๊ฐ ์ปดํฌ๋„ŒํŠธ์˜ ์—ญํ• ์ด ํ›จ์”ฌ ์„ ๋ช…ํ•˜๊ฒŒ ๋“ค์–ด์˜จ๋‹ค.


1. pom.xml์— ๋ฌด์—‡์„ ๋„ฃ์–ด์•ผ ํ•˜๋‚˜

Spring Boot 3.x ๊ธฐ๋ฐ˜ ํ”„๋กœ์ ํŠธ์— ์•„๋ž˜ ์˜์กด์„ฑ์„ ์ถ”๊ฐ€ํ•œ๋‹ค. JWT ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” JJWT 0.11.5 ๊ธฐ์ค€์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค. 0.12.x๋Š” API ๋ฉ”์„œ๋“œ๋ช…์ด ์ผ๋ถ€ ๋ณ€๊ฒฝ๋˜์—ˆ์œผ๋ฏ€๋กœ ๋ฒ„์ „์„ ์ •ํ™•ํžˆ ํ™•์ธํ•˜๊ณ  ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.

<!-- Spring Boot Starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- JJWT 0.11.5 โ€” JWT ์ƒ์„ฑ ๋ฐ ํŒŒ์‹ฑ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

<!-- Lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
XML

jjwt-api๋Š” ์ปดํŒŒ์ผ ํƒ€์ž„ ์˜์กด์„ฑ์œผ๋กœ, ์‹ค์ œ ๊ตฌํ˜„์ฒด์ธ jjwt-impl์€ ๋Ÿฐํƒ€์ž„ scope์œผ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด JJWT ๊ณต์‹ ๊ฐ€์ด๋“œ์˜ ๊ถŒ์žฅ ๋ฐฉ์‹์ด๋‹ค. jjwt-jackson์€ JWT์˜ JSON ์ง๋ ฌํ™”๋ฅผ ๋‹ด๋‹นํ•˜๋ฏ€๋กœ ํ•จ๊ป˜ ์ถ”๊ฐ€ํ•ด์•ผ ํ•œ๋‹ค.


2. ์‹œํฌ๋ฆฟ ํ‚ค๋Š” ์ฝ”๋“œ ๋ฐ–์œผ๋กœ

JWT ์‹œํฌ๋ฆฟ ํ‚ค์™€ ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ์™ธ๋ถ€ ์„ค์ •์œผ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. ์‹œํฌ๋ฆฟ ํ‚ค๋Š” ๋ฐ˜๋“œ์‹œ 256๋น„ํŠธ(32๋ฐ”์ดํŠธ) ์ด์ƒ์˜ Base64 ์ธ์ฝ”๋”ฉ ๋ฌธ์ž์—ด์„ ์‚ฌ์šฉํ•ด์•ผ HS256 ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•œ๋‹ค.

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jwt_demo
    username: root
    password: password
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

application:
  security:
    jwt:
      # 256๋น„ํŠธ ์ด์ƒ์˜ Base64 ์ธ์ฝ”๋”ฉ ์‹œํฌ๋ฆฟ ํ‚ค (์šด์˜ํ™˜๊ฒฝ์—์„œ๋Š” ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์ฃผ์ž…)
      secret-key: <secret-key>
      expiration: 86400000      # ์•ก์„ธ์Šค ํ† ํฐ ๋งŒ๋ฃŒ: 24์‹œ๊ฐ„ (๋ฐ€๋ฆฌ์ดˆ)
      refresh-token:
        expiration: 604800000   # ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ๋งŒ๋ฃŒ: 7์ผ (๋ฐ€๋ฆฌ์ดˆ)
YAML

secret-key ๊ฐ’์„ ์ฝ”๋“œ์— ํ•˜๋“œ์ฝ”๋”ฉํ•˜๋ฉด ์ ˆ๋Œ€ ์•ˆ ๋œ๋‹ค. ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ํ™˜๊ฒฝ๋ณ€์ˆ˜ APPLICATION_SECURITY_JWT_SECRET_KEY๋กœ ์ฃผ์ž…ํ•˜๊ณ  .gitignore์— ์‹ค์ œ ์„ค์ • ํŒŒ์ผ์„ ์ œ์™ธํ•ด์•ผ ํ•œ๋‹ค.


3. WebSecurityConfigurerAdapter๋Š” ์—†๋‹ค โ€” SecurityFilterChain์œผ๋กœ ์ „ํ™˜

Spring Security 6.x์—์„œ ๋ณด์•ˆ ์„ค์ •์˜ ์ง„์ž…์ ์€ SecurityFilterChain ๋นˆ์ด๋‹ค. WebSecurityConfigurerAdapter๋Š” ์™„์ „ํžˆ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ์•„๋ž˜ ํŒจํ„ด์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // JWT ๊ธฐ๋ฐ˜ Stateless ๋ฐฉ์‹์—์„œ๋Š” CSRF ๋ณดํ˜ธ๊ฐ€ ๋ถˆํ•„์š”
            .csrf(csrf -> csrf.disable())
            .cors(Customizer.withDefaults())
            .authorizeHttpRequests(auth -> auth
                // ์ธ์ฆ ์—†์ด ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๊ฒฝ๋กœ ํ—ˆ์šฉ
                .requestMatchers("/api/v1/auth/**").permitAll()
                // ๊ด€๋ฆฌ์ž ์ „์šฉ ๊ฒฝ๋กœ โ€” getAuthorities()์—์„œ "ADMIN"์„ ์ง์ ‘ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ hasAuthority() ์‚ฌ์šฉ
                .requestMatchers("/api/v1/admin/**").hasAuthority("ADMIN")
                // ๊ทธ ์™ธ ๋ชจ๋“  ์š”์ฒญ์€ ์ธ์ฆ ํ•„์š”
                .anyRequest().authenticated()
            )
            // ์„ธ์…˜์„ ์ƒ์„ฑํ•˜์ง€ ์•Š๋Š” Stateless ์ •์ฑ…
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(authenticationProvider)
            // UsernamePasswordAuthenticationFilter ์•ž์— JWT ํ•„ํ„ฐ ์‚ฝ์ž…
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
Java

.csrf(csrf -> csrf.disable())๋Š” JWT Stateless ๋ฐฉ์‹์—์„œ ์„ธ์…˜ ์ฟ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— CSRF ๊ณต๊ฒฉ ๋ฒกํ„ฐ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์•„ ๋น„ํ™œ์„ฑํ™”ํ•œ๋‹ค. .sessionManagement()์—์„œ STATELESS๋ฅผ ์ง€์ •ํ•˜๋ฉด Spring Security๊ฐ€ ์„ธ์…˜์„ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ์„œ๋ฒ„๊ฐ€ ์–ด๋– ํ•œ ์ธ์ฆ ์ƒํƒœ๋„ ๋ฉ”๋ชจ๋ฆฌ์— ์œ ์ง€ํ•˜์ง€ ์•Š๋Š”๋‹ค. addFilterBefore()๋กœ JwtAuthenticationFilter๋ฅผ ๊ธฐ๋ณธ ์ธ์ฆ ํ•„ํ„ฐ๋ณด๋‹ค ์•ž์— ๋“ฑ๋กํ•˜์—ฌ ๋ชจ๋“  ์š”์ฒญ์˜ ํ† ํฐ์„ ๋จผ์ € ๊ฒ€์ฆํ•œ๋‹ค.


4. ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•˜๊ณ  ์ฝ๋Š” ํ•ต์‹ฌ ๋กœ์ง

JwtService๋Š” JWT ํ† ํฐ ์ƒ์„ฑ, ํŒŒ์‹ฑ, ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. JJWT 0.11.5 ๊ธฐ์ค€ ์ „์ฒด ๊ตฌํ˜„์ด๋‹ค.

@Service
public class JwtService {

    @Value("${application.security.jwt.secret-key}")
    private String secretKey;

    @Value("${application.security.jwt.expiration}")
    private long jwtExpiration;

    /** UserDetails๋งŒ์œผ๋กœ ๊ธฐ๋ณธ ํ† ํฐ ์ƒ์„ฑ */
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /** ์ถ”๊ฐ€ ํด๋ ˆ์ž„์„ ํฌํ•จํ•œ ํ† ํฐ ์ƒ์„ฑ (์˜ˆ: userId, roles) */
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }

    private String buildToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails,
            long expiration
    ) {
        return Jwts.builder()
                .setClaims(extraClaims)
                // subject์— ์‚ฌ์šฉ์ž ์‹๋ณ„์ž(์ด๋ฉ”์ผ) ์ €์žฅ
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                // ํ˜„์žฌ ์‹œ๊ฐ + ๋งŒ๋ฃŒ ์‹œ๊ฐ„์œผ๋กœ exp ํด๋ ˆ์ž„ ์„ค์ •
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                // HMAC-SHA256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์œผ๋กœ ์„œ๋ช…
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    /** ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ โ€” ์‚ฌ์šฉ์ž๋ช… ์ผ์น˜ + ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                // ์„œ๋ช… ๊ฒ€์ฆ ํ›„ ํŽ˜์ด๋กœ๋“œ(Claims) ๋ฐ˜ํ™˜
                .parseClaimsJws(token)
                .getBody();
    }

    private Key getSignInKey() {
        // Base64 ๋””์ฝ”๋”ฉํ•˜์—ฌ HMAC ํ‚ค ์ƒ์„ฑ
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}
Java

buildToken() ๋ฉ”์„œ๋“œ๋Š” Jwts.builder()๋ฅผ ํ†ตํ•ด ํ—ค๋”ยทํŽ˜์ด๋กœ๋“œยท์„œ๋ช… ์„ธ ๋ถ€๋ถ„์œผ๋กœ ๊ตฌ์„ฑ๋œ JWT๋ฅผ ์ƒ์„ฑํ•˜๊ณ , compact()๋กœ ์ตœ์ข… ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. extractAllClaims()์—์„œ parseClaimsJws()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด JJWT๊ฐ€ ๋‚ด๋ถ€์ ์œผ๋กœ ์„œ๋ช…์„ ๊ฒ€์ฆํ•˜๋ฉฐ, ์„œ๋ช…์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š๊ฑฐ๋‚˜ ํ† ํฐ ํ˜•์‹์ด ์ž˜๋ชป๋œ ๊ฒฝ์šฐ JwtException์„ ๋˜์ง„๋‹ค.


5. ๋ชจ๋“  ์š”์ฒญ ์•ž์— ์„œ๋Š” ํ•„ํ„ฐ

๋ชจ๋“  HTTP ์š”์ฒญ์—์„œ Authorization ํ—ค๋”๋ฅผ ํ™•์ธํ•˜๊ณ  JWT๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ํ•„ํ„ฐ์ด๋‹ค. OncePerRequestFilter๋ฅผ ์ƒ์†ํ•˜๋ฉด ํ•˜๋‚˜์˜ ์š”์ฒญ์—์„œ ํ•„ํ„ฐ๊ฐ€ ๋‹จ ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰๋จ์„ ๋ณด์žฅํ•œ๋‹ค.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

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

        final String authHeader = request.getHeader("Authorization");

        // Authorization ํ—ค๋”๊ฐ€ ์—†๊ฑฐ๋‚˜ Bearer ์Šคํ‚ด์ด ์•„๋‹Œ ๊ฒฝ์šฐ ๊ฑด๋„ˆ๋œ€
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // "Bearer " ์ดํ›„ 7์ž๋ฆฌ๋ถ€ํ„ฐ ์‹ค์ œ ํ† ํฐ ๋ฌธ์ž์—ด ์ถ”์ถœ
        final String jwt = authHeader.substring(7);
        final String userEmail;

        try {
            userEmail = jwtService.extractUsername(jwt);
        } catch (JwtException e) {
            // ๋งŒ๋ฃŒยท๋ณ€์กฐยทํ˜•์‹ ์˜ค๋ฅ˜ ํ† ํฐ: ์ธ์ฆ ์—†์ด ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ์ „๋‹ฌ โ†’ Spring Security๊ฐ€ 401 ์ฒ˜๋ฆฌ
            filterChain.doFilter(request, response);
            return;
        }

        // ์‚ฌ์šฉ์ž๋ช…์ด ์กด์žฌํ•˜๊ณ  ์•„์ง SecurityContext์— ์ธ์ฆ์ด ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ฒ˜๋ฆฌ
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                // ์ธ์ฆ ํ† ํฐ ์ƒ์„ฑ โ€” credentials๋Š” null (JWT ๋ฐฉ์‹์—์„œ๋Š” ๋ถˆํ•„์š”)
                UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                // SecurityContext์— ์ธ์ฆ ์ •๋ณด ๋“ฑ๋ก โ€” ์ดํ›„ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ @AuthenticationPrincipal๋กœ ์ ‘๊ทผ ๊ฐ€๋Šฅ
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}
Java

SecurityContextHolder.getContext().getAuthentication() == null ์กฐ๊ฑด์„ ๋ฐ˜๋“œ์‹œ ํ™•์ธํ•ด์•ผ ํ•œ๋‹ค. ์ด ์กฐ๊ฑด์ด ์—†์œผ๋ฉด ์ด๋ฏธ ์ธ์ฆ๋œ ์š”์ฒญ์—์„œ๋„ ๋ถˆํ•„์š”ํ•˜๊ฒŒ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์กฐํšŒ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. UsernamePasswordAuthenticationToken์˜ ์„ธ ๋ฒˆ์งธ ์ธ์ž์ธ authorities๋ฅผ ์ „๋‹ฌํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•˜๋ฉฐ, ์ด๋ฅผ ์ƒ๋žตํ•˜๋ฉด isAuthenticated()๊ฐ€ false๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค.


6. ์Šคํ”„๋ง์ด ์ธ์ฆ ํŒŒ์ดํ”„๋ผ์ธ์„ ์กฐ๋ฆฝํ•˜๋Š” ๋ฐฉ๋ฒ•

UserDetailsService, AuthenticationProvider, PasswordEncoder๋ฅผ ๋นˆ์œผ๋กœ ๋“ฑ๋กํ•˜๋Š” ์„ค์ • ํด๋ž˜์Šค์ด๋‹ค.

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository repository;

    @Bean
    public UserDetailsService userDetailsService() {
        // ์ด๋ฉ”์ผ(username)๋กœ ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•˜๋Š” ๋žŒ๋‹ค ๊ตฌํ˜„
        return username -> repository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + username));
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());
        // BCrypt ํ•ด์‹œ ๋น„๊ต๋ฅผ ํ†ตํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

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

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt strength ๊ธฐ๋ณธ๊ฐ’ 10 โ€” ์šด์˜ ํ™˜๊ฒฝ์—์„œ ์ถฉ๋ถ„ํ•œ ํ•ด์‹œ ๊ฐ•๋„
        return new BCryptPasswordEncoder();
    }
}
Java

DaoAuthenticationProvider๋Š” UserDetailsService๋กœ ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•œ ๋’ค PasswordEncoder๋กœ ์ž…๋ ฅ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๊ฒ€์ฆํ•˜๋Š” Spring Security ๊ธฐ๋ณธ ์ธ์ฆ ์ œ๊ณต์ž์ด๋‹ค. AuthenticationManager๋Š” AuthService์—์„œ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์‹œ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋ฏ€๋กœ ๋นˆ์œผ๋กœ ๋…ธ์ถœํ•ด์•ผ ํ•œ๋‹ค.


7. ์—”ํ‹ฐํ‹ฐ๊ฐ€ UserDetails๋ฅผ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๋ฉด ์ƒ๊ธฐ๋Š” ์ผ

JPA ์—”ํ‹ฐํ‹ฐ์—์„œ UserDetails๋ฅผ ์ง์ ‘ ๊ตฌํ˜„ํ•œ๋‹ค.

@Entity
@Table(name = "users")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")  // JPA ํ”„๋ก์‹œ ์•ˆ์ „ โ€” id ๊ธฐ์ค€์œผ๋กœ๋งŒ ๋™๋“ฑ์„ฑ ๋น„๊ต
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String firstname;
    private String lastname;

    @Column(unique = true, nullable = false)
    private String email;

    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // Role enum ๊ฐ’์„ GrantedAuthority๋กœ ๋ณ€ํ™˜
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public String getUsername() {
        // Spring Security์˜ username ๊ธฐ์ค€์œผ๋กœ ์ด๋ฉ”์ผ ๋ฐ˜ํ™˜
        return email;
    }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return true; }

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() { return true; }
}
Java

์—”ํ‹ฐํ‹ฐ๊ฐ€ UserDetails๋ฅผ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๋ฉด ๋ณ„๋„์˜ UserDetailsImpl ๋ž˜ํผ ํด๋ž˜์Šค๊ฐ€ ํ•„์š” ์—†์–ด ์ฝ”๋“œ๊ฐ€ ๋‹จ์ˆœํ•ด์ง„๋‹ค. getUsername()์ด ์ด๋ฉ”์ผ์„ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์˜ค๋ฒ„๋ผ์ด๋“œํ•˜๋Š” ๊ฒƒ์ด ํ•ต์‹ฌ์ด๋ฉฐ, UserDetailsService.loadUserByUsername()๊ณผ JwtService.extractUsername()์˜ ๊ธฐ์ค€ ์‹๋ณ„์ž๊ฐ€ ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค.


8. ํšŒ์›๊ฐ€์ž…๋ถ€ํ„ฐ ํ† ํฐ ๋ฐœ๊ธ‰๊นŒ์ง€

AuthController์™€ AuthService๋กœ ์ธ์ฆ ์—”๋“œํฌ์ธํŠธ๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค. ๊ตฌ์กฐ๋Š” ๋‹จ์ˆœํ•˜๋‹ค.

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

    private final AuthService service;

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
        return ResponseEntity.ok(service.register(request));
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request) {
        return ResponseEntity.ok(service.authenticate(request));
    }
}
Java

์œ„ ์ปจํŠธ๋กค๋Ÿฌ๋Š” SecurityConfig์—์„œ /api/v1/auth/**๋ฅผ permitAll()๋กœ ํ—ˆ์šฉํ–ˆ์œผ๋ฏ€๋กœ ์ธ์ฆ ์—†์ด ์ ‘๊ทผ๋œ๋‹ค. ๋‘ ์—”๋“œํฌ์ธํŠธ ๋ชจ๋‘ ์„ฑ๊ณต ์‹œ JWT ํ† ํฐ์ด ๋‹ด๊ธด AuthResponse๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository repository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    public AuthResponse register(RegisterRequest request) {
        // ์ด๋ฉ”์ผ ์ค‘๋ณต ๊ฒ€์‚ฌ โ€” 409 Conflict ์‘๋‹ต์„ ์œ„ํ•ด ์ €์žฅ ์ „ ํ™•์ธ
        if (repository.findByEmail(request.getEmail()).isPresent()) {
            throw new IllegalArgumentException("์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค: " + request.getEmail());
        }
        var user = User.builder()
                .firstname(request.getFirstname())
                .lastname(request.getLastname())
                .email(request.getEmail())
                // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋ฐ˜๋“œ์‹œ BCrypt ํ•ด์‹ฑ ํ›„ ์ €์žฅ
                .password(passwordEncoder.encode(request.getPassword()))
                .role(Role.USER)
                .build();
        repository.save(user);
        // ๋“ฑ๋ก ์ฆ‰์‹œ JWT ๋ฐœ๊ธ‰ โ€” ๋ณ„๋„ ๋กœ๊ทธ์ธ ๋ถˆํ•„์š”
        var jwtToken = jwtService.generateToken(user);
        return AuthResponse.builder().token(jwtToken).build();
    }

    public AuthResponse authenticate(AuthRequest request) {
        // ์ž๊ฒฉ ์ฆ๋ช… ๊ฒ€์ฆ ์‹คํŒจ ์‹œ BadCredentialsException ๋ฐœ์ƒ
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getEmail(),
                        request.getPassword()
                )
        );
        var user = repository.findByEmail(request.getEmail())
                .orElseThrow(() -> new UsernameNotFoundException("์ธ์ฆ ํ›„ ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + request.getEmail()));
        var jwtToken = jwtService.generateToken(user);
        return AuthResponse.builder().token(jwtToken).build();
    }
}
Java

authenticate() ๋ฉ”์„œ๋“œ์—์„œ authenticationManager.authenticate()๊ฐ€ ์„ฑ๊ณตํ•˜๋ฉด Spring Security๊ฐ€ ๋‚ด๋ถ€์ ์œผ๋กœ UserDetailsService๋ฅผ ํ˜ธ์ถœํ•˜๊ณ  PasswordEncoder๋กœ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค. ๊ฒ€์ฆ ์‹คํŒจ ์‹œ BadCredentialsException์ด ๋ฐœ์ƒํ•˜๋ฏ€๋กœ ์ปจํŠธ๋กค๋Ÿฌ ๋ ˆ๋ฒจ์˜ @ExceptionHandler๋กœ 401 ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์ข‹๋‹ค.


9. JJWT 0.12.x๋กœ ์—…๊ทธ๋ ˆ์ด๋“œํ•˜๋ฉด ์ฝ”๋“œ๊ฐ€ ์ด๋ ‡๊ฒŒ ๋ฐ”๋€๋‹ค

์‹ค๋ฌด์—์„œ ๋‘ ๋ฒ„์ „์ด ํ˜ผ์šฉ๋˜๋ฏ€๋กœ ์ฃผ์š” API ์ฐจ์ด๋ฅผ ์ •๋ฆฌํ•œ๋‹ค.

๊ธฐ๋ŠฅJJWT 0.11.xJJWT 0.12.x
๋นŒ๋” ์‹œ์ž‘Jwts.builder()Jwts.builder() (๋™์ผ)
ํด๋ ˆ์ž„ ์„ค์ •.setClaims(map).claims(map)
Subject ์„ค์ •.setSubject(str).subject(str)
๋ฐœํ–‰ ์‹œ๊ฐ.setIssuedAt(date).issuedAt(date)
๋งŒ๋ฃŒ ์‹œ๊ฐ.setExpiration(date).expiration(date)
์„œ๋ช….signWith(key, algo).signWith(key)
ํŒŒ์„œ ์ƒ์„ฑJwts.parserBuilder()Jwts.parser()
ํด๋ ˆ์ž„ ํŒŒ์‹ฑ.parseClaimsJws(token).getBody().parseSignedClaims(token).getPayload()

0.12.x์—์„œ ๋ฉ”์„œ๋“œ ์ด๋ฆ„์ด setter ํ˜•์‹์—์„œ ๋นŒ๋” ํ˜•์‹์œผ๋กœ ํ†ต์ผ๋˜๊ณ , ์„œ๋ช… ์‹œ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์ œ๊ฑฐ๋˜์—ˆ๋‹ค. ๋‘ ๋ฒ„์ „์˜ API๋ฅผ ํ˜ผ์šฉํ•˜๋ฉด ์ปดํŒŒ์ผ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฏ€๋กœ ๋ฒ„์ „์„ ๋ฐ˜๋“œ์‹œ ๊ณ ์ •ํ•ด์•ผ ํ•œ๋‹ค.


10. Spring Security ๋ญ๊ฐ€ ๋‹ฌ๋ผ์กŒ๋‚˜

Spring Security JWT ์ธ์ฆ ๊ตฌํ˜„ ์‹œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์—์„œ ๊ฐ€์žฅ ํ˜ผ์„ ์ด ์ƒ๊ธฐ๋Š” ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ๋‹ค.

ํ•ญ๋ชฉSpring Security 5.xSpring Security 6.x
์„ค์ • ๋ฐฉ์‹extends WebSecurityConfigurerAdapter@Bean SecurityFilterChain
์ธ๊ฐ€ ๋ฉ”์„œ๋“œ.authorizeRequests().authorizeHttpRequests()
๊ฒฝ๋กœ ๋งค์ฒ˜.antMatchers().requestMatchers()
DSL ์—ฐ๊ฒฐ.and() ์ฒด์ด๋‹๋žŒ๋‹ค DSL (.and() ์ œ๊ฑฐ๋จ)
CSRF ๋น„ํ™œ์„ฑํ™”.csrf().disable().csrf(c -> c.disable())
๋ฉ”์„œ๋“œ ๋ณด์•ˆ ํ™œ์„ฑํ™”@EnableGlobalMethodSecurity@EnableMethodSecurity
์ตœ์†Œ Java ๋ฒ„์ „Java 8Java 17

Spring ๊ณต์‹ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ์— ๋”ฐ๋ฅด๋ฉด authorizeRequests()์™€ antMatchers() ์‚ฌ์šฉ ์‹œ spring-security-6.x์—์„œ๋Š” ์ปดํŒŒ์ผ ๊ฒฝ๊ณ ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉฐ, ์ดํ›„ ๋ฆด๋ฆฌ์Šค์—์„œ ์ œ๊ฑฐ๋  ์˜ˆ์ •์ด๋‹ค.


11. cURL๋กœ ์ง์ ‘ ๊ฒ€์ฆํ•ด๋ณด๊ธฐ

cURL๋กœ ์‹ค์ œ ๋™์ž‘์„ ํ™•์ธํ•ด๋ณด์ž.

ํšŒ์›๊ฐ€์ž…:

curl -X POST http://localhost:8080/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"firstname":"ํ™","lastname":"๊ธธ๋™","email":"hong@example.com","password":"secret123"}'
ShellScript

์‘๋‹ต์œผ๋กœ JWT ํ† ํฐ ๋ฌธ์ž์—ด์ด ๋ฐ˜ํ™˜๋œ๋‹ค.

๋กœ๊ทธ์ธ:

curl -X POST http://localhost:8080/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"hong@example.com","password":"secret123"}'
# ์‘๋‹ต: {"token":"eyJhbGciOiJIUzI1NiJ9..."}
ShellScript

๋ณดํ˜ธ๋œ ์—”๋“œํฌ์ธํŠธ ํ˜ธ์ถœ:

TOKEN="eyJhbGciOiJIUzI1NiJ9..."

curl http://localhost:8080/api/v1/users/me \
  -H "Authorization: Bearer $TOKEN"
ShellScript

Authorization: Bearer {token} ํ—ค๋” ์—†์ด ๋ณดํ˜ธ๋œ ๊ฒฝ๋กœ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด Spring Security๊ฐ€ ์ž๋™์œผ๋กœ HTTP 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์„œ๋ช…์ด ์ž˜๋ชป๋œ ๊ฒฝ์šฐ๋„ ๋™์ผํ•˜๊ฒŒ ์ฒ˜๋ฆฌ๋œ๋‹ค.


๋งˆ์ง€๋ง‰์œผ๋กœ

WebSecurityConfigurerAdapter์—์„œ SecurityFilterChain์œผ๋กœ์˜ ์ „ํ™˜์€ ๋‹จ์ˆœํ•œ API ๋ณ€๊ฒฝ์ด ์•„๋‹ˆ๋‹ค. ๋žŒ๋‹ค DSL๋กœ ๋„˜์–ด์˜ค๋ฉด ๊ฐ ๋ณด์•ˆ ์„ค์ •์ด ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌ๋˜๊ณ , ์˜์กด์„ฑ ์ฃผ์ž… ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์–ด ํ…Œ์ŠคํŠธํ•˜๊ธฐ๋„ ์ˆ˜์›”ํ•ด์ง„๋‹ค. ๋” ์„ธ๋ถ€์ ์ธ ์˜ต์…˜์€ Spring Security ๊ณต์‹ ๋ ˆํผ๋Ÿฐ์Šค์™€ JJWT GitHub์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.