ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Rest API 예외처리 (feat. 스프링의 기본적인 예외 처리 방법과 Best Practice)
    프로젝트/게시판 프로젝트 2022. 5. 10. 18:46

    긴글을 읽기 싫은 분들을 위한 요약

    1. Spring은 기본적으로 예외 처리를 지원합니다.(whitelabel 에러 페이지)

    2. 하지만 기본예외처리는 404에러가 나도 500에러를 보여줍니다.

    3. try-catch를 사용하면 가독성이 떨어지기 때문에 이를 해결하기 위해 Spring은 다양한 에러처리 방법을 지원합니다.

    4. @ResponseStatus의 단점 에러 응답 내용을 수정할 수 없음

    5. @ExceptionHandler  컨트롤러 내에서 사용되면 중복된 코드가 발생됨

    6. @ControllerAdvice와 @RestControllerAdvice를 활용하면 전역적으로 예외를 처리할 수 있음(Best Practice)

    7. 실제 프로젝트 적용예시(try-catch로 하던 예외처리를 @RestControllerAdvice 방식으로 변환)

     

     

     

     

     

    Application을 개발할 때 예외를 처리하는 것은 매우 매우 중요한 과정입니다.

    Spring은 어떤 예외 처리를 제공하고 어떤 방법이 가장 좋은지 살펴보겠습니다.

     

    1. Spring의 기본적인 예외 처리 방법

    예를 들어 다음과 같은 컨트롤러가 있다고 가정해보겠습니다.

    @RestController
    @RequiredArgsConstructor
    public class ProductController { 
    	private final ProductService productService; 
    	@GetMapping("/product/{id}") 
    	public Response getProduct(@PathVariable String id){
    		// this method throws a "NoSuchElementFoundException" exception 
    		return productService.getProduct(id); 
    	} 
    }
    
    출처: https://mangkyu.tistory.com/204 [MangKyu's Diary]

    ProductController는 상품의 id를 입력받아 조회를하고 Response 객체 타입으로 해당 상품을 반환하는 RestController로 분석됩니다.

     

    이때 getProduct에서 상품의 id가 존재하지 않을때 해당 id를 가진 상품을 조회하게 되면 NoSuchElementFoundException이 발생하게 됩니다.

     

    만약 우리가 웹페이지로 접속했다면 다음과 같은 whitelabel 에러 페이지를 반환받습니다.

    만약 웹페이지가 아닌곳에서 접속을 했다면 다음과 같은 json 응답을 받게 됩니다.

    { 
    	"timestamp": "2021-12-31T03:35:44.675+00:00", 
    	"status": 500,
    	"error": "Internal Server Error",
    	"path": "/product/5000" 
    }
    
    출처: https://mangkyu.tistory.com/204 [MangKyu's Diary]

     

     

     

    Spring은 만들어질 때부터 에러 처리를 위한 BasicErrorController를 구현해두었습니다.

    만약 웹브라우저에서 예외가 발생했다면 errorHtml()을 거쳐 ViewResolver를 통해 에러 페이지가 반환됩니다.

    만약 웹브라우저가 아닌곳에서 예외가 발생했다면 error()를 거쳐 에러 메시지를 받게 됩니다. (@RestController 일 때)

    에러 경로는 기본적으로 /error로 정의되어 있으며 properties에서 server.error.path로 변경할 수 있습니다.

     

    다음은 Spring Boot의 기본 오류 처리 properties입니다.

    # spring boot의 기본 properties
    server.error:
      include-exception: false # 오류 응답에 exception 내용을 포함할 지 여부
      include-stacktrace: never # 오류 응답에 stacktrace 내용을 포함할 지 여부
      path: '/error' # 오류 응답을 처리할 Handler의 경로
      whitelabel.enabled: true # 서버 오류 발생시 브라우저에 보여줄 기본 페이지 생성 여부

    whitelabe.enabled의 기본값이 true이기 때문에 위와 같은 오류 페이지를 얻을 수 있습니다.

     

    만약 include-exception와 include_stacktrace를 활성화하면 다음과 같은 응답을 받을 수 있습니다.

     

    HTML 응답

    json 응답

    {
      "timestamp": "2019-04-04T09:31:27.931+0000",
      "status": 500,
      "error": "Internal Server Error",
      "exception": "java.lang.IllegalStateException",
      "message": "test",
      "trace": "java.lang.IllegalStateException: test ...(길어서 줄임)",
      "path": "/rest-test"
    }

    물론 운영 환경에서 구현이 노출되는 trace는 제공하지 않는 것이 좋습니다.

    또한 위의 예시는 ProductController에서 발생한 에러 예시는 아닙니다!

    어떻게 Spring은 이런 기본 처리를 하고 있을까요?

    위에 잠깐 언급한 것처럼 Spring은 만들어질 때부터 에러 처리를 위한 BasicErrorController를 구현해두었습니다.

     

    BasicErrorController는 대략적으로 다음과 같이 구현되어 있습니다.

    @Controller
    @RequestMapping("${server.error.path:${error.path:/error}}") // 1)
    public class BasicErrorController extends AbstractErrorController {
    
      @Override
      public String getErrorPath() {
        return this.errorProperties.getPath();
      }
    
      @RequestMapping(produces = MediaType.TEXT_HTML_VALUE) // 2)
      public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
                
        HttpStatus status = getStatus(request);
        Map<String, Object> model =
          getErrorAttributes(request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));
    		
        response.setStatus(status.value());
        ModelAndView modelAndView = resolveErrorView(request, response, status, model);
        return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
      }
    
      @RequestMapping // 3)
      public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        
        // 4)
        Map<String, Object> body =
          getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<>(body, status);
      }    
    }

    1. Spring 환경 내에 server.error.path 혹은 error.pth로 등록된 property의 값을 넣거나 없는 경우에는 /error을 사용한다.

    2. HTML로 응답을 주는 경우 errorHTML에서 응답을 처리한다.

    3. HTML 외의 응답이 필요한 경우 error에서 응답을 처리한다.

    4. 실질적으로 view에 보낼 model을 생성한다.

     

    즉, HTML요청과 그 외 요청을 나누어 처리할 핸들러를 등록하고 getErrorAttributes를 통하여 응답을 위한 모델을 생성합니다.

     

    getErrorAttributes를 조금 더 알아보겠습니다.

    getErrorAttributes는 BasicErrorController의 부모 클래스인 AbstractErrorController에 구현되어 있습니다.

    상속 관계

    AbstractErrorController코드

    public abstract class AbstractErrorController implements ErrorController {
      private final ErrorAttributes errorAttributes;
        
      protected Map<String, Object> getErrorAttributes(HttpServletRequest request,
        boolean includeStackTrace) {
    
        WebRequest webRequest = new ServletWebRequest(request);
        return this.errorAttributes.getErrorAttributes(webRequest, includeStackTrace);
      }
    }

    getErrorAttriutes가 반환하는 값을 보면 ErrorAttributes인터페이스의 getErrorAttributes를 호출하여 반환합니다.

    별도의 ErrorAttributes를 등록하지 않았다면 Spring Boot는 DefaultErrorAttributes를 사용합니다.

     

    DefaultErrorAttributes는 다음과 같은 전체 항목들에서 프로퍼티 설정에 따라 불필요한 속성들을 제거합니다.

     

    Spring이 예외를 처리하는 기본적인 방법에 대해 어느 정도 알아보았습니다.

     

    어느정도 설정을 통해 에러 응답을 다음과 같이 조정할 수 있습니다.

    { 
    	"timestamp": "2021-12-31T03:35:44.675+00:00",
    	"status": 500, 
    	"error": "Internal Server Error",
    	"trace": "java.util.NoSuchElementException: No value present ...",
    	"message": "No value present",
    	"path": "/product/5000" 	
    }
    
    출처: https://mangkyu.tistory.com/204 [MangKyu's Diary]

     

    하지만 우리는 조금 더 유용한 정보를 얻고 싶습니다.

    클라이언트가 "Item with id 5000 not found"라는 메시지와 함께 해당 리소스가 없다는 404 상태 코드를 응답받는다면 훨씬 유용합니다.

     

    2. Spring이 제공하는 다양한 예외처리 방법

    Java에서는 예외 처리를 하기 위해서 try-catch를 사용합니다.

    하지만 try-catch를 모든 코드에 붙이는 것은 가독성이 떨어집니다.

    Spring은 이러한 문제를 해결하기 위해서 에러 처리라는 공통 관심사를 메인 로직으로부터 분리하는 다양한 예외 처리 방식을 고안하였습니다.

     

    이를 위해 예외 처리 전략을 추상화한 HandlerExceptionReslover 인터페이스를 만들었습니다.

    public interface HandlerExceptionResolver {
    	@Nullable
    	ModelAndView resolveException(
    		HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
    
    }

    위의 Object 타입의 handler는 예외가 발생한 컨트롤러의 객채로써 컨트롤러에서 예외가 던져지면 dispatcherServlet까지 전달됩니다.

    DispatcherServlet은 상황에 맞는 적합한 예외 처리 전략을 위해 HandlerExceptionResolver 구현체들을 빈으로 등록해서 리스트로 관리합니다.

    그리고 적용 가능한 예외 처리기(구현체)를 찾아 예외 처리를 하는데 기본적으로 아래의 4가지 구현체들이 빈으로 등록되어 있습니다.

     

    예외 처리 구현체 4가지

    • DefaultErrorAttributes : 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
    • DefaultHandlerExceptionResolver : 스프링의 예외들을 처리한다.
    • ResponseStatusExceptionResolver : @ResponseStatus 또는 ResponseStatusException에 의한 예외를 처리합니다.
    • ExceptionHandlerExceptionReslover : Controller 나 ControllerAdvice에 있는 ExceptionHandler에 의한 예외를 처리합니다.

    DefaultErrorAttributes는 직접 예외를 처리하지 않고 속성만 관리하므로 성격이 다릅니다.

    그래서 내부적으로 DefaultErrorAttributes를 제외하고 직접 예외를 처리하는 3가지 ExceptionResolver들을 HandlerExceptionResolverComposite로 모아서 관리합니다.

     

    각각 예외 처리 방식에 대해 알아보겠습니다.

    1. ResponseStatus

    2. ExceptionHandler

    3. ControllerAdivce, RestControllerAdvice

    4. ResponseStatusException

     


    @ResponseStatus

    에러 HTTP 상태를 변경하도록 도와주는 어노테이션입니다.

    @ResponseStatus는 다음과 같은 경우들에 적용할 수 있습니다.

    • Exception 클래스 자체
    • 메서드에 @ExceptionHandler와 함께
    • 클래스에 @RestControllerAdvice와 함께

     

    사용자 예외 클래스 사용 예시

    @ResponseStatus(value = HttpStatus.NOT_FOUND)
    public class NoSuchElementFoundException extends RuntimeException{
    	...
    }

     

    해당 예외가 404 상태 코드를 응답한다는 것을 직관적으로 파악할 수 있습니다.

    하지만 @ResponseStatus는 다음과 같은 한계점들을 가지고 있습니다.

    • 에러 응답 내용(Payload)을 수정할 수 없음
    • 예외 상황마다 예외 클래스를 추가해야 함
    • 예외 클래스와 강하게 결합되어 모든 예외에 대해 동일한 상태와 에러 메시지를 반환하게 됨

    @ExceptionHandler

    @ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공합니다.

    @ResponseStatus에서는 에러 응답 내용을 수정할 수 없었지만 ExceptionHandler에서는 가능합니다.

     

    @ExceptionHandler는 다음과 같은 경우들에 적용할 수 있습니다.

    • 컨트롤러의 메서드
    • @ControllerAdvice 또는 @RestControllerAdvice가 있는 클래스의 메서드

     

    @ExceptionHandler 사용 예시

    @RestController 
    @RequiredArgsConstructor
    public class ProductController { 
    	private final ProductService productService; 
    	@GetMapping("/product/{id}") 
    	public Response getProduct(@PathVariable String id){ 
    		return productService.getProduct(id); 
    	} 
        
    	@ExceptionHandler(NoSuchElementFoundException.class) 
    	public ResponseEntity<String> handleNoSuchElementFoundException(NoSuchElementFoundException exception) { 
    		return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage()); 
    	} 
    }
    
    출처: https://mangkyu.tistory.com/204 [MangKyu's Diary]

    @ExceptionHandler는 Excpetion 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있습니다.

    @ResponseStatus와도 결합할 수 있는데 만약 ResponseEntity에서도 상태 코드를 지정하고 @ResponseStatus도 존재한다면 ResponseEntity가 우선순위를 가집니다.

     

    위의 코드에서는 NoSuchElementFoundException이 발생하게 되면  NOT_FOUND(404) 상태 코드와 에러 메시지를 바디로 반환하게 됩니다.

     

     

    또한 만약에 @ExceptionHandler(Exception.class)로 설정하게 된다면 어떻게 될까요?

    Exception.class는 모든 에러의 최상위 객체이기 때문에 NoSuchElementFoundException을 제외한 에러들은 모두 @ExcpetionHandler가 Exception.class로 지정되었다면 해당 메서드는 모든 에러를 처리합니다.

     


    @ControllerAdvice와 @RestControllerAdvice

    @ExceptionHandler의 한계를 극복하고자 전역적으로 예외를 처리할 수 있도록 @ControllerAdvice와 @RestControllerAdvice가 등장했습니다.

     

    @ExceptionHandler는 컨트롤러 내에서 사용되기 때문에 A컨트롤러와 B컨트롤러에서 모두 Exception.class에 대한 에러를 처리한다면 이는 중복되는 코드입니다.

     

    따라서 @ControllerAdvice와 @RestControllerAdvice를 활용하면 예외를 전역적으로 처리할 수 있습니다.

     

    만약 특정 클래스에만 제한적으로 적용하고 싶다면 basePackages 등을 설정함으로써 제한할 수 있습니다.

     

    @RestControllerAdvice 사용 예시

    @RestControllerAdvice(annotations = RestController.class)
    public class ApiExceptionAdvice {
    
        @ExceptionHandler(DataIntegrityViolationException.class)
        public ResponseEntity<String> dataBaseExceptionHandler(DataIntegrityViolationException e){
            return ResponseEntity.status(HttpStatus.CONFLICT).body("데이터 중복 에러");
        }
    
    
        @ExceptionHandler(Exception.class)
        public ResponseEntity<String> internalServerExceptionHandler(Exception e){
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 내부 에러");
        }
    
    
    }

    annotaions 속성을 활용하여 RestController에만 적용한다는 내용입니다.

    또한 해당 ApiExceptionAdvice는 DataIntegrityViolationException과 Exception을 처리합니다.

    각 예외의 특성에 따라 상태 코드를 부여하고 에러 메시지를 지정할 수 있습니다.

     

    ControllerAdvice를 사용함으로써 다음과 같은 이점을 누릴 수 있기 때문에 해당 방식이 일반적으로 가장 좋다고 평가받습니다.

    • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능
    • 직접 정의한 에러 응답을 일관성 있게 클라이언트에게 내려줄 수 있음
    • 별도의 try-catch문 없이 코드의 가독성이 높아짐

    ResponseStatusException

    @ResponseStatus의 응답 메시지를 지정하지 못하는 단점을 극복하기 위해 등장하였습니다.

    status와 message를 설정할 수 있습니다.

     

    ResponseStatusException 사용 예시

    @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"); 
    		} 
    }
    
    출처: https://mangkyu.tistory.com/204 [MangKyu's Diary]

     

    하지만 @ControllerAdice와 달리 일관되게 예외 처리하는 것이 어렵고, 예외 처리 코드가 중복될 수 있습니다.

     

     

    결론

    Spring의 다양한 예외 처리 방법에 대해 알아보았으며, ControllerAdvice를 이용하는 것이 Best Practice임을 알아보았습니다.

     

    프로젝트 실제 적용 사례

    try-catch로 만든 코드입니다.

    @PostMapping
        public ResponseEntity<?> registerMember(@RequestBody MemberRequestDTO memberRequestDTO){
            try{
                int result = memberService.join(memberRequestDTO.toEntity());
                return new ResponseEntity<Integer>(result, HttpStatus.CREATED);
                if(memberService.validateIsDuplicate(memberRequestDTO)){
                    return new ResponseEntity<String>("Duplicated", HttpStatus.CONFLICT);
                }
                memberService.join(memberRequestDTO.toEntity());
                return new ResponseEntity<String>("회원가입 완료", HttpStatus.CREATED);
            }catch (Exception e){
                return exceptionHandling(e);
            }
            
    	private ResponseEntity<String> exceptionHandling(Exception e) {
    		e.printStackTrace();
    		return new ResponseEntity<String>("Sorry: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
        }

    웹 애플리케이션이 멀티 스레드 환경에서 어떤 아이디에 대해 중복검사를 하였을 때 동시에 일어나서 해당 아이디가 없다고 완료하고 회원가입을 진행할 경우에 DB에 Unique 제약조건이 걸려있기 때문에 Exception이 발생할 수 있다고 생각했습니다.

     

    따라서 try-catch로 이를 해결하려고 하였습니다.

     

     

     

    변경 이후

    @RestControllerAdvice(annotations = RestController.class)
    public class ApiExceptionAdvice {
    
        @ExceptionHandler(Exception.class)
        public ResponseEntity<String> exceptionHandler(Exception e){
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
        }
    }
     @PostMapping
        public ResponseEntity<?> registerMember(@RequestBody MemberRequestDTO memberRequestDTO){
                if(memberService.validateIsDuplicate(memberRequestDTO)){
                    return new ResponseEntity<String>("Duplicated", HttpStatus.CONFLICT);
                }
                memberService.join(memberRequestDTO.toEntity());
                return new ResponseEntity<String>("회원가입 완료", HttpStatus.CREATED);
        }

    ApiExceptionAdivce 클래스를 만들어 예외를 따로 관리해주었습니다.

    따라서 기존의 컨트롤러에는 try-catch 구문이 사라졌습니다.

     

     

     

     

    출처

    https://mangkyu.tistory.com/204

     

    [Spring] Spring의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)

    예외 처리는 robust한 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 어떠한 방법들이 있고 가장 좋은 방법(Best Practice)은

    mangkyu.tistory.com

     

    https://supawer0728.github.io/2019/04/04/spring-error-handling/

     

    (Spring Boot)오류 처리에 대해

    서론오류 처리는 어플리케이션 개발에 있어 매우 큰 부분을 차지한다.오류를 예측하는 것과 예방하는 것, 그리고 오류를 빨리 발견하고 고칠 수 있는 것은 훌륭한 개발자의 필수조건이라고 생

    supawer0728.github.io

     

    댓글

Designed by Tistory.