반응형
Spring Security에서 WebSecurityConfigurerAdapter를 @Bean 기반 구성으로 변경한 이유
📌 서론
Spring Security는 스프링 서버를 구성하면서 보안을 적용하는 데 많이 사용된다.
특히, 요즘처럼 클라이언트 측 렌더링(Client-Side Rendering, CSR)을 많이 사용하는 환경에서 JWT(JSON Web Token)를 사용하여 인증 및 인가를 구현하는 애플리케이션에서 Spring Security는 매우 중요한 역할을 한다.
Spring Security 5.7부터는 시큐리티의 클래스 구성 방식이 기존과는 상당히 달라졌다. 이 글에서는 그 변화를 간단히 설명하고, 새로운 보안 구성 클래스의 작성 방법을 간단히 알아보도록 하자.
1. WebSecurityConfigurerAdapter의 등장과 역할
WebSecurityConfigurerAdapter
- 기존 Spring Security에서는 보안 구성을 위해 WebSecurityConfigurerAdapter 클래스를 상속하는 방식이 주로 사용되었다. 이 클래스는 보안 설정을 위한 여러 메서드를 제공하며, 개발자는 이 메서드를 오버라이드하여 보안 정책을 정의했다.
- 예를 들어, configure(HttpSecurity http) 메서드를 오버라이드하여 HTTP 요청에 대한 보안 규칙을 설정할 수 있었다.
- 이렇게 코드를 작성하는 방식은 간편하면서도 강력했지만, WebSecurityConfigurerAdapter를 상속하는 것이 필수적이었기 때문에 클래스 설계가 경직될 수 있다는 단점이 있다.
예시코드
- 하단의 예시코드는 실제로 동작하도록 하려면 조금 더 다듬고 수정 작업을 해야 한다. "이렇게 코드를 작성한다."라는 것을 표현한 것이기 때문에 그대로 가져다 사용하기에는 무리가 있다. 이해를 위해서만 확인하도록 하자.
- @Configuration 어노테이션을 사용하여 @Bean을 등록하는 클래스는 스프링이 자동으로 관리하는 빈 컨테이너의 일부가 된다. 이 클래스 내에서 정의된 메서드들은 자동으로 스프링 빈으로 등록되고, 등록하는 과정에서 빈에서 필요로 하는 의존성(빈)들은 스프링이 자동으로 주입한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomUserDetailsService customUserDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/member/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(customUserDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userEntity = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));
return User.builder()
.username(userEntity.getUsername())
.password(userEntity.getPassword())
.roles(userEntity.getRole()) // role 필드는 "ROLE_USER"와 같은 형태로 저장되어야 함
.build();
}
}
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
final String requestTokenHeader = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
// JWT 토큰은 "Bearer [token]" 형태로 제공됨
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtTokenUtil.getUsernameFromToken(jwtToken);
} catch (IllegalArgumentException e) {
logger.error("Unable to get JWT Token");
} catch (ExpiredJwtException e) {
logger.warn("JWT Token has expired");
} catch (JwtException e) {
logger.error("JWT Token is invalid");
}
} else {
logger.warn("JWT Token does not begin with Bearer String");
}
// 토큰을 받았고 사용자가 인증되지 않았으면, 인증을 진행
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 토큰이 유효한지 검사
if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 컨텍스트에 인증 객체 설정
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}
2. WebSecurityConfigurerAdapter의 폐기와 @Bean 기반 설정의 도입
@Bean 직접 등록 방식의 도입
- Spring Security 5.7 이후, WebSecurityConfigurerAdapter가 더 이상 권장되지 않으며, 공식적으로는 사용하지 않도록 권고된다. 대신, @Bean 어노테이션을 사용하여 보안 설정을 구성하는 방식이 도입되었다.
@Bean 등록의 장점
- 유연성 증가: 설정을 더 작은 단위로 나누어 관리할 수 있으며, 이를 조합하거나 조건에 따라 설정을 변경하기가 더 쉬워졌다.
- Spring의 다른 기능과의 통합 용이성: @Bean으로 정의된 보안 구성 요소들은 Spring의 IoC(제어의 역전) 컨테이너에서 관리되며, 다른 빈과 자연스럽게 결합할 수 있다.
- 클래스 설계의 단순화: 특정 클래스를 상속하지 않아도 되므로, 보안 설정을 정의하는 클래스가 더욱 간결해졌다.
예시코드
- @EnableWebSecurity 어노테이션은 Spring Security 5.7 이후로는 더 이상 필수적이지 않다.
- 이제는 상속받는 구조가 아니기 때문에 재정의(Override)할 필요가 없다. 필요한 설정들을 내가 찾아서 코드로 작성한 다음 @Bean으로 등록시켜 주면 된다.
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/member/**").permitAll()
.anyRequest().authenticated())
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(customUserDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}
3. 왜 이러한 변화가 필요했을까?
구성의 단순화와 유연성 증대
- 이전 방식: WebSecurityConfigurerAdapter를 상속하여 보안 설정을 구성하는 방식은 개발자가 설정을 확장하거나 조정하는 데 유연성이 제한되었다. 스프링 문서에서는 이를 "상속을 통한 강제적인 코드 구조"로 인해 발생하는 제약사항으로 설명한다.
- 현재 방식: @Bean을 통한 구성은 보안 설정을 더 작은 단위로 나누어 관리할 수 있으며, 필요에 따라 쉽게 조합하거나 대체할 수 있다. 이는 Spring Security의 최신 구성 방법론에서 "컴포넌트 기반 설정"을 촉진하기 위해 도입되었다.
Spring의 일관성 유지
- Spring Framework는 지속적으로 XML 기반 설정에서 자바 기반 설정으로 발전해 왔으며, 이 과정에서 "컴포넌트 기반 설정"이 주요 원칙으로 자리 잡았다. 이는 스프링의 다른 모듈에서도 동일하게 적용되는 철학으로, 일관된 구성 패턴을 유지하기 위한 전략이다.
- 보안 설정을 빈으로 관리함으로써 다른 스프링 빈과의 상호작용이 자연스럽게 이루어지며, 애플리케이션 전체의 일관성과 효율성이 강화된다.
아래 글을 통해 어떻게 컴포넌트 기반 설정이 주요 원칙이 되었는지 진화 과정을 간단히 알아보자!
테스트와 유지보수의 용이성
- @Bean 방식은 각 구성 요소를 독립적으로 테스트할 수 있게 하며, 이는 코드의 유지보수성과 가독성을 향상시킨다.
- 공식 문서에서는 WebSecurityConfigurerAdapter 방식이 상속 구조로 인해 테스트와 유지보수가 복잡해질 수 있다고 명시되어 있으며, 이를 해결하기 위해 모듈화 된 @Bean 기반 설정이 도입되었다고 설명한다.
참고자료 (공식 가이드 문서)
반응형
'Spring Security' 카테고리의 다른 글
Spring Security6 - Authentication(인증) (1) | 2023.10.21 |
---|---|
Spring Security와 CORS: 크로스 도메인 요청 처리 기법 알아보기 (0) | 2023.10.21 |
Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편) (0) | 2023.09.04 |
Spring Boot 3.1 & Spring Security 6: JWT 검증 리팩토링 (11편) (2) | 2023.09.04 |
Spring Security 6 이해하기: 동작 원리와 보안 기능 탐구 (0) | 2023.09.04 |