바쁜 현대인을 위한 세 줄 요약
문제
Spring Security와 커스텀 SecurityFilterChain 적용 후 @WebMvcTest를 적용한 테스트 코드가 테스트를 통과하지 못했습니다.
아래는 내가 추가한 SecurityFilterChain 빈 설정 클래스입니다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
TokenAuthenticationFilter tokenAuthenticationFilter
) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
)
.build();
}
}
하지만 해당 커스텀 설정은 적용되지 않았습니다.
왜 이런 현상이 발생했을까?
로그 살펴보기
내가 설정한 필터가 제대로 적용되는지 확인하기 위해 로그를 설정했습니다.
logging:
level:
org:
springframework:
security: info
이렇게 설정하면 애플리케이션이 실행될 때 SecurityFilterChain에 설정된 필터를 확인할 수 있습니다.
아래는 @WebMvcTest가 적용된 테스트의 로그 중 필터를 나타내는 로그입니다.
(가독성을 위해 패키지명과 해시코드를 생략)
Will secure any request with [
DisableEncodeUrlFilter,
WebAsyncManagerIntegrationFilter,
SecurityContextHolderFilter,
HeaderWriterFilter,
CorsFilter,
CsrfFilter,
LogoutFilter,
UsernamePasswordAuthenticationFilter,
DefaultLoginPageGeneratingFilter,
DefaultLogoutPageGeneratingFilter,
BasicAuthenticationFilter,
RequestCacheAwareFilter,
SecurityContextHolderAwareRequestFilter,
AnonymousAuthenticationFilter,
ExceptionTranslationFilter,
AuthorizationFilter
]
16개의 필터가 설정되어 있습니다. 여기서 이상한 점은 프로젝트에 설정하지 않은 필터가 등록돼 있다는 것입니다.
즉, 제가 설정한 필터 체인이 아닌 다른 필터 체인이 등록된 것입니다.
아래는 내가 설정한 필터 목록을 애플리케이션 실행(테스트 실행이 아닌)을 통해 확인한 로그입니다.
Will secure any request with [
DisableEncodeUrlFilter,
WebAsyncManagerIntegrationFilter,
SecurityContextHolderFilter,
HeaderWriterFilter,
CorsFilter,
LogoutFilter,
TokenAuthenticationFilter,
RequestCacheAwareFilter,
SecurityContextHolderAwareRequestFilter,
AnonymousAuthenticationFilter,
SessionManagementFilter,
ExceptionTranslationFilter,
AuthorizationFilter
]
테스트 컨텍스트와 애플리케이션 컨텍스트가 다른 이유를 확인하기 위해 @WebMvcTest가 어떤 컴포넌트를 스캔하는지 확인할 필요가 있습니다.
@WebMvcTest
애플리케이션 컨텍스트 전체를 초기화하지 않고 Spring MVC 관련 컴포넌트들만으로 애플리케이션 컨텍스트를 구성해 테스트 환경을 가볍고 빠르게 만들기 위해 @WebMvcTest를 사용합니다.
Spring Security 또한 이 어노테이션을 통해 설정됩니다.
By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc
그렇다면 제가 등록한 SecurityFilterChain 빈은 Spring Security와 관련이 없는 건가?
아래는 @WebMvcTest 어노테이션에 선언된 어노테이션입니다.
...
@TypeExcludeFilters(WebMvcTypeExcludeFilter.class)
@AutoConfigureCache
@AutoConfigureWebMvc
@AutoConfigureMockMvc
@ImportAutoConfiguration
public @interface WebMvcTest {
...
}
@TypeExcludeFilters을 살펴봅시다.
이 애너테이션의 역할은 어떤 클래스를 빈으로 등록할지 나타내고 WebMvcTypeExcludeFilter 클래스에 다음과 같은 코드로 명시되어 있다.
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);
...
for (String optionalInclude : OPTIONAL_INCLUDES) {
try {
includes.add(ClassUtils.forName(optionalInclude, null));
}
catch (Exception ex) {
// Ignore
}
}
DEFAULT_INCLUDES = Collections.unmodifiableSet(includes);
}
SecurityFilterChain도 OPTIONAL_INCLUDES로 포함되어 있는 것을 알 수 있습니다.
하지만 제가 등록한 SecurityFilterChain 빈은 컴포넌트 스캔되지 않았습니다.
이유가 뭘까요?
원인
이 문제와 관련된 이슈가 이미 존재했습니다.
조금만 생각해 보면 당연한 것이었습니다.
제가 SecurityFilterChain을 등록한 방법은 @Configuration을 에서 @Bean 메서드를 통해 등록했습니다.
따라서 SecurityFilterChain 빈이 테스트 컨텍스트에 등록되려면 @Configuration이 선언된 클래스가 먼저 스캔되어야 합니다.
하지만 @Configuration은 WebMvcTypeExcludeFilter 클래스에 포함돼있지 않습니다.
결과적으로 @Configuration 빈을 스캔하지 못해 제가 등록한 SecurityFilterChain을 스캔하지 못한 것입니다.
테스트를 위해 SecurityFilterChain을 직접 구현해 보았습니다.
@Component
public class CustomSecurityFilterChain implements SecurityFilterChain {
@Override
public boolean matches(HttpServletRequest request) {
return false;
}
@Override
public List<Filter> getFilters() {
return Collections.emptyList();
}
}
위 코드를 추가하니 테스트가 통과했습니다.
그렇다면 @Configuration에서 빈을 등록하고 @WebMvcTest에서 해당 SecurityFilterChain을 사용하는 방법은 무엇일까요?
해결
@Import 어노테이션을 통해 SecurityFilterChain을 등록하는 @Bean 메서드가 있는 @Configuration을 스캔합니다.
@WebMvcTest
@Import(WebSecurityConfig.class)
class Test {
...
}
만약 @Configuration 클래스에 @WebMvcTest가 스캔하지 않는 추가적인 컴포넌트가 필요한 경우 @Import에 추가해주어야 합니다.
의문
처음 마주한 상황에서는 Spring Boot가 제공하는 기본 SecurityFilterChain이 등록되었습니다. 어떻게 이런 일이 가능했을까요?
@WebMvcTest 애노테이션을 살펴보면 답을 알 수 있습니다.
...
@TypeExcludeFilters(WebMvcTypeExcludeFilter.class)
@AutoConfigureCache
@AutoConfigureWebMvc
@AutoConfigureMockMvc
@ImportAutoConfiguration
public @interface WebMvcTest {
...
}
@ImportAutoConfiguration이라는 애노테이션이 기본 SecurityFilterChain을 스캔해주는 것이었습니다!
추가적으로
@WebMvcTest에서 @Configuration으로 설정한 SecurityFilterChain 빈을 스캔하는 방법을 알아봤습니다.
위 방법도 훌륭한 방법이라 생각합니다.
하지만 모든 @WebMvcTest에 @Import를 붙이는 것은 쉬운 일이 아닙니다. 게다가 @Configuration에서 새로운 컴포넌트를 의존하게 된다면 모든 @WebMvcTest에 해당 컴포넌트를 @Import 하거나 @MockBean을 사용하여 모킹해야 합니다.
그리고 생각해봐야 할 것이 또 한 가지 있습니다.
Spring의 Test Context 캐싱입니다. Spring은 테스트를 위해 만든 Context를 캐싱해두고 사용합니다. 하지만 Context가 오염된 경우 Context를 재구성합니다. 예를 들어 @MockBean 애노테이션이 있으면 빈을 새로 정의하는 것과 같기 때문에 Context를 다시 구성합니다. 이 점은 @WebMvcTest에서 크게 다가옵니다.
일반적으로 @Controller는 @Service 컴포넌트를 의존합니다. 하지만 @WebMvcTest는 이를 스캔하지 않아 일반적으로 @MockBean으로 @Service 컴포넌트를 모킹합니다. 그래서 테스트 클래스가 늘어날 수록 테스트 Context를 구성하는데 오랜 시간이 걸리게 됩니다.
단일 테스트 실행의 경우 @WebMvcTest로 필요한 빈만 스캔하는 것은 빠를 수 있지만 CI 환경 같이 전체 테스트케이스 실행의 경우 위와 같은 점은 치명적일 수 있습니다.
위 내용을 고려하여 저의 프로젝트에서 적용한 방법은 다음 글에서 설명해보려 합니다.
세 줄 요약
@WebMvcTest는 @Configuration에서 등록한 SecurityFilterChain 빈을 스캔하지 않는다.
따라서 @Import로 SecurityFilterChain을 설정한 @Configuration 클래스를 임포트하자.
@WebMvcTest
@Import(컨피그.class)
class Test {
...
}
Test Context 캐싱을 고려한다면 @SpringBootTest를 사용하는 것도 고려해보자.
'Spring' 카테고리의 다른 글
생성자에서 @Value로 외부 변수 주입 (0) | 2023.08.29 |
---|---|
테스트 네이밍 (0) | 2023.08.26 |