Spring Boot Security JWT ์ธ์ฆ ๊ตฌํ˜„ – SecurityFilterChain ์„ค์ • ๊ฐ€์ด๋“œ

์ด๋ฒˆ ํฌ์ŠคํŒ…์—์„œ๋Š” Spring Boot Security๋ฅผ ํ™œ์šฉํ•œ JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ์‹œ์Šคํ…œ ๊ตฌํ˜„์— ๋Œ€ํ•ด์„œ ์ •๋ฆฌํ•˜๊ณ ์ž ํ•œ๋‹ค. Spring Boot Security๋Š” Spring ์ƒํƒœ๊ณ„์—์„œ ์ธ์ฆ๊ณผ ์ธ๊ฐ€๋ฅผ ๋‹ด๋‹นํ•˜๋Š” ํ•ต์‹ฌ ํ”„๋ ˆ์ž„์›Œํฌ๋กœ, Spring Boot 3.x ๋ฒ„์ „๋ถ€ํ„ฐ ์„ค์ • ๋ฐฉ์‹์ด ํฌ๊ฒŒ ๋ฐ”๋€Œ์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ธฐ์กด ๋ฐฉ์‹๊ณผ ํ˜ผ์šฉํ•˜๋ฉด ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ธฐ๊ธฐ๋„ ํ•œ๋‹ค. ์ด ํฌ์ŠคํŒ…์—์„œ๋Š” Spring Boot 3.2, Java 21, Spring Security 6 ๊ธฐ์ค€์œผ๋กœ JWT ์ธ์ฆ ํ๋ฆ„ ์ „์ฒด๋ฅผ ์ฒ˜์Œ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค.


ํ”„๋กœ์ ํŠธ ์˜์กด์„ฑ ์„ค์ •

Spring Boot Security์™€ JWT๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์•„๋ž˜ ์˜์กด์„ฑ์ด ํ•„์š”ํ•˜๋‹ค. JWT ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” JJWT๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ•œ๋‹ค.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // Spring Boot Security ๊ธฐ๋ณธ ์˜์กด์„ฑ
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // JPA ์‚ฌ์šฉ ์‹œ ์ถ”๊ฐ€
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // JJWT - JWT ์ƒ์„ฑ ๋ฐ ํŒŒ์‹ฑ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    // H2 ์ธ๋ฉ”๋ชจ๋ฆฌ DB (ํ…Œ์ŠคํŠธ์šฉ)
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}
Groovy

์ „์ฒด ๊ตฌ์กฐ ์ดํ•ด

Spring Boot Security JWT ์ธ์ฆ ํ๋ฆ„์€ ํฌ๊ฒŒ ์„ธ ๋‹จ๊ณ„๋กœ ๋‚˜๋‰œ๋‹ค.

  1. ๋กœ๊ทธ์ธ ์š”์ฒญ ์‹œ ์‚ฌ์šฉ์ž ์ž๊ฒฉ์ฆ๋ช…์„ ๊ฒ€์ฆํ•˜๊ณ  JWT ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•œ๋‹ค.
  2. ์ดํ›„ ์š”์ฒญ์—์„œ๋Š” ์š”์ฒญ ํ—ค๋”์˜ JWT ํ† ํฐ์„ ํŒŒ์‹ฑํ•˜์—ฌ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ SecurityContext์— ๋“ฑ๋กํ•œ๋‹ค.
  3. SecurityFilterChain์ด ๊ฐ ์š”์ฒญ์˜ ์ธ๊ฐ€ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•œ๋‹ค.

Spring Security 6๋ถ€ํ„ฐ๋Š” WebSecurityConfigurerAdapter๊ฐ€ ์™„์ „ํžˆ ์ œ๊ฑฐ๋˜์—ˆ๋‹ค. ๋ชจ๋“  ๋ณด์•ˆ ์„ค์ •์€ SecurityFilterChain์„ Bean์œผ๋กœ ๋“ฑ๋กํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ๋งŒ ์ฒ˜๋ฆฌ๋œ๋‹ค. ์ด ์ ์„ ๋จผ์ € ์ˆ™์ง€ํ•˜๋Š” ๊ฒƒ์ด Spring Boot Security ์„ค์ •์˜ ์ถœ๋ฐœ์ ์ด๋‹ค.

spring boot security JWT ์ธ์ฆ ํ๋ฆ„
Spring Boot Security JWT ์ธ์ฆ ํ๋ฆ„ ๋‹ค์ด์–ด๊ทธ๋žจ

User ์—”ํ‹ฐํ‹ฐ์™€ UserDetailsService ๊ตฌํ˜„

Spring Boot Security๋Š” ์ธ์ฆ ๊ณผ์ •์—์„œ UserDetails ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค. ๋จผ์ € UserDetails๋ฅผ ๊ตฌํ˜„ํ•œ ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ž‘์„ฑํ•œ๋‹ค.

// User.java
package com.example.security.domain;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User implements UserDetails {

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

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

    @Column(nullable = false)
    private String password;

    // ROLE_USER, ROLE_ADMIN ๋“ฑ ๊ถŒํ•œ ์ •๋ณด ์ €์žฅ
    @Enumerated(EnumType.STRING)
    private Role role;

    @Builder
    public User(String email, String password, Role role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }

    // ๊ถŒํ•œ ๋ชฉ๋ก ๋ฐ˜ํ™˜ - SecurityContext์—์„œ ์ธ๊ฐ€ ํŒ๋‹จ์— ์‚ฌ์šฉ
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    // Spring Security๋Š” username์œผ๋กœ ์‚ฌ์šฉ์ž๋ฅผ ์‹๋ณ„ - ์—ฌ๊ธฐ์„œ๋Š” email์„ ์‚ฌ์šฉ
    @Override
    public String getUsername() {
        return this.email;
    }

    // ๊ณ„์ • ๋งŒ๋ฃŒ, ์ž ๊ธˆ ๋“ฑ์˜ ์ƒํƒœ๋ฅผ true๋กœ ์„ค์ • (์‹ค๋ฌด์—์„œ๋Š” DB ์ปฌ๋Ÿผ์œผ๋กœ ๊ด€๋ฆฌ ๊ถŒ์žฅ)
    @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
// Role.java
package com.example.security.domain;

public enum Role {
    ROLE_USER,
    ROLE_ADMIN
}
Java
// UserRepository.java
package com.example.security.repository;

import com.example.security.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    // email์œผ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ - ๋กœ๊ทธ์ธ ์‹œ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฒ€์ƒ‰์— ํ•„์ˆ˜
    Optional<User> findByEmail(String email);
}
Java

์ด์ œ UserDetailsService๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค. Spring Boot Security๋Š” ์ธ์ฆ ๊ณผ์ •์—์„œ ์ด ์„œ๋น„์Šค๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋กœ๋“œํ•œ๋‹ค.

// CustomUserDetailsService.java
package com.example.security.service;

import com.example.security.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    // username(email)์œผ๋กœ DB์—์„œ ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒ - ์—†์œผ๋ฉด ์ธ์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + username));
    }
}
Java

JWT ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค ๊ตฌํ˜„

JWT ํ† ํฐ์˜ ์ƒ์„ฑ, ํŒŒ์‹ฑ, ๊ฒ€์ฆ ๋กœ์ง์„ ํ•˜๋‚˜์˜ ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. JJWT ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉด ์ƒ์„ธํ•œ ์˜ต์…˜์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

// JwtTokenProvider.java
package com.example.security.jwt;

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

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtTokenProvider {

    // ์šด์˜ํ™˜๊ฒฝ์—์„œ๋Š” ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์ฃผ์ž… ๊ถŒ์žฅ - ์ตœ์†Œ 256๋น„ํŠธ(32๋ฐ”์ดํŠธ) ์ด์ƒ์˜ Base64 ์ธ์ฝ”๋”ฉ ๊ฐ’ ์‚ฌ์šฉ
    @Value("${jwt.secret}")
    private String secretKey;

    // ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (๋ฐ€๋ฆฌ์ดˆ ๋‹จ์œ„) - 1์‹œ๊ฐ„ = 3600000
    @Value("${jwt.expiration}")
    private long jwtExpiration;

    // SecretKey ๊ฐ์ฒด ์ƒ์„ฑ - HMAC-SHA ์•Œ๊ณ ๋ฆฌ์ฆ˜์— ์ ํ•ฉํ•œ ํ‚ค ์ƒ์„ฑ
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // JWT ํ† ํฐ์—์„œ ์ด๋ฉ”์ผ(subject) ์ถ”์ถœ
    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);
    }

    // UserDetails๋กœ๋ถ€ํ„ฐ JWT ํ† ํฐ ์ƒ์„ฑ
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    // ์ถ”๊ฐ€ Claims๋ฅผ ํฌํ•จํ•œ JWT ํ† ํฐ ์ƒ์„ฑ (role, userId ๋“ฑ ์ปค์Šคํ…€ ์ •๋ณด ์‚ฝ์ž… ๊ฐ€๋Šฅ)
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
                // HS256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์œผ๋กœ ์„œ๋ช… - ์„œ๋ฒ„๋งŒ ์•Œ๊ณ  ์žˆ๋Š” ํ‚ค๋กœ ํ† ํฐ ์œ„๋ณ€์กฐ ๋ฐฉ์ง€
                .signWith(getSigningKey())
                .compact();
    }

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

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}
Java

application.yml์— JWT ๊ด€๋ จ ์„ค์ •์„ ์ถ”๊ฐ€ํ•œ๋‹ค.

# application.yml
jwt:
  # ์šด์˜ํ™˜๊ฒฝ์—์„œ๋Š” ํ™˜๊ฒฝ๋ณ€์ˆ˜(JWT_SECRET)๋กœ ์ฃผ์ž… - ์˜ˆ: ${JWT_SECRET}
  secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
  # 1์‹œ๊ฐ„ ๋งŒ๋ฃŒ
  expiration: 3600000

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
YAML

JwtAuthenticationFilter ๊ตฌํ˜„

์š”์ฒญ๋งˆ๋‹ค JWT ํ† ํฐ์„ ๊ฒ€์‚ฌํ•˜๋Š” ํ•„ํ„ฐ๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค. ์ด ํ•„ํ„ฐ๊ฐ€ Spring Boot Security์˜ ์ธ์ฆ ํ๋ฆ„์—์„œ ํ•ต์‹ฌ ์—ญํ• ์„ ํ•œ๋‹ค.

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

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
// OncePerRequestFilter: ๋™์ผ ์š”์ฒญ์—์„œ ํ•„ํ„ฐ๊ฐ€ ์ค‘๋ณต ์‹คํ–‰๋˜์ง€ ์•Š๋„๋ก ๋ณด์žฅ
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    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 " ์ดํ›„์˜ ์‹ค์ œ ํ† ํฐ ๋ฌธ์ž์—ด ์ถ”์ถœ
        final String jwt = authHeader.substring(7);
        final String userEmail;

        try {
            userEmail = jwtTokenProvider.extractUsername(jwt);
        } catch (JwtException e) {
            // ํ† ํฐ ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ์ธ์ฆ ์—†์ด ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ๋„˜๊น€ - ์ดํ›„ ์ธ๊ฐ€ ๋‹จ๊ณ„์—์„œ ๊ฑฐ๋ถ€๋จ
            filterChain.doFilter(request, response);
            return;
        }

        // ์ด๋ฏธ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋ผ๋ฉด ๋‹ค์‹œ ์ธ์ฆ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Œ
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

            if (jwtTokenProvider.isTokenValid(jwt, userDetails)) {
                // ํ† ํฐ์ด ์œ ํšจํ•˜๋ฉด Authentication ๊ฐ์ฒด ์ƒ์„ฑ ํ›„ SecurityContext์— ๋“ฑ๋ก
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null, // ์ด๋ฏธ ์ธ์ฆ๋œ ์ƒํƒœ์ด๋ฏ€๋กœ credentials์€ null๋กœ ์„ค์ •
                        userDetails.getAuthorities()
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}
Java

Spring Boot Security – SecurityFilterChain ์„ค์ •

Spring Boot Security ์„ค์ •์˜ ํ•ต์‹ฌ์ธ SecurityFilterChain์„ Bean์œผ๋กœ ๋“ฑ๋กํ•œ๋‹ค. Spring Security ๊ณต์‹ ๋ฌธ์„œ์˜ SecurityFilterChain ๊ตฌ์„ฑ ๊ฐ€์ด๋“œ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉด ๋” ๋‹ค์–‘ํ•œ ์˜ต์…˜์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

// SecurityConfig.java
package com.example.security.config;

import com.example.security.jwt.JwtAuthEntryPoint;
import com.example.security.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
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 // Spring Boot Security ํ™œ์„ฑํ™” - ๊ธฐ๋ณธ ๋ณด์•ˆ ์ž๋™์„ค์ • ์ปค์Šคํ„ฐ๋งˆ์ด์ง•
@EnableMethodSecurity // @PreAuthorize, @PostAuthorize ์–ด๋…ธํ…Œ์ด์…˜ ์‚ฌ์šฉ ํ™œ์„ฑํ™”
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final JwtAuthEntryPoint jwtAuthEntryPoint;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF ๋น„ํ™œ์„ฑํ™” - JWT ๊ธฐ๋ฐ˜ Stateless ๋ฐฉ์‹์—์„œ๋Š” CSRF ํ† ํฐ์ด ๋ถˆํ•„์š”
            .csrf(AbstractHttpConfigurer::disable)
            .exceptionHandling(exception -> exception
                // ์ธ์ฆ ์‹คํŒจ ์‹œ JSON ํ˜•ํƒœ์˜ 401 ์‘๋‹ต ๋ฐ˜ํ™˜ - ๊ธฐ๋ณธ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋Œ€์‹  REST API ์‘๋‹ต ์ฒ˜๋ฆฌ
                .authenticationEntryPoint(jwtAuthEntryPoint)
            )
            .authorizeHttpRequests(auth -> auth
                // ํšŒ์›๊ฐ€์ž…, ๋กœ๊ทธ์ธ ์—”๋“œํฌ์ธํŠธ๋Š” ์ธ์ฆ ์—†์ด ์ ‘๊ทผ ํ—ˆ์šฉ
                .requestMatchers("/api/auth/**").permitAll()
                // H2 ์ฝ˜์†” ์ ‘๊ทผ ํ—ˆ์šฉ (๊ฐœ๋ฐœํ™˜๊ฒฝ ํ•œ์ • - ์šด์˜์—์„œ๋Š” ์ œ๊ฑฐ)
                .requestMatchers("/h2-console/**").permitAll()
                // ๋‚˜๋จธ์ง€ ๋ชจ๋“  ์š”์ฒญ์€ ์ธ์ฆ ํ•„์š”
                .anyRequest().authenticated()
            )
            // ์„ธ์…˜ ๋ฏธ์‚ฌ์šฉ - JWT Stateless ๋ฐฉ์‹์ด๋ฏ€๋กœ ์„œ๋ฒ„์— ์„ธ์…˜์„ ์ €์žฅํ•˜์ง€ ์•Š์Œ
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(authenticationProvider())
            // JwtAuthenticationFilter๋ฅผ UsernamePasswordAuthenticationFilter ์•ž์— ๋ฐฐ์น˜
            // ์š”์ฒญ์ด ์˜ฌ ๋•Œ JWT ๊ฒ€์‚ฌ๋ฅผ ๋จผ์ € ์ˆ˜ํ–‰ํ•˜๋„๋ก ์ˆœ์„œ ์ง€์ • - ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์ด ํผ ๊ธฐ๋ฐ˜ ์ธ์ฆ๋ณด๋‹ค ์šฐ์„ 
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // DB์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋กœ๋“œํ•˜๋Š” UserDetailsService ์„ค์ • - loadUserByUsername() ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
        authProvider.setUserDetailsService(userDetailsService);
        // ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ๋ฐฉ์‹ ์„ค์ • - BCrypt๋Š” salt๋ฅผ ์ž๋™์œผ๋กœ ํฌํ•จํ•˜์—ฌ ๊ฐ™์€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋„ ๋งค๋ฒˆ ๋‹ค๋ฅด๊ฒŒ ์•”ํ˜ธํ™”๋จ
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        // Spring Boot๊ฐ€ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•œ ๊ธฐ๋ณธ AuthenticationManager ๋ฐ˜ํ™˜
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCryptPasswordEncoder - strength ๊ธฐ๋ณธ๊ฐ’ 10 (๋†’์„์ˆ˜๋ก ๋А๋ฆฌ์ง€๋งŒ ๋ณด์•ˆ ๊ฐ•๋„ ์ฆ๊ฐ€)
        // strength ๊ฐ’: 4~31 ๋ฒ”์œ„, ์‹ค๋ฌด ๊ถŒ์žฅ๊ฐ’์€ 10~12
        return new BCryptPasswordEncoder();
    }
}
Java

์ธ์ฆ ์„œ๋น„์Šค์™€ ์ปจํŠธ๋กค๋Ÿฌ ๊ตฌํ˜„

Spring Boot Security ์ธ์ฆ ์„œ๋น„์Šค

// AuthenticationService.java
package com.example.security.service;

import com.example.security.domain.Role;
import com.example.security.domain.User;
import com.example.security.dto.AuthRequest;
import com.example.security.dto.AuthResponse;
import com.example.security.dto.RegisterRequest;
import com.example.security.jwt.JwtTokenProvider;
import com.example.security.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthenticationService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManager authenticationManager;

    public AuthResponse register(RegisterRequest request) {
        var user = User.builder()
                .email(request.getEmail())
                // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋ฐ˜๋“œ์‹œ ์•”ํ˜ธํ™”ํ•˜์—ฌ ์ €์žฅ - ํ‰๋ฌธ ์ €์žฅ ์ ˆ๋Œ€ ๊ธˆ์ง€
                .password(passwordEncoder.encode(request.getPassword()))
                .role(Role.ROLE_USER)
                .build();
        userRepository.save(user);

        var jwtToken = jwtTokenProvider.generateToken(user);
        return new AuthResponse(jwtToken);
    }

    public AuthResponse authenticate(AuthRequest request) {
        // AuthenticationManager๊ฐ€ UserDetailsService์™€ PasswordEncoder๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ž๊ฒฉ์ฆ๋ช… ๊ฒ€์ฆ
        // ์‹คํŒจ ์‹œ BadCredentialsException ๋ฐœ์ƒ - Spring Boot Security๊ฐ€ ์ž๋™ ์ฒ˜๋ฆฌ
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getEmail(),
                        request.getPassword()
                )
        );

        var user = userRepository.findByEmail(request.getEmail())
                .orElseThrow();
        var jwtToken = jwtTokenProvider.generateToken(user);
        return new AuthResponse(jwtToken);
    }
}
Java
// AuthController.java
package com.example.security.controller;

import com.example.security.dto.AuthRequest;
import com.example.security.dto.AuthResponse;
import com.example.security.dto.RegisterRequest;
import com.example.security.service.AuthenticationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationService authService;

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

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

DTO ํด๋ž˜์Šค๋„ ๊ฐ„๋‹จํžˆ ์ถ”๊ฐ€ํ•œ๋‹ค.

// RegisterRequest.java
package com.example.security.dto;

import lombok.Data;

@Data
public class RegisterRequest {
    private String email;
    private String password;
}

// AuthRequest.java
package com.example.security.dto;

import lombok.Data;

@Data
public class AuthRequest {
    private String email;
    private String password;
}

// AuthResponse.java
package com.example.security.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class AuthResponse {
    private String token;
}
Java

๋ฉ”์„œ๋“œ ์ˆ˜์ค€ ์ธ๊ฐ€ ์ฒ˜๋ฆฌ

@EnableMethodSecurity๋ฅผ ํ™œ์„ฑํ™”ํ–ˆ์œผ๋ฏ€๋กœ ์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ ๋‹จ์œ„๋กœ ๊ถŒํ•œ์„ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ค.

// DemoController.java
package com.example.security.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/demo")
public class DemoController {

    // ์ธ์ฆ๋œ ๋ชจ๋“  ์‚ฌ์šฉ์ž ์ ‘๊ทผ ๊ฐ€๋Šฅ - SecurityFilterChain์˜ anyRequest().authenticated()์™€ ๋™์ผ
    @GetMapping("/user")
    public ResponseEntity<String> userEndpoint() {
        return ResponseEntity.ok("์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ ‘๊ทผ ์„ฑ๊ณต");
    }

    // ROLE_ADMIN ๊ถŒํ•œ์„ ๊ฐ€์ง„ ์‚ฌ์šฉ์ž๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ
    // Spring Boot Security์˜ ๋ฉ”์„œ๋“œ ์ˆ˜์ค€ ์ธ๊ฐ€ ์ฒ˜๋ฆฌ
    @GetMapping("/admin")
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    public ResponseEntity<String> adminEndpoint() {
        return ResponseEntity.ok("๊ด€๋ฆฌ์ž ์ ‘๊ทผ ์„ฑ๊ณต");
    }
}
Java

์˜ˆ์™ธ ์ฒ˜๋ฆฌ – Spring Boot Security AuthEntryPoint

์ธ์ฆ ์‹คํŒจ๋‚˜ ์ธ๊ฐ€ ๊ฑฐ๋ถ€ ์‹œ์˜ ์‘๋‹ต์„ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•˜๋ ค๋ฉด AuthenticationEntryPoint์™€ AccessDeniedHandler๋ฅผ ๊ตฌํ˜„ํ•˜๋ฉด ๋œ๋‹ค.

// JwtAuthEntryPoint.java
package com.example.security.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Component
// AuthenticationEntryPoint - ์ธ์ฆ๋˜์ง€ ์•Š์€ ์š”์ฒญ์ด ๋ณดํ˜ธ๋œ ๋ฆฌ์†Œ์Šค์— ์ ‘๊ทผํ•  ๋•Œ ํ˜ธ์ถœ
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    ) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        // 401 Unauthorized ๋ฐ˜ํ™˜
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        final Map<String, Object> body = new HashMap<>();
        body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
        body.put("error", "Unauthorized");
        body.put("message", authException.getMessage());
        body.put("path", request.getServletPath());

        final ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getOutputStream(), body);
    }
}
Java

SecurityConfig์— exceptionHandling ์„ค์ •์„ ์ถ”๊ฐ€ํ•œ๋‹ค.

// SecurityConfig.java - securityFilterChain ๋ฉ”์„œ๋“œ์— ์ถ”๊ฐ€
.exceptionHandling(exception -> exception
    // ์ธ์ฆ ์‹คํŒจ ์‹œ JSON ํ˜•ํƒœ์˜ 401 ์‘๋‹ต ๋ฐ˜ํ™˜
    .authenticationEntryPoint(jwtAuthEntryPoint)
)
Java

๋™์ž‘ ํ™•์ธ

์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•œ ๋’ค ์•„๋ž˜ ์ˆœ์„œ๋กœ API๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ์ „์ฒด Spring Boot Security JWT ์ธ์ฆ ํ๋ฆ„์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

# 1. ํšŒ์›๊ฐ€์ž…
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"password123"}'

# ์‘๋‹ต ์˜ˆ์‹œ
# {"token":"eyJhbGciOiJIUzI1NiJ9..."}

# 2. ํ† ํฐ์œผ๋กœ ๋ณดํ˜ธ๋œ ๋ฆฌ์†Œ์Šค ์ ‘๊ทผ
curl -X GET http://localhost:8080/api/demo/user \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

# 3. ํ† ํฐ ์—†์ด ์ ‘๊ทผ ์‹œ - 401 ๋ฐ˜ํ™˜
curl -X GET http://localhost:8080/api/demo/user
ShellScript

Spring Boot Security์™€ JWT๋ฅผ ์ฒ˜์Œ ์—ฐ๋™ํ•  ๋•Œ ์ž์ฃผ ๋งˆ์ฃผ์น˜๋Š” ๋ฌธ์ œ ์ค‘ ํ•˜๋‚˜๋Š” Filter ๋“ฑ๋ก ์ˆœ์„œ๋‹ค. JwtAuthenticationFilter๊ฐ€ UsernamePasswordAuthenticationFilter ๋’ค์— ๋ฐฐ์น˜๋˜๋ฉด ํ† ํฐ์ด ์žˆ์–ด๋„ ์ธ์ฆ์ด ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š๋Š”๋‹ค. addFilterBefore๋กœ ์ˆœ์„œ๋ฅผ ๋ช…ํ™•ํžˆ ์ง€์ •ํ•˜๋Š” ์ด์œ ๊ฐ€ ์—ฌ๊ธฐ ์žˆ๋‹ค. ๋˜ํ•œ Spring Security 6์˜ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์— ๋Œ€ํ•ด์„œ๋Š” Spring Security 6 ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉด ๊ตฌ๋ฒ„์ „๊ณผ์˜ ์ฐจ์ด์ ์„ ํ•œ๋ˆˆ์— ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

Refresh Token ๊ตฌํ˜„์ด๋‚˜ Redis๋ฅผ ํ™œ์šฉํ•œ ํ† ํฐ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ์ฒ˜๋ฆฌ ๊ฐ™์€ ๊ณ ๊ธ‰ ์ฃผ์ œ๋Š” ์ด๋ฒˆ ํฌ์ŠคํŒ… ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚˜์ง€๋งŒ, ์‹ค๋ฌด์—์„œ๋Š” Access Token ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ์งง๊ฒŒ ์œ ์ง€ํ•˜๊ณ  Refresh Token์œผ๋กœ ์žฌ๋ฐœ๊ธ‰ํ•˜๋Š” ํŒจํ„ด์ด ๋ณด์•ˆ ์ธก๋ฉด์—์„œ ๋‚˜์˜์ง€ ์•Š์€ ๊ฒƒ ๊ฐ™๋‹ค. Spring Security OAuth2 Resource Server๋ฅผ ํ™œ์šฉํ•˜๋ฉด JWT ๊ฒ€์ฆ ๋กœ์ง์˜ ์ƒ๋‹น ๋ถ€๋ถ„์„ ํ”„๋ ˆ์ž„์›Œํฌ์— ์œ„์ž„ํ•  ์ˆ˜๋„ ์žˆ๋‹ค.


๋งˆ์น˜๋ฉฐ

์ง€๊ธˆ๊นŒ์ง€ Spring Boot Security์™€ JWT๋ฅผ ์—ฐ๋™ํ•œ ์ธ์ฆ ์‹œ์Šคํ…œ ๊ตฌํ˜„ ์ „์ฒด ํ๋ฆ„์„ ์ •๋ฆฌํ•ด ๋ณด์•˜๋‹ค. SecurityFilterChain ์„ค์ •๋ถ€ํ„ฐ JwtAuthenticationFilter, UserDetailsService, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๊นŒ์ง€ Spring Boot 3.2 ๊ธฐ์ค€์œผ๋กœ ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ค‘์‹ฌ์œผ๋กœ ๋‹ค๋ค˜๋Š”๋ฐ, Spring Security 6๋กœ ๋„˜์–ด์˜ค๋ฉด์„œ ์„ค์ • ๋ฐฉ์‹์ด ๊ฝค ๋งŽ์ด ๋‹ฌ๋ผ์ง„ ๋งŒํผ ๊ตฌ๋ฒ„์ „ ๋ ˆํผ๋Ÿฐ์Šค๋ฅผ ๊ทธ๋Œ€๋กœ ๋”ฐ๋ผ๊ฐ€๋‹ค ๋ณด๋ฉด ์ปดํŒŒ์ผ ์˜ค๋ฅ˜๋‚˜ ๋™์ž‘ ์ด์ƒ์„ ๊ฒช๋Š” ๊ฒฝ์šฐ๊ฐ€ ์ ์ง€ ์•Š์€ ๊ฒƒ ๊ฐ™๋‹ค. ์ด ํฌ์ŠคํŒ…์ด Spring Boot Security๋ฅผ ์ฒ˜์Œ ๋„์ž…ํ•˜๊ฑฐ๋‚˜ ๋ฒ„์ „์„ ์˜ฌ๋ฆฌ๋Š” ๊ณผ์ •์—์„œ ์ž‘์€ ์ฐธ๊ณ ๊ฐ€ ๋˜์—ˆ์œผ๋ฉด ํ•˜๋Š” ๋ฐ”๋žŒ์ด๋‹ค.