try catch만 보여..@ControllerAdvice 적용기
이글은 ControllerAdvice를 적용하여 예외처리를 처리한 글입니다.
🚨코드 반복좀 줄이고 싶다
계속해서 반복된 코드가 컨트롤러에서 많아지고 서비스에서 발생하는 예외처리를 해주기 위해 컨트롤러의 코드가 너무나 길어져 핵심적인 내용을 보기에는 불편하였습니다.
@PostMapping("/myschedule")
public ResponseEntity<ResponseMessage> createMySchedule(@AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestBody @Valid MyScheduleInputDTO myScheduleInputDTO, BindingResult bindingResult) {
// 유효성 검사 실패 시 에러 메시지 반환
if (bindingResult.hasErrors()) {
String errorMessage = ValidationOutput.getValidationErrors(bindingResult);
ResponseMessage response = new ResponseMessage(errorMessage);
return ResponseEntity.badRequest().body(response);
}
// MySchedule 생성
try {
myScheduleCreateService.createMySchedule(customUserDetails, myScheduleInputDTO);
ResponseMessage response = new ResponseMessage("스케쥴 생성 성공");
return ResponseEntity.ok(response);
} catch (InvalidScheduleDateException | InvalidTokenUser e) {
ResponseMessage response = new ResponseMessage(e.getMessage());
return ResponseEntity.status(e.getHttpStatus()).body(response);
} catch (Exception e) {
ResponseMessage response = new ResponseMessage("스케쥴 생성 실패");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@ConstrollerAdvice를 적용하기전 코드입니다. 핵심적인 로직은 사실 여기 뿐입니다.
myScheduleCreateService.createMySchedule(customUserDetails, myScheduleInputDTO);
ResponseMessage response = new ResponseMessage("스케쥴 생성 성공");
return ResponseEntity.ok(response);
나머지는 예외를 처리하기 위한 코드입니다. 이렇게 컨트롤러에 예외를 처리하기 위한 코드가 너무 많았습니다. 중복을 줄이고 이를 다른곳에서 처리해줄수 없을까 하는 생에서 시작되었습니다.
💡ControllerAdvice로 AOP 적용할 수 있다는데?
AOP란?
관점지향 프로그래밍으로 불립니다. 관점 지향은 어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것입니다. 제 코드에서 핵심적인 관점 즉, 비즈니스 로직은 다음과 같습니다.
AOP에서 각 관점을 기준으로 로직을 모듈화한다는 것은 코드들을 부분적으로 나누어서 모듈화하겠다는 의미입니다. 이때, 소스 코드상에서 계속 반복해서 쓰는 코드들을 발견할 수 있는 데 이것을 흩어진 관심사 (Crosscutting Concerns)라 부릅니다.
AOP의 주요 용어는 다음과 같습니다.
- Aspect : 여러곳에서 쓰이는 공통 부분 코드를 모듈화한 것
- Target : Aspect가 적용되는 곳(Ex: Class, Method ...)
- Advice : Aspect 에서 실질적인 기능에 대한 구현체
- Joint point : Advice 가 Target에 적용되는 시점. 메서드 진입할 때, 생성자 호출할 때, 필드에서 값을 꺼낼 때 등
- Point cut : Joint Point 의 상세 스펙을 정의한 것
- Proxy : 클라이언트와 타겟 사이에 투명하게 존재하며 부가기능을 제공하는 오브젝트.
DI를 통해 타겟 대신 클라이언트에게 주입되며 클라이언트의 메소드 호출을 대신 받아서 타겟에 위임하며 이 과정에서 부가기능을 부여한다
제 소스코드상에서 핵심적인관점(비즈니스로직), 흩어진 관심사는 다음과 같습니다.
Spring AOP의 적용 방식
AOP의 적용 방식은 크게 3가지가 있습니다.
- 컴파일 시점
- 클래스 로딩 시점
- 런타임 시점 ( 프록시 사용 )
Spring AOP는 런타임 시점에 적용하는 방식을 사용합니다. 이유는 컴파일 시점과 클래스 로딩 시점에 적용하려면 별도의 컴파일러와 클래스로더 조작기를 써야 하는데, 이것을 정하고 사용 및 유지하는 과정이 매우 어렵고 복잡하기 때문입니다.
Spring 일반적인 예외처리 과정
Spring은 만들어질 때(1.0)부터 에러 처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해두었다. 그래서 별도의 설정이 없다면 예외 발생 시에 BasicErrorController로 에러 처리 요청이 전달된다. (참고로 이는 스프링 부트의 WebMvcAutoConfiguration를 통해 자동 설정이 되는 WAS의 설정이다.)
일반적인 요청 흐름은 다음과 같이 진행된다.
WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러
-> 컨트롤러(예외발생) -> 인터셉터 -> 서블릿(디스패처 서블릿) -> 필터 -> WAS(톰캣)
-> WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러(BasicErrorController)
기본적인 에러 처리 방식은 결국 에러 컨트롤러를 한번 더 호출하는 것이다.
{
"timestamp": "2024-07-04T03:35:44.675+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/product/5000"
}
개발하면서 많이 봤을 것이다. 이는 기본 설정으로 받는 에러 응답인다.
📜스프링이 제공하는 예외처리 방법
일반적인 예외처리는 특별한 정보를 제공하지 않는다. 그렇기 때문에 클라이언트가 유용하게 사용하지 못해 우리는 별도로 예외를 처리한다.
Spring은 아래와 같은 도구들로 ExceptionResolver를 동작시켜 에러를 처리할 수 있는데, 각각의 방식 대해 자세히 살펴보도록 하자.
@ResponseStatus
에러 HTTP 상태를 변경하도록 하는 어노테이션이다.
@ResponseStatus는 다음과 같은 경우들에 적용할 수 있다.
- Exception 클래스 자체
- 메소드에 @ExceptionHandler와 함께
- 클래스에 @RestControllerAdvice와 함께
이렇게 하면 위에서 기본 설정으로 받는 에러 응답에 status만 바뀌어 응답을 준다.@ResponseStatus(value = HttpStatus.NOT_FOUND) public class NoSuchElementFoundException extends RuntimeException { ... }
하지만 이역시 에러 응답의 내용을 수정할 수 없다. 즉, 같은 예외는 항상 같은 상태와 에러 메세지를 반환하는등 한계가 명확하다.{ "timestamp": "2024-07-04T03:35:44.675+00:00", "status": 500, "error": "Internal Server Error", "path": "/product/5000" }
ResponseStatusException
@ResponseStatus의 대안으로써 손쉽게 에러를 반환할 수 있다. ResponseStatusException는 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있고, 언체크 예외을 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다.
@GetMapping("/product/{id}")
public ResponseEntity<Product> getProduct(@PathVariable String id) {
try {
return ResponseEntity.ok(productService.getProduct(id));
} catch (NoSuchElementFoundException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Item Not Found");
}
}
오 좋아보이는데? 편해보인다. 하지만 이또한 한계점들을 가지고 있다.
- 직접 예외 처리를 프로그래밍하므로 일관된 예외 처리가 어려움
- 예외 처리 코드가 중복될 수 있음
- 예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행됨
잠시 다시 정리해서 내가 처음 원했던 내용은 다음과 같다.
예외 처리 코드를 중복시키지 않으면서 기존에 일관되게 응답으로 주는 객체를 계속해서 사용하고 싶음.
이러한 것을 모두 만족하는게 @ExceptionHandler다.
사실 위에 내용은 찾아보면서 함께 알면 좋을것 같아 넣었다.
@ExceptionHandler
@ExceptionHandler 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.
- 컨트롤러의 메소드
- @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드
@ExceptionHandler는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다. 만약 ExceptionHandler 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다.일단 응답을 자유롭게 다룰수 있게되었다. 자유롭게 응답 객체를 만들어 사용하면 된다. 예시의 경우에는 ErrorResponse이름을 가진 객체를 사용하였다.
하지만 @ExceptionHandler만을 사용한다면 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외만 처리된다. 즉, 컨트롤러에 에러 처리 코드가 섞이며, 에러 처리 코드가 중복될 가능성이 높다. 그래서 스프링은 전역적으로 예외를 처리할 수 있는 @ControllerAdvice와 @RestControllerAdvice를 함께 사용한다. @RestController @RequiredArgsConstructor public class ProductController { ... @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { ... } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception exception) { ... } }
@ControllerAdvice, @RestControllerAdvice
전역적으로 @ExceptionHandler를 적용할수있는 어노테이션이다. 차이는 @ControllerAdvice의 확장판으로, RESTful API를 위한 예외 처리를 위해 사용된다. 실제 코드에 적용해보았다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ResponseMessage> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
List<String> errorMessages = e.getBindingResult().getFieldErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.toList();
ResponseMessage response = new ResponseMessage(errorMessage);
return ResponseEntity.badRequest().body(response);
return ResponseEntity.badRequest().body(response);
}
이제 이거 하나만 만들어 놓으면 된다. 이러한 ControllerAdvice를 이용함으로써 다음과 같은 장점을 얻었다.
- 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
- 직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음
- 별도의 try-catch문이 없어 코드의 가독성이 높아짐
예외처리 흐름
- ExceptionHandlerExceptionResolver가 동작
컨트롤러의 @ExceptionHandler에서 처리가능하다면 처리. 그렇지 않으면 ControllerAdvice로 넘어감. - ControllerAdvice안에 적합한 @ExceptionHandler가 있는지 검사하고 없으면 다음 처리기로 넘어감. (2번이 바로 내가 사용한 예외처리 방법이다. 하지만 만약 여기에 없을경우 다음으로 넘어감.)
- ResponseStatusExceptionResolver가 동작
@ResponseStatus가 있는지 또는 ResponseStatusException인지 검사함.
맞으면 ServletResponse의 sendError()로 예외를 서블릿까지 전달되고, 서블릿이 BasicErrorController로 요청을 전달함. - DefaultHandlerExceptionResolver가 동작
Spring의 내부 예외인지 검사하여 맞으면 에러를 처리하고 아니면 넘어감 - ResponseStatusExceptionResolver가 동작
- @ResponseStatus가 있는지 또는 ResponseStatusException인지 검사함
- 맞으면 ServletResponse의 sendError()로 예외를 서블릿까지 전달되고, 서블릿이 BasicErrorController로 요청을 전달함
- DefaultHandlerExceptionResolver가 동작
- Spring의 내부 예외인지 검사하여 맞으면 에러를 처리하고 아니면 넘어감
- 적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달.
서블릿은 SpringBoot가 진행한 자동 설정에 맞게 BasicErrorController로 요청을 다시 전달.
1,2번처럼 직접 에러를 반환하면 BasicErrorController를 거치지 않지만 3번부터는 직접 에러 응답을 반환하지 않기 때문에 최종적으로 BasicController를 거쳐 에러가 처리된다. 즉, 내부에서는 2번 컨트롤러로 요청이 전달된다.
정리
최종적으로 간결해진 나의 컨트롤러 코드는 다음과 같다. 핵심적인 서비스 로직만을 컨트롤러는 포함하고 있도록 하였다.
@PostMapping("/myschedule")
public ResponseEntity<ApiResponseMessage> createMySchedule(@AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestBody @Valid MyScheduleInputDTO myScheduleInputDTO) {
myScheduleCreateService.createMySchedule(customUserDetails, myScheduleInputDTO);
ApiResponseMessage response = ApiResponseMessage.success();
return ResponseEntity.ok(response);
}
해당 수정된 코드를 보면 핵심적인 서비스 로직만을 컨트롤러가 포함하고 있으므로 성공적으로 수정되었다고 할수있다.
부록( ControllerAdvice는 AOP로 구현되어 있을까? )
이 부분을 공부하면서 이부분에 대한 의견이 갈리는것 같아 부록으로 추가하였다.
ControllerAdvice 동작과정을 생각해보자.
- 디스패처 서블릿이 에러를 catch함
- 해당 에러를 처리할 수 있는 처리기(HandlerExceptionResolver)가 에러를 처리함
- 컨트롤러의 ExceptionHandler로 처리가능한지 검사함
- ControllerAdvice의 ExceptionHandler로 처리가능한지 검사함
- ControllerAdvice의 ExceptionHandler 메소드를 invoke하여 예외를 반환함AOP로 구현되어있다.나의 생각은 '맞다'이다. 위에서도 말하였지만 AOP라는 관점 지향은 어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것이다.
Spring AOP가 제공하는 프록시 기반의 기술이 아니라고 해서 AOP가 아닌건 아니라고 생각한다.
참고
https://mangkyu.tistory.com/246
https://catsbi.oopy.io/fb62f86a-44d2-48e7-bb9d-8b937577c86c
https://thalals.tistory.com/272
https://hstory0208.tistory.com/entry/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-AOPAspect-Oriented-Programming%EB%9E%80-Aspect