TIL

2023.12.01 TIL - 테스트 환경 (H2) 분리 중 만난 문제 (Feat. Security MvcRequestMatcher)

Repository 테스트 코드 작성을 위해 테스트 DB로 H2 를 사용하기 위해 설정을 하던 중 문제가 발생했다.

문제


application.yaml 을 환경 별로 분리하고 h2 관련 설정을 한 후 어플리케이션을 실행하니 다음과 같은 에러가 나온다.

 

rg.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration': Unsatisfied dependency expressed through method 'setFilterChains' parameter 0: Error creating bean with name 'securityFilterChain' defined in class path resource [com/sparta/todo/config/SecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'securityFilterChain' threw exception with message: This method cannot decide whether these patterns are Spring MVC patterns or not. If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); otherwise, please use requestMatchers(AntPathRequestMatcher).

This is because there is more than one mappable servlet in your servlet context: {org.h2.server.web.JakartaWebServlet=[/h2-console/*], org.springframework.web.servlet.DispatcherServlet=[/]}.

For each MvcRequestMatcher, call MvcRequestMatcher#setServletPath to indicate the servlet path.
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.resolveMethodArguments(AutowiredAnnotationBeanPostProcessor.java:875) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.inject(AutowiredAnnotationBeanPostProcessor.java:828) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:145) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:492) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1416) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:597) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:973) ~[spring-beans-6.0.13.jar:6.0.13]
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:950) ~[spring-context-6.0.13.jar:6.0.13]
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:616) ~[spring-context-6.0.13.jar:6.0.13]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) ~[spring-boot-3.1.5.jar:3.1.5]
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:738) ~[spring-boot-3.1.5.jar:3.1.5]
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:440) ~[spring-boot-3.1.5.jar:3.1.5]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:316) ~[spring-boot-3.1.5.jar:3.1.5]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1306) ~[spring-boot-3.1.5.jar:3.1.5]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1295) ~[spring-boot-3.1.5.jar:3.1.5]
	at com.sparta.todo.ToDoListApplication.main(ToDoListApplication.java:10) ~[main/:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]

 

에러로그를 좀더 보니 다음과 같은 에러가 스택 트레이스에 나타났다.

 

This method cannot decide whether these patterns are Spring MVC patterns or not. If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); otherwise, please use requestMatchers(AntPathRequestMatcher).

 

해당 에러에 대해서 조금 알아보니 원인을 알아낼 수 있었다.

 

 

원인


발생 원인은 

org.h2.server.web.JakartaWebServletorg.springframework.web.servlet.DispatcherServlet 두 개의 Servlet이 Servlet Context에 등록되어 어떤 Servlet을 사용해야하는지 명시되어 있지 않아 발생한 것이다.

 

현재 구현한 SecurityConfig 코드는 다음과 같다.

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
	private final JwtProvider jwtProvider;
	private final RedisUtils redisUtils;
	private final CustomUserDetailService userDetailService;
	private final AuthenticationConfiguration authenticationConfiguration;
	private final ObjectMapper objectMapper;

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		// CSRF 설정
		http.csrf(AbstractHttpConfigurer::disable);
		// FormLogin 비활성
		http.formLogin(AbstractHttpConfigurer::disable);
		// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
		http.sessionManagement(sessionManagement ->
			sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		);

		http.authorizeHttpRequests(authorizeHttpRequests ->
			authorizeHttpRequests
				.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
				.requestMatchers(PathRequest.toH2Console()).permitAll()
				.requestMatchers(WHITE_LIST_URL).permitAll() //허용 url 리스트
				.anyRequest().authenticated() // 그 외 모든 요청 인증처리
		);

		http
			.exceptionHandling(handle ->
				handle
					.authenticationEntryPoint(authenticationEntryPoint())
			);

		// 필터 관리
		http.addFilterBefore(jwtAuthenticationFilter(), JwtAuthenticationFilter.class);
		http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);

		return http.build();
	}

	@Bean
	public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
		throws Exception {
		return authenticationConfiguration.getAuthenticationManager();
	}

	@Bean
	public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
		JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtProvider, redisUtils, objectMapper);
		filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
		return filter;
	}

	@Bean
	public JwtAuthorizationFilter jwtAuthorizationFilter() {
		return new JwtAuthorizationFilter(jwtProvider, userDetailService, redisUtils);
	}

	@Bean
	public AuthenticationEntryPoint authenticationEntryPoint() {
		return new CustomAuthenticationEntryPoint(objectMapper);
	}

	private static final String[] WHITE_LIST_URL = {
		"/api/v1/auth/**",
		"/api/v1/member/signup",
		"/v1/api-docs/**",
		"/v2/api-docs",
		"/v3/api-docs",
		"/v3/api-docs/**",
		"/swagger-resources",
		"/swagger-resources/**",
		"/configuration/ui",
		"/configuration/security",
		"/swagger-ui/**",
		"/webjars/**",
		"/swagger-ui.html"};
}

 

여기서 에러가 발생한 부분은 다음코드이다.

.requestMatchers(WHITE_LIST_URL).permitAll() //허용 url 리스트



코드를 보면 RequestMatcher를 사용하지 않고 String 배열을 인자로 넘겨주고 있다.

그래서 해당부분에서 에러가 발생이 되는 것이다.

 

 

해결


 

해결방법은 String[]으로 넘겨주는 인자들을 MvcRequestMatcher[]로 전달해서 MvcRequestMatcher 사용을 명시해주면 된다.

구현 순서는 다음과 같다.

1. HandlerMapptingIntrospector 빈을 주입 받고, MvcRqeustMatcher.Builder를 통해 

    인스턴스를 생성하고 빈으로 등록하기

 

@Bean
public MvcRequestMatcher.Builder mvcRequestMatcherBuilder(HandlerMappingIntrospector introspector) {
   return new MvcRequestMatcher.Builder(introspector);
}

 

2. MvcReqeustMatcher.Builder 인스턴스를 주입받아 String[] 인자를 MvcRequesetMatcher[] 로 매핑하기

필자는 메소드로 분리해놓았습니다.

 

private MvcRequestMatcher[] whiteListMapToMvcRequestMatchers(MvcRequestMatcher.Builder mvc) {
   return Stream.of(WHITE_LIST_URL).map(mvc::pattern).toArray(MvcRequestMatcher[]::new);
}

 

 

3. 필터체인의 requestMatchers에 매핑한 MvcRequestMatcher[]로 전달하기

 

.requestMatchers(this.whiteListMapToMvcRequestMatchers(mvc)).permitAll() //허용 url 리스트

 

 

설정 후 실행하면 정상 동작하는 것을 확인할 수 있다.

 

 

시큐리티 설정 전체코드도 남깁니다.

package com.sparta.todo.config;

import java.util.stream.Stream;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.todo.jwt.JwtAuthenticationFilter;
import com.sparta.todo.jwt.JwtAuthorizationFilter;
import com.sparta.todo.jwt.JwtProvider;
import com.sparta.todo.security.CustomAuthenticationEntryPoint;
import com.sparta.todo.security.CustomUserDetailService;
import com.sparta.todo.util.RedisUtils;

import lombok.RequiredArgsConstructor;

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;

@RequiredArgsConstructor
@EnableMethodSecurity
@EnableWebSecurity
@Configuration
public class SecurityConfig {
   private final JwtProvider jwtProvider;
   private final RedisUtils redisUtils;
   private final CustomUserDetailService userDetailService;
   private final AuthenticationConfiguration authenticationConfiguration;
   private final ObjectMapper objectMapper;

   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
      // CSRF 설정
      http.csrf(AbstractHttpConfigurer::disable);
      // FormLogin 비활성
      http.formLogin(AbstractHttpConfigurer::disable);
      // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
      http.sessionManagement(sessionManagement ->
         sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      );

      http.authorizeHttpRequests(authorizeHttpRequests ->
         authorizeHttpRequests
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
            .requestMatchers(PathRequest.toH2Console()).permitAll()
            .requestMatchers(this.whiteListMapToMvcRequestMatchers(mvc)).permitAll() //허용 url 리스트
            .anyRequest().authenticated() // 그 외 모든 요청 인증처리
      );

      http
         .exceptionHandling(handle ->
            handle
               .authenticationEntryPoint(authenticationEntryPoint())
         );

      // 필터 관리
      http.addFilterBefore(jwtAuthenticationFilter(), JwtAuthenticationFilter.class);
      http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);

      return http.build();
   }

   private MvcRequestMatcher[] whiteListMapToMvcRequestMatchers(MvcRequestMatcher.Builder mvc) {
      return Stream.of(WHITE_LIST_URL).map(mvc::pattern).toArray(MvcRequestMatcher[]::new);
   }

   @Bean
   public MvcRequestMatcher.Builder mvcRequestMatcherBuilder(HandlerMappingIntrospector introspector) {
      return new MvcRequestMatcher.Builder(introspector);
   }

   @Bean
   public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
      throws Exception {
      return authenticationConfiguration.getAuthenticationManager();
   }

   @Bean
   public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
      JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtProvider, redisUtils, objectMapper);
      filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
      return filter;
   }

   @Bean
   public JwtAuthorizationFilter jwtAuthorizationFilter() {
      return new JwtAuthorizationFilter(jwtProvider, userDetailService, redisUtils);
   }

   @Bean
   public AuthenticationEntryPoint authenticationEntryPoint() {
      return new CustomAuthenticationEntryPoint(objectMapper);
   }

   private static final String[] WHITE_LIST_URL = {
      "/api/v1/auth/**",
      "/api/v1/member/signup",
      "/v1/api-docs/**",
      "/v2/api-docs",
      "/v3/api-docs",
      "/v3/api-docs/**",
      "/swagger-resources",
      "/swagger-resources/**",
      "/configuration/ui",
      "/configuration/security",
      "/swagger-ui/**",
      "/webjars/**",
      "/swagger-ui.html"};
}
728x90