이번 포스팅에서는 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 인증 흐름은 크게 세 단계로 나뉜다.
- 로그인 요청 시 사용자 자격증명을 검증하고 JWT 토큰을 발급한다.
- 이후 요청에서는 요청 헤더의 JWT 토큰을 파싱하여 사용자 정보를 SecurityContext에 등록한다.
- SecurityFilterChain이 각 요청의 인가 여부를 판단한다.
Spring Security 6부터는 WebSecurityConfigurerAdapter가 완전히 제거되었다. 모든 보안 설정은 SecurityFilterChain을 Bean으로 등록하는 방식으로만 처리된다. 이 점을 먼저 숙지하는 것이 Spring Boot Security 설정의 출발점이다.

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));
}
}JavaJWT 유틸리티 클래스 구현
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();
}
}Javaapplication.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: trueYAMLJwtAuthenticationFilter 구현
요청마다 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);
}
}JavaSpring 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));
}
}JavaDTO 클래스도 간단히 추가한다.
// 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);
}
}JavaSecurityConfig에 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/userShellScriptSpring 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를 처음 도입하거나 버전을 올리는 과정에서 작은 참고가 되었으면 하는 바람이다.
