Spring에서의 예외 처리

jungseo·2023년 6월 14일
0

Spring

목록 보기
6/23

예외 처리

{
    "timestamp": "2023-06-14T02:40:38.768+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/v4/coffees/"
}
  • 기존 작성했던 코드는 유효성 검사를 통과하지 못하면 위와 같이 응답하여 클라이언트 요청 데이터 중에서 어떤 항목이 유효성 검증에 실패했는지 알 수 없음

  • 유효성을 검사하고, 예외가 발생할 경우 예외 처리를 통해 클라이언트에게 유효성 검사 실패를 알려 잘못된 입력을 방지

  • 위의 Response Body는 예외 발생시 Spring에서 전달해주는 에러 응답 메세지

  • MethodArgumentNotValidException

    • 요청 인자가 유효하지 않을 때 발생
    • 사용자로부터 받은 입력 데이터 필드가 유효성 검사를 통과하지 못할 경우 발생
    • BindingResult 객체를 통해 결과 확인 가능
  • ConstraintViolationException

    • 객체 필드에 설정된 제약 조건을 만족시키지 못할 경우 발생
    • ConstraintViolation 객체의 컬렉션을 통해 결과 확인 가능

@ExceptionHandler

1. @ExceptionHandler 사용

  • @ExceptionHandler 를 추가한 MemberController class
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {

// 생략

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

        // (2)
        return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
    }
}
  • 처리 과정
  1. 클라이언트가 요청 전송
  2. RequestBody에 유효하지 않은 데이터가 포함되어 유효성 검증 실패하고 MethodArgumentNotValidException 발생
  3. Controller의 @ExceptionHandler 애너테이션이 붙은 hadleException() 메서드가 예외를 전달 받음
  • getBindingResult().getFieldErrors()로 에러 정보 확인

  • 유효성 검사를 통과하지 못한 요청
{
    "email":"jungseo@",
    "name":"jungseo",
    "phone":"010-1111-1111"
}

  • 응답
[
    {
        "codes": [
            "Email.memberPostDto.email",
            "Email.email",
            "Email.java.lang.String",
            "Email"
        ],
        "arguments": [
            {
                "codes": [
                    "memberPostDto.email",
                    "email"
                ],
                "arguments": null,
                "defaultMessage": "email",
                "code": "email"
            },
            [],
            {
                "arguments": null,
                "codes": [
                    ".*"
                ],
                "defaultMessage": ".*"
            }
        ],
        "defaultMessage": "must be a well-formed email address",
        "objectName": "memberPostDto",
        "field": "email",
        "rejectedValue": "jungseo@",
        "bindingFailure": false,
        "code": "Email"
    }
]
  • 클라이언트 측에서 어느부분이 문제인지 인식 가능
  • 불필요한 정보 다수 포함

2. ErrorResponse class 사용

  • 에러 정보를 기반으로 ErrorResponse 클래스를 만들어 필요한 정보만 담아 전달
  • ErrorResponse class
@Getter
@AllArgsConstructor
public class ErrorResponse {
    List<FieldError> fieldErrors;

    @Getter
    @AllArgsConstructor
    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String reason;
    }
}
  • 에러가 하나 이상일 수 있기 때문에 List 객체 이용
  • FieldError class
    • ErrorResponse와 공통의 관심사를 가지고 있어 inner 클래스보다 static 멤버 클래스

  • ErrorResponse class를 적용한 MemberController class
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {

// 생략

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

        // (2)
        List<ErrorResponse.FieldError> errors =
                fieldErrors.stream()
                            .map(error -> new ErrorResponse.FieldError(
                                error.getField(),
                                error.getRejectedValue(),
                                error.getDefaultMessage()))
                            .collect(Collectors.toList());

        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }
}
  • (1)의 FieldError는 SpringFramework에서 제공
    • objectName : 유효성 검사를 수행하는 객체 이름
    • field : 유효성 감사가 실패한 필드 이름
    • rejectesValue : 무효화된 값
    • bindingFailure : 바인딩 과정에서 실패한 경우에 대한 플래그
    • codes : 에러 코드 배열
    • defaultMessage : 기본 에러 메세지
    • arguments : 에러 메세지에 사영될 인자 배열
  • (2)의 FieldError는 ErrorResponse에서 정의한 객체
  • 처리 과정
    • 유효성 검사에 실패하여 예외 발생
    • 예외 정보를 (1)에 담음
    • (1)에 담긴 예외 정보 중 필요한 데이터만 ErrorResponse 객체에 담아서 반환

  • 요청
{
    "email":"jungseo@",
    "name":"",
    "phone":"010-1111-1111"
}

  • 응답
{
    "fieldErrors": [
        {
            "field": "name",
            "rejectedValue": "",
            "reason": "이름은 공백이 아니어야 합니다."
        },
        {
            "field": "email",
            "rejectedValue": "jungseo@",
            "reason": "must be a well-formed email address"
        }
    ]
}

3. @ExceptionHandler 단점

  • 각 Controller 클래스마다 코드 중복 발생
  • 처리해야하는 예외가 늘어날 수록 핸들러 메서드도 늘어남
  • 다양한 유형의 예외 처리 제한

@RestControllerAdvice

  • 특정 클래스에 @RestControllerAdvice를 추가하여 여러 Controller 클래스에서 @ExceptionHandler가 추가된 메서드를 공유해서 사용 가능
  • 예외 처리 공통화

  • GlobalExceptionAdvice class
import com.codestates.Response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolationException;

@RestControllerAdvice
public class GlobalExceptionAdvice {
//    (1) 필드의 유효성 검사에 대한 에러 처리
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse hadleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        final ErrorResponse response = ErrorResponse.of(e.getBindingResult());

        return response;
    }
//    (2) URI 변수로 넘어오는 값의 유효성 검증에 대한 에러 처리
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleConstraintViolationException(
            ConstraintViolationException e) {
        final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());

        return response;
    }
}
  • @RestControllerAdvice
    • @ControllerAdvice + @ResponseBody
    • JSON 형식으로 Response Body을 전달하기 위해 ResponseEntity로 래핑할 필요가 없음
  • @ResponseStatus
    • HTTP Status를 HTTP Response에 포함하여 대신 표현

  • ErrorResponse class 수정
import lombok.Getter;
import org.springframework.validation.BindingResult;

import javax.validation.ConstraintViolation;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@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)
    public static ErrorResponse of(BindingResult bindingResult) {
        return new ErrorResponse(FieldError.of(bindingResult), null);
    }

//    (5)
    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) 생성자의 접근 제어자를 private으로 지정

      • 정적 팩토리 메서드 패턴

      • 외부에서 객체를 생성할 수 없게 하여 생성자의 의미와 용도를 명확하게 함

      • 객체 생성에 필요한 로직을 해당 메서드 내부에 캡슐화

      • 객체 생성과 관련된 복잡한 로직이 있을 경우 코드의 가독성과 유지보수성을 향상

    • (4) MethodArgumentNotValidException에 대한 ErrorResponse 객체 생성

      • 메서드를 호출하는 쪽에서 BindingResult 객체를 넘겨줌
      • 에러 정보 추출, 가공을 FieldError 클래스에 위임
    • (5) ConstrainViolationException에 대한 ErrorResponse 객체 생성

      • 메서드를 호출하는 쪽에서 Set<ConstraintViolation<?>> 객체를 넘겨줌
      • 에러 정보 추출, 가공을 ConstraintViolationError 클래스에 위임
    • (6) 필드의 유효성 검사에서 발생하는 에러 정보 생성

    • (7) URI 변수 값에 대한 에러 정보 생성


  • 다른 Controller에 대한 예외도 정상적으로 처리

예외 던지기와 사용자 정의 예외

1. Checked Exception / UnChecked Exception

  • Checked Exception
    • 예외를 체크해 구체적으로 처리해야 하는 예외
  • UnChecked Exception
    • 어떤 처리를 할 필요가 없는 예외
    • 개발자가 코드 작성을 잘못해서 발생하는 오류는 대부분 RuntimeException을 상속한 예외

2. 의도적으로 예외 던지기

  • 예시 상황

    • 커피 주문 애플리케이션에서 클라이언트가 회원 정보를 조회하기 위해 Controller의 getMember() 핸들러 메서드에 요청을 보냄
    • DB에 해당 회원 정보가 없을때
    • 서비스 계층에서 해당 회원 정보가 없다는 예외를 의도적으로 전송해 클라이언트에게 알림
  • throw 키워드

    • 해당 키워드를 사용하여 예외를 메서드 밖으로 던짐
    • 예외는 메서드를 호출한 지점으로 던져짐
    • API 계층의 Controller의 핸들러 메서드가 서비스 계층의 메서드를 호출하므로 서비스 계층에서 던져진 예외는 Controller 핸들러 메서드에서 잡아서 처리 가능
    • Controller에서 발생하는 예외를 Exception Advice에서 처리하게 공통화 하였으므로 서비스 계층에서 발생한 예외도 Exception Advice에서 처리
  • Service 클래스에서 예외 던지기

@Service
public class MemberService {
// 생략
    public Member findMember(long memberId) {
        // TODO should business logic
				
				// (1)
        throw new RuntimeException("Not found member");
    }
// 생략
}

  • ExceptionAdvice 클래스에서 예외 받기
@RestControllerAdvice
public class GlobalExceptionAdvice {
// 생략	
		// (1)
    @ExceptionHandler
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleResourceNotFoundException(RuntimeException e) {
        System.out.println(e.getMessage());
        return null;
    }
}

2. 사용자 정의 예외 사용

  • 위 예시 처럼 서비스 계층에서 RuntimeException을 그대로 던지고 ExceptionAdvice에서 RuntimeExveption을 그대로 잡는 것은 예외의 의도가 명확하지 않고 어떤 예외가 발생했는지에 대한 예외 정보를 얻기 어려움

  • 추상적인 RuntimeException 같은 예외가 아닌 예외를 구체적으로 표현할 수 있는 Custom Exception을 만들어 예외 던지기 가능

  • ExceptionCode Enum

import lombok.Getter;

public enum ExceptionCode {
    MEMBER_NOT_FOUND(404, "Member Not Found");

    @Getter
    private int status;

    @Getter
    private String message;

    ExceptionCode(int status, String message) {
        this.status = status;
        this.message = message;
    }
}
  • 서비스 계층에서 던질 Custom Exception에 사용할 ExceptionCode를 enum으로 정의

  • ExceptionCode를 enum으로 정의하면 비즈니스 로직에서 발생하는 다양한 유형의 예외를 enum에 추가해서 사용 가능


  • BusinessLogicException 클래스
import lombok.Getter;

public class BusinessLogicException extends RuntimeException {
    @Getter
    private ExceptionCode exceptionCode;

    public BusinessLogicException(ExceptionCode exceptionCode) {
        super(exceptionCode.getMessage());
        this.exceptionCode = exceptionCode;
    }
}
  • BusinessLogicException은 RuntimeException을 상속하고 있으며 ExceptionCode를 멤버 변수로 지정하여 생성자를 통해서 조금 더 구체적인 예외 정보들을 제공

  • 상위 클래스인 RuntimeException의 생성자(super)로 예외 메시지를 전달


  • 서비스 계층에 BusinessLogicException 적용
@Service
public class MemberService {
// 생략
    public Member findMember(long memberId) {
        // TODO should business logic

				// (1)
        throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
    }
// 생략
}

  • Exception Advice에서 BusinessLogicException 처리
@RestControllerAdvice
public class GlobalExceptionAdvice {
// 생략
	// (1) 정의했던 예외 처리하기
    @ExceptionHandler
    public ResponseEntity handleBusinessLogicException(BusinessLogicException e) {
        System.out.println(e.getExceptionCode().getStatus());
        System.out.println(e.getMessage());

        ErrorResponse response = ErrorResponse.of(e.getExceptionCode());

        return new ResponseEntity<>(response, HttpStatus.valueOf(e.getExceptionCode()
                .getStatus()));
    }

    // (2) HttpStatus 클래스를 이용하여 예외 처리하기
    @ExceptionHandler
    public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        ErrorResponse response = ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED);

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

    // (3) 구현 상 실수로 발생하는 전반적인 예외 처리하기
    @ExceptionHandler
    public ResponseEntity handleException(Exception e) {
        ErrorResponse response = ErrorResponse.of(ExceptionCode.INTERNAL_SERVER_ERROR);

        return new ResponseEntity(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
    }
}
  • (1) 위에서 정의 했던 BusinessLogicException 처리
    • ErrorResponse 클래스의 생성자에 BusinessLogicException의 ExceptionCode를 전달
  • (2) catch할 예외에 해당하는 HttpStatus Enum을 ErrorResponse 생성자에 전달

  • ErrorResponse 클래스 수정
@Getter
public class ErrorResponse {
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private int status;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String message;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private List<FieldError> fieldErrors;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private List<ConstraintViolationError> violationErrors;

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

// (1) 정의했던 예외
    private ErrorResponse(ExceptionCode exceptionCode) {
        this.fieldErrors = null;
        this.violationErrors = null;
        this.status = exceptionCode.getStatus();
        this.message = exceptionCode.getMessage();
    }

// (2) HttpStatus 클래스 이용
    private ErrorResponse(HttpStatus httpStatus) {
        this.status = httpStatus.value();
        this.message = httpStatus.getReasonPhrase();
    }

    public static ErrorResponse of(BindingResult bindingResult) {
        return new ErrorResponse(FieldError.of(bindingResult), null);
    }

    public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
        return new ErrorResponse(null, ConstraintViolationError.of(violations));
    }
    
// (3)
    public static ErrorResponse of(ExceptionCode exceptionCode) {
        return new ErrorResponse(exceptionCode);
    }

// (4)
    public static ErrorResponse of(HttpStatus httpStatus) {
        return new ErrorResponse(httpStatus);
    }

    @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());
        }
    }

    @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());
        }
    }
}

0개의 댓글