java/spring

[Spring] @ControllerAdvice, 특정 예외 발생 시 404에러가 발생하는 이슈

danuri 2023. 6. 21. 22:54

문제

서버에서 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로 바꾸니 정상적으로 원하는 예외를 발생시킬 수

있었다.

 

느낀점

 디버그 모드

이번 이슈를 처리하면서 인텔리제이의 디버그 모드에 익숙해지게 되었다.

이제 눈으로 보이는 에러를 넘어 스프링의 동작 원리를 파악해야 하는 경우가 많은데, 디버그 모드에 익숙해질수록 디버깅이 편리하다는 생각이 들었다.

 

익숙한 것에 속지 말자

이번 이슈의 해결책은 단순히 애노테이션을 변경하는 것이었지만,

하나의 애노테이션으로도 스프링 내부적인 로직은 천차만별일 수 있다는 생각이 들었다.

늘 관례처럼 작성하던 코드도 어떤 영향을 줄 수 있는지, 다른 방법과 비교했을 때 왜 이 코드를 선택했는지에 대해 신경 써야한다.