Spring Boot 예외처리

강서진·2023년 12월 6일
0
post-custom-banner

이런저런 요청을 처리하다보면 예외가 발생하게 된다.
요청이 들어오면 API의 규격에 맞춰 응답이 내려가고, 클라이언트는 이 규격에 맞춰 응답을 파싱한다. 그런데 예외가 발생하면 에외 상황에 따라 규격에 맞지 않는 응답이 내려가게 되고, 정상적으로 작동하지 않게 된다.
예외가 발생했을 때 서버가 동일한 응답을 하게 만들려면 예외 처리를 해야한다. 하나씩 try-catch로 잡을 수도 있겠지만, Spring Boot가 가진 예외 처리에는 그보다 효율적으로 처리할 수 있는 다음과 같은 방법들이 있다.

Controller 내부에 ExceptionHandler

먼저 컨트롤러 내부에 ExceptionHandler를 만들어서 예외를 처리할 수 있다. 이 경우에는 해당 컨트롤러 하에서 발생하는 예외만 처리할 수 있으며, 컨트롤러 외부의 예외 핸들러보다 먼저 예외를 처리한다.

@Slf4j
@RestController
@RequestMapping("/api/b")
public class RestApiBController {

    @GetMapping("/hello")
    public void hello(){
        throw new NumberFormatException("number format exception");
    }

    @ExceptionHandler(value={NumberFormatException.class})
    public ResponseEntity numberFormatException(NumberFormatException e){
        log.error("RestApiBController", e);

        return ResponseEntity.status(200).build();
    }
}

@ExceptionHandler는 예외 처리 애너테이션으로, 값으로는 처리할 예외 클래스를 넘겨준다. 이 애너테이션이 붙은 메서드는 항상 요청과 응답에 귀를 기울이고 있다가, 예외가 발생하면 바톤을 이어받는다고 생각하면 된다.
예외에는 여러 종류가 있는데, 모든 예외는 Exception을 상속받으므로 모든 예외를 잡고 싶다면 Exceptions를 주면 된다.

특정 예외가 나왔을 때, 별다른 처리를 하지 않으면 일반적으로 오류 페이지와 4, 500번대 상태코드가 뜬다. 디폴트 오류 발생 화면 말고 지정된 오류 페이지를 띄우거나 다른 페이지로 이동하는 등 지정된 작업을 실행할 수도 있다. 이런 경우에는 ResponseEntity를 반환하도록 만들어서 상태 코드를 바꿔 반환하고, 헤더를 수정하는 등 추가적인 작업을 진행한다.
이 경우에는 200 코드를 반환하도록 바꾸었다. 실행하면 log에 NumberFormatException이 찍히고, 상태 코드는 200이 뜬다.

RestControllerAdvice

하지만 컨트롤러 내에 ExceptionHandler를 만드는 것은 컨트롤러 코드가 길어질수록 비효율적이다. 컨트롤러 외부에 따로 ExceptionHandler 클래스를 만드는 것이 훨씬 효율적이고, 코드 관리 측면에서도 추천되는 방식이다.

@Slf4j
//@RestControllerAdvice(basePackages="com.example.exception.controller")
@RestControllerAdvice(basePackageClasses = {RestApiBController.class, RestApiController.class})
public class RestApiExceptionHandler {

    @ExceptionHandler(value={Exception.class})
    public ResponseEntity exception(Exception e){
        log.error("RESTApiExceptionHandler: ", e);

        return ResponseEntity.status(200).build();
    }

    @ExceptionHandler(value={IndexOutOfBoundsException.class})
    public ResponseEntity outOfBound(IndexOutOfBoundsException e){
        log.error("IndexOutOfBoundsException: ", e);

        return ResponseEntity.status(200).build();
    }
}

@RestControllerAdvice는 컨트롤러 외부에 만든 예외 핸들러에 붙이는 애너테이션으로, 위처럼 관여하는 클래스 및 패키지를 따로 basePackages, basePackageClasses로 명시해줄 수도 있다. 붙이지 않으면 프로젝트 전체의 예외 처리를 관리하게 된다. 특정 예외만 따로 처리하고 싶거나 할 때 범위를 명시해준다.
@RestControllerAdvice 애너테이션을 붙인 클래스 아래 컨트롤러 내부에서 만들었던 ExceptionHandler를 동일하게 만들면, 프로젝트 내부에서 발생하는 예외를 해당하는 Exception Handler가 처리하게 된다.
만약 컨트롤러 내부에도 ExceptionHandler가 있고, ControllerAdvice 안에도 ExceptionHandler가 있는 상황에 둘 다 잡을 수 있는 예외가 발생한다면, Controller 내부의 ExceptionHandler가 우선적으로 예외를 처리한다.


적용

예외가 발생하더라도 클라이언트가 응답을 파싱하는 규격에 맞춰 이를 전달하려면, 반환하는 객체에 예외 값을 담아 보내면 된다.
예외가 발생했을 때 객체를 받으면 일단 응답을 받았기 때문에 상태 코드 200을 띄운다. 예외 상태코드를 유지하려면 ResponseEntity에 한번 감싸 반환한다.

예시로 Api라는 user 정보를 담은 객체가 있다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value= PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Api<T> {
    private String resultCode;
    private String resultMessage;
    private T data;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value= PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserResponse {
    private String id;
    private String name;
    private Integer age;
}

* builder는 Lombok의 애너테이션으로, builder()와 build()를 사용해 쉽게 객체를 생성할 수 있게 해준다.

위의 Api 클래스는 제네릭으로 타입을 설정해둔 data에 UserResponse 정보 객체를 받고, resultCode와 resultMessage라는 규격을 갖추고 있어 상태코드와 상태메시지를 함께 반환한다.

@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    private static List<UserResponse> userList = List.of(
            UserResponse.builder().id("1").age(30).name("red bean").build()
            ,
            UserResponse.builder().id("2").age(20).name("green bean").build()
    );

    @GetMapping("/id/{userId}")
    public Api<UserResponse> getUser(@PathVariable String userId){
        if (true){
            throw new RuntimeException();
        }

        var user = userList.stream().
                filter(it -> it.getId().equals(userId)).
                findFirst(). // null일 수도 있음
                get();

        // 혹은 isPresent()로 한번 더 필터 걸 수도

        Api<UserResponse> response = Api.<UserResponse>builder()
                .resultCode(""+HttpStatus.OK.value())
                .resultMessage(HttpStatus.OK.name())
                .data(user).build();

        return response;
    }
}

UserResponse 객체를 2개 만들어놓고, PathVariable로 userId를 받으면 해당 유저를 Api 객체 안에 넣어 반환하게 만들었다. 만약 등록되지 않은 userId 30을 입력하면, NoSuchElementException이 발생한다. 이 예외를 처리하기 위해 예외 핸들러 클래스에 예외 핸들러를 추가한다.

@ExceptionHandler(value={NoSuchElementException.class})
    public ResponseEntity noSuchElement(NoSuchElementException e){
        log.error("", e);
        var response =  Api.builder().
                resultCode(""+HttpStatus.NOT_FOUND.value()).
                resultMessage(HttpStatus.NOT_FOUND.getReasonPhrase()).
                build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

NoSuchElementException이 발생하면 해당 예외 핸들러가 호출된다.

  1. 로그에 에러를 찍어내고,
  2. Api 객체에 UserResponse는 비워두고, 발생한 Http 상태코드와 오류 이름을 전달한다.
  3. 이 Api 객체를 그대로 반환하면 어쨌든 객체를 받은 것이기 때문에 200이 뜬다. 하여 Api 객체를 ResponseEntity에 담고, 오류 상태코드를 담은 ResponseEntity를 반환한다.

수많은 예외들에 각각 ExceptionHandler를 만들어주는 것은 사실 힘들다. 그래서 앞에서 언급했던 예외 부모 클래스인 Exception.class를 잡는 전역(=Global) 예외 핸들러를 만들어줄 수 있다. 다른 핸들러들이 잡지 못하는 예외를 처리하는 최후의 보루처럼 생각하면 된다.
만약 @RestControllerAdvice가 여러 개 있다면, @Order 애너테이션을 통해 작동하는 순서를 정해줄 수 있다.

@Slf4j
@RestControllerAdvice
@Order(value=Integer.MAX_VALUE) // default
public class GlobalExceptionHandler {

    @ExceptionHandler(value = {Exception.class})
    public ResponseEntity<Api> exception(Exception e){
        log.error("",e);

        var response = Api.builder().
                resultCode(String.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value())).
                resultMessage(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()).
                build();

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

@Order 애너테이션은 최후순위가 디폴트이며, Ordered 인터페이스를 사용한다.

	/**
	 * Useful constant for the highest precedence value.
	 * @see java.lang.Integer#MIN_VALUE
	 */
	int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;

	/**
	 * Useful constant for the lowest precedence value.
	 * @see java.lang.Integer#MAX_VALUE
	 */
	int LOWEST_PRECEDENCE = Integer.MAX_VALUE;

최후순위는 Integer.MAX_VALUE이고 최우선순위가 Integer.MIN_VALUE로 음수이다. 따라서 작은 수로 설정할 수록 먼저 실행된다는 것을 알 수 있다.

post-custom-banner

0개의 댓글