SecurityFilterChain 적용 후 @WebMvcTest 테스트 실패하는 문제 (feat: @WebMvcTest 까보기)
문제 상황
사내 프로젝트의 스프링 부트 및 자바 버전을 업데이트하면서 Deprecated
된 클래스들 또한 새롭게 업데이트하고 있었다.
그 중, Spring Security의 WebSecurityConfigurerAdpater
가 스프링 부트 2.7 버전부터 Deprecated
되었고 이를 대신해 SecurityFilterChain
을 사용하도록 권고했다.
교체하는 것 자체는 어렵지 않았다.
기존 WebSecurityConfigurerAdpater
를 상속하는 것 대신 SecurityFilterChain
빈을 등록해주면 되는 형태였다.
// 기존
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
}
}
// 수정
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeRequests()
...
.build();
}
}
하지만, 해당 수정을 반영 후 @WebMvcTest
를 사용하는 테스트코드들에서 Status Code가 모두 401 Unauthorized
가 내려오며 오류가 발생했다.
원인
동일한 이슈가 이미 스프링 부트 깃헙 이슈에서 다루어 진 적 있어서 원인은 의외로 금방 파악할 수 있었다.
https://github.com/spring-projects/spring-boot/issues/31162
명확한 원인은 해당 댓글에 잘 정리되어있다.
요약하자면, 기존에 WebSecurityConfigurerAdapter
를 상속하는 클래스들은 @WebMvcTest
의 컴포넌트 스캔 대상이 되지만, SecurityFilterChain
빈을 등록한 클래스는 단순 @Configuration
을 통해 빈을 등록하므로 @WebMvcTest
의 컴포넌트 스캔 대상이 되지 못한다는 것이었다.
아하! SecurityFilterChain
은 단순히 빈을 등록한 것일 뿐이므로 MVC와 관련된 빈들만 스캔하는 @WebMvcTest
에선 따로 스캔을 하지 않겠구나!
이걸 알고보니 SecurityFilterChain
빈이 왜 @WebMvcTest
에서 스캔되지 않는가보다는,
Web과 단지 간접적으로만 관련이 있는 WebSecurityConfigurerAdapter
상속 클래스들이 어떻게 @WebMvcTest
에서 스캔되는지가 더욱 궁금해졌다.
디깅
아래 공식문서에 따르면, @WebMvcTest
어노테이션에는 기본적으로 Spring Security 와 관련된 자동 설정을 해준다고 나와있다.
By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver). For more fine-grained control of MockMVC the @AutoConfigureMockMvc annotation can be used.
실제 구현이 어떻게 되어있는지는 코드로 직접 확인해보자.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(WebMvcTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(WebMvcTypeExcludeFilter.class)
@AutoConfigureCache
@AutoConfigureWebMvc
@AutoConfigureMockMvc
@ImportAutoConfiguration
public @interface WebMvcTest {
...
}
@WebMvcTest
는 WebMvcTypeExcludeFillter
를 @TypeExcludeFilters
에 등록해주고 있다.
그렇담 WebMvcTypeExcludeFilter
를 까보자.
public final class WebMvcTypeExcludeFilter extends StandardAnnotationCustomizableTypeExcludeFilter<WebMvcTest> {
...
private static final String[] OPTIONAL_INCLUDES = { "com.fasterxml.jackson.databind.Module",
"org.springframework.security.config.annotation.web.WebSecurityConfigurer",
"org.springframework.security.web.SecurityFilterChain", "org.thymeleaf.dialect.IDialect" };
private static final Set<Class<?>> DEFAULT_INCLUDES;
static {
Set<Class<?>> includes = new LinkedHashSet<>();
includes.add(ControllerAdvice.class);
includes.add(JsonComponent.class);
includes.add(WebMvcConfigurer.class);
includes.add(WebMvcRegistrations.class);
includes.add(javax.servlet.Filter.class);
includes.add(FilterRegistrationBean.class);
includes.add(DelegatingFilterProxyRegistrationBean.class);
includes.add(HandlerMethodArgumentResolver.class);
includes.add(HttpMessageConverter.class);
includes.add(ErrorAttributes.class);
includes.add(Converter.class);
includes.add(GenericConverter.class);
includes.add(HandlerInterceptor.class);
for (String optionalInclude : OPTIONAL_INCLUDES) {
try {
includes.add(ClassUtils.forName(optionalInclude, null));
}
catch (Exception ex) {
// Ignore
}
}
DEFAULT_INCLUDES = Collections.unmodifiableSet(includes);
}
...
}
MVC와 관련된 Controller
, Filter
, Interceptor
등등의 클래스들을 includes
에 포함시키고 있으며, 그 외 객체 직렬-역직렬화를 위한 jackson
및 Security와 관련된 WebSecurityConfigurer
와 SecurityFilterChain
클래스들 또한 optional로 포함하고 있었다.
WebSecurityConfigurer
는 Securiy 관련된 사용자의 커스텀 설정을 위한 인터페이스이고, 우리가 지금껏 썼던(이제는 Deprecated
인) WebSecurityConfigurerAdpater
는 해당 인터페이스를 구현하는 추상클래스이다.
public abstract class WebSecurityConfigurerAdapter implements WebSecurityConfigurer<WebSecurity> {
...
}
따라서 WebSecurityConfigurerAdpater
를 상속하는 우리의 SecurityConfig
클래스 또한 컴포넌트 스캔 대상에서 포함되게 된 것이었다.
오잉? 근데 새로 도입된 SecurityFilterChain
인터페이스 역시 @WebMvcTest
에서 includes
에 포함시키고 있는데?
차이점은 우리의 새로운 SecurityConfig
는 WebSecurityConfigurerAdpater
에서 처럼 추상 클래스를 상속하는 것이 아닌 단순히 SecurityFilterChain
이라는 인터페이스의 구현체라는 것이다.
즉, 해당 인터페이스 구현체는 Spring Security 모듈이 아닌 우리의 프로젝트 내부에서 빈으로 등록된 것이기 때문에 직접 등록해주지 않으면 당연히 컴포넌트 스캔 범위에 포함되지 못한다.
해결
해결 방법은 매우 간단하다.
@WebMvcTest
@Import(SecurityConfig.class) // 추가
public class BaseControllerTest {
...
}
@WebMvcTest
를 사용하는 곳에 SecurityFilterChain
빈을 등록하는 Config
클래스를 Import
하여 컴포넌트 스캔 대상에 포함시켜주면 된다.
이는 스프링 공식문서에 명시된 방법이기도 하다.
For a @WebMvcTest for an application with the above @Configuration class, you might expect to have the SecurityFilterChain bean in the application context so that you can test if your controller endpoints are secured properly. However, MyConfiguration is not picked up by @WebMvcTest’s component scanning filter because it doesn’t match any of the types specified by the filter. You can include the configuration explicitly by annotating the test class with @Import(MyConfiguration.class). This will load all the beans in MyConfiguration including the BasicDataSource bean which isn’t required when testing the web tier. Splitting the configuration class into two will enable importing just the security configuration.
참고: https://docs.spring.io/spring-boot/docs/2.7.x/reference/htmlsingle/#howto.testing.slice-tests