@RestControllerAdvice를 이용한 예외 처리 방법

박준수·2023년 8월 15일
0

[토이프로젝트]

목록 보기
5/5
post-thumbnail

RestControllerAdvice를 이용하여 예외를 처리하는 이유는 스프링의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)이 블로그에서 너무나도 잘 설명해주었다. 따라서 나는 RestControllerAdvice를 이용해 예외를 처리하는 방법을 적용해보겠다.

Business Exception

@Getter
public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}
  • 언체크 예외(런타임 예외)를 상속받는 예외 클래스를 작성한다.
  • 여기서 체크 예외가 아닌 언체크 예외를 상속받도록 한 이유가 있다. 왜냐하면 일반적인 비지니스 로직들은 따로 catch해서 처리할 것이 없으므로 만약 체크 예외로 한다면 불필요하게 throws가 전파될 것이기 때문이다.
  • 또한 Spring은 내부적으로 발생한 예외를 확인하여 언체크 예외이거나 에러라면 자동으로 롤백시키도록 처리한다. Spring에서 체크 예외만 롤백을 안하는 이유는 체크 예외는 처리가 강제되기 때문에 개발자가 무언가를 처리할 것이라는 기대 때문이다.

NotFoundException

public class UserNotFoundException extends BusinessException {
    public UserNotFoundException() {
        super(ErrorCode.USER_NOT_FOUND_ERROR);
    }
}
public class RoomNotFoundException extends BusinessException {
    public RoomNotFoundException() {
        super(ErrorCode.ROOM_NOT_FOUND_ERROR);
    }
}
  • 각 도메인에서 발생할 수 있는 RuntimeException들을 BusinessException을 상속받아 적절한 ErrorCode를 넘겨 준다.
    public User findUserById(Long userId){
       return userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
    }
  • Service에서 다음과 같이 Exception을 사용할 수 있다.

ErrorCode

@Getter
@AllArgsConstructor
public enum ErrorCode {

    // Global
    INTERNAL_SERVER_ERROR(500, "G001", "서버 오류"),
    INPUT_INVALID_ERROR(400, "G002", "잘못된 입력"),

    // 채팅방
    ROOM_NOT_FOUND_ERROR(404, "R001", "존재하지 않는 채팅방입니다."),

    // 사용자
    USER_NOT_FOUND_ERROR(404, "U001", "존재하지 않는 유저입니다.");


    private final int status;
    private final String code;
    private final String message;

}
  • ErrorCode에는 status, code, message 필드를 두고 발생할 수 있는 에러 목록을 enum 타입으로 작성한다.

ErrorResponse

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorResponse {

    private int status;
    private String code;
    private String errorMessage;
    private List<FieldException> fieldExceptions;


    public static ErrorResponse of(ErrorCode errorCode){
        return new ErrorResponse(errorCode, null);
    }

    public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult){
        return new ErrorResponse(errorCode, FieldException.of(bindingResult));
    }

    public ErrorResponse(ErrorCode errorCode, List<FieldException> fieldExceptions){
        this.status = errorCode.getStatus();
        this.code = errorCode.getCode();
        this.errorMessage = errorCode.getMessage();
        this.fieldExceptions = fieldExceptions;
    }
}
  • ErrorCode의 에러 필드들을 반환하도록 ResponseDTO를 작성한다.
  • List<FieldException>은 아래에서 설명한다.

GlobalExceptionHandler

이제 발생할 수 있는 Error를 @RestControllerAdvice 를 통해 처리를 하면 된다.

Business Error

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Business Error
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e){
        log.warn(e.getMessage());
        ErrorCode errorCode = e.getErrorCode();
        ErrorResponse errorResponse = ErrorResponse.of(errorCode);
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}
  • BusinessException 이 NotFoundException인 경우만 있어 일단은 HttpStatus를 NOT_FOUND로 했다.

  • 다음과 같이 에러 처리가 되는 것을 확인할 수 있다.

Default Error

// Default Error
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e){
    log.error(e.getMessage(), e);
    ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
  • 매개변수가 Exception 으로 INTERNAL_SERVER_ERROR(500, "G001", "서버 오류"), 같은 에러를 처리하기 위한 기본 에러 처리이다. Spring은 예외가 발생하면 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾으므로 이 메서드에서 에러를 처리하게 된다.

Valid Error

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class UserLoginRequestDTO {

    @NotBlank(message = "사용자 이메일(ID)은 필수 입니다.")
    @Email
    private String email;

    @NotBlank(message = "사용자 비밀번호는 필수 입니다.")
    private String password;
}
  • 다음과 같이 RequestDTO를 작성했을 때 @NotBlank, @NotNull, @NotEmpty, @Email등등 에서 발생하는 에러를 Validation 처리해야하는 로직이 필요하다.
  • 이메일 형식에 맞지 않게 ‘@’ 를 빼고 요청을 보네니 MethodArgumentNotValidException 에러가 발생한다.
{
    "email":"junsu123naver.com",
    "password": "123"
}

// Valid Error
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
    log.warn(e.getMessage());
    ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INPUT_INVALID_ERROR, e.getBindingResult());
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
  • @ExceptionHandler(MethodArgumentNotValidException.class)를 작성한다.
  • 이때 ErrorResponse에 매개변수로 e.getBindingResult()를 넣는다.
public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult){
        return new ErrorResponse(errorCode, FieldException.of(bindingResult));
    }
  • 위에 언급된 ErrorResponse에서 ErrorCode와 bindingResult를 매개 변수로 받아 ResponseDTO로 처리를 할 수 있다.
  • 이때 BindingResult.getFieldErrors() 를 통해 FieldError 로 이루어진 List 를 반환 받을 수 있다.
  • 그래서 ErrorResponse 필드에 private List<FieldException> fieldExceptions; 가 있었던 것이다.

FieldException

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class FieldException {

    private static final String NOT_INPUT_MESSAGE = "입력값이 없습니다.";

    private String field;
    private String value;
    private String reason;

    public static List<FieldException> of(BindingResult bindingResult){
        List<FieldError> fieldExceptions = bindingResult.getFieldErrors();
        return fieldExceptions.stream()
                .map(error -> new FieldException(
                        error.getField(),
                        getValue(error),
                        error.getDefaultMessage()
                ))
                .toList();
    }

    private static String getValue(FieldError error) {
        if(error.getRejectedValue() == null){
            return NOT_INPUT_MESSAGE;
        }
        return error.getRejectedValue().toString();
    }
}
  • BindingResult.getFieldErrors() 를 통해 얻은 FieldError들을 처리할 객체가 필요하다.
  • FieldError에서는 예외가 발생한 필드, 예외 된 값, 이유 를 포함하는 class이다. 이 데이터들을 stream을 통해 field, value, reason으로 mapping 하여 List로 만든 것이다.

  • 여기서 getRejectedValue는 Nullable이기에 Null 처리를 해야하는데 3항연산자로 작성할 수 있지만 우테코 프리코스에서 3항 연산자 쓰지 말라길레 메서드를 따로 뺐다 ㅋ.ㅋ

전체

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Default Error
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e){
        log.error(e.getMessage(), e);
        ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }

    // Valid Error
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
        log.warn(e.getMessage());
        ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INPUT_INVALID_ERROR, e.getBindingResult());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // Business Error
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e){
        log.warn(e.getMessage());
        ErrorCode errorCode = e.getErrorCode();
        ErrorResponse errorResponse = ErrorResponse.of(errorCode);
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

  • 다음과 같이 어디에서 어떤 값이 왜 잘못 되었는지 알 수 있다.
  • 따라서 @RestControllerAdvice를 이용해 다음과 같이 전역적으로 에러를 처리할 수 있었다.

참고 :
[Spring] 스프링의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)
[Spring] @RestControllerAdvice를 이용한 Spring 예외 처리 방법 - (2/2)
[예외처리] @Valid 예외처리에 사용되는 BindingResult 객체는 무엇일까?
김기현 개발자 깃허브

profile
방구석개발자

1개의 댓글

comment-user-thumbnail
2023년 8월 15일

유익한 자료 감사합니다.

답글 달기