ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring boot] Error Handling
    프로젝트/아카이뷰 2024. 1. 10. 21:29

    Error Handling 왜 궁금했을까 ❓

    - SSAFY 1학기 때, 프로젝트 마감 기한을 맞추기 위해 기능 완성에만 초점을 맞춰 개발을 진행하다보니 Client에게 보내는 응답에 대해 신경쓰지 못하였고 일관성이 없는 Error 응답을 전송하였다.
    - 이로 인해, Client의 입장에서는 어느 부분에서 Error가 발생했는 지 정확한 응답을 받지 못하고 일관되지 못한 Error 응답으로 인해 혼란을 야기할 수 있을 것이라 생각되었다.
    - 이러한 부분을 해결하기 위해, Error Handling에 대해 학습하고 일관된 Error 응답을 전송하여 앞선 문제를 해결해보고자 한다.

     

    Error Handling

    BasicErrorController

    • Spring은 에러 처리를 기본적으로 BasicErrorController를 통해서 하도록 설정이 되어있고 Spring boot는 예외 처리를 /error로 에러를 다시 전송하도록 WAS 설정이 되어있다.

    • 공식 문서를 확인해보면 properties에 server.error.path가 있으면 그 값을 사용하고 없으면 error.path를 참조한다. 이 값도 없다면 최종적으로 /error 값을 가지게 된다.
    • Spring boot의 경우 WebMvcAutoConfiguration를 통해 WAS 설정이 자동으로 이루어지게 된다.
    WAS(Tomcat) => Filter => Servlet => Interceptor => Controller => Controller(Occured Exception) => Interceptor => Servlet => Interceptor => Servlet => Filter => WAS(Tomcat) => Filter => Servlet => Interceptor => Controller(BasicErrorController)
    • 위 과정은 Client의 API 요청을 처리하는 과정에서 Controller에서 오류가 발생한 경우이다.
    •  기본적으로 설정된 Spring boot에서 에러를 처리 과정이고 이러한 경우 에러 처리를 위해 Controller를 2번 거치게 된다는 문제점이 존재한다. 
    {
        "timestamp": "2024-01-10T17:22:44.675+00:00",
        "status": 500,
        "error": "Internal Server Error",
        "path": "/user/chan978444"
    }
    • BasicErrorController로 인해 에러가 처리되면 위와 같은 응답을 받는데 일관되지 않은 응답은 Client에게 유용하지 않을 것이며 에러 발생 사유를 알지 못할 것이다.
    • 또한, 지정한 에러를 발생시키기 위해서는 try ~ catch를 통해서 모든 에러를 처리해줘야 하는데 이는 가독성을 떨어뜨리고 비효율적이다.
    • 이러한 문제점을 해결하기 위해 Spring에서는 에러 처리 과정을 따로 분리하였고 이 과정에서 다양한 Error Handling 방법이 등장하였다.

    Spring Error Handling 방법

    ExceptionResolver

    • DefaultErrorAttributes - 에러 속성을 저장하며 직접 예외를 처리하지 않는다.
    • ExceptionHandlerExceptionResolver - 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
    • ResponseStatusExceptionResolver - Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
    • DefaultHandlerExceptionResolver - 스프링 내부의 기본 예외들을 처리한다.
    예외가 발생하면 적합한 예외 처리기를 찾아 예외를 처리하게 되는데 위의 ExceptionResolver중 하나를 이용하게 된다.

    Error Equipment

    • ResponseStatus
    • ResponseStatusException
    • ExceptionHandler
    • ControllerAdvice, RestControllerAdvice
    위의 4가지 도구를 통해 ExceptionResolver를 동작시켜 에러를 처리하게 된다. 리팩토링의 도구로 RestControllerAdvice를 선택했는데 각 도구들의 특징을 살펴보며 이유를 설명해보도록 하겠다.

     

    @ResponseStatus

    • 발생한 에러의 HTTP 상태만을 변경하는 Annotation이며 아래 3가지 경우에 적용할 수 있다.
      • Exception 클래스 자체
      • Method에 @ExceptionHandler와 함께
      • Class에 @RestControllerAdvice와 함께
    {
        "timestamp": "2024-01-10T17:22:44.675+00:00",
        "status": 404,
        "error": "Not Found",
        "path": "/user/chan978444"
    }
    • 응답 결과를 보면 BasicErrorController에 의한 응답인 것을 알 수 있으며 앞서 설명한 문제점들을 해결할 수 없다.

    ResponseStatusException 

    • 발생한 에러의 HTTP 상태를 변경할 수 있으며 에러 메세지도 직접 설정이 가능하다.
    • 이러한 이유로 프로그래밍 방식으로 직접 예외를 발생시킬 수 있어 예외 제어를 보다 유연하게 할 수 있다.
    {
        "timestamp": "2024-01-10T17:22:44.675+00:00",
        "status": 404,
        "error": "Custom Not Found",
        "path": "/user/chan978444"
    }

     

    @ExceptionHandler

    • Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다.
    • 에러 응답을 자유롭게 다룰 수 있어 code, message, errors 등에 대한 정보를 Custom하여 Client에게 유연하고 일관되게 응답이 가능하다.
    {
        "code": 404,
        "message": "Custom Not Found"
    }
    • 하지만, 컨트롤러에서만 구현이 가능하여 특정 컨트롤러의 예외만을 처리하게 되며 이로 인해 중복 코드가 발생할 가능성이 높다. 이러한 이유 때문에 에러를 공통으로 처리할 수 있는 @ControllerAdvice와 @RestControllerAdvice가 존재한다.

    @ControllerAdvice, @RestControllerAdvice

    • 전역으로 @ExceptionHandler를 적용할 수 있는 Annotation들로 2개의 차이는 controller와 RestController의 차이로 응답을 ResponseBody인 JSON으로 내려준다는 점이다.
    • 해당 Annotation들은 클래스에 적용할 수 있으며 에러 처리를 해당 클래스에게 위임하게 된다.
    • Spring은 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 제공하는데 해당 추상 클래스안에는 ExceptionHandler가 모두 구현이 되어 있어 Annotation이 적용된 클래스에 상속받게 하면 모든 Error를 Handling 할 수 있게 된다.

    프로젝트 리팩토링

    @RestControllerAdvice
    public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    	
    	/**
    	 * RestApiException 예외 처리
    	 */
    	@ExceptionHandler(RestApiException.class)
        public ResponseEntity<Object> handleRestApiException(final RestApiException e) {
            final ResponseCode responseCode = e.getResponseCode();
            return handleExceptionInternal(responseCode);
        }
     	
        @ExceptionHandler(Exception.class)
        public ResponseEntity<Object> handleAllException(final Exception e) {
            final ResponseCode responseCode = CommonResponseCode.INTERNAL_SERVER_ERROR;
            return handleExceptionInternal(responseCode);
        }
        
        private ResponseEntity<Object> handleExceptionInternal(final ResponseCode responseCode) {
            return ResponseEntity.status(responseCode.getHttpStatus())
                    .body(makeErrorResponse(responseCode));
        }
        
        private ErrorResponse makeErrorResponse(final ResponseCode responseCode) {
            return ErrorResponse.builder()
                    .code(responseCode.name())
                    .message(responseCode.getMessage())
                    .build();
        }
        
        ...
    }
    • ResponseEntityExceptionHandler를 상속받아 GlobalExceptionHandler가 모든 Error를 위임받아 처리하고 있다.
    @Getter
    @RequiredArgsConstructor
    public class RestApiException extends RuntimeException {
    	private final ResponseCode responseCode;
    }
    • Unchecked Error를 Custom하고 정확한 에러 응답을 보내기 위해 Custom Exception Class를 만들어 위임하였다.
    @Getter
    @RequiredArgsConstructor
    public enum CustomResponseCode implements ResponseCode {
    
        USER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저의 정보가 존재하지 않습니다."),
        INVALID_USER_INFO(HttpStatus.BAD_REQUEST, "유저의 정보가 올바르지 않습니다."),
        PASSWORD_NOT_CREATED(HttpStatus.INTERNAL_SERVER_ERROR, "패스워드를 생성하지 못하였습니다."),
        INVALID_USER_ID(HttpStatus.BAD_REQUEST, "사용할 수 없는 ID입니다."),
        ;
    
        private final HttpStatus httpStatus;
        private final String message;
    
    }
    • 각 상황에 맞는 에러 코드와 응답 메세지를 보내기 위해 ENUM 클래스를 활용하여 유지 보수가 쉽게 리팩토링하였다.
    @Getter
    @Builder
    @RequiredArgsConstructor
    public class ErrorResponse {
    	private final String code;
    	private final String message;
    
    	@JsonInclude(JsonInclude.Include.NON_EMPTY)
    	private final List<ValidationError> errors;
    	
    	@Getter
    	@Builder
    	@RequiredArgsConstructor
    	public static class ValidationError {
    		private final String field;
    		private final String message;
    		
    		public static ValidationError of(final FieldError fieldError) {
    			return ValidationError.builder()
    					.field(fieldError.getField())
    					.message(fieldError.getDefaultMessage())
    					.build();
    		}
    		
    	}
    }
    • 일관된 응답을 위해 위와 같은 구조를 만들었고 Client는 각 상황에 맞는 일관된 응답과 정확한 내용을 파악할 수 있게 된다.
    @Override
    public void deleteUser(String userId) {
        int cnt = userMapper.deleteUser(userId);
    
        if(cnt == 0) {
            throw new RestApiException(CustomResponseCode.USER_NOT_FOUND);
        }
    }
    • service 코드에서 다음과 같이 활용하여 user가 삭제되지 않을 시, 유저의 정보가 없다는 뜻이므로 아래와 같은 error 응답을 보내게 된다.

    '프로젝트 > 아카이뷰' 카테고리의 다른 글

    [Spring boot] 유저 인증 처리  (0) 2024.01.15
    [Spring boot] JPA(Java Persistence API)  (0) 2024.01.13
    [Spring boot] Common Response  (0) 2024.01.09
    [Spring boot] WebRTC  (0) 2024.01.08
    [Spring boot] @Builder 어노테이션  (0) 2024.01.05
Designed by Tistory.