예외 처리 과정
프로그래밍에서 예외 처리는 아주 중요하면서도 아주 어렵다.
과하다할 만큼 상세하고 다양하게 예외를 잡아 처리해준다면, 클라이언트도 그렇고 서버도 그렇고 더 안정적인 프로그램이 될 수 있게 도와준다.
예외 처리를 하는 경우와 방법은 다양하다.
- 메서드 내에서 예외 상황을 예측해서 처리하는 try-catch문을 이용하는 방법
- 요구사항에 의한 예외 처리 (ex. validation > 특정 값이 0~255범위가 아니면 유효하지 않은 값으로 판단하고 예외 처리)
- 스프링 시큐리티에서 인터셉터로 잡아서 UnauthorizedException 같은 예외 처리
기타 여러 예외 처리들을 적용하다보면 코드가 엄청나게 복잡해진다.
if문으로 잡든 try-catch로 잡든 상위 메서드로 예외처리를 위임하든 코드는 복잡해진다
그렇게 되면 유지보수하기 아주 어려워진다.
비즈니스 로직에 집중하기 어렵고, 비즈니스 로직과 관련된 코드보다 예외 처리를 위한 코드가 더 많아지는 경우도 생긴다.
이런 문제를 조금이라도 개선하기 위해 @ExceptionHandler와 @ControllerAdvice를 사용한다고 보면 이해가 쉬워진다.
@ExceptionHandler
@ExceptionHandler같은 경우는 @Controller, @RestController가 적용된 Bean내에서 발생하는 예외를 잡아서 하나의 메서드에서 처리해주는 기능을 한다.
@RestController
public class MyRestController {
...
...
@ExceptionHandler(NullPointerException.class)
public Object nullex(Exception e) {
System.err.println(e.getClass());
return "myService";
}
}
위와 같이 적용하기만 하면 된다. @ExceptionHandler라는 어노테이션을 쓰고 인자로 캐치하고 싶은 예외클래스를 등록해주면 끝난다.
→ @ExceptionHandler({ Exception1.class, Exception2.class}) 이런식으로 두 개 이상 등록도 가능하다.
위의 예제에서 처럼하면 MyRestController에 해당하는 Bean에서 NullPointerException이 발생한다면, @ExceptionHandler(NullPointerException.class)가 적용된 메서드가 호출될 것이다.
주의사항/알아 둘 것
- Controller, RestController에만 적용가능하다. (@Service같은 빈에서는 안됨.)
- 리턴 타입은 자유롭게 해도 된다. (Controller내부에 있는 메서드들은 여러 타입의 response를 할 것이다. 해당 타입과 전혀다른 리턴 타입이어도 상관없다.)
- @ExceptionHandler를 등록한 Controller에만 적용된다. 다른 Controller에서 NullPointerException이 발생하더라도 예외를 처리할 수 없다.
- 메서드의 파라미터로 Exception을 받아왔는데 이것 또한 자유롭게 받아와도 된다.
예제를 보면서 테스트를 해보도록하자.
@RestController
public class MyRestController {
@Autowired
private MyService myService;
@GetMapping("/hello")
public String hello() {
return "hello";//문자열 반환
}
@GetMapping("/myData")
public MyData myData() {
return new MyData("myName");//object 반환
}
@GetMapping("/service")
public String serviceCall() {
return myService.serviceMethod();//일반적인 service호출
}
@GetMapping("/serviceException")
public String serviceException() {
return myService.serviceExceptionMethod(); //service에서 예외발생
}
@GetMapping("/controllerException")
public void controllerException() {
throw new NullPointerException();//controller에서 예외발생
}
@GetMapping("/customException")
public String custom() {
throw new CustomException();//custom예외 발생
}
@ExceptionHandler(NullPointerException.class)
public Object nullex(Exception e) {
System.err.println(e.getClass());
return "myService";
}
}
[MyRestController.class]
String타입과 MyData라는 나만의 객체타입을 리턴하는 메서드등의 존재하지만 ExceptionHandler하나로 다 처리할 수 있다.
myService.serviceExceptionMethod는 Service안에서 Exception이 발생하는데 이 메서드를 호출하면 서비스에서 예외가 발생했지만 결국 컨트롤러 내에서 발생한 것과 같으므로 ExceptionHandler가 예외를 잡아내어 "myService"가 리턴된다.
public class CustomException extends RuntimeException{
private static final long serialVersionUID = 1L;
}
RuntimeException을 확장한 클래스로 CustomException을 만들었다.
이 예외는 NullPointerException이 아니기 때문에 발생하더라도 ExceptionHandler에 의해서 처리되지 않는다.
만약 하나로 더 많은 예외 처리를 하길 원한다면 모든 예외의 부모클래스인 Exception.class를 핸들링하게하면 된다.
@ExceptionHandler(Exception.class)
@ControllerAdvice
@ExceptionHandler가 하나의 클래스에 대한 것이라면, @ControllerAdvice는 모든 @Controller 즉, 전역에서 발생할 수 있는 예외를 잡아 처리해주는 annotation이다.
@RestControllerAdvice
public class MyAdvice {
@ExceptionHandler(CustomException.class)
public String custom() {
return "hello custom";
}
}
위와 같이 새로운 클래스파일을 만들어서 annotation을 붙이기만 하면 된다. 그 다음에 @ExceptionHandler로 처리하고 싶은 예외를 잡아 처리하면 된다.
별도의 속성값이 없이 사용하면 모든 패키지 전역에 있는 컨트롤러를 담당하게 된다.
@RestControllerAdvice랑 @ControllerAdvice가 있는데 말 그대로 @RestControllerAdvice는 @RestController에서 발생한 Exception만 캐치하고, @ControllerAdvice는 @Controller에서 발생한 Exception만 캐치한다.
@RestControllerAdvice와 @ControllerAdvice가 존재하는데 @RestControllerAdvice 어노테이션을 들여다보면 아래와 같이 되어있다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
//...
}
위와 같이 두개의 컨트롤러를 작성했을 때 @RestControllerAdvice를 사용하면 @Controller로 적용한 HelloController에서 발생하는 예외는 @RestControllerAdvice에서 잡아주지 못한다.
@ControllerAdvice와 동일한 역할 즉, 예외를 잡아 핸들링 할 수 있도록 하는 기능을 수행하면서 @ResponseBody를 통해 객체를 리턴할 수도 있다는 얘기다.
ViewResolver를 통해서 예외 처리 페이지로 리다이렉트 시키려면 @ControllerAdvice만 써도 되고, API서버여서 에러 응답으로 객체를 리턴해야한다면 @ResponseBody 어노테이션이 추가된 @RestControllerAdvice를 적용하면 되는 것이다.
@RestController에서 예외가 발생하든 @Controller에서 예외가 발생하든 @ControllerAdvice + @ExceptionHandler 조합으로 다 캐치할 수 있고 @ResponseBody의 필요 여부에 따라 적용하면 된다는 것이다. (제 불찰로 잘못된 정보를 공유했던 점을 수정하였습니다.)
또한, 만약에 전역의 예외를 잡긴하되 패키지 단위로 제한할 수도있다.
@RestControllerAdvice("com.example.demo.login.controller")
login모듈에 있는 RestController에서 발생하는 예외를 잡으려면 위와 같이 하면 된다. (패키지 구성을 잘하면 유용하다)
실무에선 어떻게...?
일반적인 실무에서 어떻게 사용하는지는 사실 명확하지 않다.
필자의 경험상 에러 인터페이스 정의를 제대로 해야한다.
무슨 얘기냐면 에러메시지로 나가는 포맷이 일정해야한다는 얘기다.
만약 로그인 모듈에서 발생한 예외에 응답하는 메세지는 에러코드랑 설명을 리턴해준다고 하고, 배송 모듈에서 발생한 예외는 에러코드랑 에러가 난 배송 번호를 리턴해준다고 하자
그러면 @ControllerAdvice를 이용해서 통합으로 처리하려고 했지만 리턴 타입이 다르니까 통합해서 처리할 수 없다.
HTTP 상태코드, ErrorResponse같은 경우는 좋은 예다.
에러 인터페이스, 포맷이 다 같고 클라이언트 측에서도 이해하기 좋은 에러가 날라오는 것이다.
그래서 @ExceptionHandler와 함께 @ResponseStatus(value = HttpStatus.UNAUTHORIZED)
이런 것도 집어넣어서 HTTP상태코드를 리턴하기도 한다. (앞에서 설명하진 않았지만...)
다시 한 번 정리하지만 에러 메시지가 잘 정의되어있어야 하는게 전제 조건이다.
→ 에러 관리하기
public enum LoginErrorCode {
OperationNotAuthorized(6000,"Operation not authorized"),
DuplicateIdFound(6001,"Duplicate Id"),
//...
UnrecognizedRole(6010,"Unrecognized Role");
private int code;
private String description;
private LoginErrorCode(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
}
보통 에러를 위와 같이 한 곳에 정리를 할 것이다.
저렇게 미리 정의해놓고 실제 사용할 때는 LoginErrorCode.OperationNotAuthorized.getCode(); 이런식으로 불러와서 에러객체를 만들어서 리턴할 것이다.
그러면 1차적으로 에러객체 관리는 위와 같은 방법으로 끝난다.
그 다음에 아까 배운 @ControllerAdvice, @ExeptionHandler를 이용해서 에러를 처리해본다.
만약 @ControllerAdvice, @ExceptionHandler로 처리할 때, InvalidArgumentProvided 라는 에러코드를 만들었다면, 모든 컨트롤러에서 들어오는 인자(arguments)에 대해서 한 곳에서 처리하게 되므로, 중복된 코드를 쓰지 않게 된다.
그로인해 비즈니스 로직에 더 집중할 수 있고, 코드도 간단하게 조건문에 따라 throw new XXXXException(); 하고 호출해버리면 끝나기 때문에 유지보수에 아주 큰 도움이 된다.