예외가 발생했을 때 어떻게 처리하는 것이 좋은 방법일까?
- 예외 복구 : 예외가 발생하면 예외 상황에 맞게 처리하여 다시 복구한다. (ex. try, catch)
- 예외 회피 : 예외를 직접 처리하지 않고 예외를 다음(상위) 메소드에 위임한다. 즉, 나 대신 너가 처리해줘 (ex. throw)
- 예외 전환 : 예외를 위임하긴 하지만 발생한 예외를 있는 그대로 위임하지 않고 적절한 예외로 전환하여 위임한다. (
이렇게 주로 예외는 3가지 방식으로 처리할 수 있다. (ex. CustomException)
이 중에서 예외 복구에 대해 좀 더 자세히 살펴보자!
앞서 예외 복구 방식은 예외가 발생했을 때 try, catch를 사용하여 예외 상황을 대응하고 처리할 수 있다고 했다.
허나 실제로 프로젝트를 개발을 진행하면 많은 예외 복구 로직이 필요하고,
예외(Exception)에 따라 비슷하게 처리되는 경우도 심심치 않게 생길 수 있다.
그러다보면 자연스레 동일한 예외 복구 로직에 대한 유사한 코드를 줄이고 싶은 상황이 발생할 수 있으며,
다양한 예외처리를 한 곳에서 관리하고 싶은 경우 또한 생길 수 있다.
이러한 다양한 개선사항을 해결할 수 있는 방법에 대해 이번에 살펴보자
자바의 예외 클래스
스프링 예외 처리 전에 복습의 의미로 중요한 자바의 예외 클래스를 한번 정리하고 넘어가려 한다.
- 모든 예외 클래스는 Throwable를 상속받는다.
- Exception은 수많은 자식 클래스가 있고,
- RuntimeException은 Unchecked Exception 그 외의 Exception은 Checked Exception으로 볼 수 있다.
더불어 Checked Exception과 Unckecked Exception에 대한 차이를 명확하게 정리하고 넘아가자!
스프링 부트의 예외 처리 방식
스프링 부트의 예외 처리 방식은 크게 2가지가 존재한다.
- @ControllerAdvice를 통한 모든 Controller에서 발생할 수 있는 예외 처리
- @ExceptionHandler를 통한 특정 Controller의 예외 처리
@ControllerAdvice를 모든 컨트롤러에서 발생할 예외를 정의하고,
그 안에서 @ExceptionHandler를 통해 발생하는 예외 마다 처리해줄 메서드를 정의하여 예외를 처리할 수 있다.
우선, 예외 복구 범위부터 정해보자 (메서드, 클래스, 전역 영역)
- 메서드 영역 : 가장 구체적이고 즉각적인 처리를 위해 메서드 내에서 try-catch 블록을 사용한다.
public class ProductService {
public Product findProductById(String id) {
try {
return someRepository.findById(id).orElseThrow(() -> new ProductNotFoundException("Product not found for ID: " + id));
} catch (ProductNotFoundException e) {
return null;
}
}
}
- 클래스 영역 : 클래스 내 공통된 예외 복구 처리는 @ExceptionHandler를 사용하여 처리한다.
@RestController
@RequestMapping("/products")
public class ProductController {
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable String id) {
...
throw new ProductNotFoundException();
}
@GetMapping("/{id2}")
public ResponseEntity<Product> getProductById2(@PathVariable String id2) {
...
throw new ProductNotFoundException();
}
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<String> handleProductNotFoundException(ProductNotFoundException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND);
}
}
- 전역 영역 : 여러 클래스의 공통된 예외 복구 처리는 @(Rest)ControllerAdvice를 사용하여 전역 예외 처리 역할을 하는 클래스를 정의하고 해결한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<String> handleProductNotFoundException(ProductNotFoundException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND);
}
}
@ExceptionHandler
- 예외 처리 상황이 발생하면 해당 Hanlder로 처리하겠다고 명시하는 어노테이션
- Exception.class는 최상위 클래스로 하위 세부 예외 처리 클래스로 설정한 핸들러가 존재하면, 그 핸들러가 우선순위를 갖고 처리하게 되며, 처리 되지 못한 예외 처리에 대해선 ExceptionClass에서 핸들링한다.
- 전역(글로벌) 설정(@ControllerAdvice)보다 지역(Controller) 설정으로 정의한 핸들러가 우선순위를 가진다.
@ExceptionHandler로 예외 처리하는 방법
- @ExceptionHandler 어노테이션을 선언하고,
- 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
- 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다.
@ExceptionHandler는 하나만 처리하지 않는다. 다양한 예외를 처리할 수 있다.
@RestController
@RequestMapping("/products")
public class ProductController {
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable String id) {
...
throw new ProductNotFoundException();
}
@GetMapping("/{id2}")
public ResponseEntity<Product> getProductById2(@PathVariable String id2) {
...
throw new NullPointerException();
}
@ExceptionHandler({ProductNotFoundException.class, NullPointerException.class})
public ResponseEntity<String> handle(Exception ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND);
}
}
@ExceptinHandler는 { } 안에 여러 예외를 넣으면 해당 예외를 처리할 수 있다.
위의 예시 코드는 컨트롤러 내에서 ExceptionHandler가 같이 있어서 컨트롤러 내의 예외만 처리할 수 있다.
더불어 정상 로직과 예외 처리 로직이 하나의 컨트롤러에 섞여 있는 문제도 있다.
이를 해결할 수 있는 방법 중 하나가 관심사의 분리 즉, AOP가 적용된 @(Rest)ControllerAdvice를 사용하는 방법이다.
@ControllerAdvice
그래서 @ControllerAdvice는 뭐야?
- @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 등의 기능을 부여해주는 역할을 한다.
- @Controller나 @RestController에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 어노테이션이다. (단, 예외 뿐만 아니라 다른 기능도 있다. 주로 예외 처리를 위해 사용한다는 걸 명심하자!)
- @ControllerAdvice에 대상을 따로 지정하지 않으면 모든 컨트롤러에 적용된다. (즉, 글로벌 적용이 된다.)
- @RestController와의 차이는 @ResponseBody가 추가 유무이다. 예외 발생 시 json 형태로 결과를 반환하고 싶다면? @RestControllerAdvice를 사용하면 된다.
- 예외 처리하는 부분만 생각해본다면, @ControllerAdvice가 지정한 컨트롤러에서 발생한 예외를 가로채서 직접 처리할 수 수 있다.
예시 코드는 앞서 말했던 예외 복구 범위의 전역 영역을 보면 된다.
따라서 이번에는 대상 컨트롤러를 지정하는 방법에 대해 언급하려고 한다.
스프링 공식 문서에 관련 코드가 있다.
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-advice.html
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
대상 컨트롤러를 지정하는 방법
- 특정 어노테이션이 있는 컨트롤러를 지정하는 방법
- 특정 패키지를 직접 지정하는 방법 (단, 해당 패키지와 하위에 있는 컨트롤러까지도 대상이 된다.)
- 특정 클래스를 지정하는 방법이 있다.
- 앞서 말했듯 만약 따로 컨트롤러를 지정하지 않은 경우엔 모든 컨트롤러를 지정한 것과 같다. (글로벌 적용)
중요 - 프로젝트를 위한 개별 예외 클래스를 만들고 예외 처리해보자
이전 글에서는 API 공통 스펙과 에러 코드 처리를 위한 ErrorCode enum클래스와 ErrorCodeIfs인터페이스를 만들었다.
이번엔 그것들을 바탕으로 별도의 프로젝트에 맞는 예외(ApiException)클래스를 만들고 이를 예외 처리(ControllerAdvice, ExceptionHandler)해보려 한다.
사실 이 부분이 이번 글에서 가장 중요하다.
우선, 예외가 발생하면 body에 들어가는 Result 즉, 응답 결과가 들어가는 일은 거의 없다고 생각하기에 errorCode와 errorDescription만 응답으로 보내도록 했다.
ApiExceptionIfs 인터페이스
public interface ApiExceptionIfs {
ErrorCodeIfs getErrorCodeIfs();
String getErrorDescription();
}
ApiException 클래스
@Getter
public class ApiException extends RuntimeException implements ApiExceptionIfs {
private final ErrorCodeIfs errorCodeIfs;
private final String errorDescription;
public ApiException(ErrorCodeIfs errorCodeIfs) {
super(errorCodeIfs.getDescription());
this.errorCodeIfs = errorCodeIfs;
this.errorDescription = errorCodeIfs.getDescription();
}
public ApiException(ErrorCodeIfs errorCodeIfs, String errorDescription) {
super(errorDescription);
this.errorCodeIfs = errorCodeIfs;
this.errorDescription = errorCodeIfs.getDescription();
}
public ApiException(ErrorCodeIfs errorCodeIfs, Throwable tx) {
super(tx);
this.errorCodeIfs = errorCodeIfs;
this.errorDescription = errorCodeIfs.getDescription();
}
public ApiException(ErrorCodeIfs errorCodeIfs, Throwable tx, String errorDescription) {
super(tx);
this.errorCodeIfs = errorCodeIfs;
this.errorDescription = errorDescription;
}
}
컴파일 단계에서 체크되는 예외까지 상속받을 필요는 없다고 생각했기에 RuntimeException을 상속받았다.
이렇게 ApiException 클래스 정의하여 좀 더 프로젝트에 적합한 상황별 예외를 처리할 수 있게끔 했다.
ApiExceptionHandler
@Slf4j
@RestControllerAdvice
@Order(value = Integer.MIN_VALUE)
public class ApiExceptionHandler {
@ExceptionHandler(value = ApiException.class)
public ResponseEntity<Object> apiException(
ApiException apiException
) {
log.error("", apiException);
ErrorCodeIfs errorCode = apiException.getErrorCodeIfs();
return ResponseEntity
.status(errorCode.getHttpStatusCode())
.body(
Api.ERROR(errorCode, apiException.getErrorDescription())
);
}
}
ApiException 예외를 처리할 ApiExceptionHandler다.
GlobalExceptionHandler
@Slf4j
@RestControllerAdvice
@Order(value = Integer.MAX_VALUE)
public class GlobalExceptionHandler {
@ExceptionHandler(value = Exception.class)
public ResponseEntity<Api<Object>> exception(
Exception exception
) {
log.error("", exception);
return ResponseEntity
.status(500)
.body(
Api.ERROR(ErrorCode.SERVER_ERROR)
);
}
}
ApiExceptionHandler에서 처리하지 못한 경우를 대비해 가장 넓은 범위의 Exception 클래스를 처리하는 핸들러를 만들었다. 만약 이를 만들지 않는다면, 놓치는 예외를 처리하지 못하기에 항상 프로젝트 전반의 예외를 처리할 Exception 클래스를 처리하는 핸들러를 만들어주는 것이 매우 중요하다.
사용 예시
@RequiredArgsConstructor
@Service
public class UserOrderService {
private final UserOrderRepository userOrderRepository;
public UserOrderEntity getUserOrderWithOutStatusWithThrow(
Long id,
Long userId
) {
return userOrderRepository.findAllByIdAndUserId(id, userId)
.orElseThrow(() -> new ApiException(ErrorCode.NULL_POINT));
}
...
}
정리
- @ControllerAdvice, @ExceptionHandler로 발생하는 예외를 처리할 수 있다.
- 전역(글로벌) 설정(@ControllerAdvice)보다 지역(Controller) 설정으로 정의한 핸들러가 우선순위를 가진다.
이번 블로그를 쓰면서 @ControllerAdvice에 대해 좀 더 자세히 살펴봐야겠다고 생각했다.
더불어 @ControllerAdvice가 단순히 @ExceptionHandler 외에 또 다른 공통 기능을 제공한다는 사실을 알게 되었기에 이 또한 같이 공부하고 블로그로 정리할 예정이다.
스프링에선 예외 처리를 컨트롤러에서 하거나 전역으로 관리하는 방식이 있는데 이는 상황마다 다를 수 있지만,
하나의 클래스에 정상 로직과 예외 처리 로직이 같이 있는 건 어찌보면 단일 책임 원칙이 지켜지지 않는다고도 할 수 있을 것 같다는 생각도 들었다.
더불어 프로젝트의 상황에 맞는 CustomeException 클래스를 만들어서 이를 처리할 ExceptionHandler를 만드는 부분은 중요하니까 반드시 기억하고 있도록 하자!
참고
https://www.youtube.com/watch?v=nyN4o9eXqm0&list=PLlTylS8uB2fBOi6uzvMpojFrNe7sRmlzU&index=18
'Spring' 카테고리의 다른 글
[Spring] 비동기 프로그래밍 - ThreadPoolExcutor (0) | 2024.04.13 |
---|---|
[Spring] Profile 설정 (feat. profiles.active) (0) | 2024.04.12 |
[Spring] 스프링 부트 예외 처리(1) - API Error Code 적용하기 (feat. API 공통 스펙) (0) | 2024.04.12 |
[Spring] ObjectMapper 커스텀해서 사용해보기 (feat. serialization) (0) | 2024.03.20 |
[Spring] Swagger (0) | 2024.03.19 |