자바 개발자를 위한 온라인 리소스 및 교육 플랫폼 중 "Baeldung" 내에서 제공하는 REST API 관련 Tutorial에 대해 학습해보는 시간을 갖겠습니다.
해당 내용은 전적으로 Baeldung에 기재된 내용을 바탕으로 서술되어 있으며, 관련 내용 중 필자가 부족한 개념은 추가로 포스팅할 예정입니다.
📜 참고 글
이번 포스팅에서는 Spring에서 REST API를 위해 어떻게 예외를 처리하는 지에 대해서 포스팅하겠습니다.
Spring은 버전에 따라 옵션이 추가됨으로써 차이가 발생합니다.
Spring 3.2 버전 전에는 HandlerExceptionResolver 또는 @ExceptionHandler 어노테이션을 사용하여 처리하는 방식 2가지가 Spring MVC 애플리케이션에서 예외를 처리하는 대표적인 방법이었습니다.
Spring 버전 3.2 이후부터 2가지 방식의 한계를 극복하고 예외를 전역에서 처리하기 위해 @ControllerAdvice 어노테이션 옵션이 추가됐습니다.
현재 Spring 5에서는 ResponseStatusException 클래스를 도입했습니다.
위의 내용들에 대해서 조금 더 자세하게 알아보겠습니다.
처음으로 예외 처리에 사용할 것은 @ExceptionHandler 어노테이션 입니다.
public class ExceptionHandleController {
@ExceptionHandler
public void handleException(){
}
}
해당 방식에는 큰 단점이 존재하는데, 이는 @ExceptionHandler 어노테이션으로 처리되는 예외는 특정 controller 내에서만 활성화된다는 점입니다.
이를 해결하기 위한 방법으로 모든 controller가 ExceptionHandler를 가지고 있는 controller를 상속하도록 하여 해결할 수 있습니다.
하지만, @ExceptionHandler를 단독으로 사용하는 방식은 상속을 통해서 일정 문제를 해결해준다고는 하지만, controller의 확장에 제약이 걸리게 됩니다.(다른 클래스를 확장해야 하는 경우, 클래스는 하나의 클래스만 상속 가능합니다.)
이러한 문제를 해결하고, 전역적으로 사용하기 위한 방법이 있습니다.
두 번째 해결책은 HandlerExceptionResolver를 정의하는 것입니다. 이렇게 하면, 애플리케이션에서 발생하는 모든 예외를 처리 가능합니다. 또한, REST API에서 공통 예외 처리 메커니즘을 구현할 수도 있습니다.
위 클래스는 Spring 3.1 버전에서 도입되었으며, @ExceptionHandler의 매커니즘이 동작하는 방식의 핵심 구성 요소 입니다.
위 클래스는 Spring 3.0 버전에서 도입되었습니다.
이는 Spring에서 예외를 처리하는데 표준으로 사용됩니다. Spring MVC 예외들을 error 상태 코드로 변환해주며, 해당 설정은 DispatcherServlet에 기본으로 설정되어 있습니다.
📜 Spring 공식 문서
해당 클래스를 통해서 응답 상태 코드를 적절하게 설정할 수 있지만, 응답 본문(body)에 아무 내용도 담지 않는다는 한계가 있습니다. REST API의 경우, 클라이언트에게 응답 시 상태 코드만 보여주는 것은 충분하지 않은 정보를 제공하게 됩니다.
해당 문제는 ModelAndView를 통해 오류와 관련된 데이터를 렌더링하여 해결은 가능하지만, 최적화된 해결 방안은 아닙니다.
위 클래스는 Spring 3.0 버전에서 도입되었습니다.
주로 사용자 정의 예외에 사용할 수 있는 @ResponseStatus 어노테이션을 사용하고 해당 예외를 HTTP 상태 코드에 매핑하는 역할을 합니다.
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class MyResourceNotFoundException extends RuntimeException {
public MyResourceNotFoundException() {
super();
}
public MyResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
public MyResourceNotFoundException(String message) {
super(message);
}
public MyResourceNotFoundException(Throwable cause) {
super(cause);
}
}
DefaultHandlerExceptionResolver 와 ResponseStatusExceptionResolver 의 조합은 Spring RESTful 서비스에 대한 우수한 오류 처리 메커니즘을 제공합니다. 단점은 앞서 언급했듯이 응답 본문을 제어할 수 없다는 것입니다.
응답 본문을 제어한다는 것을 일례로 설명하면, 클라이언트가 Accept 헤더를 통해 요청한 형식에 따라 JSON 또는 XML 형식의 본문을 출력할 수 있는 것을 말합니다.
이를 위해서는 사용자 정의 HandlerExceptionResolver를 정의해줘야 합니다.
@Component
public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {
@Override
protected ModelAndView doResolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
return handleIllegalArgument(
(IllegalArgumentException) ex, response, handler);
}
...
} catch (Exception handlerException) {
logger.warn("Handling of [" + ex.getClass().getName() + "]
resulted in Exception", handlerException);
}
return null;
}
private ModelAndView
handleIllegalArgument(IllegalArgumentException ex, HttpServletResponse response)
throws IOException {
response.sendError(HttpServletResponse.SC_CONFLICT);
String accept = request.getHeader(HttpHeaders.ACCEPT);
...
return new ModelAndView();
}
}
위 코드에서 주목할 점 중 하나는 HttpServletRequest를 통해서 요청 헤더에 접근이 가능하여 클라이언트가 요청한 Accept 값을 확인할 수 있다는 점입니다.
그리고, ModelAndView를 사용하여 응답 본문을 생성할 수 있다는 점입니다.
하지만, 이러한 방식은 낮은 수준의 HttpServletResponse와 상호 작용하고 ModelAndView를 사용하는 이전 MVC 모델에 적합하기 때문에 조금 더 개선의 여지가 있습니다.
Spring 3.2q 버전 이후부터 @ControllerAdvice 어노테이션을 사용하여 전역에 @ExceptionHandler를 지원합니다.
해당 어노테이션을 통해 이전 MVC 모델에서 벗어나서 @ExceptionHandler의 타입 안정성 및 유연성과 함께 ResponseEntity를 활용하는 메커니즘이 가능해졌습니다.
@ControllerAdvice 어노테이션을 사용하면 이전에 분리되어 있던 @ExceptionHandler를 하나의 클래스로 정의하여 전역에서 처리 가능하도록 통합할 수 있습니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value
= RuntimeException.class)
protected ResponseEntity<String> handleConflict(
RuntimeException ex, WebRequest request) { // 일치 O
String accept = request.getHeader(HttpHeaders.ACCEPT);
System.out.println(ex.getMessage());
if("application/json".equals(accept)) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("json 형태의 결과");
}else{
return ResponseEntity.status(HttpStatus.CONFLICT).body("xml 형태의 결과");
}
}
}
새로운 controller를 생성하여 내부적으로 RuntimeException이 발생하도록 코드를 구현한 뒤 테스트를 진행하였습니다. 결과는 메커니즘에 따라서 정상 수행됐습니다.
@RestController
public class OtherController {
@GetMapping("api-call/global")
public ResponseEntity<String> method(){
throw new RuntimeException("Global 예외 처리");
}
}
사용 중 명심해야할 점은 @ExceptionHandler로 선언된 예외를 처리하는 메서드에 매개변수로 사용되는 예외와 일치시켜줘야 합니다.
일치하지 않을 경우, 관련된 컴파일 또는 런타임 오류는 발생하지 않지만 예상과는 다르게 예외처리 메커니즘이 수행될 것입니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value
= IllegalArgumentException.class)
protected ResponseEntity<String> handleConflict(
RuntimeException ex, WebRequest request) { // 일치 X
String accept = request.getHeader(HttpHeaders.ACCEPT);
System.out.println(ex.getMessage());
if("application/json".equals(accept)) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("json 형태의 결과");
}else{
return ResponseEntity.status(HttpStatus.CONFLICT).body("xml 형태의 결과");
}
}
}
Spring 5 이상부터 도입된 클래스입니다. HttpStatus를 제공하고 선택적으로 원인을 제공하기 위한 인스턴스 생성이 가능합니다.
@RestController
public class Spring5Controller {
// Spring 5 이후 Exception handling 을 위한 ResponseStatusException 처리
@GetMapping("api-call/five")
public String five(){
try{
// 서비스 로직 ... 중 RuntimeException 발생 가정
throw new RuntimeException("리소스 없음");
}catch(RuntimeException ex){
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Not Found", ex);
}
}
}
ResponseStatusException을 사용하여 예외를 처리하면 어떤 장점이 있을까요?
하지만, 이를 사용하면 전역으로 예외 처리하는 것이 어렵고, 코드의 복제가 많아질 수 있습니다.
Forbidden은 인증된 사용자가 권한이 없는 리소스에 접근하려고 할 때 발생합니다.
메서드 수준 보안 어노테이션(@PreAuthorize, @PostAuthorize, @Secure)에서 발생하는 접근 권한 거부 관련 예외 처리 방법에 대해서 살펴보겠습니다.
인가 관련 예외 처리도 전역 예외처리 메커니즘을 사용하여 처리합니다.
@ControllerAdvice
public class RestResponseEntityExceptionHandler
extends ResponseEntityExceptionHandler {
@ExceptionHandler({ AccessDeniedException.class })
public ResponseEntity<Object> handleAccessDeniedException(
AccessDeniedException ex, WebRequest request) {
return new ResponseEntity<Object>(
"Access denied message here", new HttpHeaders(), HttpStatus.FORBIDDEN);
}
...
}
Spring Boot 에서는 예외 처리 지원을 위해 ErrorController 구현을 제공합니다.
이를 간단히 설명하면, 브라우저에 대한 whitelabel 페이지와 HTML이 아닌 RESTful 요청에 대한 JSON 응답을 제공합니다.
{
"timestamp": "2019-01-17T16:12:45.977+0000",
"status": 500,
"error": "Internal Server Error",
"message": "Error processing the request!",
"path": "/my-endpoint-with-exceptions"
}
Spring Boot에서는 속성을 사용하여 다음 기능을 설정할 수 있습니다.
위와 같은 속성 외에도 whitelabel 페이지를 재정의하여 오류에 대한 자체적인 viewResolver 매핑 사용이 가능합니다.
또한, Spring Context에 ErrorAttributes 빈을 포함시켜 응답에 표시하기 위한 속성을 사용자 정의할 수도 있습니다. Spring Boot에서 제공하는 DefaultErrorAttributes 클래스를 확장하여 작업을 더 쉽게 수행할 수 있습니다.
@Component
public class MyCustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(
WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> errorAttributes =
super.getErrorAttributes(webRequest, options);
errorAttributes.put("locale", webRequest.getLocale()
.toString());
errorAttributes.remove("error");
//...
return errorAttributes;
}
}
더 나아가서 애플리케이션이 특정 content 타입에 대한 오류를 처리하는 방법을 재정의하기 위해 BasicErrorController 빈을 사용하면 됩니다.
예를 들어, 애플리케이션이 XML 엔드 포인트에서 발생한 오류를 처리하기 위해 사용자 정의하고 싶다고 가정한다면, @RequestMapping을 사용하여 메서드를 정의한 뒤 appliccation/xml 미디어 유형을 생성한다고 명시해주는 방법이 있습니다.
@Component
public class MyErrorController extends BasicErrorController {
public MyErrorController(
ErrorAttributes errorAttributes, ServerProperties serverProperties) {
super(errorAttributes, serverProperties.getError());
}
@RequestMapping(produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<Map<String, Object>> xmlError(HttpServletRequest request) {
// ...
}
}
이전에 Exception Handling 관련 메커니즘부터 시작하여 최근 Spring 5.X 까지 계속해서 Spring REST API에 대한 예외 처리 메커니즘을 구현하는 방식이 발전하고 있습니다.
이번 포스팅을 계기로 Exception Handling에 대한 이해와 어떠한 방식으로 발전해 왔는지에 대해 알 수 있었습니다.