본문 바로가기
Spring, Springboot

전역 에러 처리

by 이상한나라의개발자 2024. 1. 30.

에러 전역 처리는 어플리케이션 내에서 발생하는 예외와 에러를 중앙에서 일괄적으로 처리하는 것을 의미합니다. 전역 처리는 주로 다음과 같은 이슈로 사용됩니다.

 

  • 유지보수 향상 : 에러 처리 로직을 각각의 메소드나 컨트롤러에서 반복해서 사용하는 대신, 중앙에서 일괄적으로 처리하면 코드의 중복을 방지하고 유지보수성을 향상 시킵니다. 수정이 필요한 경우 전역처리 로직만 변경하면 됩니다.
  • 일관성 유지 : 모든 예외와 에러에 대해 특정한 로깅, 응답 형식, 또는 에러 코드를 반환하려면 각각의 메소드에서 일일이 처리하는 것이 아니라, 전역에서 처리하면 일관된 방식으로 응답을 생성할 수 있습니다.
  • 사용자 경험 향상 : 전역 에러 처리를 통해 예상치 못한 에러에 대한 사용자 경험을 개선할 수 있습니다. 사용자에게 친숙한 에러 메시지를 제공하거나, 개발자에게 자세한 디버깅 정보를 로깅할 수 있습니다.
  • 보안 강화 : 어플리케이션에서 발생하는 예외에 대한 적절한 처리를 통해 보안을 강화할 수 있습니다. 민감한 정보를 노출하지 않고 공격에 대한 대응이나 로깅을 강화할 수 있습니다.
  • 에러 통합 모니터링 : 전역 에러 처리를 설정하면 시스템 전체에서 발생한 에러를 효과적으로 모니터링 하고 로깅할 수 있습니다. 이는 프로덕션 환경에서의 문제 식별과 디버깅을 용이하게 합니다.

전역 에러 처리는 주로 Spring Framework의 @ControllerAdvice 또는 @RestControllerAdvice 어노테이션을 이용하여 구현 됩니다. 이를 통해 어플리케이션 전체에서 발생하는 예외를 일관된 방식으로 처리할 수 있습니다.

 

아래 코드를 통해서 전역 에러 처리를 구현해 보겠습니다.

 

Error 전역 처리 구현 

ErrorCode 구현 

이 코드는 열거형(enum)을 사용하여 각각의 에러 코드를 정의하는 클래스 입니다. 주로 사용자 정의 예외에 대한 처리를 하기 위해 작성 됩니다. 이러한 방식을 사용하게 되면 코드의 가독성과 유지보수을 높일 수 있습니다.

@Getter
public enum ErrorCode {
    
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "0001", "Invalid Input Value"),
    
    ;

    private HttpStatus httpStatus;
    private String errorCode;
    private String message;

    ErrorCode(HttpStatus httpStatus, String errorCode, String message) {
        this.httpStatus = httpStatus;
        this.errorCode = errorCode;
        this.message = message;
    }
}

 

사용자 정의 예외 구현

이 코드는 BusinessException 이라는 예외 클래스를 정의하고 있습니다. 이 예외 클래스는 unchecked exception인 RuntimeException을 상속하고 있습니다. 해당 클래스는 비즈니스 로직에서 발생하는 얘외를 나타내는 클래스입니다. 주로 비즈니스 로직에서 예상 가능한 예외 상황을 나타내고, 그에 따른 처리를 수행합니다.

@Getter
public class BusinessException extends RuntimeException {

    private ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

 

에러 응답 구현 

ErrorResponse 클래스는 사용자에게 일관된 에러 형태의 응답을 생성하기 위해 사용됩니다. 주로 API에서 클라이언트에게 응답을 반환할  때 사용됩니다.

@Data
@Builder
public class ErrorResponse {

    private String errorCode;
    private String errorMessage;

    public static ErrorResponse of(String errorCode, String errorMessage) {
        return ErrorResponse.builder()
                .errorCode(errorCode)
                .errorMessage(errorMessage)
                .build();
    }

    public static ErrorResponse of(String errorCode, BindingResult bindingResult) {
        return ErrorResponse.builder()
                .errorCode(errorCode)
                .errorMessage(createErrorMessage(bindingResult))
                .build();
    }

    private static String createErrorMessage(BindingResult bindingResult) {
        StringBuilder sb = new StringBuilder();
        boolean isFirst = true;
        List<FieldError> fieldErrors = bindingResult.getFieldErrors();
        for (FieldError fieldError : fieldErrors) {
            if (!isFirst) {
                sb.append(",");
            } else {
                isFirst = false;
            }
            sb.append("[");
            sb.append(fieldError.getField());
            sb.append("]");
            sb.append(fieldError.getDefaultMessage());
        }
        return sb.toString();
    }
}

 

 

전역 에러 Advice 설정

@RestControllerAdvice 어노테이션으로 전역으로 예외를 설정할 수 있는 클래스 입니다.

  • handleBindException : BindException 예외가 발생할 경우 처리됩니다. 주로, javax.validation.Valid 또는 @Validated 로 인한 바인딩 에러에 대한 응답을 생성하고 반환 합니다.
  • handleMethodArgumentTypeMismatchException : 주로 @RequestParam으로 받은 파라미터의 타입이 일치하지 않을 경우 발생하는 예외 입니다.
  • handleHttpRequestMethodNotSupportedException : 지원하지 않는 HTTP 메서드가 호출 되었을 때 처리를 수행합니다.
  • handleConflict : 예외 예상이 가능한 비즈니즈 로직 처리중 발생한 예외에 대해 처리를 수행합니다. ErrorResponse 객체를 생성하여 예외 정보를 클라이언트에게 응답으로 전송합니다.
  • handleException : 나머지 모든 예외에 대해 처리를 수행하는 메서드 입니다. Exception 클래스를 상속한 모든 예외 클래스에 대한 처리를 수행하며, 기본적으로 500 Internal Server Error 응답을 생성합니다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * javax.validation.Valid 또는 @Validated binding error가 발생할 경우
     */
    @ExceptionHandler(BindException.class)
    protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {
        log.error("handleBindException", e);
        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.BAD_REQUEST.toString(), e.getBindingResult());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(errorResponse);
    }

    /**
     * 주로 @RequestParam에 사용되며 타입이 맞지 않아 binding 못했을 경우 발생
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    protected ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
        log.error("handleMethodArgumentTypeMismatchException", e);
        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.BAD_REQUEST.toString(), e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(errorResponse);
    }

    /**
     * 지원하지 않은 HTTP method 호출 할 경우 발생
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        log.error("handleHttpRequestMethodNotSupportedException", e);
        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED.toString(), e.getMessage());
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse);
    }

    /**
     * 비즈니스 로직 실행 중 오류 발생
     */
    @ExceptionHandler(value = { BusinessException.class })
    protected ResponseEntity<ErrorResponse> handleConflict(BusinessException e) {
        log.error("BusinessException", e);
        ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode().getErrorCode(), e.getMessage());
        return ResponseEntity.status(e.getErrorCode().getHttpStatus())
                .body(errorResponse);
    }

    /**
     * 나머지 예외 발생
     */
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Exception", e);
        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR.toString(), e.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

 

예외 테스트 

예외 테스트시에 사용되는 툴은 Postman or Httpie 입니다. 

사용자 정의 예외 ( handleConflict )

아래 로직을 호출하게 되면 handleConflict 전역 메소드가 호출되어 ErrorResponse 객체를 생성하여 클라이언트에게 전달 되게 됩니다.

@GetMapping("/exception-test/bindresult0")
public ResponseEntity<?> exceptionTestBindResult0(String ex) {
    if (ex != null) {
        throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
    }
    return ResponseEntity.ok("exception-test/bindresult0");
}

 

http get http://localhost:8080/api/exception-test/bindresult0?ex=ex

 

{
    "errorCode": "0001",
    "errorMessage": "Invalid Input Value"
}

 

 

지원하지 않는 HTTP 메서드 호출 ( handleHttpRequestMethodNotSupportedException )

http put http://localhost:8080/api/exception-test/bindresult0\?ex\=ex

 

HTTP/1.1 405
Connection: keep-alive
Content-Type: application/json
Date: Tue, 30 Jan 2024 09:25:24 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "errorCode": "405 METHOD_NOT_ALLOWED",
    "errorMessage": "Request method 'PUT' is not supported"
}

 

 

파라미터 타입이 맞지 않는 예외 ( handleMethodArgumentTypeMismatchException )

Controller에서 받는 타입은 int 이고, 넘어온 값은 String 문자 입니다.

@GetMapping("/exception-test/bindresult11")
public ResponseEntity<?> exceptionTestBindResult11(int value) {
    return ResponseEntity.ok("exception-test/bindresult0");
}

또는 아래와 같이 사용

@GetMapping("/exception-test/bindresult11")
public ResponseEntity<?> exceptionTestBindResult11(@RequestParam int value) {
    return ResponseEntity.ok("exception-test/bindresult0");
}

 

http get http://localhost:8080/api/exception-test/bindresult11\?value\=df

 

HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Tue, 30 Jan 2024 09:28:59 GMT
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers

{
    "errorCode": "400 BAD_REQUEST",
    "errorMessage": "Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: \"df\""
}

 

 

validation 예외 ( handleBindException )

BindResult 예외는 여러 방법으로 처리가 가능합니다. 먼저 전역 처리 방법을 소개 합니다. 해당 예외도 전역으로 처리하게 되면 에러 형태의 일관성을 유지할 수 있습니다.

 

먼저, validation 처리를 위한 의존성 주입을 해야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

validation 처리할 request 모델을 생성합니다. 

@Data
public class ExceptionTestDto {

    @NotBlank(message = "username is mandatory")
    private String username;

    @Max(value = 10, message = "age must be less than or equal to 10")
    private int age;

}

 

controller을 작성하여 테스트를 진행합니다.

@GetMapping("/exception-test/bindresult")
public String exceptionTestBindResult(@Validated ExceptionTestDto exceptionTestDto) {
    return "exception-test/bindresult";
}

 

http get http://localhost:8080/api/exception-test/bindresult\?username\=\&age\=11

 

HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Tue, 30 Jan 2024 09:39:16 GMT
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers

{
    "errorCode": "400 BAD_REQUEST",
    "errorMessage": "[username]username is mandatory,[age]age must be less than or equal to 10"
}

 

 

controller에 post 방식을 테스트 합니다. ( GetMapping 에서는 주로 Http GET 메서드를 처리하는 데 사용되기 때문에, 요청 본문을 읽는 @RequestBody는 Post or Put 등에 주로 사용합니다. 

 

@PostMapping("/exception-test/bindresult4")
public ResponseEntity<ExceptionTestDto> exceptionTestBindResult4(@Validated @RequestBody ExceptionTestDto exceptionTestDto) {
    return ResponseEntity.ok(exceptionTestDto);
}

 

http POST http://localhost:8080/api/exception-test/bindresult4 'ExceptionTestDto={"username":"","age":10}'

 

HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Tue, 30 Jan 2024 09:46:55 GMT
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers

{
    "errorCode": "400 BAD_REQUEST",
    "errorMessage": "[username]username is mandatory"
}

 

BindResult를 다르게 처리하는 방법 

전역으로 처리하지 않고 controller에서 직접 BindResult를 받아서 처리하는 방법 입니다.

@PostMapping("/exception-test/bindresult3")
public ResponseEntity<?> exceptionTestBindResult3(@Validated @RequestBody ExceptionTestDto exceptionTestDto, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        // 에러가 있을 경우 에러 정보를 반환
        return ResponseEntity.badRequest().body(bindingResult);
    }
    // 에러가 없을 경우 성공 메시지 반환
    return ResponseEntity.ok("exception-test/bindresult3");
}

 

 

아래와 같이 ErrorSerializer을 구현하는 이유는 Spring에서는 얘외가 발생하면 기본적으로 그 예외를 Json 형태로 변환하여 클라이언트에게 전달합니다. 그러나 BindingResult 와 같은 예외에 대해서는 기본적인 Json 변환만으로는 원하는 형태의 응답을 만들기 어렵습니다.

 

이때, ErrorSerializer를 통해 BindingResult 객체를 어떻게 Json으로 변환할지를 정의 해주면, Spring은 이를 활요하여 해당 예외에 대한 응답을 클라이언트에게 전달하게 됩니다.

@JsonComponent
public class ErrorSerializer extends JsonSerializer<Errors>  {

    @Override
    public void serialize(Errors errors, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeStartObject(); // adding this line
        jsonGenerator.writeFieldName("errors");
        jsonGenerator.writeStartArray();
        errors.getFieldErrors().stream().forEach(e -> {
            try {
                jsonGenerator.writeStartObject();
                jsonGenerator.writeStringField("objectName", e.getObjectName());
                jsonGenerator.writeStringField("field", e.getField());
                jsonGenerator.writeStringField("defaultMessage", e.getDefaultMessage());

                Object rejectedValue = e.getRejectedValue();
                if (rejectedValue != null) {
                    jsonGenerator.writeStringField("rejectedValue", rejectedValue.toString());
                } else {
                    jsonGenerator.writeStringField("rejectedValue", "");
                }
                jsonGenerator.writeEndObject();
            } catch (IOException e1) {
                throw new RuntimeException(e1);
            }
        });
        jsonGenerator.writeEndArray();
    }
}

 

http POST http://localhost:8080/api/exception-test/bindresult3 'ExceptionTestDto={"username":"","age":10}'

 

HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Tue, 30 Jan 2024 09:49:32 GMT
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers

{
    "errors": [
        {
            "defaultMessage": "username is mandatory",
            "field": "username",
            "objectName": "exceptionTestDto",
            "rejectedValue": ""
        }
    ]
}

 

 

'Spring, Springboot' 카테고리의 다른 글

자가 호출  (0) 2024.07.16
타입 기반 주입  (0) 2024.07.15
서비스 ( Service )  (1) 2024.07.10
Spring Cloud OpenFeign  (1) 2024.02.07
CORS 이해와 설정  (0) 2024.01.29