- 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 설정이 자동으로 이루어지게 된다.
에러 응답을 자유롭게 다룰 수 있어 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 응답을 보내게 된다.