Java/Spring & Spring Boot

Exception, 예외 처리

마손리 2023. 4. 18. 22:39

체크 예외(Checked Exception)와 언체크 예외(Unchecked Exception)

체크 예외(Checked Exception)

체크 예외란 위의 그림에서와 같이 RuntimeException 클래스를 상속받지 않는 예외 클래스들이다. 예를 들면 ClassNotFoundException등이 있으며 catch 혹은 throws 키워드를 사용하여 반드시 해당 예외를 처리해주어야한다.

 

언체크 예외(Unchecked Exception)

언체크 예외는 RuntimeException 클래스를 상속받는 예외들이다. 체크 예외와 달리 해당 예외를 반드시 처리해 주지 않아도 되며 NullPointerException, ArrayIndexOutOfBoundsException등이 있다.

 

또한 RuntimeException  클래스를 상속하여 개발자가 직접 새로운 예외를 만들어 줄 수도 있다.

 

 

Spring MVC에서의 예외처리

 

@ExceptionHandler를 이용하여 controller에서 예외 처리

@RestController
@RequestMapping("/v1/members")
public class MemberControllerV6 {
	...
    
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
    				// (1) 
        Member member = mapper.memberPostDtoToMember(memberDto);
        Member response = memberService.createMember(member);
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.CREATED);
    }

    @ExceptionHandler
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        					// (2)
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();

        // (3)
        return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
    }
}

(1)번 @Valid 에너테이션을 통해 RequestBody에 유효성검증을 해주며 만약 유효성검증에 실패할시 (2)번의 MethodArgumentNotValidException 예외가 발생하며 @ExceptionHandler에서 해당 예외를 매개변수로 받는 메서드 즉 handlerException 메서드를 호출한다. 이후 (3)번과 같이 해당 예외의 내용을 클라이언트로 반환해주면 다음과 같다.

 

[
    {
        "codes": [
            "Email.memberPostDto.email",
            "Email.email",
            "Email.java.lang.String",
            "Email"
        ],
        "arguments": [
            {
                "codes": [
                    "memberPostDto.email",
                    "email"
                ],
                "arguments": null,
                "defaultMessage": "email",
                "code": "email"
            },
            [],
            {
                "arguments": null,
                "defaultMessage": ".*",
                "codes": [
                    ".*"
                ]
            }
        ],
        "defaultMessage": "올바른 형식의 이메일 주소여야 합니다",
        "objectName": "memberPostDto",
        "field": "email",
        "rejectedValue": "hgd@",
        "bindingFailure": false,
        "code": "Email"
    }
]

해당 반환값은 email에 관한 유효성검증을 통과하지 못했을때 받은 예외의 에러 메세지이다.

(이러한 메세지를 예외들에 맞게 통합하여 필요한 정보만을 맵핑할 수 있는데 밑에서 다룹니다.)

 

이처럼 controller에서 예외를 다루게되면 모든 controller에 코드를 작성해 주어야 하기 때문에 코드의 중복이 발생한다. 또한 처리해야될 예외는 위와같이 하나가 아니기때문에 모든 예외를 모든 controller에 작성한다는 것은 매우 비효율적인 방법이다. 이를 해결하기 위해 @RestControllerAdvice를 이용해준다.

 

 

 

@RestControllerAdvice를 이용하여 예외 클래스 생성

특정 클래스에 @RestControllerAdvice 애너테이션을 추가하면 여러 개의 Controller 클래스에서 @ExceptionHandler, @InitBinder 또는 @ModelAttribute가 추가된 메서드를 공유해서 사용할 수 있다. 

(@InitBinder와 @ModelAttribute는 주로 SSR방식에서 사용된다.)

 

@RestControllerAdvice vs @ControllerAdvice
Spring MVC 4.3 버전 이후부터 @RestControllerAdvice 애너테이션을 지원하는데, 둘 사이의 차이점을 한마디로 설명하자면 아래와 같다.
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody
@RestControllerAdvice 애너테이션은 @ControllerAdvice의 기능을 포함하고 있으며, @ResponseBody의 기능 역시 포함하고 있기 때문에 JSON 형식의 데이터를 Response Body로 전송하기 위해서 ResponseEntity로 데이터를 래핑 할 필요가 없다.

 

@RestControllerAdvice // (1)
public class GlobalExceptionAdvice {
    @ExceptionHandler // (2)
    @ResponseStatus(HttpStatus.BAD_REQUEST) // (3)
    public ErrorResponse handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) { // (4)
        final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
		// (5)
        return response;
    }

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleConstraintViolationException(
            ConstraintViolationException e) {
        final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());

        return response;
    }
}

controller에서 예외가 발생하면 (1)번과 같이 @RestControllerAdvice가 추가된 클래스에서 (2)번 @ExceptionHandler의 메서드들에게서 (4)번에 매개변수로 지정된 자신의 예외를 찾은뒤 해당 메서드의 코드를 실행한다.

 

위의 예제 코드는 MethodArgumentNotValidException (RequestBody의 유효성검사 실패)와 ConstraintViolationException (PathVariable의 유효성검사 실패)의 예외를 다룬다.

 

(3)번의 @ResponseStatus를 사용하면 ResponseEntity를 사용하지 않아도 원하는 HTTP상태코드를 응답할 수 있다.

 

(5)번의 ErrorResponse라는 클래스를 만들어주어 각각의 예외들에 대한 에러 메세지들을 통합해 주고 필요한 정보만을 가공하여 클라이언트에 보내주었다.

 

@Getter
public class ErrorResponse {
    private List<FieldError> fieldErrors; // (1)
    private List<ConstraintViolationError> violationErrors;  // (2)

		// (3)
    private ErrorResponse(List<FieldError> fieldErrors, List<ConstraintViolationError> violationErrors) {
        this.fieldErrors = fieldErrors;
        this.violationErrors = violationErrors;
    }

		// (4) BindingResult에 대한 ErrorResponse 객체 생성
    public static ErrorResponse of(BindingResult bindingResult) {
        return new ErrorResponse(FieldError.of(bindingResult), null);
    }

		// (5) Set<ConstraintViolation<?>> 객체에 대한 ErrorResponse 객체 생성
    public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
        return new ErrorResponse(null, ConstraintViolationError.of(violations));
    }

		// (6) Field Error 가공
    @Getter
    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String reason;

	private FieldError(String field, Object rejectedValue, String reason) {
            this.field = field;
            this.rejectedValue = rejectedValue;
            this.reason = reason;
        }

        public static List<FieldError> of(BindingResult bindingResult) {
            final List<org.springframework.validation.FieldError> fieldErrors =
                                                        bindingResult.getFieldErrors();
            return fieldErrors.stream()
                    .map(error -> new FieldError(
                            error.getField(),
                            error.getRejectedValue() == null ?
                                            "" : error.getRejectedValue().toString(),
                            error.getDefaultMessage()))
                    .collect(Collectors.toList());
        }
    }

		// (7) ConstraintViolation Error 가공
    @Getter
    public static class ConstraintViolationError {
        private String propertyPath;
        private Object rejectedValue;
        private String reason;

	private ConstraintViolationError(String propertyPath, Object rejectedValue,
                                   String reason) {
            this.propertyPath = propertyPath;
            this.rejectedValue = rejectedValue;
            this.reason = reason;
        }

        public static List<ConstraintViolationError> of(
                Set<ConstraintViolation<?>> constraintViolations) {
            return constraintViolations.stream()
                    .map(constraintViolation -> new ConstraintViolationError(
                            constraintViolation.getPropertyPath().toString(),
                            constraintViolation.getInvalidValue().toString(),
                            constraintViolation.getMessage()
                    )).collect(Collectors.toList());
        }
    }
}

 

  • (1)은 MethodArgumentNotValidException으로부터 발생하는 에러 정보를 담는 멤버 변수이다. 즉, DTO 멤버 변수 필드의 유효성 검증 실패로 발생한 에러 정보를 담는 멤버 변수.
  • (2)는 ConstraintViolationException으로부터 발생하는 에러 정보를 담는 멤버 변수이다. 즉, URI 변수 값의 유효성 검증에 실패로 발생한 에러 정보를 담는 멤버 변수.
  • (3)은 ErrorResponse 클래스의 생성자인데 특이하게도 생성자 앞에 private 접근 제한자(Access Modifier)를 지정했다.
    이렇게 함으로써 ErrorResponse 클래스는 new 생성자를 통해 ErrorResponse 객체를 생성할 수 없다.
    대신에 (4)와 (5)처럼 of() 메서드를 이용해서 ErrorResponse의 객체를 생성할 수 있다.
    이렇게 코드를 구성한 이유는 ErrorResponse의 객체를 생성함과 동시에 ErrorResponse의 역할을 명확하게 해준다.
  • (4)는 MethodArgumentNotValidException에 대한 ErrorResponse 객체를 생성해 준다. MethodArgumentNotValidException에서 에러 정보를 얻기 위해 필요한 것이 바로 BindingResult 객체이므로 이 of() 메서드를 호출하는 쪽에서 BindingResult 객체를 파라미터로 넘겨주면 된다.
    그런데 이 BindingResult 객체를 가지고 에러 정보를 추출하고 가공하는 일은 ErrorResponse 클래스의 static 멤버 클래스인 FieldError 클래스에게 위임하고 있다.
  • (5)는 ConstraintViolationException에 대한 ErrorResponse 객체를 생성해 준다. ConstraintViolationException에서 에러 정보를 얻기 위해 필요한 것이 바로 Set<ConstraintViolation<?>> 객체이므로 이 of() 메서드를 호출하는 쪽에서 Set<ConstraintViolation<?>> 객체를 파라미터로 넘겨주면 된다.
    Set<ConstraintViolation<?>> 객체를 가지고 에러 정보를 추출하고 가공하는 일은 ErrorResponse 클래스의 static 멤버 클래스인 ConstraintViolationError 클래스에게 위임하고 있다.
    (4)와 (5)를 통해서 ErrorResponse 객체에 에러 정보를 담는 역할이 명확하게 분리된다.
  • (6)에서는 필드(DTO 클래스의 멤버 변수)의 유효성 검증에서 발생하는 에러 정보를 생성한다.
  • (7)에서는 URI 변수 값에 대한 에러 정보를 생성한다.

이처럼 ErrorResponse 클래스에 데이터를 가공하는 과정을 넘겨주어 GlobalExceptionAdvice의 코드를 더 간편하게 해주었다.

 

of() 메서드

of() 메서드는 Java 8의 API에서도 흔히 볼 수 있는 네이밍 컨벤션(Naming Convention)이다.
주로 객체 생성 시 어떤 값들의(of~) 객체를 생성한다는 의미에서 of() 메서드를 사용한다

 

 

Custom Exception

앞서 말한 것처럼 다른 Exception클래스를 상속받아 개발자가 직접 예외를 정의하여 사용할 수 있다.

 

커스텀 예외 클래스 생성

public class BusinessLogicException extends RuntimeException { // (1)
    @Getter
    private ExceptionCode exceptionCode;
		//(2)
    public BusinessLogicException(ExceptionCode exceptionCode) {
        super(exceptionCode.getMessage());
        this.exceptionCode = exceptionCode;
    }
}

(1) RuntimeException을 상속받아 언체크 예외에 속함

(2) ExceptionCode라는 클래스를 새로 생성하여 주입받음 (만약 커스텀예외가 몇가지가 안될경우 이런과정없이 해당 예외만을 위한 커스텀 예외 클래스만을 생성해주면되지만 여러 예외를 만들어야 될경우 위와 같이 다른 클래스를 주입받아 특정한 예외를 명시할 수 있음)

 

ExceptionCode enum생성

public enum ExceptionCode {
    MEMBER_NOT_FOUND(404, "Member Not Found"),
    MEMBER_NOT_ALLOWED(404,"Member Not Allowed");
    
    @Getter
    private int status;

    @Getter
    private String message;

    ExceptionCode(int code, String message) {
        this.status = code;
        this.message = message;
    }
}

위와 같이 MEMBER_NOT_FOUND와 MEMBER_NOT_ALLOWED 두가지 경우의 커스텀 예외를 명시하기 위해 ExceptionCode을 열거형으로 생성해 주었다. (필요에따라 더 추가가능)

 

@RestControllerAdvice 클래스에 해당 예외 등록

@RestControllerAdvice
public class GlobalExceptionAdvice {
    ...
    ...
    @ExceptionHandler
    public ResponseEntity handleBusinessLogicException(BusinessLogicException e) {
        final ErrorResponse response = ErrorResponse.of(e);

        return new ResponseEntity(response, HttpStatus.NOT_FOUND);
    }
}

해당 예외에 대해서도 형식에 마춰 응답을 보낼수 있도록 ErrorResponse 클래스를 수정해 준다.

 

ErrorResponse 클래스 수정

@Getter
public class ErrorResponse {
    // (1) @JsonInclude(JsonInclude.Include.NON_NULL) 
    private Integer status;
    private String message;
    ...
    ...

	// (2)
    private ErrorResponse(final Integer status, final String message){
        this.status=status;
        this.message=message;
    }
    ...
    ...
    
    // (3)
    public static ErrorResponse of(BusinessLogicException businessLogicException){
        return new ErrorResponse(businessLogicException.getExceptionCode().getStatus(),businessLogicException.getMessage());
    }

}

 

(1)의 에너테이션을 줄경우 Null값을 가진 요소들은 응답 메세지에서 생략되어 보내짐

(2)와 같이 새로 추가된 전역변수를 위한 생성자 추가

(3) 커스텀 예외로 새로 만든 BusinessLogicException을 위한 of()메서드

 

이후 비즈니스 로직에서 throw 키워드를 사용하여 커스텀 예외 강제 발생

@Service
public class MemberService {
    public Member findMember(long memberId) {
        throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
        // throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_ALLOWED);
    }
}

 

 

마무리

사실 자바를 배우면서 예외와 에러의 차이를 잘 알지 못했다. 

 

이번기회로 이것 저것 실험해보면서 개인적으로 내리게된 정의는 자바에서는 코딩을 하면서 보편적으로 발생하는 에러들을 예외라는 클래스에 담아 처리가능한 상태로 만들어 주는 것이라고 생각된다.

 

예를들어 NullPointerException의 경우 자바스크립트에서는 다른 에러들과 같이 런타임 에러만을 발생시킨다. 하지만 자바에서는 해당 예외가 발생할경우 다른 처리방법을 개발자에게 요구하며 특정한 상황에서의 해결방안을 제시하도록 알려준다고 생각한다. 

 

물론 자바스크립트에서도 if문을 사용하여 해당 매개변수를 체크한 뒤 그에 따라 다른 로직을 제공할 수도 있지만 자바에서처럼 해당 예외의 처리만을 위한 객체를 생성한다면 더 객체지향적인 프로그래밍이 될 것이다. 

'Java > Spring & Spring Boot' 카테고리의 다른 글

Spring Data JDBC  (0) 2023.04.20
JDBC(Java Database Connectivity)  (0) 2023.04.20
Entity와 Mapper  (0) 2023.04.13
DTO (Data Transfer Object) 과 Validation  (0) 2023.04.12
Servlet과 JSP 그리고 MVC(Model, View, Controller)  (0) 2023.04.11