문제
서버에서 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 |