본문 바로가기
부트캠프 개발일기/Spring MVC

44일차: Spring MVC(예외처리)

by shyun00 2023. 4. 14.

❯ 예외 처리

애플리케이션을 구현하다보면 여러가지 예외를 마주하게된다.

그 중 현재 진행중인 Spring MVC에서 겪을 수 있는 예외 중 가장 대표적인 것이

클라이언트 요청 데이터의 유효성 검증에서 발생하는 예외이다.

현재까지는 예외가 발생할 경우 정상적으로 작동하지 않았음을 나타낼뿐 그에 대한 구체적인 정보는 제공하지 않았다.

Postman을 통해 올바르지 않은 요청을 했을 때 리턴 결과

따라서 어떠한 예외가 왜, 어떤부분에서 발생했는지를 알려주기 위해 예외 처리를 할 수 있다.

❯ @ExceptionHandler 사용

위 그림의 리턴 결과는 예외가 발생했을때 스프링이 전송해주는 응답 메세지이다.

이를 적당히 가공하여 우리가 원하는 예외에, 원하는 형태의 응답을 전송해줄 수 있다.

1. Controller 에 @ExceptionHandler 메서드 생성

@ExceptionHandler 애너테이션이 붙은 handelException() 메서드를 컨트롤러에 추가한다.

만약 메서드 파라미터와 맞는 예외가 발생하면 해당 메서드가 자동으로 실행된다.

@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
       ...
    }
		...

    @ExceptionHandler // MethodArgumentNotValidException이 발생하면 호출된다.
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        // 리턴값의 형태는 ResponseEntity이다.
        return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
    }
}

위 코드를 작성한 상태에서 다시 Postman에서 유효성에 맞지 않는 post 요청을 보내게 되면 아래와 같은 결과가 출력된다.

JSON 형태의 응답으로 phone과 email 이 양식에 맞지 않다는 것을 알 수 있다.

[
    {
        "codes": [
            "Pattern.memberPostDto.phone",
            "Pattern.phone",
            "Pattern.java.lang.String",
            "Pattern"
        ],
        "arguments": [
            {
                "codes": [
                    "memberPostDto.phone",
                    "phone"
                ],
                "arguments": null,
                "defaultMessage": "phone",
                "code": "phone"
            },
            [],
            {
                "defaultMessage": "^010-\\d{3,4}-\\d{4}$",
                "arguments": null,
                "codes": [
                    "^010-\\d{3,4}-\\d{4}$"
                ]
            }
        ],
        "defaultMessage": "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.",
        "objectName": "memberPostDto",
        "field": "phone",
        "rejectedValue": "010-332",
        "bindingFailure": false,
        "code": "Pattern"
    },
    {
        "codes": [
            "Email.memberPostDto.email",
            "Email.email",
            "Email.java.lang.String",
            "Email"
        ],
        "arguments": [
            {
                "codes": [
                    "memberPostDto.email",
                    "email"
                ],
                "arguments": null,
                "defaultMessage": "email",
                "code": "email"
            },
            [],
            {
                "defaultMessage": ".*",
                "arguments": null,
                "codes": [
                    ".*"
                ]
            }
        ],
        "defaultMessage": "올바른 형식의 이메일 주소여야 합니다",
        "objectName": "memberPostDto",
        "field": "email",
        "rejectedValue": "os",
        "bindingFailure": false,
        "code": "Email"
    }
]

 

2. 예외 발생 시 응답으로 리턴해줄 ErrorResponse 클래스를 정의해서 사용할 수 있다.

그런데 위 코드를 응답으로 받기에는 불필요한 내용이 많아보인다.

우리가 실제로 필요한것은 어디에서 오류가 발생했는지, 어떤값이 문제인지, 무엇이 문제인지만 알면 된다.

따라서 ErrorResponse 클래스를 사용해 필요한 값만 리턴해줄 수 있다.

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter // 멤버 변수의 getter를 자동으로 생성해주는 애너테이션 
@AllArgsConstructor  // 모든 멤버 변수를 매개변수로 가지는 생성자 생성
public class ErrorResponse {

    private List<FieldError> fieldErrors; // 발생할 수 있는 예외는 여러개일 수 있으므로 List 형태임

    @Getter
    @AllArgsConstructor
    public static class FieldError { // 발생한 개별 에러를 객체로 정의
        private String field;
        private Object rejectedValue;
        private String reason;
    }
}

 

3. ErrorResponse 클래스를 예외 처리 리턴 타입으로 설정하기 위해 Controller 메서드를 일부 수정한다.

아래 코드에서 .getBindingResult() 는 예외 객체에서 유효성 검사 결과를 담은 BindingResult 객체를 반환하며,

.getFieldErrors() 는 BindingResult 객체에서 유효성 검사에 실패한 필드 관련된 정보를 담은 List<FieldError>를 반환한다.

@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
       ...
    }
		...

    @ExceptionHandler // MethodArgumentNotValidException이 발생하면 호출된다.
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        
        // 리턴값의 내용으로 ErrorRespone 타입을 인자로 가지는 List를 사용한다.
        // 발생된 예외에서 필요한 정보(필드명, 문제인 값, 예외메세지)만 선택하여 출력한다.
        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);
    }
}

위 코드에서 조금은 복잡했던것이 아래 두 FieldError를 구분하는 것이었다.

(1) FieldError는 스프링에서 자체적으로 구현되어있는 클래스로 ObjectError를 상속받은 클래스이다. 

(2) FieldError는 ErrorResponse 내부에서 우리가 직접 구현한 클래스이다.(ErrorResponse 클래스의 static 멤버 클래스이다.)

따라서 이 둘은 다른 클래스이며, 위에서도 (1) 에 해당하는 타입명은 FieldError 로 그대로 사용하고 있으며 (2) 에 해당하는 타입명은 ErrorResponse.FieldError 형태로 사용하는 것을 알 수 있다.

(1) org.springframework.validation.FieldError;
(2) com.codestates.response.v1.ErrorResponse.FieldError;

// (1) 의 구조
public class FieldError extends ObjectError {

	private final String field;

	@Nullable
	private final Object rejectedValue;

	private final boolean bindingFailure;
    
    ...
}

// (2) 의 구조
public class ErrorResponse {

    private List<FieldError> fieldErrors;

    @Getter
    @AllArgsConstructor
    public static class FieldError { // 클래스 내부에 구현
        private String field;
        private Object rejectedValue;
        private String reason;
    }
}

 

@ExceptionHandler와 ErrorResponse 를 사용하면 클라이언트 요청이 유효성 검사를 통과하지 못했을때 아래와 같이 원하는 내용만 출력되는것을 볼 수 있다.

초기 응답 결과보다 내용이 구체적이며 필요한 부분만 출력되었다.


그렇다면, 이렇게 모든 예외를 처리하는것이 과연 효율적인 방법일까? 답은 '아니오' 다.

만약 발생할 수 있는 예외가 여러가지일 경우 컨트롤러 내에 많은 @ExceptionHandler 메서드를 정의해야하고,

여러 컨트롤러에서 동일한 예외를 각각 처리하게 되면 코드의 중복이 발생한다.

이를 해결하기 위해 사용하는 방법이 @RestControllerAdvice 이다.

❯ @RestControllerAdvice

특정 클래스에 @RestControllerAdvice를 추가하면 다른 클래스에서 @ExceptionHandler, @InitBinder, @ModelAttribute 가 붙은 메서드를 사용할 수 있게 된다.

이 중 @InitBinder, @ModelAttribute는 SSR 방식에 사용된다.

CSR 방식을 학습하고 있는 지금은 @ExceptionHandler에 대해서만 다룰 예정이다.

1. @RestControllerAdvice를 사용하는 방식은 예외처리를 할 클래스를 분리하여 @RestControllerAdvice를 붙여주는 것이다.

(컨트롤러에 @ExceptionHandler 메서드를 작성할 필요가 없다.)

아래 코드는 ErrorResponse 수정까지 반영된 것이므로, 2. 와 함께 봐야한다.

(...)

@RestControllerAdvice
public class GlobalExceptionAdvice {

    // 클라이언트 요청이 유효성 검사를 통과하지 못했을 경우 예외 처리
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        final ErrorResponse response = ErrorResponse.of(e.getBindingResult());

        return response;
    }

    // 클라이언트 URI 변수값 유효성 검사 예외 처리
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleConstraintViolationException(
            ConstraintViolationException e) {
        final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());

        return response;
    }
}

 

2. 또한 ErrorResponse 클래스에 @RestControllerAdvice 클래스에서 다루고 있는 각 에러에 대한 정보를 포함시킨다.

@Getter
public class ErrorResponse {
    private List<FieldError> fieldErrors; // DTO 멤버변수 필드 유효성 검증 실패 에러를 다룸
    private List<ConstraintViolationError> violationErrors;  // URI 변수값 유효성 검증 실패 에러 다룸

    // 두 에러를 파라미터로 가지는 생성자. private로 접근 제한자를 설정하여 외부에서 생성할 수 없도록 제한함
    private ErrorResponse(List<FieldError> fieldErrors, List<ConstraintViolationError> violationErrors) {
        this.fieldErrors = fieldErrors;
        this.violationErrors = violationErrors;
    }

		// of() 메서드를 통해 ErrorResponse 객체를 생성한다.(매개변수가 아래와 다름)
    public static ErrorResponse of(BindingResult bindingResult) {
        return new ErrorResponse(FieldError.of(bindingResult), null);
    }

		// of() 메서드를 통해 ErrorResponse 객체를 생성한다.(매개변수가 위와 다름)
    public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
        return new ErrorResponse(null, ConstraintViolationError.of(violations));
    }

		// FieldError에 필요한 정보를 가공한다. (에러에 관한 모든 정보를 리턴할 필요 X)
    @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());
        }
    }

		// ConstraintViolationError에 필요한 정보를 가공한다.
    @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());
        }
    }
}

이번주에 진행된 내용은 코드를 직접 작성해보는 과정이 많았다.

이론공부와 실습을 같이 진행하니 재밌게 할 수 있었다. 특히 복잡한 코드를 @~~~ 한줄로 대신할 수 있는걸 보면서

이런걸 처음 구상하고 만든 사람들은 얼마나 대단한건가... 하는 생각도 들었다.

코드가 점점 길어지고 복잡해져서 어려운 부분이 있기는 하지만, 적어도 코드를 보고 해석할 수 있는 수준으로 실력을 올리고싶다.