이전 글에 이어서, 이번 글에서는 여러 삽질을 거쳐 에러 처리를 세분화할 수 있었던 과정을 적어보려고 한다.
✅ 유효성 검증 기능
이번 프로젝트에서는 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 이 발생한다고 한다.
GlobalExceptionHandler (기존)
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 에러가 발생했을 때 이를 처리하는 부분이 없기에 원하는 처리가 진행되지 않았던 것이다.
handleMethodArgumentNotValid
// 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 메서드가 이미 존재하기에 위와 같은 에러가 발생했던 것이다!
@Override
그렇기에 우리가 원하는대로 에러처리를 하기 위해서는, 이미 있는 메서드를 오버라이드해서 커스텀해야한다.
// 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) MethodArgumentNotValidException2)HttpHeaders3) HttpStatusCode4) WebRequest
여기서 한 가지 이상한 점이 있다.
위에서 나는 HttpStatus를 파라미터로 받는데, 여기서는 HttpStatusCode를 파라미터로 받고 있었다.
오버라이드를 할 때는 반환 타입과 메서드명, 그리고 파라미터가 당연히 동일해야하기 때문에 오버라이드가 되지 않았던 것이다.
글들을 많이 찾아보았었는데, 대부분 HttpStatus를 파라미터로 받았다.
비교적 최근 (2024.08)에 적힌 해당 글에서만 HttpStatusCode로 한 것을 보아, 최근에 변경된 점이라는 것을 알 수 있었다.
HttpStatus to 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 외에도,
- MissingRequestHeaderException (필수 헤더가 누락된 경우)
- DataIntegrityViolationException (데이터베이스 제약 조건 위반)
- NoHandlerFoundException (요청 경로에 매핑된 핸들러가 없는 경우)
- HttpRequestMethodNotSupportedException (지원하지 않는 HTTP 메소드 요청이 들어온 경우) …
등 발생할 수 있을만한 에러들의 처리도 추가해두었다.
이에 대한 원본 코드는 GitHub에서, 처리 과정 PR은 이 곳에서 확인해 볼 수 있다!
느낀 점
- 보통은 상속을 받고 별 생각없이 사용을 했는데, 이번 기회로 내부까지 볼 수 있어서 좋은 경험이었던 것 같다. 오버라이드를 할 때는 꼭 파라미터를 잘 보아야겠다는 생각이 들었다!
- 에러 처리에 대한 흐름을 공부할 수 있어서 뜻 깊은 시간이었던 것 같다. 또한 그동안에는 유효성 검증을 잘 몰랐는데, 이제 알게 되어 프론트측에도 더욱 자세한 응답을 보내줄 수 있어 좋은 것 같다.
- 현업에서도 이와 같은 방식으로 에러처리를 하는지, 그렇지 않다면 어떻게 하고 있는지도 궁금해졌다!