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