에러를 쥐잡듯이 잡아보자 - 2 🪤

상호·2024년 11월 3일
3

🌱 Spring Boot

목록 보기
6/7
post-thumbnail

이전 글에 이어서, 이번 글에서는 여러 삽질을 거쳐 에러 처리를 세분화할 수 있었던 과정을 적어보려고 한다.

✅ 유효성 검증 기능

이번 프로젝트에서는 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) MethodArgumentNotValidException 2)HttpHeaders 3) HttpStatusCode 4) 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 외에도,

1) MissingRequestHeaderException (필수 헤더가 누락된 경우)
2) DataIntegrityViolationException (데이터베이스 제약 조건 위반)
3) NoHandlerFoundException (요청 경로에 매핑된 핸들러가 없는 경우)
4) HttpRequestMethodNotSupportedException (지원하지 않는 HTTP 메소드 요청이 들어온 경우)
...

등 발생할 수 있을만한 에러들의 처리도 추가해두었다.

이에 대한 원본 코드는 GitHub에서, 처리 과정 PR은 이 곳에서 확인해 볼 수 있다!


느낀 점

  1. 보통은 상속을 받고 별 생각없이 사용을 했는데, 이번 기회로 내부까지 볼 수 있어서 좋은 경험이었던 것 같다. 오버라이드를 할 때는 꼭 파라미터를 잘 보아야겠다는 생각이 들었다!
  2. 에러 처리에 대한 흐름을 공부할 수 있어서 뜻 깊은 시간이었던 것 같다. 또한 그동안에는 유효성 검증을 잘 몰랐는데, 이제 알게 되어 프론트측에도 더욱 자세한 응답을 보내줄 수 있어 좋은 것 같다.
  3. 현업에서도 이와 같은 방식으로 에러처리를 하는지, 그렇지 않다면 어떻게 하고 있는지도 궁금해졌다!
profile
상호작용하는 백엔드 개발자

2개의 댓글

comment-user-thumbnail
2024년 11월 7일

ResponseEntityExceptionHandler 안에 보면서 해결하는 과정 너무 재밌다 ㅋㅋㅋ
덕분에 에러 쥐잡듯이 잡을 수 있겠네여

1개의 답글