Cronex
spring-boot + vue 405 해결법(1) 본문
spring-boot 와 vue로 프로젝트 중 vue에서 request를 보내면 405 error를 발생시키면서 method "POST"를 지원하지 않는다는 로그를 보았다. 하지만 작성한 api는 POST였고... 무엇이 문제인지 모른 채 있다가, 겨우.. 해결하였다.
결론
결론부터 말하자면, 해당 api 에서 특정상황에서 exception을 발생시키는데 exception 발생 시 내부적으로 servlet path = "/error" 로 forward하는데 따로 설정하지 않은 상태에서의 /error path를 지원하는 메소드는 GET, HEAD 였고 api요청으로는 POST로 던졌기 때문에 "POST /error" 로 인식되어 method POST 는 지원하지 않는다는 exception을 발생시키며 status 405 를 던진 것 이었다.
해결법
@ExceptionHandler([Exception.class]) 를 추가하여 특정 exception에 대해 핸들링 해주면 된다.
package jpaBook.jpaShop.controller.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<String> handlerException(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.toString());;
}
}
그러면 exception 발생하면 해당 handler를 타고 처리하게 될 것이다. 에제 코드는 IllegalStateException이 발생하면 500 method를 response로 내려준다.
이 후 아랫글은 해당 문제를 해결하기 위해 디버깅을 한 내용을 정리한 것입니다.
디버깅...
디버깅하면서 알게된 것은 디스패처서블릿은 기본적으로 5개의 handler를 갖고 요청을 처리하는데, handler 종류는 아래와 같았다.

우리가 평소에 사용하는 controller는 RequestMappingHandlerMapping에 들어가게된다. 좀 더 들어가보면..


lookupPath와 일치하는 HandlerMathod를 찾습니다. 이 후 handler에 interceptor도 추가도하고


핸들러 어댑터를 찾은 후, 아래로 조금 내려가 보면

찾은 adapter를 통해 handle method를 호출하는 것을 볼 수 있다. handle 내부로 들어가보면

abstractHandlerMethodAdapter 클래스를 거쳐서

RequestmappingHandlerAdapter클래스에 도달하였다. 여기에서 checkRequest method를 통해 미리 오류를 검출한다. 이 부분은 나중에 오류 발생 디버깅 때 중요한부분을 차지합니다..

checkRequest method를 거친 후 HandleInternal method에서 실질적으로 [거의..] controller를 호출하게 됩니다. 아래는 invokeHandlerMethod 코드입니다.

/**
* Invoke the {@link RequestMapping} handler method preparing a {@link ModelAndView}
* if view resolution is required.
* @since 4.2
* @see #createInvocableHandlerMethod(HandlerMethod)
*/
@Nullable
protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
ServletWebRequest webRequest = new ServletWebRequest(request, response);
try {
WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
ModelFactory modelFactory = getModelFactory(handlerMethod, binderFactory);
ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
if (this.argumentResolvers != null) {
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
if (this.returnValueHandlers != null) {
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
invocableMethod.setDataBinderFactory(binderFactory);
invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
ModelAndViewContainer mavContainer = new ModelAndViewContainer();
mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request));
modelFactory.initModel(webRequest, mavContainer, invocableMethod);
mavContainer.setIgnoreDefaultModelOnRedirect(this.ignoreDefaultModelOnRedirect);
AsyncWebRequest asyncWebRequest = WebAsyncUtils.createAsyncWebRequest(request, response);
asyncWebRequest.setTimeout(this.asyncRequestTimeout);
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
asyncManager.setTaskExecutor(this.taskExecutor);
asyncManager.setAsyncWebRequest(asyncWebRequest);
asyncManager.registerCallableInterceptors(this.callableInterceptors);
asyncManager.registerDeferredResultInterceptors(this.deferredResultInterceptors);
if (asyncManager.hasConcurrentResult()) {
Object result = asyncManager.getConcurrentResult();
mavContainer = (ModelAndViewContainer) asyncManager.getConcurrentResultContext()[0];
asyncManager.clearConcurrentResult();
LogFormatUtils.traceDebug(logger, traceOn -> {
String formatted = LogFormatUtils.formatValue(result, !traceOn);
return "Resume with async result [" + formatted + "]";
});
invocableMethod = invocableMethod.wrapConcurrentResult(result); // 이부분
}
invocableMethod.invokeAndHandle(webRequest, mavContainer);
if (asyncManager.isConcurrentHandlingStarted()) {
return null;
}
return getModelAndView(mavContainer, modelFactory, webRequest);
}
finally {
webRequest.requestCompleted();
}
}
다음으로 실질적으로 우리가 작성한 [ 컨트롤러 -> service -> etc... ] 등등 처리 후 다시 invoke한 클래스로 돌아와 return 에서 getModelAndView method의 return값을 return 하고 있다.
getModelAndView method를 보자면

ModelAndView를 만들어서 Return 하는 method 같아보였다. 여기서 파란색 조건문에서 조건이 참이 되어 null을 리턴한다. 추측이지만 restController로 설정한 값에 의해 바뀌는 건가 싶다.
이 후 작업이 끝나면 다시 디스패처 서블릿으로 돌아와 finally 문을 마무리 짓고 여러 filter를 걸쳐 응답을 보내며 끝난다.


문제는 이 이후 2번 째 요청에서 발생하였다. 2번 째 부터는 검증에 의해 오류를 뱉는데 이 오류를 처리하는 과정에서 발생한 문제이다.
동일하게 요청을 보내보면 오류를 뱉기 전 까지는 동일하게 흘러간다. 아래는 오류가 발생한 이 후 디버깅이다. exception이 발생하면 inpoke, proxy 등을 거쳐 디스패처 서블릿 catch문에 들어와 dispatchException[ 1081 Line ]에 값을 할당되게 된다.

내부 try문 마지막에 processDispatchResult 메소드를 실행한다. [dispatchException 에 값 할당 됨]

processDispatchResult method 내에서는 뷰를 렌더링하기도 하고, 오류가 있다면 오류에 대한 뷰가 존재하는지, 뷰가 존재한다면 해당 뷰를 렌더링한다.

보면 에러가 존재한다면 ModelAndViewDefiningException 오류가 아니라면 else문을 타면서 processHandlerException method를 호출하는데 해당 메소드를 확인해보면

2가지의 핸들러를 가지고 ModelAndView 값을 찾는다 만약 찾지못한다면 인자로 받았던 exception을 다시 던지게된다.

던진 오류는 디스패처서블릿의 바깥 catch문을 호출하게 되고 triggerAfterCompletion 메소드를 호출하게 된다.


triggerAfterCompletion 내부에서는 triggerAfterCompletion method를 호출하는데 메소드 위에 적힌 설명으로는 "매핑된 HandlerInterceptor에서 afterCompletion 콜백을 트리거합니다. preHandle 호출이 성공적으로 완료되고 true를 반환한 모든 인터셉터에 대해 afterCompletion을 호출합니다." 와 같이 적혀있다. 이 부분에서 인터셉터의 afterCompletion이 호출되는 듯 하다.

위에서도 볼 수 있듯이 triggerAfterCompletion은 호출이 끝나고 나면 인자로 받았던 exception 그대로 다시 던진다. 호출이 끝나면 다시 디스패처 서블릿으로 돌아와 finally문을 완료한다.

계속해서~ processRequest method가 있는 FramewokServlet에서 catch문을 타게되고 여기서는 NestedServletException을 던진다.

이 후 몇 개의 필터와 클래스를 거쳐.. StandardWrapperValve 클래스에서 console에 로그를 찍게 된다. 로그를 찍으면서 아래 에러를 뱉게된다.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalStateException: 이미 존재하는 회웝입니다.] with root cause

handler에서 처리하지못한 에러는 그대로 StandardHostValve.java 에서 에러 페이지를 찾게된다. [throwable(request, res, t)]

throwable 메소드는 대략 아래와 같다.context에서 findErrorPage method의 인자로 Throwable을 넘겨 일치하는 에러 페이지를 찾는다. 만약에 없다면 null을 반환한다.

에러 페이지 다음으로 response의 status에 500 을 담고 status 메소드를 호출한다.

status 메소드에서도 비슷하게 context에서 errorPage를 찾는데 이번에는 throwable이 아닌 statusCode로 찾는데, 만약에 없다면 errorCode[0 (default)]로 찾게된다


다음 request에 여러 설정값을 설정 한 후에 custom 메소드를 호출하게 된다. 아래는 custom 메소드의 설명이다.
"Handle an HTTP status code or Java exception by forwarding control to the location included in the specified errorPage object. It is assumed that the caller has already recorded any request attributes that are to be forwarded to this page. Return true if we successfully utilized the specified error page location, or false if the default error report should be rendered."

자세히 봐야하는 부분은 servlerContext.getRequestDispatcher 메소드인데... "/error" 값을 인자로 넘기면서

ApplicationContext에서 queryString 등 해서 매핑되는 data를 찾는 듯 한데, 이 부분은 정확히 모르겠다..



이 부분에서 return 하게 된다. 다시 StandardHostValve.java 로 돌아와서 rd가 null 이 되어서 동일하게 false를 반환하게 된다. 아래 에러와 함께
2023-04-28 17:00:56.019 ERROR 77110 --- [nio-8080-exec-2] o.a.c.c.C.[Tomcat].[localhost] : Custom error page [/error] could not be dispatched correctly

계속해서 디버깅을 해보면 ErrorReportValve.java 의 report 메소드에 도달한다. "Prints out an error report." report 메소드의 설명이다. 여기서도 동일하게 errorPage를 찾은 후 에러 페이지가 있다면 에러페이지를 내려주고, 없다면 error message를 담은 html 파일을 만들어서 내려준다. 아래는 ErrorReportValve.java의 report, sendErrorPage의 코드이다.





브라우저를 확인해보면 500에러가 떨어지면서 위에서 만든 html이 리턴된 것을 확인할 수 있다.


<!doctype html>
<html lang="en">
<head><title>HTTP Status 500 – Internal Server Error</title>
<style type="text/css">body {
font-family: Tahoma, Arial, sans-serif;
}
h1, h2, h3, b {
color: white;
background-color: #525D76;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 16px;
}
h3 {
font-size: 14px;
}
p {
font-size: 12px;
}
a {
color: black;
}
.line {
height: 1px;
background-color: #525D76;
border: none;
}</style>
</head>
<body><h1>HTTP Status 500 – Internal Server Error</h1></body>
</html>
다른 문제..
디버깅을 천천히 하면 500에러를 내려주지만, 디버깅 없이 아래부분을 거치게 되면 rd에 값이 할당되면서 custom 메소드의 아래 조건식으로 넘어가게 되고 거기서 forward를 통해 문제의 "/error" servlet으로 넘어가게 된다..


먼저 servletContext.getRequestDispatcher(errorPage.getLocation()); 을 살펴보자면, 이전에는 dispatchData.get() 에서 null을 반환했지만 이번에는 값이 존재하고, 반환 값으로 RequestDispatcher를 상속받은 ApplicationDispatcher를 반환한다.
ApplicationDispatcher의 설명은 아래에----
ApplicationDispatcher class 를 살펴보면 아래와 같이 구성되어 있으며, 설명을 번역으로 보자면

"요청을 다른 리소스로 전달하여 궁극적인 응답을 생성하거나 이 리소스의 응답에 다른 리소스의 출력을 포함할 수 있도록 하는 RequestDispatcher의 표준 구현입니다. 이 구현을 통해 애플리케이션 레벨 서블릿은 래핑 클래스가 javax.servlet.ServletRequestWrapper 및 javax.servlet.ServletResponseWrapper를 확장하는 한 호출된 자원에 전달되는 요청 및/또는 응답 객체를 래핑할 수 있습니다." 라고 적혀있습니다.
내무에 정의된 메서드 중에 forward를 통해 요청을 다른 리소스에 전달하게 됩니다.

"처리를 위해 이 요청 및 응답을 다른 리소스로 전달합니다. 호출된 서블릿에서 발생하는 모든 런타임 예외, IOException 또는 ServletException은 호출자에게 전파됩니다."

이어서 보자면 else 블록에 들어와 반환받은 ApplicationDispatcher의 forward 메서드를 req, res를 넘기면서 호출한다.



문제가 여기서 부터 시작된다.이제 forward를 통해서 전달하게 되는데 순서를 보자면 먼저 forward 메소드를 호출하게 되고 else 블록으로 들어가게 되고 내부에 속한 ApplicationDispatcher class 에 속한 doForward를 호출한다.

doForward에서는 ApplicationHttpRequest 값을 설정하고 processRequest를 호출한다.


processRequest 메소드는 "필터 구성에 따라 요청을 준비합니다." 하는 메소드이다. 이 메소드에서는 invoke를 호출한다.

invoke 메소드에서는 filter를 얻어서 doFilter를 호출하게 된다. 주석에 설명되어있듯이 "할당된 서블릿 인스턴스에 대한 service() 메소드 호출" 하게 된다. 실질적으로는 ApplicationFilterChain의 internalDoFilter에서 호출한다.



여기에서 method가 POST인 점을 주목하자. 이 후에 오류의 원인이 된다. HttpServlet의 doPost를 지나

DispatcherServlet의 doService와 disDispatch를 호출한다.

doDispatch에서 핸들러를 찾는데 "/error"로 정의한 controller도 없고, 따로 정의한 것이 없기 때문에 SimpleUrlHandlerMapping을 채택하는데, lokkupHandler에서 이전에 webConfig에서 설정한 값에 매칭되게 된다.



webConfig는 아래와 같다.

이제 디스패처 서블릿에서 핸들러를 찾은 후 핸들러의 handle 메소드를 호출한다.

HttpRequestHandlerAdapter를 거쳐 ResourceHttpRequestHandler의 handleRequest를 호출한다.

ResourceHttpRequestHandler에서 getResource()를 호출하는데 여기서 이 전에 webConfig에서 설정한 값이 사용된다.



DefaultResourceResolverChain에서는 resolver.resolveResource()를 호출한다.



위에서 보면 최초에 cachingResourceResolver에 resource가 null 인데, 요청 이후 아래에 chain.resolverResource로 처리한 값을 캐싱해두어 2번 째 조회시에는 resource 값을 갖고 있어서 cachingResourceResolver에서 그 값을 리턴한다.

아래 getResource()를 호출하는데 여기서 webConfig 설정에서 재정의한 메소드를 호출하게 된다.

createRelative() 에서 ClassPathResource 객체를 생성한다.


webConfig로 돌아와 재정의한 조건을 실행하게 된다. static 밑에 error.html을 따로 만들지 않아서 resource.exists()는 false를 반환하고 resource.isReadable() 또한 false를 반환한다. 조건에 의해 new ClassPathResource를 만들어 반환한다.


이렇게 반환받은 resource는 cachingResourceResolver 내부에 있는 Cache에 저장된다. 2번째 요청시에는 cache에서 가져오게된다.


그렇게 돌고 돌아.. resource를 찾은 후 ResourceHttpRequestHandler에서 checkRequest를 호출하는데 이 부분에서 Request method 'POST' not supported 오류가 발생하는 것이다.. 보다시피 지원하는 메소드는 GET과 HEAD 이다.




기나긴 오류 발견이었다.. 이렇게 오류가 발생하는 것이었다...! 다음으로는 ControllerAdvice와 ExceptionHandler를 추가했을 때 어떻게 동작하는지 디버깅 해 볼 것이다..
'spring-boot > errors' 카테고리의 다른 글
spring-boot + vue 405 해결법(2) - 디버깅 (0) | 2023.04.30 |
---|---|
spring boot + vue project router문제와 404 해결 (0) | 2023.04.25 |
spring boot vue 프로젝트 설정 시 webpack-dev-server 오류 (0) | 2023.04.18 |
spring-boot Intellij에서 실행 시 Execution failed for task ':.main()'. 오류 (0) | 2023.04.18 |