프레임워크/Spring

뜯어보며 배우는 DispatcherServlet의 @RequestMapping 기반 핸들러 매핑 과정

SeongOnion 2023. 10. 22. 23:47
728x90

서론

사내 프로젝트에서 새로운 API를 추가한 후, MockMvc를 통해 통합 테스트를 하는 과정에서 계속해서 오류가 발생했다.

 

테스트 코드 로그를 통해 자세한 오류를 확인한 결과, 요청이 엉뚱한 RequestHandler에게 가고 있었다.

 

예컨대, 아래와 같이 API가 정의되어있다고 가정해보자.

@RestController
public class OrderController {

    @RequestMapping(method = RequestMethod.GET, value = "/orders/{no}", produces = "application/json")
    public long getOrder(@PathVariable long no) {
        ...
    }
    
    // 새로 추가
    @RequestMapping(method = RequestMethod.GET, value = "/orders/download", produces = "application/zip")
    public long downloadOrder() {
        ...
    }
}

 

이후, 아래의 테스트코드를 실행시킨다.

@Test
void test() throws Exception {
    ResultActions perform = mockMvc.perform(get("/orders/download").accept("application/json"));
    perform.andExpect(status().isNotAcceptable());
}

해당 테스트는 application/jsonAccept 헤더를 담아 GET /ordrers/downalod 요청을 호출한다.

 

해당 요청을 처리하는 OrderController.downloadOrder()에선 produces값을 application/zip으로 제한해두고 있으므로, 406 Not Acceptable 예외가 발생하길 기대하였다.

 

하지만, 테스트는 실패했으며 실제론 406에러가 아닌 400 Bad Reqeust 에러가 발생했다.

java.lang.AssertionError: Status expected:<406> but was:<400>
Expected :406
Actual   :400

 

관련된 로그를 살펴본 결과, 해당 요청은 기대했던 OrderController.downloadOrder()가 아닌 GET /orders/{no}를 처리하는 OrderController.getOrder()가 핸들러로 선택됨을 알 수 있었다.

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /orders/download
          Headers = [Accept:"application/json"]
             Body = null
    Session Attrs = {}

Handler:
             Type = com.example.demo.controller.OrderController
           Method = com.example.demo.controller.OrderController#getOrder(Long)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = org.springframework.web.method.annotation.MethodArgumentTypeMismatchException

...

java.lang.AssertionError: Status expected:<406> but was:<400>
Expected :406
Actual   :400

 

원인을 확인하기 위해 DispatcherServlet이 HTTP 요청을 어떤 방식으로 핸들러에 매핑하고 작업 처리를 위임하는지 직접 코드를 뜯어봤다.

 

DispatcherServlet이 핸들러를 매핑하는 대략적인 구조

DispatcherServlet은 HTTP 요청이 오면 해당 요청의 메서드, URI, 헤더 등의 정보를 기반으로 해당 요청을 처리해줄 핸들러(컨트롤러)를 찾고 Adapter를 통해 해당 핸들러에게 요청 처리 작업을 위임한다.

 

해당 구조는 아래 그림과 같이 표현될 수 있다.

 

RequestMappingHandlerMapping 클래스 톺아보기

현재 일반적인 스프링 프로젝트에서 가장 많이 사용되는 @Controller@RequestMapping 어노테이션 방식의 핸들러는 RequestMappingHandlerMapping 클래스에서 처리된다.

 

해당 클래스는 RequestMappingInfoHandlerMapping이라는 추상 클래스를 상속하고, 또 해당 클래스는 AbstractHandlerMethodMapping라는 추상클래스를 상속하고 있다. (물론 AbstractHandlerMethodMapping 클래스 또한 다른 추상 클래스를 상속하고 있지만, 편의상 여기까지만 보자)

 

AbstractHandlerMethodMappingmappingRegistry 필드를 보면 DispatcherServlet이 스프링 로드 시점에 스캔한 모든 Request Mapping 정보들이 들어있다.

DispatcherServlet이 받은 요청들은 해당 정보들과 비교되어 올바른 Request Handler에 매핑된다.

 

mappingRegistry에 들어있는 RequestMappingInfo 객체를 살펴보면, 요청에 맞는 Request Handler를 결정하는데 어떤 요소들이 고려되는지 짐작해볼 수 있다.

public final class RequestMappingInfo implements RequestCondition<RequestMappingInfo> {
    ...

	@Nullable
	private final PathPatternsRequestCondition pathPatternsCondition;

	@Nullable
	private final PatternsRequestCondition patternsCondition;

	private final RequestMethodsRequestCondition methodsCondition;

	private final ParamsRequestCondition paramsCondition;

	private final HeadersRequestCondition headersCondition;

	private final ConsumesRequestCondition consumesCondition;

	private final ProducesRequestCondition producesCondition;

	private final RequestConditionHolder customConditionHolder;
    
    ...
}

URI Path의 패턴, Method, Parameter, Header, Consume(Request의 Content-Type 헤더), Produces(Request의 Accept 헤더) 및 그 외 추가적으로 등록한 customCondition 등등이 고려되는 것을 확인할 수 있다.

 

이번엔 실제 비교 과정을 보자.

 

헨들러를 찾는 구체적인 작업은 추상클래스인 AbstractHandlerMethodMappinggetHandlerInternal() -> lookupHandlerMethod()를 거치며 일어난다.

 

먼저, getHandlerInternal()부터 살펴보자.

public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMapping implements InitializingBean {
    
    @Override
    @Nullable
    protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
	    String lookupPath = initLookupPath(request);
		this.mappingRegistry.acquireReadLock();
		try {
			HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
			return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
		}
		finally {
			this.mappingRegistry.releaseReadLock();
		}
	}
}

해당 메서드에서 처리되는 일은 간단하게 아래와 같이 요약될 수 있다.

 

1. Request에서 Path를 추출해가져온다. (initLookupPath())

 

2. 해당 Path를 기반에 적합한 HandlerMethod를 찾는다. (lookupHandlerMethod())

 

3. HandlerMethod를 찾았다면, 빈으로부터 HandlerMethod를 재생성(createWithResolvedBean()) 후 리턴, 찾지 못했다면 null을 리턴한다.

 

initLookupPath() 메서드의 구현은 다음과 같다.

protected String initLookupPath(HttpServletRequest request) {
    if (usesPathPatterns()) {
        request.removeAttribute(UrlPathHelper.PATH_ATTRIBUTE);
        RequestPath requestPath = ServletRequestPathUtils.getParsedRequestPath(request);
        String lookupPath = requestPath.pathWithinApplication().value();
        return UrlPathHelper.defaultInstance.removeSemicolonContent(lookupPath);
    }
    else {
        return getUrlPathHelper().resolveAndCacheLookupPath(request);
    }
}

 

요약하자면 요청에서 URI Path를 추출하는 메서드이다.

예컨대 GET /orders/1 을 호출하면 initLookupPath()로 추출한 값은 /orders/1가 된다.

 

다음은 핵심적인 부분이자 핸들러를 찾는 구체적인 구현이 담긴 lookupHandlerMethod()를 살펴보자.

@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
	List<Match> matches = new ArrayList<>();
	List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
	if (directPathMatches != null) {
		addMatchingMappings(directPathMatches, matches, request);
	}
	if (matches.isEmpty()) {
		addMatchingMappings(this.mappingRegistry.getRegistrations().keySet(), matches, request);
	}
	if (!matches.isEmpty()) {
	    Match bestMatch = matches.get(0);
		if (matches.size() > 1) {
			Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
			matches.sort(comparator);
			bestMatch = matches.get(0);
			if (logger.isTraceEnabled()) {
				logger.trace(matches.size() + " matching mappings: " + matches);
			}
			if (CorsUtils.isPreFlightRequest(request)) {
				for (Match match : matches) {
					if (match.hasCorsConfig()) {
						return PREFLIGHT_AMBIGUOUS_MATCH;
					}
				}
			}
			else {
				Match secondBestMatch = matches.get(1);
				if (comparator.compare(bestMatch, secondBestMatch) == 0) {
					Method m1 = bestMatch.getHandlerMethod().getMethod();
					Method m2 = secondBestMatch.getHandlerMethod().getMethod();
					String uri = request.getRequestURI();
					throw new IllegalStateException(
						"Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
				}
			}
		}
		request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.getHandlerMethod());
		handleMatch(bestMatch.mapping, lookupPath, request);
		return bestMatch.getHandlerMethod();
	}
	else {
		return handleNoMatch(this.mappingRegistry.getRegistrations().keySet(), lookupPath, request);
	}
}

꽤 방대한 양이지만 하나씩 살펴보며 이해해보자.

 

우선, initLookupPath()로 추출한 URI Path와 정확하게 일치하는 Path가 있는지 확인한다. (getMappingsByDirectPath())

private final MultiValueMap<String, T> pathLookup = new LinkedMultiValueMap<>();

@Nullable
public List<T> getMappingsByDirectPath(String urlPath) {
    return this.pathLookup.get(urlPath);
}

pathLookupDispatchrServlet이 스캔한 URI Path를 Key로, 해당 Path에 대한 RequestMappingInfo 리스트를 Value값으로 갖고 있는 Map객체이다.

 

여기선 오로지 URI Path만 보기 때문에 HTTP 메서드, 헤더 정보 등이 달라도 동일한 URI Path라면 모두 리스트에 뽑혀나온다.

 

이렇게 검색한, 그러니깐 요청의 URI Path와 정확히 일치하는 ReuqestMappingInfo 객체가 있다면 해당 객체들을 반복문으로 돌면서 조금 더 구체적으로 매칭 여부를 확인한다.

 

이 과정에서 RequestMappingInfogetMatchingCondition()를 보면 요청의 어떤 요소들이 비교되는지 확인할 수 있다.

@Override
@Nullable
public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
   RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
   if (methods == null) {
      return null;
   }
   ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
   if (params == null) {
      return null;
   }
   HeadersRequestCondition headers = this.headersCondition.getMatchingCondition(request);
   if (headers == null) {
      return null;
   }
   ConsumesRequestCondition consumes = this.consumesCondition.getMatchingCondition(request);
   if (consumes == null) {
      return null;
   }
   ProducesRequestCondition produces = this.producesCondition.getMatchingCondition(request);
   if (produces == null) {
      return null;
   }
   PathPatternsRequestCondition pathPatterns = null;
   if (this.pathPatternsCondition != null) {
      pathPatterns = this.pathPatternsCondition.getMatchingCondition(request);
      if (pathPatterns == null) {
         return null;
      }
   }
   PatternsRequestCondition patterns = null;
   if (this.patternsCondition != null) {
      patterns = this.patternsCondition.getMatchingCondition(request);
      if (patterns == null) {
         return null;
      }
   }
   RequestConditionHolder custom = this.customConditionHolder.getMatchingCondition(request);
   if (custom == null) {
      return null;
   }
   return new RequestMappingInfo(this.name, pathPatterns, patterns,
         methods, params, headers, consumes, produces, custom, this.options);
}

각각 RequestMappingInfo의 매칭 컨디션과 요청값의 요소 값들을 비교한다.

 

만약 RequestMappingInfo에 별도로 설정한 매칭 컨디션이 없다면 해당 값은 빈 리스트이며 따라서 비교할 값이 없으므로 그냥 패스된다.

 

하지만 RequestMappingInfo에 매칭 컨디션이 설정되어있음에도 요청 값의 해당 요소가 매칭 컨디션에 부합하지 않는다면 null이 반환되고, 그대로 해당 RequestMappingInfo는 요청에 부합하지 않는 핸들러라고 판단하여 매칭 후보에서 탈락된다.

 

 

만약 해당 분기문들을 모두 통과한다면 해당 정보는 lookupHandlerMethod()에서 초기화한 matches 리스트에 담긴다.

private void addMatchingMappings(Collection<T> mappings, List<Match> matches, HttpServletRequest request) {
   for (T mapping : mappings) {
      T match = getMatchingMapping(mapping, request);
      if (match != null) {
         matches.add(new Match(match, this.mappingRegistry.getRegistrations().get(mapping)));
      }
   }
}

 

만약 해당 과정을 거친 후에도 matches 리스트가 비어있다면, mappingRegistry에 등록된 모든 Request Mapping 정보를 후보군으로 두고 위 작업을 진행한다.

 

matches 리스트에 후보군을 모두 추린 후에, 만약 후보군이 하나밖에 없다면 해당 매핑 정보의 핸들러 메서드를 호출한다.

request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.getHandlerMethod());
handleMatch(bestMatch.mapping, lookupPath, request);
return bestMatch.getHandlerMethod();

 

만약 그렇지 않다면? 가장 잘 맞다고 판단되는(bestMatch)를 찾는 과정을 거친다. 

if (!matches.isEmpty()) {
   ...
   
   // 후보군이 여러개일 때 진행
   if (matches.size() > 1) {
      Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
      matches.sort(comparator);
      bestMatch = matches.get(0);
      if (logger.isTraceEnabled()) {
         logger.trace(matches.size() + " matching mappings: " + matches);
      }
      if (CorsUtils.isPreFlightRequest(request)) {
         for (Match match : matches) {
            if (match.hasCorsConfig()) {
               return PREFLIGHT_AMBIGUOUS_MATCH;
            }
         }
      }
      else {
         Match secondBestMatch = matches.get(1);
         if (comparator.compare(bestMatch, secondBestMatch) == 0) {
            Method m1 = bestMatch.getHandlerMethod().getMethod();
            Method m2 = secondBestMatch.getHandlerMethod().getMethod();
            String uri = request.getRequestURI();
            throw new IllegalStateException(
                  "Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
         }
      }
   }
   ...
}

비교 과정은 위와 같은데, 요약하자면 이렇다.

 

1. matches를 정렬한다.

 

2. 정렬 후 첫 번째 RequestMappingInfo와 두 번째 RequestMappingInfo가 같을 땐 오류를 던진다. (동일한 조건의 @RequestMapping이 두 개 이상 정의된 경우이다)

 

3. 같지 않다면 첫 번째 RequestMappingInfo를 기반으로 한 핸들러 메서드를 반환한다.

 

사실상 후보군이 몇 개가 되었든 정렬 후 2개까지만 비교를 하는 것으로 보인다.

 

그렇다면 정렬은 어떻게 일어나는가?

 

RequestMappingInfocompareTo 메서드를 보자.

@Override
public int compareTo(RequestMappingInfo other, HttpServletRequest request) {
   int result;
   // Automatic vs explicit HTTP HEAD mapping
   if (HttpMethod.HEAD.matches(request.getMethod())) {
      result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
      if (result != 0) {
         return result;
      }
   }
   result = getActivePatternsCondition().compareTo(other.getActivePatternsCondition(), request);
   if (result != 0) {
      return result;
   }
   result = this.paramsCondition.compareTo(other.getParamsCondition(), request);
   if (result != 0) {
      return result;
   }
   result = this.headersCondition.compareTo(other.getHeadersCondition(), request);
   if (result != 0) {
      return result;
   }
   result = this.consumesCondition.compareTo(other.getConsumesCondition(), request);
   if (result != 0) {
      return result;
   }
   result = this.producesCondition.compareTo(other.getProducesCondition(), request);
   if (result != 0) {
      return result;
   }
   // Implicit (no method) vs explicit HTTP method mappings
   result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
   if (result != 0) {
      return result;
   }
   result = this.customConditionHolder.compareTo(other.customConditionHolder, request);
   if (result != 0) {
      return result;
   }
   return 0;
}

메서드, 헤더, 파라미터 등등의 컨디션 객체들에 저장된 compareTo로 모두 비교하면서 하나라도 값이 비교 대상보다 크거나 작다면 즉, 두 객체 간의 우위가 생긴다면 바로 해당 결과를 리턴한다.

 

모두 적기엔 양이 많으니, 간단하게 HTTP 메서드를 어떻게 비교하는지 살펴보자.

@Override
public int compareTo(RequestMethodsRequestCondition other, HttpServletRequest request) {
   if (other.methods.size() != this.methods.size()) {
      return other.methods.size() - this.methods.size();
   }
   else if (this.methods.size() == 1) {
      if (this.methods.contains(RequestMethod.HEAD) && other.methods.contains(RequestMethod.GET)) {
         return -1;
      }
      else if (this.methods.contains(RequestMethod.GET) && other.methods.contains(RequestMethod.HEAD)) {
         return 1;
      }
   }
   return 0;
}

HTTP 메서드에선 RequestMappingInfo가 허용하는 메서드의 개수가 더 적을수록, 그리고 GET 요청에서 HEAD메서드보단 GET메서드로 정의된 RequestMappingInfo 객체를 더 우위로 취급하는 걸 확인할 수 있다.

 

그 밖의 컨디션들도 모두 최선의 핸들러를 고르기 위한 저마다의 비교 조건을 갖추고 있음을 확인할 수 있었다.

 

무엇이 문제였나?

먼 길을 왔다.

 

그렇다면 나의 DispatcherServlet은 왜 GET /orders/download, Accept: "application/json" 요청을 날렸을 때,

 

GET /orders/download, Accept: "application/zip" 가 아닌 GET /orders/{no}, Accept: "application/json"를 핸들러로 매핑했을까?

 

정답은 당연하게도 Accept 헤더에 있었다.

 

해당 요청은 directPathMatches에까진 정상적으로 잡힌다.

하지만, RequestMappingInfoProducesCondition을 비교하는 과정에서 후보군에서 탈락된다.

GET /orders/download의 ProducesCondition엔 "application/zip"만 있으므로 실제 요청의 "application/json"과 맞지 않아 null이 반환된다.

그래. 여기까진 이해할 수 있다.

 

그렇다면 왜 아예 핸들러를 찾지 못하는 게 아니라 엉뚱한 GET /orders/{no}에 매핑이 되버리는걸까?

 

원인은 URI Path를 비교하는 방식에 있었다. 디버거를 계속 따라가보자.

 

GET /orders/downloadProducesCondition 비교에서 탈락하고 이젠 mappingRegistry에 등록된 모든 RequestMappingInfo들 중에서 가장 적합해보이는 매핑 정보를 찾는다.

GET /orders/{no}ProducesCondition"application/json"을 포함하고 있으므로, 해당 조건을 성공적으로 통과한다.

 

다음으로, 통과하지 못할거라고 예상했던 PatternsCondition을 살펴보자.

if (this.patternsCondition != null) {
   patterns = this.patternsCondition.getMatchingCondition(request);
   if (patterns == null) {
      return null;
   }
}

해당 메서드는, 내부적으로 아래의 패턴 매칭 메서드를 호출한다.

@Nullable
private String getMatchingPattern(String pattern, String lookupPath) {
   if (pattern.equals(lookupPath)) {
      return pattern;
   }
   if (this.useSuffixPatternMatch) {
      if (!this.fileExtensions.isEmpty() && lookupPath.indexOf('.') != -1) {
         for (String extension : this.fileExtensions) {
            if (this.pathMatcher.match(pattern + extension, lookupPath)) {
               return pattern + extension;
            }
         }
      }
      else {
         boolean hasSuffix = pattern.indexOf('.') != -1;
         if (!hasSuffix && this.pathMatcher.match(pattern + ".*", lookupPath)) {
            return pattern + ".*";
         }
      }
   }
   if (this.pathMatcher.match(pattern, lookupPath)) {
      return pattern;
   }
   if (this.useTrailingSlashMatch) {
      if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) {
         return pattern + "/";
      }
   }
   return null;
}

 

pattern/orders/{no}, lookupPath/orders/download 이므로 첫 번째 if절은 통과된다.

 

그리고 우리 프로젝트의 경우엔 useSuffixPatternMatchuseTrailingSlashMatch 등의 옵션을 켜지 않았으므로 해당 분기문들도 우선 무시했다.

 

문제가 되는 부분은 아래의 pathMatcher.match() 부분이었다.

if (this.pathMatcher.match(pattern, lookupPath)) {
    return pattern;
}

GET /orders/{no}의 경우엔 Path Variable인 {no}가 포함되어있어 pathMatcher 객체가 /orders/ 이하에 들어온 값들을 패턴매칭시키는 작업을 거친다.

{no}는 별다른 정규식이 없으므로 와일드카드로 인식되고, /orders/download 또한 해당 정규식을 통과하므로, 결과적으론 PatternsCondition 또한 정상 통과하게 되는 것이었다.

patterns엔 null이 반환되지 않고 정상적으로 통과되었다.

이와 같은 과정을 거쳐 결국엔 GET /orders/download, Accept: "application/json" 요청은 잘못된 핸들러에 매핑이 되게 되었다.

 

해결 과정

해결 과정은 디버깅 과정과는 다르게 매우 심플했다.

 

GET /orders/{no}GET /orders/{no:\\d+} 와 같이 수정하여 {no}에 오로지 숫자만 올 수 있도록 강제하였고, 드디어 테스트에 성공했다.

@RestController
public class OrderController {

    @RequestMapping(method = RequestMethod.GET, value = "/orders/{no:\\d+}", produces = "application/json")
    public long getOrder(@PathVariable long no) {
        ...
    }
}

여기서 하나 더 궁금한 점은, 결국 위와 같이 수정하면 GET /orders/download, Accept: "application/json" 요청은 어떠한 매칭되는 핸들러도 찾지 못하게 될텐데, 404 Not Found406 Not Acceptable은 어떻게 구분하는걸까? 였다.

 

모든 RequestMappingInfo를 뒤졌는데도 핸들러를 찾지못해 matchesempty이면 마지막에 handleNoMatch 객체를 반환하게 된다.

return handleNoMatch(this.mappingRegistry.getRegistrations().keySet(), lookupPath, request);

 

그리고 해당 객체는 생성 시 예외를 발생시킨다.

@Nullable
protected HandlerMethod handleNoMatch(Set<T> mappings, String lookupPath, HttpServletRequest request)
      throws Exception {

   return null;
}

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/class-use/RequestMappingInfo.html

그리고 이 handleNoMatch가 결국엔 어떻게 처리되는지는 공식문서에 잘 나와있다.

 

RequestMappingInfo들을 다시 한 번 돌면서, 최소한 URL이라도 매칭되는 것이 있는지 찾아보고 어떤 부분이 맞지 않아 핸들러 매핑에 실패했는지 예외를 뱉는다고 한다.

 

따라서 해당 예외에 따라 아예 존재하지 않는 URL로 요청이 들어오면 404가, 매칭되는 URL이 있으나 Produces가 지원되지 않는다면 406 에러를 최종 응답으로 내려주는 것으로 보인다.

 

그 이후

원인을 파악한 후에, 솔직히 도대체 왜 정확히 일치되는 URI Path가 있음에도 Accept 헤더 때문에 후보군에서 탈락이되고, 엉뚱한 핸들러가 패턴 매칭으로인해 매핑되는지, 해당 코드가 정말 의도된 것인지 의심됐었다.

 

그래서 해당 내용을 spring-framework 깃헙 이슈에 올렸다.

답변은 해당 작동 방식은 설계된대로 옳게 작동한게 맞다고 한다.. ㅎㅎ

의견을 조금 더 나눠보고 싶어서 이니시를 걸었으나..

내가 제안하는 방식(Direct Matching URL이 있지 않을 때에만 Pattern Matching을 사용하는 방식)은 작동 방식을 추론하는 것을 더 여럽게 만들 것 같다며 바로 짤 당했다.

 

적어도 useSuffixPatternMatchuseTrailingSlashMatc와 유사하게 정규식 매칭 사용여부에 대한 옵션이라도 지정할 수 있도록 하면 좋을텐데.. 하여튼 이건 따로 PR을 올려보던가 해야겠다.