지난 포스팅에서 API 호출에서 발생하는 에러를 처리하기 위해, ExceptionResolver를 직접 만들어 구현해보았다.
Controller에서 에러가 발생하면 디스패처 서블릿으로 전파된다. 디스패처 서블릿은 에러가 WAS로 전파되지 않도록 에러를 핸들링 할 수 있는 ExceptionResolver를 탐색한다. 오류 종류에 따라 ExceptionResolver 구현체를 다양하게 생성하면 다양한 오류에 대응할 수 있다.
그러나 한 가지 문제가 있다.
ExceptionResolver가 너무 비대해진다.
ExceptionResolver는 에러상태코드를 바꾸고 클라이언트로 응답할 에러데이터를 생성하고 HTTP Body 영역에 에러데이터를 write 할 수 있도록 Response 객체를 설정하고 WAS에 에러가 전파되지 않도록 ModelAndView를 정상 반환한다. 모든 에러마다 이 과정을 반복하여 작성하면 비효율적이다.
그러므로 가변적인 부분을 따로 분리하는 것이 좋다.
SpringBoot는 ExceptionResolver에서 가변적인 부분은 개발자가 개발하도록 넘기고 ExceptionResolver가 참조할 수 있도록 어노테이션으로 표시하는 방향으로 기능을 제공한다.
에러상태코드 변환이나 에러데이터 생성은 API 호출에 따라 언제든 변하는 코드이다. 그러므로 이는 개발자가 필요에 따라 개발하고 @ResponseStatus와 @ExceptionHandler로 표시를 남기면, ExceptionResolver가 알아서 해당 코드를 참조하는 방식이다. 이로써 개발자는 더이상 반복적으로 ExceptionResolver를 개발할 필요가 사라진다.
@ResponseStatus는 ResponseStatusExceptionResolver가 처리하고
@ExceptionHandler ExceptionHandlerExceptionResolver가 처리한다.
두 리졸버는 SpringBoot가 ExceptionResolver 구현체로 등록해놓았다. 그러므로 우리는 오류에 따라 어떻게 에러상태코드를 바꿀지 어떤 에러데이터를 생성할지만 코드로 구현하면 된다.
그럼 Controller를 보자.
@Slf4j
@RestController
public class ExceptionController {
// == 요청 받는 Controller 메소드 ==
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id){
// RuntimeException 발생
if(id.equals("ex")){
throw new RuntimeException("잘못된 사용자");
}
// IlleagalArgumentException 발생
if(id.equals("bad")){
throw new IllegalArgumentException("잘못된 입력값");
}
return new MemberDto(id,"hello " + id);
}
// == ExceptionResolver의 ExceptionHandler ==
// IllegealArgumentException이 발생한 경우
@ResponseStatus(HttpStatus.BAD_REQUEST) // 에러상태코드 변환 : 500 -> 400
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
// 객체 반환하기 ( JSON으로 변환 )
return new ErrorResult("BAD",e.getMessage()); // 에러데이터 생성
}
// RuntimeException이 발생한 경우
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResult> userExHandler(RuntimeException e){
// 에러데이터 생성
ErrorResult errorResult = new ErrorResult("USER-EX",e.getMessage());
// ResponseEntity로 에러상태코드 변환하기
return new ResponseEntity<>(errorResult,HttpStatus.BAD_REQUEST);
}
}
Controller는 두 구간으로 나뉜다.
1) 요청과 매핑되는 Controller 메소드
2) ExceptionResolver가 참조하는 ExceptionHandler 코드
클라이언트에서 요청이 들어오면 1) 구간에서 처리한다. 그러다가 에러가 발생하면 ExceptionHandlerExceptionResolver가 동작하는데, 이때 에러가 난 컨트롤러의 2) 구간에 선언된 @ExceptionHandler를 탐색한다. 그러므로 특정 Controller에서 발생한 에러를 특정한 로직으로 유연하게 처리할 수 있게 된 것이다.
위 코드를 보면 id로 ex가 들어오면 RuntimeException이 발생한다. 같은 컨트롤러 안에서 @ExceptionHandler로 RuntimeException이 발생한 경우 처리되는 로직이 정리되어 있다. 에러상태코드도 ResponseEntity를 반환하여 변경할 수도 있다. 아니면 IllegalArgumentException이 발생한 경우처럼 @ResponseStatus 어노테이션으로 처리 할 수도 있다.
이처럼 ExceptionResolver를 구현할 필요없이 가변적으로 에러데이터 생성 및 에러상태코드 변경만 어노테이션으로 구현해주면 된다. 이는 특정 컨트롤러 전용으로 동작하므로, 컨트롤러마다 다양한 오류처리가 가능해진다.
@ControllerAdvice
컨트롤러는 비즈니스 로직을 처리하는 책임이 있는 클래스이다.
1) 요청과 매핑되는 Controller 메소드
2) ExceptionResolver가 참조하는 ExceptionHandler 코드
그런데 두 가지 책임을 갖게 되었다. 하나의 클래스는 하나의 책임만 가지도록 해야 객체지향적 설계가 가능하다. 그러므로 2) 영역을 분리해보자. SpringBoot는 @ControllerAdvice를 제공하여 ExceptionHandler 코드가 분리될 수 있도록 지원한다.
Controller ( 요청을 처리하는 컨트롤러 )
@Slf4j
@RestController
public class ExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id){
// RuntimeException 발생
if(id.equals("ex")){
throw new RuntimeException("잘못된 사용자");
}
// IlleagalArgumentException 발생
if(id.equals("bad")){
throw new IllegalArgumentException("잘못된 입력값");
}
return new MemberDto(id,"hello " + id);
}
}
@ControllerAdvice ( Exception을 처리하는 컨트롤러 )
@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {
// IllegealArgumentException이 발생한 경우
@ResponseStatus(HttpStatus.BAD_REQUEST) // 에러상태코드 변환 : 500 -> 400
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
// 객체 반환하기 ( JSON으로 변환 )
return new ErrorResult("BAD",e.getMessage()); // 에러데이터 생성
}
// RuntimeException이 발생한 경우
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResult> userExHandler(RuntimeException e){
// 에러데이터 생성
ErrorResult errorResult = new ErrorResult("USER-EX",e.getMessage());
// ResponseEntity로 에러상태코드 변환하기
return new ResponseEntity<>(errorResult,HttpStatus.BAD_REQUEST);
}
}
두 개의 책임이 각각의 클래스로 분리되었다. 이로써 Controller는 깔끔해졌다.
에러가 발생하면 @ControllerAdvice로 선언된 클래스의 메소드가 호출된다. @ControllerAdvice는 경로를 설정할 수 있는데, 이는 ExceptionHandler를 적용 가능한 범위를 가리킨다. 이로써 컨트롤러에서 오류가 발생해도 그에 맞는 적절한 오류를 처리가 가능해진다.
@RestControllerAdvice
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
// 중략...
}
@RestControllerAdvice는 오류데이터를 HTTP Body 영역의 Message로 전달하는 어노테이션이다. 어노테이션을 열어보면 위 코드와 같다. @ControllerAdvice와 @ResponseBody 기능을 모두 가진 어노테이션이다.
정리하면,
@ExceptionHandler는 현재 실무에서 가장 많이 사용되고 있는 API 에러처리 방식으로 효율적이고 유연한 에러처리가 가능하다. 또한 @ControllerAdvice 어노테이션을 사용하면 책임을 분리하여 객체지향적으로 에러처리가 가능해진다.
참고자료
'SPRING > Spring MVC' 카테고리의 다른 글
[SpringMVC] API 예외처리하기(2) - HandlerExceptionResolver (0) | 2023.09.12 |
---|---|
[SpringMVC] API 예외처리하기(1) - BasicErrorController (0) | 2023.09.11 |
[SpringMVC] SpringBoot에서 오류 페이지 띄우기 (0) | 2023.09.01 |
[SpringMVC] 예외처리 ( 필터, 인터셉터 ) (0) | 2023.09.01 |
[SpringMVC] 오류페이지 띄우기 (0) | 2023.09.01 |