들어가며
Spring Security는 스프링 기반 애플리케이션의 인증(Authentication)과 인가(Authorization)를 담당하는 핵심 프레임워크입니다. Spring Security 6부터는 기존의 WebSecurityConfigurerAdapter가 완전히 제거되고, 컴포넌트 기반의 SecurityFilterChain으로 전환되었습니다. 이번 글에서는 Spring Security 6 환경에서 JWT 토큰 인증과 OAuth2 리소스 서버 설정을 실무 수준으로 다루겠습니다.
1. Spring Security 6 주요 변경점
Spring Security 6(Spring Boot 3.x 이상)에서 달라진 핵심 사항을 정리합니다.
Before: WebSecurityConfigurerAdapter 방식 (Spring Security 5)
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}
After: SecurityFilterChain 방식 (Spring Security 6)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
| 항목 | Security 5 (이전) | Security 6 (현재) |
|---|---|---|
| 설정 방식 | WebSecurityConfigurerAdapter 상속 | SecurityFilterChain @Bean 등록 |
| URL 매칭 | antMatchers() | requestMatchers() |
| 인가 메서드 | authorizeRequests() | authorizeHttpRequests() |
| Lambda DSL | 선택 | 기본 권장 |
| Jakarta EE | javax.* | jakarta.* |
2. JWT 토큰 생성 및 검증
실무에서 가장 많이 사용되는 JWT 기반 인증 구현입니다. io.jsonwebtoken:jjwt 라이브러리를 사용합니다.
의존성 추가 (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
JwtTokenProvider 구현
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-validity:3600000}")
private long accessTokenValidity; // 1시간
@Value("${jwt.refresh-token-validity:604800000}")
private long refreshTokenValidity; // 7일
private SecretKey key;
@PostConstruct
protected void init() {
this.key = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(secretKey)
);
}
public String createAccessToken(String email, List<String> roles) {
return createToken(email, roles, accessTokenValidity);
}
public String createRefreshToken(String email) {
return createToken(email, Collections.emptyList(), refreshTokenValidity);
}
private String createToken(String subject, List<String> roles, long validity) {
Date now = new Date();
Date expiration = new Date(now.getTime() + validity);
return Jwts.builder()
.subject(subject)
.claim("roles", roles)
.issuedAt(now)
.expiration(expiration)
.signWith(key)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public String getSubject(String token) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
@SuppressWarnings("unchecked")
public List<String> getRoles(String token) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token)
.getPayload()
.get("roles", List.class);
}
}
JwtAuthenticationFilter 구현
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
String email = jwtTokenProvider.getSubject(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
3. SecurityFilterChain에 JWT 필터 통합
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 활성화
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.httpBasic(basic -> basic.disable())
.formLogin(form -> form.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService),
UsernamePasswordAuthenticationFilter.class
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, authEx) -> {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"error\":\"인증이 필요합니다.\"}");
})
.accessDeniedHandler((req, res, accessEx) -> {
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"error\":\"접근 권한이 없습니다.\"}");
})
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
4. OAuth2 리소스 서버 설정
외부 인가 서버(Keycloak, Auth0 등)와 연동할 때 OAuth2 리소스 서버로 동작하도록 설정합니다.
application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/my-realm
jwk-set-uri: https://auth.example.com/realms/my-realm/protocol/openid-connect/certs
OAuth2 리소스 서버 SecurityFilterChain
@Bean
public SecurityFilterChain oauth2ResourceServerFilterChain(
HttpSecurity http) throws Exception {
http
.securityMatcher("/api/v2/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v2/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthorities =
new JwtGrantedAuthoritiesConverter();
grantedAuthorities.setAuthoritiesClaimName("roles");
grantedAuthorities.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthorities);
return converter;
}
5. @PreAuthorize 권한 관리
@EnableMethodSecurity를 활성화하면 메서드 수준에서 세밀한 권한 제어가 가능합니다.
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@GetMapping
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public ResponseEntity<List<PostResponse>> getPosts() {
return ResponseEntity.ok(postService.findAll());
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<PostResponse> createPost(
@RequestBody @Valid PostRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(postService.create(request));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @postService.isAuthor(#id, authentication.name)")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/my")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<PostResponse>> getMyPosts(
@AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok(
postService.findByAuthor(userDetails.getUsername()));
}
}
6. 로그인/토큰 발급 API
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(
@RequestBody @Valid LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(), request.getPassword())
);
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
String accessToken = jwtTokenProvider.createAccessToken(
request.getEmail(), roles);
String refreshToken = jwtTokenProvider.createRefreshToken(
request.getEmail());
return ResponseEntity.ok(
new TokenResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(
@RequestBody RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new InvalidTokenException("유효하지 않은 리프레시 토큰입니다.");
}
String email = jwtTokenProvider.getSubject(refreshToken);
// DB에서 사용자 정보와 roles를 다시 조회
List<String> roles = userService.getRoles(email);
String newAccessToken = jwtTokenProvider.createAccessToken(email, roles);
String newRefreshToken = jwtTokenProvider.createRefreshToken(email);
return ResponseEntity.ok(
new TokenResponse(newAccessToken, newRefreshToken));
}
}
public record TokenResponse(String accessToken, String refreshToken) {}
public record LoginRequest(String email, String password) {}
public record RefreshTokenRequest(String refreshToken) {}
7. 테스트 코드
@WebMvcTest(PostController.class)
class PostControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "ADMIN")
void 관리자는_게시글을_생성할_수_있다() throws Exception {
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"테스트\",\"content\":\"내용\"}"))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(roles = "USER")
void 일반_사용자는_게시글을_생성할_수_없다() throws Exception {
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"테스트\",\"content\":\"내용\"}"))
.andExpect(status().isForbidden());
}
@Test
void 비인증_사용자는_접근할_수_없다() throws Exception {
mockMvc.perform(get("/api/posts"))
.andExpect(status().isUnauthorized());
}
}
마치며
Spring Security 6은 기존 방식에서 크게 바뀌었지만, 오히려 설정이 더 직관적이고 유연해졌습니다. 핵심 포인트를 정리하면 다음과 같습니다.
- SecurityFilterChain을 @Bean으로 등록하여 여러 보안 정책을 분리 관리할 수 있습니다.
- JWT 인증은 커스텀 필터를 만들어 UsernamePasswordAuthenticationFilter 앞에 배치합니다.
- OAuth2 리소스 서버는 외부 인가 서버와 연동할 때 사용하며, JWK Set URI만 설정하면 토큰 검증이 자동으로 됩니다.
- @EnableMethodSecurity와 @PreAuthorize로 메서드 수준 권한 제어가 가능합니다.
- CORS/CSRF 설정은 Lambda DSL로 깔끔하게 작성합니다. REST API 서버라면 CSRF는 비활성화합니다.
특히 마이그레이션 시에는 antMatchers -> requestMatchers, authorizeRequests -> authorizeHttpRequests 변경을 놓치지 마세요. Spring Security 공식 마이그레이션 가이드도 함께 참고하시길 권장합니다.
'Spring Boot' 카테고리의 다른 글
| Spring Boot 4 마이그레이션 가이드 - Virtual Threads와 Spring AI 통합 (1) | 2026.04.02 |
|---|---|
| Spring WebFlux 입문 - 리액티브 프로그래밍의 모든 것 (0) | 2026.04.02 |
| Spring AI 2.0과 Spring Boot 4 - AI 퍼스트 Java 개발의 시작 (0) | 2026.04.01 |
| Spring Boot 테스트 전략 - 단위 테스트부터 통합 테스트까지 (0) | 2026.03.31 |
| Spring AOP 실전 활용 - 로깅부터 성능 측정까지 (0) | 2026.03.26 |