1. 개요
Spring Boot에서 정의되지 않은 API를 호출할 때, 기본적으로 반환되는 404 응답을 원하는 형식으로 커스텀하고자 한다. 이를 위해 Spring Boot에서 예외를 처리하는 동작 방식을 분석하고, 응답을 커스텀 하게 정의해보자.
기본적으로 정의되지 않은 API를 호출하면 아래와 같은 응답이 반환된다.
1. 헤더의 미디어 타입이 text/html 일 때
미리 정의된 에러 페이지를 반환한다.
2. 헤더의 미디어 타입이 text/html 아닐 때
미리 정의된 JSON 형식의 응답을 반환한다.
{
"timestamp": "2025-04-03T06:05:25.489+00:00",
"status": 404,
"error": "Not Found",
"path": "/api/v1/not-exist"
}
2. 동작 과정
스프링부트에서 기본적으로 예외 처리 컨트롤러(BasicErrorController) 제공한다.
DispatcherServlet에서 예외 사항이 발생(컨트롤러를 찾지 못하는 상황 등)할 때 BasicErrorController에 정의된 앤드포인드를 호출한다.
BasicErrorController
@Controller
@RequestMapping({"${server.error.path:${error.path:/error}}"})
public class BasicErrorController extends AbstractErrorController {
private final ErrorProperties errorProperties;
public BasicErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties) {
this(errorAttributes, errorProperties, Collections.emptyList());
}
public BasicErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties, List<ErrorViewResolver> errorViewResolvers) {
super(errorAttributes, errorViewResolvers);
Assert.notNull(errorProperties, "ErrorProperties must not be null");
this.errorProperties = errorProperties;
}
@RequestMapping(
produces = {"text/html"}
)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = this.getStatus(request);
Map<String, Object> model = Collections.unmodifiableMap(this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);
return modelAndView != null ? modelAndView : new ModelAndView("error", model);
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = this.getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity(status);
} else {
Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity(body, status);
}
}
@ExceptionHandler({HttpMediaTypeNotAcceptableException.class})
public ResponseEntity<String> mediaTypeNotAcceptable(HttpServletRequest request) {
HttpStatus status = this.getStatus(request);
return ResponseEntity.status(status).build();
}
protected ErrorAttributeOptions getErrorAttributeOptions(HttpServletRequest request, MediaType mediaType) {
ErrorAttributeOptions options = ErrorAttributeOptions.defaults();
if (this.errorProperties.isIncludeException()) {
options = options.including(new ErrorAttributeOptions.Include[]{Include.EXCEPTION});
}
if (this.isIncludeStackTrace(request, mediaType)) {
options = options.including(new ErrorAttributeOptions.Include[]{Include.STACK_TRACE});
}
if (this.isIncludeMessage(request, mediaType)) {
options = options.including(new ErrorAttributeOptions.Include[]{Include.MESSAGE});
}
if (this.isIncludeBindingErrors(request, mediaType)) {
options = options.including(new ErrorAttributeOptions.Include[]{Include.BINDING_ERRORS});
}
options = this.isIncludePath(request, mediaType) ? options.including(new ErrorAttributeOptions.Include[]{Include.PATH}) : options.excluding(new ErrorAttributeOptions.Include[]{Include.PATH});
return options;
}
protected boolean isIncludeStackTrace(HttpServletRequest request, MediaType produces) {
boolean var10000;
switch (this.getErrorProperties().getIncludeStacktrace()) {
case ALWAYS -> var10000 = true;
case ON_PARAM -> var10000 = this.getTraceParameter(request);
case NEVER -> var10000 = false;
default -> throw new IncompatibleClassChangeError();
}
return var10000;
}
protected boolean isIncludeMessage(HttpServletRequest request, MediaType produces) {
boolean var10000;
switch (this.getErrorProperties().getIncludeMessage()) {
case ALWAYS -> var10000 = true;
case ON_PARAM -> var10000 = this.getMessageParameter(request);
case NEVER -> var10000 = false;
default -> throw new IncompatibleClassChangeError();
}
return var10000;
}
protected boolean isIncludeBindingErrors(HttpServletRequest request, MediaType produces) {
boolean var10000;
switch (this.getErrorProperties().getIncludeBindingErrors()) {
case ALWAYS -> var10000 = true;
case ON_PARAM -> var10000 = this.getErrorsParameter(request);
case NEVER -> var10000 = false;
default -> throw new IncompatibleClassChangeError();
}
return var10000;
}
protected boolean isIncludePath(HttpServletRequest request, MediaType produces) {
boolean var10000;
switch (this.getErrorProperties().getIncludePath()) {
case ALWAYS -> var10000 = true;
case ON_PARAM -> var10000 = this.getPathParameter(request);
case NEVER -> var10000 = false;
default -> throw new IncompatibleClassChangeError();
}
return var10000;
}
protected ErrorProperties getErrorProperties() {
return this.errorProperties;
}
}
1. 헤더의 미디어 타입이 text/html 일 때
미디어 타입이 text/html일 때 BasicErrorController.errorHtml() 메서드가 호출된다.
@RequestMapping(produces = {"text/html"})
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = this.getStatus(request);
Map<String, Object> model = Collections.unmodifiableMap(this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);
return modelAndView != null ? modelAndView : new ModelAndView("error", model);
}
2. 헤더의 미디어 타입이 text.html 아닐 때
미디어 타입이 text/html아닐 때 BasicErrorController.error() 메서드가 호출된다.
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = this.getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity(status);
} else {
Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity(body, status);
}
}
동작 과정 정리
- 존재하지 않는 API를 호출
- DispatcherServlet이 해당 URL을 처리할 컨트롤러를 찾지 못함.
- DispatcherServlet에서 예외 코드를 HttpServletRequest객체에 저장한다.
- BasicErrorController가 매핑된 /error 앤드포인트로 요청을 전달한다.
- 헤더의 미디어 타입을 보고, BasicErrorController.errorHtml() 또는 BasicErrorController.error()를 호출한다.
- HttpServletRequest 객체에서 예외 코드를 확인하여 응답을 처리한다.
3. 에러 응답 커스텀
1. application.yml 설정
- 우선 현재 상황에서는 Exception이 발생하지 않는다. API를 찾지 못할 경우 Exception을 발생 시키도록 설정이 필요하다.
- 아래의 설정을 추가하여 존재하지 URI를 호출할 때 NoHandlerFoundException을 발생 시키도록 한다.
spring:
web.resources.add-mappings: false # 기본 리소스 처리 비활성화
필자는 Spring Boot 3.4.3 버전을 사용 중이다. 위 옵션만 추가했음에도 Exception이 발생하지 않으면 아래의 옵션을 추가해보자.
아래 옵션은 Spring Boot 3.2.0에서 Deprecated 되었다.
spring:
web.resources.add-mappings: false # 기본 리소스 처리 비활성화
mvc.throw-exception-if-no-handler-found: true
Home
Spring Boot helps you to create Spring-powered, production-grade applications and services with absolute minimum fuss. - spring-projects/spring-boot
github.com
2. 예외 핸들러 추가
- Exception 발생 시 커스텀 응답을 반환하도록 @RestControllerAdvice를 사용하여 전역 예외 처리를 구현한다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionRestAdvice {
@ExceptionHandler
public ResponseEntity<ApiResponse<Void>> noHandlerFoundException(NoHandlerFoundException e) {
log.error(e.getMessage(), e);
HttpStatus status = HttpStatus.NOT_FOUND;
return ResponseEntity
.status(status.value())
.body(ApiResponse.of(status.value(), e.getMessage()));
}
// 기타 예외 처리 핸들러 추가 가능
}
응답 결과
{
"code": 404,
"message": "No endpoint POST /api/v1/not-exist."
}
참고자료
https://docs.spring.io/spring-boot/reference/web/servlet.html#web.servlet.spring-mvc.error-handling
Servlet Web Applications :: Spring Boot
If you want to build servlet-based web applications, you can take advantage of Spring Boot’s auto-configuration for Spring MVC or Jersey.
docs.spring.io
Spring 3.x 존재하지 않은 API 예외 처리
근래 미니 프로젝트를 통해 RequestBody의 입력 값 오류, Spring Security를 사용한 인증, 인가 오류, Controller에 들어온 이후 발생한 오류를 예외 처리했다.
medium.com
[Spring] 예외 처리(BasicErrorController, HandlerExceptionResolver)
웹 애플리케이션은 잘못된 요청, 서버 내부의 에러 등 여러 원인으로 예외 상황을 맞딱뜨리게 된다.위의 화면은 예외 발생 시, Spring이 기본으로 설정해놓은 Whitelavel Error Page이다.응답 코드나, 예
velog.io
'Programming > Spring' 카테고리의 다른 글
[Spring Boot] Spring Boot + InfluxDB 연동 (0) | 2025.02.06 |
---|---|
[Spring] Spring에서 비동기 @Async 이해하기 (0) | 2024.08.23 |
[Spring] 스프링 MVC 1편(스프링 MVC 기본기) (0) | 2024.04.09 |
[Spring] Spring 단위 테스트 작성 (JUnit5, Mockito) (0) | 2024.04.04 |
[Spring] 스프링 MVC 1편(MVC 구조 이해) (0) | 2024.04.02 |