이전 글에 이어서, 이번 글에서는 여러 삽질을 거쳐 에러 처리를 세분화할 수 있었던 과정을 적어보려고 한다.
이번 프로젝트에서는 DTO를 Record로 만들어 사용하고 있는데,
다음과 같은 부분이 눈에 띄었다.
package kusitms.backend.chatbot.dto.request;
import jakarta.validation.constraints.NotBlank;
public record GetClovaChatbotAnswerRequest(
@NotBlank(message = "사용자 메세지는 빈 값일 수 없습니다.") String message
) {
}
@NotBlank
어노테이션은 유효성 검증을 위한 것인데,
나는 이를 처음 사용해봐 잘 모르는 상태로 우선 따라서 코드를 작성했다.
참고로 아래와 같은 어노테이션 종류들이 존재한다.
@NotNull
: null을 허용하지 않는다. "", " "은 허용한다.
@NotEmpty
: null, ""를 허용하지 않는다. " "은 허용한다.
@NotBlank
: null, "", " "을 허용하지 않는다.
@Min(value = @number)
: value 이상의 값을 허용한다.
@Max(value = @number)
: value 이하의 값을 허용한다.
...
해당 Request 객체에 대해서 유효성 검증을 시행하기 위해서는,
Controller에서 받을 때 @Valid
어노테이션을 붙여주어야 한다.
아래와 같이 말이다.
// Clova 챗봇 답변 조회 API
@PostMapping("/clova")
public ResponseEntity<ApiResponse<GetClovaChatbotAnswerResponse>> getClovaChatbotAnswer(
@Valid @RequestBody GetClovaChatbotAnswerRequest request) {
GetClovaChatbotAnswerResponse response = clovaService.getClovaChatbotAnswer(request.message());
return ApiResponse.onSuccess(ChatbotSuccessStatus._GET_CLOVA_CHATBOT_ANSWER, response);
}
하지만 이렇게 했음에도 처리는 제대로 되지 않았는데,
위와 같이 지정한 메세지가 반환되는 것이 아니라, 기본적인 400 에러만이 반환되었다.
찾아 보니 필수값이 들어오지 않아 유효성 에러가 발생할 때는,
MethodArgumentNotValidException
이 발생한다고 한다.
package kusitms.backend.global.exception;
import kusitms.backend.global.dto.ApiResponse;
import kusitms.backend.global.dto.ErrorReasonDto;
import kusitms.backend.global.status.ErrorStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// 커스텀 예외 처리
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleCustomException(CustomException e) {
log.error("CustomException occurred: {}", e.getMessage());
return ApiResponse.onFailure(e.getErrorCode());
}
// Security 인증 관련 처리
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleSecurityException(SecurityException e) {
log.error("SecurityException: {}", e.getMessage());
return ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED);
}
// 기타 Exception 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleException(Exception e) {
log.error("Exception: {}", e.getMessage());
if (e instanceof IllegalArgumentException) {
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST);
}
// 그 외 내부 서버 오류로 처리
return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR);
}
}
GlobalExceptionHandler
을 살펴 보면,
커스텀 예외처리와 Security 인증 관련 처리, 그리고 그 나머지를 모두 기타 Exception으로 묶어 처리하고 있었다.
때문에 MethodArgumentNotValidException
에러가 발생했을 때 이를 처리하는 부분이 없기에 원하는 처리가 진행되지 않았던 것이다.
// MethodArgumentNotValidException 처리 (RequestBody로 들어온 필드들의 유효성 검증에 실패한 경우)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e)
그래서 위와 같은 방식으로 에러 처리를 추가해주었는데,
실행부터 다음과 같은 에러가 발생했다.
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'handlerExceptionResolver' defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Failed to instantiate [org.springframework.web.servlet.HandlerExceptionResolver]: Factory method 'handlerExceptionResolver' threw exception with message: Ambiguous @ExceptionHandler method mapped for [class org.springframework.web.bind.MethodArgumentNotValidException]: {public org.springframework.http.ResponseEntity kusitms.backend.global.exception.GlobalExceptionHandler.handleCustomException(org.springframework.web.bind.MethodArgumentNotValidException), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:648) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:636) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1355) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1185) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:562) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:975) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:971) ~[spring-context-6.1.13.jar:6.1.13]
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:625) ~[spring-context-6.1.13.jar:6.1.13]
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) ~[spring-boot-3.3.4.jar:3.3.4]
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754) ~[spring-boot-3.3.4.jar:3.3.4]
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456) ~[spring-boot-3.3.4.jar:3.3.4]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335) ~[spring-boot-3.3.4.jar:3.3.4]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363) ~[spring-boot-3.3.4.jar:3.3.4]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352) ~[spring-boot-3.3.4.jar:3.3.4]
at kusitms.backend.BackendApplication.main(BackendApplication.java:10) ~[main/:na]
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.web.servlet.HandlerExceptionResolver]: Factory method 'handlerExceptionResolver' threw exception with message: Ambiguous @ExceptionHandler method mapped for [class org.springframework.web.bind.MethodArgumentNotValidException]: {public org.springframework.http.ResponseEntity kusitms.backend.global.exception.GlobalExceptionHandler.handleCustomException(org.springframework.web.bind.MethodArgumentNotValidException), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:178) ~[spring-beans-6.1.13.jar:6.1.13]
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:644) ~[spring-beans-6.1.13.jar:6.1.13]
... 19 common frames omitted
Caused by: java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [class org.springframework.web.bind.MethodArgumentNotValidException]: {public org.springframework.http.ResponseEntity kusitms.backend.global.exception.GlobalExceptionHandler.handleCustomException(org.springframework.web.bind.MethodArgumentNotValidException), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}
at org.springframework.web.method.annotation.ExceptionHandlerMethodResolver.addExceptionMapping(ExceptionHandlerMethodResolver.java:114) ~[spring-web-6.1.13.jar:6.1.13]
at org.springframework.web.method.annotation.ExceptionHandlerMethodResolver.<init>(ExceptionHandlerMethodResolver.java:78) ~[spring-web-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver.initExceptionHandlerAdviceCache(ExceptionHandlerExceptionResolver.java:289) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver.afterPropertiesSet(ExceptionHandlerExceptionResolver.java:256) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport.addDefaultHandlerExceptionResolvers(WebMvcConfigurationSupport.java:1063) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport.handlerExceptionResolver(WebMvcConfigurationSupport.java:1005) ~[spring-webmvc-6.1.13.jar:6.1.13]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:146) ~[spring-beans-6.1.13.jar:6.1.13]
... 20 common frames omitted
...
이에 대해 찾아 보니,
해당 에러는 MethodArgumentNotValidException을 처리하는 @ExceptionHandler 메서드가 중복되어 스프링이 어떤 메서드를 사용할지 혼란스러워할 때 발생하는 문제라고 한다.
즉, MethodArgumentNotValidException에 대해 두 개의 @ExceptionHandler 메서드가 정의되어 있는 상태이기에 에러가 발생한 것이다.
이를 해결하기 위해 여러 블로그 글들을 찾아본 결과,
ResponseEntityExceptionHandler
를 상속 받아 GlobalExceptionHandler을 구현한 경우에는 해당 에러 처리 메서드를 @Override
하여 구현해야한다는 것을 알 수 있었다.
해결한 것에 앞서,
ResponseEntityExceptionHandler
에 대해 간략히 정리해보려고 한다.
ResponseEntityExceptionHandler는 스프링 프레임워크에서 제공하는 기본적인 예외 처리 클래스이다. 이 클래스는 다양한 예외 상황에 대해 기본적인 HTTP 응답을 생성하는 기능을 제공한다.
즉 다양한 예외 처리를 기본적으로 해주는 핸들러로,
이를 상속받는다면 대부분의 에러는 자동적으로 처리해주게 된다.
그리고 이 ResponseEntityExceptionHandler
내에 MethodArgumentNotValidException
을 처리하는 handleMethodArgumentNotValid
메서드가 이미 존재하기에 위와 같은 에러가 발생했던 것이다!
그렇기에 우리가 원하는대로 에러처리를 하기 위해서는, 이미 있는 메서드를 오버라이드해서 커스텀해야한다.
// MethodArgumentNotValidException 처리 (RequestBody로 들어온 필드들의 유효성 검증에 실패한 경우)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
String combinedErrors = extractFieldErrors(ex.getBindingResult().getFieldErrors());
logError("Validation error", combinedErrors);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, combinedErrors);
}
위와 같이 메서드를 만들었으나,
계속해서 빨간줄이 뜨며 제대로 오버라이드가 되지 않았다.
블로그 글들을 계속해서 찾아봐도 해결이 안되어서...도대체 뭐가 문제인지 한참 고민했다 😇
그러다가 ResponseEntityExceptionHandler
클래스 내부를 보는 게 가장 정확하겠다라는 생각이 들어 파헤치기 시작했다.
내부에는 이런식으로 각종 에러들을 처리하는 메서드들이 존재하는 것을 볼 수 있다.
여기서 handleMethodArgumentNotValid
메서드를 자세히 보자.
@Nullable
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return this.handleExceptionInternal(ex, (Object)null, headers, status, request);
}
ResponseEntity<Object>
를 반환한다.1) MethodArgumentNotValidException
2)HttpHeaders
3) HttpStatusCode
4) WebRequest
여기서 한 가지 이상한 점이 있다.
위에서 나는 HttpStatus
를 파라미터로 받는데, 여기서는 HttpStatusCode
를 파라미터로 받고 있었다.
오버라이드를 할 때는 반환 타입과 메서드명, 그리고 파라미터가 당연히 동일해야하기 때문에 오버라이드가 되지 않았던 것이다.
글들을 많이 찾아보았었는데, 대부분 HttpStatus
를 파라미터로 받았다.
비교적 최근 (2024.08)에 적힌 해당 글에서만 HttpStatusCode
로 한 것을 보아, 최근에 변경된 점이라는 것을 알 수 있었다.
해당 글을 보고 스프링 부트 3.3.3 버전부터는 HttpStatusCode
를 사용함을 알 수 있었다!
위와 같은 과정을 거쳐 결과적으로 아래와 같이 메서드를 만들었고,
// MethodArgumentNotValidException 처리 (RequestBody로 들어온 필드들의 유효성 검증에 실패한 경우)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String combinedErrors = extractFieldErrors(ex.getBindingResult().getFieldErrors());
logError("Validation error", combinedErrors);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, combinedErrors);
}
결국 원하는 응답을 얻을 수 있었다!
하지만 @RequestBody
가 아닌 쿼리 파라미터에 대해서는 유효성 검증이 되지 않았기에 이 부분도 추가해보기로 했다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/chatbot")
@Validated
public class ChatbotController {
private final ChatbotService chatbotService;
private final ClovaService clovaService;
// 가이드 챗봇 답변 조회 API
@GetMapping("/guide")
public ResponseEntity<ApiResponse<GetGuideChatbotAnswerResponse>> getGuideChatbotAnswer(
@RequestParam("stadiumName") @NotBlank String stadiumName,
@RequestParam("categoryName") @NotBlank String categoryName,
@RequestParam("orderNumber") @Min(1) int orderNumber){
GetGuideChatbotAnswerResponse response = chatbotService.getGuideChatbotAnswer(stadiumName, categoryName, orderNumber);
return ApiResponse.onSuccess(ChatbotSuccessStatus._GET_GUIDE_CHATBOT_ANSWER, response);
}
쿼리 파라미터에 대한 유효성 검증을 하기 위해서는,
위와 같이 컨트롤러 클래스 단에 @Validated
라는 어노테이션을 붙여주어야 한다.
@RequestParam("stadiumName") @NotBlank String stadiumName,
@RequestParam("categoryName") @NotBlank String categoryName,
@RequestParam("orderNumber") @Min(1) int orderNumber
또한 이렇게 @NotBlank
와 같은 어노테이션을 파라미터마다 붙여주어야한다.
그리고 이는 MethodArgumentNotValidException
이 아닌 ConstraintViolationException
이기 때문에,
// ConstraintViolationException 처리 (쿼리 파라미터에 올바른 값이 들어오지 않은 경우)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Object> handleValidationParameterError(ConstraintViolationException ex) {
String errorMessage = ex.getMessage();
logError("ConstraintViolationException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
GlobalExceptionHandler
에 위와 같은 메서드를 추가해 주었다.
그럼 이렇게 쿼리 파라미터에 대해서도 유효성 검증이 되는 모습을 볼 수가 있다.
하지만 이렇게 파라미터 2개가 빠져있음에도 앞에 있는 stadiumName
에 대한 에러만 던지는 것을 볼 수가 있는데, 이는 파라미터당 각각 에러를 반환하기에 여러 개를 처리할 수가 없어서 그렇다고 한다.
이 부분은 동시에 처리할 수 있는 방법이 있는지에 대해서는 아직 학습이 조금 더 필요할 듯 하다!
package kusitms.backend.global.exception;
import jakarta.validation.ConstraintViolationException;
import kusitms.backend.global.dto.ApiResponse;
import kusitms.backend.global.dto.ErrorReasonDto;
import kusitms.backend.global.status.ErrorStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// 커스텀 예외 처리
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleCustomException(CustomException e) {
logError(e.getMessage(), e);
return ApiResponse.onFailure(e.getErrorCode());
}
// Security 인증 관련 처리
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleSecurityException(SecurityException e) {
logError(e.getMessage(), e);
return ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED);
}
// IllegalArgumentException 처리 (잘못된 인자가 전달된 경우)
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException e) {
String errorMessage = "잘못된 요청입니다: " + e.getMessage();
logError("IllegalArgumentException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// NullPointerException 처리
@ExceptionHandler(NullPointerException.class)
public ResponseEntity<Object> handleNullPointerException(NullPointerException e) {
String errorMessage = "서버에서 예기치 않은 오류가 발생했습니다. 요청을 처리하는 중에 Null 값이 참조되었습니다.";
logError("NullPointerException", e);
return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR, errorMessage);
}
// NumberFormatException 처리
@ExceptionHandler(NumberFormatException.class)
public ResponseEntity<Object> handleNumberFormatException(NumberFormatException e) {
String errorMessage = "숫자 형식이 잘못되었습니다: " + e.getMessage();
logError("NumberFormatException", e);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// IndexOutOfBoundsException 처리
@ExceptionHandler(IndexOutOfBoundsException.class)
public ResponseEntity<Object> handleIndexOutOfBoundsException(IndexOutOfBoundsException e) {
String errorMessage = "인덱스가 범위를 벗어났습니다: " + e.getMessage();
logError("IndexOutOfBoundsException", e);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// ConstraintViolationException 처리 (쿼리 파라미터에 올바른 값이 들어오지 않은 경우)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Object> handleValidationParameterError(ConstraintViolationException ex) {
String errorMessage = ex.getMessage();
logError("ConstraintViolationException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// MissingRequestHeaderException 처리 (필수 헤더가 누락된 경우)
@ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<Object> handleMissingRequestHeaderException(MissingRequestHeaderException ex) {
String errorMessage = "필수 헤더 '" + ex.getHeaderName() + "'가 없습니다.";
logError("MissingRequestHeaderException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// DataIntegrityViolationException 처리 (데이터베이스 제약 조건 위반)
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Object> handleDataIntegrityViolationException(DataIntegrityViolationException e) {
String errorMessage = "데이터 무결성 제약 조건을 위반했습니다: " + e.getMessage();
logError("DataIntegrityViolationException", e);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// MissingServletRequestParameterException 처리 (필수 쿼리 파라미터가 입력되지 않은 경우)
@Override
protected ResponseEntity<Object> handleMissingServletRequestParameter(MissingServletRequestParameterException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String errorMessage = "필수 파라미터 '" + ex.getParameterName() + "'가 없습니다.";
logError("MissingServletRequestParameterException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// MethodArgumentNotValidException 처리 (RequestBody로 들어온 필드들의 유효성 검증에 실패한 경우)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String combinedErrors = extractFieldErrors(ex.getBindingResult().getFieldErrors());
logError("Validation error", combinedErrors);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, combinedErrors);
}
// NoHandlerFoundException 처리 (요청 경로에 매핑된 핸들러가 없는 경우)
@Override
protected ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String errorMessage = "해당 경로에 대한 핸들러를 찾을 수 없습니다: " + ex.getRequestURL();
logError("NoHandlerFoundException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._NOT_FOUND_HANDLER, errorMessage);
}
// HttpRequestMethodNotSupportedException 처리 (지원하지 않는 HTTP 메소드 요청이 들어온 경우)
@Override
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String errorMessage = "지원하지 않는 HTTP 메소드 요청입니다: " + ex.getMethod();
logError("HttpRequestMethodNotSupportedException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._METHOD_NOT_ALLOWED, errorMessage);
}
// HttpMediaTypeNotSupportedException 처리 (지원하지 않는 미디어 타입 요청이 들어온 경우)
@Override
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String errorMessage = "지원하지 않는 미디어 타입입니다: " + ex.getContentType();
logError("HttpMediaTypeNotSupportedException", errorMessage);
return ApiResponse.onFailure(ErrorStatus._UNSUPPORTED_MEDIA_TYPE, errorMessage);
}
// HttpMessageNotReadableException 처리 (잘못된 JSON 형식)
@Override
public ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String errorMessage = "요청 본문을 읽을 수 없습니다. 올바른 JSON 형식이어야 합니다.";
logError("HttpMessageNotReadableException", ex);
return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage);
}
// 내부 서버 에러 처리 (500)
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleException(Exception e) {
logError(e.getMessage(), e);
return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR);
}
// 유효성 검증 오류 메시지 추출 메서드 (FieldErrors)
private String extractFieldErrors(List<FieldError> fieldErrors) {
return fieldErrors.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(", "));
}
// 로그 기록 메서드
private void logError(String message, Object errorDetails) {
log.error("{}: {}", message, errorDetails);
}
}
최종적으로 위와 같이 GlobalExceptionHandler
를 구성하여 사용하고 있다.
앞서 말한 두 Exception 외에도,
1) MissingRequestHeaderException (필수 헤더가 누락된 경우)
2) DataIntegrityViolationException (데이터베이스 제약 조건 위반)
3) NoHandlerFoundException (요청 경로에 매핑된 핸들러가 없는 경우)
4) HttpRequestMethodNotSupportedException (지원하지 않는 HTTP 메소드 요청이 들어온 경우)
...
등 발생할 수 있을만한 에러들의 처리도 추가해두었다.
이에 대한 원본 코드는 GitHub에서, 처리 과정 PR은 이 곳에서 확인해 볼 수 있다!
ResponseEntityExceptionHandler 안에 보면서 해결하는 과정 너무 재밌다 ㅋㅋㅋ
덕분에 에러 쥐잡듯이 잡을 수 있겠네여