💐 Spring/개념 및 이해

Spring 존재하지 않는 API 응답 커스터마이징 (feat. 내부 동작도 살펴보기)

iseunghan 2023. 9. 21. 22:44
반응형

Intro

기본적으로 Spring MVC는 존재하지 않는 API 요청 시 응답을 다음과 같이 주고 있습니다.

{
	"timestamp":"2023-09-21T11:30:05.517+00:00",
	"status":404,
	"error":"Not Found",
	"path":"/api/404"
}

위 응답은 Spring MVC가 기본적으로 제공해주는 오류 응답 입니다. 보통 실무에서는 프레임워크에서 기본적으로 제공해주는 응답값을 사용하기 보단 공통 응답 포맷을 맞춰서 사용할 것 입니다. 어떻게 하면 Not Found에 대한 응답을 커스텀할 수 있는지와 내부적으로 어떻게 동작하고 있는지도 함께 에러 로그와 코드를 통해 살펴보겠습니다.

에러 응답이 생성되기까지의 여정

그렇다면 어떻게 스프링MVC에서는 에러 메세지를 생성하고 있을까요?

간단한 테스트를 통해서 알아보겠습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class APINotFoundExceptionTest {
    @LocalServerPort private int port;

    @Test
    void 없는_API라면_404에러가_발생한다() throws Exception {
        // given
        RestTemplate restTemplate = new RestTemplate();

        // when & then
        assertThatThrownBy(() -> restTemplate.getForObject("<http://localhost>:" + port + "/api/404", String.class));
    }
}

자세한 로그를 살펴보기 위해 application.yaml에 web 관련 로그 레벨을 DEBUG로 설정하겠습니다.

logging:
  level:
    web: debug

실행을 해주면 다음과 같은 로그가 출력됩니다.

2023-09-21T21:09:26.457+09:00 DEBUG 56326 [1] [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/api/404", parameters={}
2023-09-21T21:09:26.460+09:00 DEBUG 56326 [2] [o-auto-1-exec-1] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped to ResourceHttpRequestHandler [classpath [META-INF/resources/], classpath [resources/], classpath [static/], classpath [public/], ServletContext [/]]
2023-09-21T21:09:26.463+09:00 DEBUG 56326 [3] [o-auto-1-exec-1] o.s.w.s.r.ResourceHttpRequestHandler     : Resource not found
2023-09-21T21:09:26.463+09:00 DEBUG 56326 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Completed 404 NOT_FOUND
2023-09-21T21:09:26.466+09:00 DEBUG 56326 [4] [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : "ERROR" dispatch for GET "/error", parameters={}
2023-09-21T21:09:26.467+09:00 DEBUG 56326 [5] [o-auto-1-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)
2023-09-21T21:09:26.477+09:00 DEBUG 56326 --- [o-auto-1-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [text/plain, application/json, application/*+json, */*] and supported [application/json, application/*+json]
2023-09-21T21:09:26.478+09:00 DEBUG 56326 --- [o-auto-1-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [{timestamp=Thu Sep 21 21:09:26 KST 2023, status=404, error=Not Found, path=/api/404}]
2023-09-21T21:09:26.493+09:00 DEBUG 56326 --- [    Test worker] o.s.web.client.RestTemplate              : Response 404 NOT_FOUND
2023-09-21T21:09:26.493+09:00 DEBUG 56326 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Exiting from "ERROR" dispatch, status 404
  • [1]: RestTemplate으로 보냈던 요청을 DispatcherServlet이 받았습니다
  • [2]: 우리의 요청이 ResourceHttpRequestHandler에게 매핑되었다고 합니다? (*뒤에서 자세하게 살펴보겠습니다)
  • [3]: Resource를 못찾았다고 합니다.
  • [4]: 그러고는 우리의 요청을 /error로 보냅니다
  • [5]: 최종적으로 BasicErrorController가 우리에게 응답을 내려주는 것 같습니다.

자 이제 로그 분석이 끝났으니 한번 코드로 살펴볼까요?

ResourceHttpRequestHandler

public class ResourceHttpRequestHandler extends WebContentGenerator
		implements HttpRequestHandler, EmbeddedValueResolverAware, InitializingBean, CorsConfigurationSource {
	...

	@Override
	public void handleRequest(HttpServletRequest request, HttpServletResponse response)	throws ServletException, IOException {
		// For very general mappings (e.g. "/") we need to check 404 first
		Resource resource = getResource(request);
		if (resource == null) {
			logger.debug("Resource not found"); // **[1]
			response.sendError(HttpServletResponse.SC_NOT_FOUND); // **[2]
			return;
	 }
	...
}
  • [1]: 로그에 찍혔던 “Resource not found”가 보입니다!
  • [2]: 우리에게 404 응답을 여기서 보내고 있었습니다. 이 에러는 BasicErrorController에 의해 처리됩니다.

근데 뭔가 이상합니다.. ResourceHttpRequestHandler는 정적 리소스를 제공하는 핸들러인데, 얘는 우리의 요청을 정적 리소스 요청으로 보고 정적 파일을 찾으려고 시도하는 것이죠. 왤까요?

SimpleUrlHandlerMapping

2023-09-21T21:09:26.460+09:00 DEBUG 56326 [2] [o-auto-1-exec-1] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped to ResourceHttpRequestHandler [classpath [META-INF/resources/], classpath [resources/], classpath [static/], classpath [public/], ServletContext [/]]

[2] 로그에서 SimpleUrlHandlerMapping이 ResourceHttpRequestHandler를 매핑했다고 로그를 찍었습니다. 우리가 요청한 저 /api/404를 처리할 핸들러는 없을텐데 Handler가 있다는게 뭔가 이상합니다.

DispatcherServlet을 살펴봅시다

기본적인 Handler를 가지고 반복문을 돌려서 우리의 요청을 처리할 SimpleUrlHandlerMapping 핸들러를 찾아냅니다. 즉, 우리의 요청을 정적 리소스 요청으로 간주한 것입니다.

스프링 공식문서에 따르면 다음과 같습니다.

By default, Spring MVC will send a 404 Not Found error response if a handler is not found for a request. To have a NoHandlerFoundException thrown instead, set configprop:spring.mvc.throw-exception-if-no-handler-found to true. Note that, by default, the serving of static content is mapped to /** and will, therefore, provide a handler for all requests. For a NoHandlerFoundException to be thrown, you must also set spring.mvc.static-path-pattern to a more specific value such as /resources/** or set spring.web.resources.add-mappings to false to disable serving of static content entirely.

기본적으로 Spring MVC는 요청에 대한 핸들러를 찾을 수 없는 경우 404 Not Found 오류 응답을 보냅니다. 대신 NoHandlerFoundException이 발생하도록 하려면 configprop:spring.mvc.throw-exception-if-no-handler-found를 true로 설정하세요. 기본적으로 정적 콘텐츠 제공은 /**에 매핑되므로 모든 요청에 대해 핸들러를 제공합니다. NoHandlerFoundException이 발생하려면 spring.mvc.static-path-pattern을 /resources/**와 같은 보다 구체적인 값으로 설정하거나 spring.web.resources.add-mappings를 false로 설정하여 정적 콘텐츠의 서빙을 완전히 비활성화해야 합니다.

spring.web.resources.add-mappings: false 로 설정해주면 됩니다!

NoHandlerFoundException 발생시키기

2023-09-21T22:01:09.091+09:00 DEBUG 57850 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/api/404", parameters={}
2023-09-21T22:01:09.094+09:00  WARN 57850 [1] [o-auto-1-exec-1] o.s.web.servlet.PageNotFound             : No mapping for GET /api/404
2023-09-21T22:01:09.095+09:00 DEBUG 57850 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Completed 404 NOT_FOUND
2023-09-21T22:01:09.098+09:00 DEBUG 57850 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : "ERROR" dispatch for GET "/error", parameters={}
2023-09-21T22:01:09.100+09:00 DEBUG 57850 --- [o-auto-1-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)
2023-09-21T22:01:09.167+09:00 DEBUG 57850 --- [o-auto-1-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [text/plain, application/json, application/*+json, */*] and supported [application/json, application/*+json]
2023-09-21T22:01:09.169+09:00 DEBUG 57850 --- [o-auto-1-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [{timestamp=Thu Sep 21 22:01:09 KST 2023, status=404, error=Not Found, path=/api/404}]
2023-09-21T22:01:09.197+09:00 DEBUG 57850 --- [    Test worker] o.s.web.client.RestTemplate              : Response 404 NOT_FOUND
2023-09-21T22:01:09.198+09:00 DEBUG 57850 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Exiting from "ERROR" dispatch, status 404
  • [1]: 이제 그 어떠한 HandlerMapping이 할당되지 않았습니다.

DispatcherServlet을 살펴보겠습니다.

public class DispatcherServlet {
	...
	protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
		if (pageNotFoundLogger.isWarnEnabled()) {
			pageNotFoundLogger.warn("No mapping for " + request.getMethod() + " " + getRequestUri(request));
		}
		if (this.throwExceptionIfNoHandlerFound) { // **
			throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request),
					new ServletServerHttpRequest(request).getHeaders());
		}
		else {
			response.sendError(HttpServletResponse.SC_NOT_FOUND);
		}
	}
	...
}
  • 현재 throwExceptionIfNoHandlerFound값이 참이면 NoHandlerFoundException을 발생시킵니다. 기본값은 false로 설정되어 있습니다.

이제 NoHandlerFoundException를 발생시키도록 해서 우리가 ExceptionHandler를 통해 예외를 처리해주면 끝입니다! 공식문서에 따르면 [spring.mvc.throw-exception-if-no-handler-found](<https://docs.spring.io/spring-boot/docs/3.1.4/reference/htmlsingle/#application-properties.web.spring.mvc.throw-exception-if-no-handler-found>) 라는 옵션을 true로 설정하면 된다고 합니다! 이렇게 설정 1줄로 간단하게 바꿀 수 있다니 너무 편리한 것 같습니다..!

다시 테스트를 실행해보면?

2023-09-21T22:11:07.767+09:00  WARN 58155 --- [o-auto-1-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.servlet.NoHandlerFoundException: No endpoint GET /api/404.]

DefaultHandlerExceptionResolver에서 NoHandlerFoundException을 처리한 것을 볼 수 있습니다!

커스텀 ExceptionHandler 생성

존재하지 않는 API로 요청이 왔을 경우, NoHandlerFoundException이 발생하게끔 설정하였습니다. 그리고 이 예외를 Advice에서 잡아서 우리가 커스텀하게 응답을 내려줄 수 있습니다. 바로 아래처럼 말이죠.

@RestControllerAdvice
public class GlobalControllerAdvice {
    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity<Map<String, String>> noHandlerFoundHandle(NoHandlerFoundException e) {
        return ResponseEntity.status(NOT_FOUND)
                .body(Map.of(
                        "statusCode", "404",
                        "message", "존재하지 않는 API 입니다."
                ));
    }
}

Outro

존재하지 않는 API 요청에 대해서 추적을 하는데 꽤 애를 많이 먹었습니다. 왜 존재하지 않는 URL을 가지고 Static Resource를 찾는지 스프링이 기본적으로 SimpleUrlHandlerMapping을 통해 처리하는지, 내부적으로 어떻게 동작하는지 이번 기회에 자세하게 알게되었습니다. 앞으로 이런 문제가 발생하더라도 차근차근 한단계식 추적해나가며 내부동작이 어떻게 이뤄지는지, 또 어떻게 하면 문제를 해결할 수 있는지 도움이 될 것 같습니다.

긴 글 읽어주셔서 감사합니다!

REFERENCES

반응형