[Spring] @ExceptionHandler와 @ControllerAdvice를 활용하여 예외처리하기
예외 처리
어떤 프로그램을 만들더라도 예외는 발생하기 마련이다.
예전부터 이러한 예외처리를 위해 다양한 방법이 생겨났다.
기본적인 if문부터 시작해서 try-catch, throws 등등 예외 처리를 위한 많은 방법이 존재한다.
그러나 수많은 예외를 if문, try-catch 문으로 모두 처리하기에는 코드가 너무 복잡하고 어려워진다.
스프링은 @ExceptionHandler라는 어노테이션으로 매우 편리한 예외 처리 기능을 제공한다.
@ExceptionHandler
@ExceptionHanlder 어노테이션을 사용하면 컨트롤러(Controller) 계층에서 발생하는 예외를 처리할 수 있다.
@RestController
@Slf4j
public class BoardController {
@ExceptionHandler(IllegalArgumentException.class)
public void catchIllegalExeption(IllegalArgumentException e){
log.error(e.getLocalizedMessage());
}
public ResponseEntity someMethod(SomeReqDto someReqDto){
...
}
}
예를 들어 위와 같이 @RestController나 @Controller 어노테이션이 붙은 컨트롤러 클래스에
@ExceptionHandler 어노테이션을 붙인 예외 처리 메서드를 선언할 수 있다.
이때 해당 컨트롤러의 어떠한 메서드에서 IllegalArgumentException이 발생하더라도 catchIllegalException이 실행된다.
예를 들어 someMethod가 실행될 때 IllegalArgumentException이 발생하면 catchIllegalException이 실행되고 예외 처리를 진행하게 된다.
@ExceptionHandler({IllegalArgumentException.class, IOException.class})
public void catchIllegalExeption(Exception e){
log.error(e.getLocalizedMessage());
}
또한 위와 같이 하나의 메서드에 여러 개의 예외를 같이 처리할 수도 있다.
이때 @ExceptionHandler에서 지정한 예외의 자식 클래스도 전부 캐치하게 된다.
예를 들어 IOException의 자식 클래스인 FileSystemException, RemotException 등을 모두 잡게 된다.
@ExceptionHandler( IOException.class)
public void catchIOExeption(IOException e){
log.error(e.getLocalizedMessage());
}
@ExceptionHandler( FileSystemException.class)
public void catchFileSystemException(FileSystemException e){
log.error(e.getLocalizedMessage());
}
부모 클래스와 자식 클래스를 처리하는 메서드가 동시에 존재한다면, 항상 자세한 것이 우선순위를 갖는다.
예를 들어 FileSystemException이 발생하면 catchFileSystemException이 실행된다.
그외 IOException이나 그 자식 클래스 예외가 발생하면 catchIOException이 실행된다.
@ExceptionHandler
public void catchIOExeption(IOException e){
log.error(e.getLocalizedMessage());
}
위와 같이 @ExceptionHandler의 옵션에 예외를 지정하지 않으면 메서드 파라미터의 예외가 지정된다.
@ExceptionHandler의 한계
이처럼 @ExceptionHandler를 사용하면 간편하게 예외 처리를 할 수 있지만, 치명적인 단점이 존재한다,
그것은 바로 다른 컨트롤러에서 발생한 예외는 처리할 수 없다는 점이다. 예를 들어 위 BoardController가 아닌 UserController에서 같은 IllegalArgumentException이 발생하더라도 같은 예외 처리를 할 수 없다.
따라서 @ExceptionHandler만을 이용한다면 모든 컨트롤러에서 각자 예외처리를 해주어야 하기때문에 코드에 중복이 발생할 수 있다.
또한 비즈니스 로직과 예외처리 로직이 같은 곳에 위치하여 코드가 복잡해진다는 단점이 있다.
한 컨트롤러에 비즈니스 로직과 예외 처리 로직이 같이 있는 것보다는 기능을 분리하는 것이 유지보수에 좋을 것이다.
이러한 단점을 극복할 수 있는 기능이 바로 @ControllerAdvice이다.
@ControllerAdvice와 @RestControllerAdvice
@ControllerAdvice는 대상으로 지정한 컨트롤러들에 @ExceptionHandler 기능을 부여해준다.
참고로 @RestControllerAdvice는 @ControllerAdvice에서 @ResponseBody가 추가된 어노테이션이라고 보면 된다.
// @RestController가 붙은 모든 컨트롤러
@ControllerAdvice(annotations = RestController.class)
public class ControllerAdvice1 {}
// 특정 패키지의 컨트롤러
@ControllerAdvice("org.example.controllers")
public class ControllerAdvice2 {}
// 특정 컨트롤러 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ControllerAdvice3 {}
// 모든 컨트롤러 적용(글로벌 적용)
@ControllerAdvice
public class ControllerAdvice4{}
위와 같이 @ControllerAdvice는 특정 컨트롤러들에 적용하거나 모든 컨트롤러에 적용할 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ResponseDto> catchIllegalArgumentException(IllegalArgumentException e){
// 예외 처리
}
@ExceptionHandler(IOException.class)
public ResponseEntity<ResponseDto> catchIOException(IOException e){
// 예외 처리
}
}
위와 같이 GlobalExceptionHandler 클래스를 작성해 모든 예외를 한 곳에서 처리할 수도 있다.
어떤 컨트롤러에서 IllegalArgumentException이나 IOException이 발생하더라도 공통적으로 예외 처리를 진행 할 수 있다.
특히 @RestControllerAdvice를 사용하면 자세한 오류 내용을 Json 형태로 내려줄 수 있다는 점이 큰 강점이다.
직접 만든 에러 응답 객체를 이용해 다양한 정보를 전달 할 수 있다.
실무 활용법
나는 실무에서 @ExceptionHandler와 @RestControllerAdvice를 아래와 같이 많이 활용한다.
우선 messages.properties에 에러 메시지들을 기록한다.
예를 들어
USER_INVALID_PARAMETER = {0}이/가 유효하지 않습니다
위와 같은 에러 메시지를 만든다.
@RestController
@Slf4j
public class BoardController {
@PostMapping(...)
public ResponseEntity login(LoginReq loginReq){
if(!validParameter(loginReq)){
throw new BizException(MessageSource.getMessage("USER_INVALID_PARAMETER"));
}
}
컨트롤러에서 validParameter와 같은 유효성 검증 메서드를 만들고, 해당 메서드를 통과하지 못하면 직접 정의한 BizException 같은 예외를 throw한다.
public class BizException extends RuntimeException{
private final String code;
public BizException(String code){
super();
this.code = code;
}
}
위와 같이 비즈니스 로직 예외 클래스를 하나 만들어두고 공통 처리를 하면 편리하다.
@ControllerAdvice
public class MyExceptionHandler{
@ExceptionHandler(BizException.class)
public ResponseEntity<ResponseDto> bizHandler(BizException e){
log.error(e.getMessage());
return new ResponseEntity<>(
ResponseDto.create(e.getCode(), e.getLocalizedMessage()), HttpStatus.OK);
}
}
마지막으로 @ControllerAdvice를 붙인 예외 처리 핸들러 클래스를 작성해서 BizException을 처리하는 메서드를 작성하면 된다.
이때 미리 선언해둔 객체를 이용해 통일화된 응답을 내려줄 수 있다.