프로젝트/아카이뷰
[Spring boot] Error Handling
cks._.hong
2024. 1. 10. 21:29
Error Handling 왜 궁금했을까 ❓
- SSAFY 1학기 때, 프로젝트 마감 기한을 맞추기 위해 기능 완성에만 초점을 맞춰 개발을 진행하다보니 Client에게 보내는 응답에 대해 신경쓰지 못하였고 일관성이 없는 Error 응답을 전송하였다.
- 이로 인해, Client의 입장에서는 어느 부분에서 Error가 발생했는 지 정확한 응답을 받지 못하고 일관되지 못한 Error 응답으로 인해 혼란을 야기할 수 있을 것이라 생각되었다.
- 이러한 부분을 해결하기 위해, Error Handling에 대해 학습하고 일관된 Error 응답을 전송하여 앞선 문제를 해결해보고자 한다.
Error Handling
BasicErrorController
- Spring은 에러 처리를 기본적으로 BasicErrorController를 통해서 하도록 설정이 되어있고 Spring boot는 예외 처리를 /error로 에러를 다시 전송하도록 WAS 설정이 되어있다.
- 공식 문서를 확인해보면 properties에 server.error.path가 있으면 그 값을 사용하고 없으면 error.path를 참조한다. 이 값도 없다면 최종적으로 /error 값을 가지게 된다.
- Spring boot의 경우 WebMvcAutoConfiguration를 통해 WAS 설정이 자동으로 이루어지게 된다.
WAS(Tomcat) => Filter => Servlet => Interceptor => Controller => Controller(Occured Exception) => Interceptor => Servlet => Interceptor => Servlet => Filter => WAS(Tomcat) => Filter => Servlet => Interceptor => Controller(BasicErrorController)
- 위 과정은 Client의 API 요청을 처리하는 과정에서 Controller에서 오류가 발생한 경우이다.
- 기본적으로 설정된 Spring boot에서 에러를 처리 과정이고 이러한 경우 에러 처리를 위해 Controller를 2번 거치게 된다는 문제점이 존재한다.
{
"timestamp": "2024-01-10T17:22:44.675+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/user/chan978444"
}
- BasicErrorController로 인해 에러가 처리되면 위와 같은 응답을 받는데 일관되지 않은 응답은 Client에게 유용하지 않을 것이며 에러 발생 사유를 알지 못할 것이다.
- 또한, 지정한 에러를 발생시키기 위해서는 try ~ catch를 통해서 모든 에러를 처리해줘야 하는데 이는 가독성을 떨어뜨리고 비효율적이다.
- 이러한 문제점을 해결하기 위해 Spring에서는 에러 처리 과정을 따로 분리하였고 이 과정에서 다양한 Error Handling 방법이 등장하였다.
Spring Error Handling 방법
ExceptionResolver
- DefaultErrorAttributes - 에러 속성을 저장하며 직접 예외를 처리하지 않는다.
- ExceptionHandlerExceptionResolver - 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
- ResponseStatusExceptionResolver - Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
- DefaultHandlerExceptionResolver - 스프링 내부의 기본 예외들을 처리한다.
예외가 발생하면 적합한 예외 처리기를 찾아 예외를 처리하게 되는데 위의 ExceptionResolver중 하나를 이용하게 된다.
Error Equipment
- ResponseStatus
- ResponseStatusException
- ExceptionHandler
- ControllerAdvice, RestControllerAdvice
위의 4가지 도구를 통해 ExceptionResolver를 동작시켜 에러를 처리하게 된다. 리팩토링의 도구로 RestControllerAdvice를 선택했는데 각 도구들의 특징을 살펴보며 이유를 설명해보도록 하겠다.
@ResponseStatus
- 발생한 에러의 HTTP 상태만을 변경하는 Annotation이며 아래 3가지 경우에 적용할 수 있다.
- Exception 클래스 자체
- Method에 @ExceptionHandler와 함께
- Class에 @RestControllerAdvice와 함께
{
"timestamp": "2024-01-10T17:22:44.675+00:00",
"status": 404,
"error": "Not Found",
"path": "/user/chan978444"
}
- 응답 결과를 보면 BasicErrorController에 의한 응답인 것을 알 수 있으며 앞서 설명한 문제점들을 해결할 수 없다.
ResponseStatusException
- 발생한 에러의 HTTP 상태를 변경할 수 있으며 에러 메세지도 직접 설정이 가능하다.
- 이러한 이유로 프로그래밍 방식으로 직접 예외를 발생시킬 수 있어 예외 제어를 보다 유연하게 할 수 있다.
{
"timestamp": "2024-01-10T17:22:44.675+00:00",
"status": 404,
"error": "Custom Not Found",
"path": "/user/chan978444"
}
@ExceptionHandler
- Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다.
- 에러 응답을 자유롭게 다룰 수 있어 code, message, errors 등에 대한 정보를 Custom하여 Client에게 유연하고 일관되게 응답이 가능하다.
{
"code": 404,
"message": "Custom Not Found"
}
- 하지만, 컨트롤러에서만 구현이 가능하여 특정 컨트롤러의 예외만을 처리하게 되며 이로 인해 중복 코드가 발생할 가능성이 높다. 이러한 이유 때문에 에러를 공통으로 처리할 수 있는 @ControllerAdvice와 @RestControllerAdvice가 존재한다.
@ControllerAdvice, @RestControllerAdvice
- 전역으로 @ExceptionHandler를 적용할 수 있는 Annotation들로 2개의 차이는 controller와 RestController의 차이로 응답을 ResponseBody인 JSON으로 내려준다는 점이다.
- 해당 Annotation들은 클래스에 적용할 수 있으며 에러 처리를 해당 클래스에게 위임하게 된다.
- Spring은 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 제공하는데 해당 추상 클래스안에는 ExceptionHandler가 모두 구현이 되어 있어 Annotation이 적용된 클래스에 상속받게 하면 모든 Error를 Handling 할 수 있게 된다.
프로젝트 리팩토링
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
/**
* RestApiException 예외 처리
*/
@ExceptionHandler(RestApiException.class)
public ResponseEntity<Object> handleRestApiException(final RestApiException e) {
final ResponseCode responseCode = e.getResponseCode();
return handleExceptionInternal(responseCode);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleAllException(final Exception e) {
final ResponseCode responseCode = CommonResponseCode.INTERNAL_SERVER_ERROR;
return handleExceptionInternal(responseCode);
}
private ResponseEntity<Object> handleExceptionInternal(final ResponseCode responseCode) {
return ResponseEntity.status(responseCode.getHttpStatus())
.body(makeErrorResponse(responseCode));
}
private ErrorResponse makeErrorResponse(final ResponseCode responseCode) {
return ErrorResponse.builder()
.code(responseCode.name())
.message(responseCode.getMessage())
.build();
}
...
}
- ResponseEntityExceptionHandler를 상속받아 GlobalExceptionHandler가 모든 Error를 위임받아 처리하고 있다.
@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException {
private final ResponseCode responseCode;
}
- Unchecked Error를 Custom하고 정확한 에러 응답을 보내기 위해 Custom Exception Class를 만들어 위임하였다.
@Getter
@RequiredArgsConstructor
public enum CustomResponseCode implements ResponseCode {
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저의 정보가 존재하지 않습니다."),
INVALID_USER_INFO(HttpStatus.BAD_REQUEST, "유저의 정보가 올바르지 않습니다."),
PASSWORD_NOT_CREATED(HttpStatus.INTERNAL_SERVER_ERROR, "패스워드를 생성하지 못하였습니다."),
INVALID_USER_ID(HttpStatus.BAD_REQUEST, "사용할 수 없는 ID입니다."),
;
private final HttpStatus httpStatus;
private final String message;
}
- 각 상황에 맞는 에러 코드와 응답 메세지를 보내기 위해 ENUM 클래스를 활용하여 유지 보수가 쉽게 리팩토링하였다.
@Getter
@Builder
@RequiredArgsConstructor
public class ErrorResponse {
private final String code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private final List<ValidationError> errors;
@Getter
@Builder
@RequiredArgsConstructor
public static class ValidationError {
private final String field;
private final String message;
public static ValidationError of(final FieldError fieldError) {
return ValidationError.builder()
.field(fieldError.getField())
.message(fieldError.getDefaultMessage())
.build();
}
}
}
- 일관된 응답을 위해 위와 같은 구조를 만들었고 Client는 각 상황에 맞는 일관된 응답과 정확한 내용을 파악할 수 있게 된다.
@Override
public void deleteUser(String userId) {
int cnt = userMapper.deleteUser(userId);
if(cnt == 0) {
throw new RestApiException(CustomResponseCode.USER_NOT_FOUND);
}
}
- service 코드에서 다음과 같이 활용하여 user가 삭제되지 않을 시, 유저의 정보가 없다는 뜻이므로 아래와 같은 error 응답을 보내게 된다.