문제
서버에서 IllegalArgumentException 등의 여러 예외는 미리 설정한 @ControllerAdvice를 지나게 되는데,
분명 ExceptionHandler에서 특정 예외에 대해 400, 403 에러 등의 status code를 지정했지만 전부 404 에러로 바뀌어서 리턴된다.
@ControllerAdvice class CherryAdminGlobalExceptionHandler { @ResponseStatus(HttpStatus.BAD_REQUEST) // -> 400코드 @ExceptionHandler(IllegalArgumentException::class) fun handle(ex: IllegalArgumentException): Response<Unit> { log.error("{}", ex.message, ex) return Response.builder<Unit>() .msg(ex.message) .build() } } // 그런데 결과는 404코드가 발생한다
원인
결론은 Rest API(@RestController)를 사용중이었는데,
ExceptionHandler에 @RestControllerAdvice가 아닌, @ControllerAdvice가 붙어있었기 때문이다.
Spring 코드를 확인해보니,
DispatcherServlet은 핸들러에 대한 실제 dispatch를 처리하는,
doDispatch라는 메서드에서 ModelAndView 객체를 조회한다.
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { ... ModelAndView mv = null; ... // Actually invoke the handler. mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); ... }
이 때, ExceptionHandler가
@ControllerAdvice면 ModelAndView를 조회하고,
@RestControllerAdvice면 JSON 응답을 주면 되기 때문에, ModelAndView를 조회하지 않는다. (null 조회)
그래서 @RestControllerAdvice의 경우 문제 없이 ExceptionHandler가 지정한 예외를 리턴하지만, (400코드)
@ControllerAdvice의 경우 DispatcherServlet.processDispatchResult()에서 뷰 렌더링을 진행하게 된다.
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv, @Nullable Exception exception) throws Exception { ... // Did the handler return a view to render? if (mv != null && !mv.wasCleared()) { render(mv, request, response); // mv가 null이 아니기 때문에 렌더링 진행 if (errorView) { WebUtils.clearErrorRequestAttributes(request); } } ... }
render(mv, request, response)를 들어가보자.
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception { ... View view; String viewName = mv.getViewName(); if (viewName != null) { // We need to resolve the view name. view = resolveViewName(viewName, mv.getModelInternal(), locale, request); ... } ... }
여기서 resolveViewName()으로 view resolver를 통해 실제 화면을 찍어낼 View를 조회하게 되는데, 이 때 특이한건, viewName이 호출했던 API 경로로 설정되어 있다. (ex. /api/v1/users)
찾아보니, Rest API를 위해 @RestController를 사용하는 경우,
해당 API 경로가 자체적으로 데이터를 반환한다는 측면에서 viewName에 API 경로를 넣어주는 것 같다.
즉, ExceptionHandler는 @ControllerAdvice여서, 예외 발생 시 화면을 찍어내야 하는데, 컨트롤러가 @RestController라 서 화면의 경로가 API URL 자체가 되는 이상한 상황이 발생한 것이다.
여기서 코드를 더 타고 들어가다 보면 Request.getRequestDispatcher(String path) 메서드에서 view 경로를 만들어준다.
@Override public RequestDispatcher getRequestDispatcher(String path) { ... String servletPath = (String) getAttribute( RequestDispatcher.INCLUDE_SERVLET_PATH); if (servletPath == null) { servletPath = getServletPath(); // API 경로 } // Add the path info, if there is any String pathInfo = getPathInfo(); String requestPath = null; if (pathInfo == null) { requestPath = servletPath; // API 경로 복사 } else { requestPath = servletPath + pathInfo; } int pos = requestPath.lastIndexOf('/'); String relative = null; if (context.getDispatchersUseEncodedPaths()) { if (pos >= 0) { relative = URLEncoder.DEFAULT.encode( requestPath.substring(0, pos + 1), StandardCharsets.UTF_8) + path; // requestPath의 마지막 경로를 제외하고 기존 path와 합침 } else { relative = URLEncoder.DEFAULT.encode(requestPath, StandardCharsets.UTF_8) + path; } } ... return context.getServletContext().getRequestDispatcher(relative); } }
해당 메서드의 파라미터인 path는 앞서 설명한 viewName(API URL)이고,
여기서 requestPath(= API URL)를 가져와서, (15번째 라인)
마지막 경로를 제외하고(requestPath.lastIndexOf('/')) , path 앞에 붙여준다. (25번째 라인)
ex) 만약 API 경로가 /api/v1/users라면,
viewName도 /api/v1/users이고,
requestPath도 /api/v1/users이라서,
최종적으로 만들어지는 경로는 /api/v1/api/v1/users이다.
그리고 해당 view resolver는 화면을 찾기 위해, 해당 경로로 다시 API를 호출하게 되는데,
저런 API는 컨트롤러에 없기 때문에, 404코드를 발생시키게 된다.
해결
즉, ExceptionHandler를 @ControllerAdvice에서 @RestControllerAdvice로 바꾸니 정상적으로 원하는 예외를 발생시킬 수
있었다.
느낀점
✅ 디버그 모드
이번 이슈를 처리하면서 인텔리제이의 디버그 모드에 익숙해지게 되었다.
이제 눈으로 보이는 에러를 넘어 스프링의 동작 원리를 파악해야 하는 경우가 많은데, 디버그 모드에 익숙해질수록 디버깅이 편리하다는 생각이 들었다.
✅익숙한 것에 속지 말자
이번 이슈의 해결책은 단순히 애노테이션을 변경하는 것이었지만,
하나의 애노테이션으로도 스프링 내부적인 로직은 천차만별일 수 있다는 생각이 들었다.
늘 관례처럼 작성하던 코드도 어떤 영향을 줄 수 있는지, 다른 방법과 비교했을 때 왜 이 코드를 선택했는지에 대해 신경 써야한다.
'java > spring' 카테고리의 다른 글
[Spring] 스프링 디렉터리 패키지 구조 (0) | 2023.06.22 |
---|---|
[Spring] Spring Data JPA의 페이징 (2) | 2023.06.21 |
[Spring] Spring AOP와 실무 응용 (0) | 2023.01.12 |
[Spring] PostgreSQL - PostGIS, JPA를 통해 공간 데이터 다루기 (0) | 2023.01.12 |
[Spring] Mockito when으로 repository save 리턴받기 (0) | 2022.11.21 |