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 {
@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 응답을 보내게 된다.