[Section 3] Spring MVC 예외 처리

Kim·2022년 10월 25일
0

Boot Camp

목록 보기
36/64
post-thumbnail
post-custom-banner

지난번에 작성했던 코드들은 애플리케이션에서 발생할 수 있는 예외를 처리하는 프로세스가 전혀 적용되지 않았었다.
그래서 애플리케이션에 어떤 예외가 발생했는지 클라이언트 쪽에서 구체적으로 알 수 있는 방법이 없었기 때문에, 지난번에 작성한 샘플 코드에 예외 처리를 적용해 보는 실습을 진행해봤다.


@ExceptionHandler를 이용한 예외 처리

{
	"timestamp": "2022-10-25T07:58:16.228+00:00",
    "status": 400,
    "error": "Bad Request",
    "path": "/v1/members"
}

위와 같은 Response Body의 내용만으로는 요청 데이터 중 어떤 항목이 유효성 검사에 실패했는지 알 수 없다.

이전 실습에서 클라이언트가 전달 받는 Response Body는 애플리케이션에서 예외가 발생했을 때, 내부적으로 Spring에서 전송해주는 에러 응답 메시지 중 하나이다.
Spring이 처리하는 에러 응답 메시지를 직접 처리하도록 코드를 수정해봤다.

@ExceptionHandler 적용

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

MemberController 클래스에 @ExceptionHandler 애너테이션을 통해 예외를 처리할 수 있게 handleException() 메서드를 추가한다.

유효성 검증에 실패할 경우 예외 처리 과정

클라이언트 쪽에서 회원 등록을 위해 MemberControllerpostMember() 핸들러 메서드에 요청을 전송한다.
RequestBody에 유효하지 않은 요청 데이터가 포함되어 있어 유효성 검증에 실패하면, MethodArgumentNotValidException이 발생하게 된다.
MemberController에는 @ExceptionHandler 애너테이션이 추가된 예외 처리 메서드 handleException()이 있기 때문에 유효성 검증 과정에서 내부적으로 던져진 MethodArgumentNotValidExceptionhandleException() 메서드가 전달 받는다.
(1)과 같이 MethodArgumentNotValidException 객체에서 getBindingResult().getFieldErrors()를 통해 발생한 에러 정보를 확인할 수 있다.
(1)에서 얻은 에러 정보를 (2)에서 ResponseEntity를 통해 Response Body로 전달한다.

이제 postman에서 회원 등록 요청을 전송해보자.
회원 등록 정보에서 유효하지 않은 이메일 주소를 포함하여 요청을 전송해보면 이전과는 다른 응답 메시지를 전달 받게 될 것이다.

MemberController의 handleException() 메서드에서 유효성 검사 실패에 대한 에러 메시지를 구체적으로 전송해주기 때문에 클라이언트 입장에서는 어느 곳에 문제가 있는지를 구체적으로 알 수 있게 되었다.

다만, 의미를 알 수 없는 정보를 모두 포함한 Response Body의 전체 정보를 다 전달 받게 되므로, 요청 전송 시 Request Body의 JSON 프로퍼티 중 문제가 된 프로퍼티가 무엇인지 정도만 전달 받아도 될 것이다.
위에서 확인한 에러 정보를 기반으로 Error Response 클래스를 만들어서 필요한 정보만 담은 후에 클라이언트 쪽에 전달해주면 된다.

ErrorResponse 클래스 적용

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

    @Getter
    @AllArgsConstructor
    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String reason;
    }
}

확인했던 Response Body를 보면 JSON 응답 객체가 배열()로 되어있었는데, 그 이유는 DTO 클래스에서 검증해야 할 멤버 변수에서 유효성 검증에 실패하는 변수가 하나 이상 될 수 있기 때문이다. 즉, 유효성 검증 실패 에러 역시 하나 이상 될 수 있다는 의미다.
그래서 하나 이상의 윻성 검증에 실패한 필드의 에러 정보를 담기 위해 List 객체를 이용했다.

MemberController 클래스의 handleException() 메서드 수정

@RestController
@RequestMapping("/v7/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)의 List<FieldError>를 통째로 ResponseEntity 클래스에 실어서 전달했었다.
이번에는 (2)와 같이 필요한 정보들만 ErrorResponse.FieldError 클래스에 담아서 List로 변환하고 List<ErrorResponse.FieldError>ResponseEntity 클래스에 실어서 전달한다.

여기까지 하면 유효성 검증에 실패한 필드가 2개일 경우 에러 정보 역시 2개를 보여주고, 필요한 정보만을 표시하고 있음을 알 수 있다.

@ExceptionHandler의 단점

@ExceptionHandler 애너테이션과 ErrorResponse 클래스를 이용하여 Request Body에 대한 유효성 검증 실패 시 필요한 에러 정보만 담아서 클라이언트에게 응답으로 전송할 수 있게 되었다.

하지만, @ExceptionHandler 애너테이션을 사용해 Request Body에 대해 유효성 검증 실패에 대한 에러 처리를 해야하므로 각각의 Controller 클래스마다 코드 중복이 발생한다.
또 Controller에서 처리해야 할 예외가 유요성 검증 실패에 대한 것만 있지 않기 때문에 하나의 Controller 클래스 내에 @ExceptionHandler를 추가한 에러 처리 핸들러 메서드가 증가한다.

Key Summary!

Controller 클래스 레벨에서 @ExceptionHandler 애너테이션을 사용해 해당 Controller에서 발생하는 예외를 처리할 수 있다.
필요한 에러 정보만 담을 수 있는 Error 전용 Response 객체를 사용하여 클라이언트에게 친절한 에러 정보를 제공할 수 있다.
@ExceptionHandler 애너테이션을 사용한 방법은 Controller마다 동일하게 발생하는 예외 처리에 대한 중복 코드가 발생할 수 있다.
또한, 다양한 유형의 예외를 처리하기엔 적합하지 않다.


@RestControllerAdvice를 이용한 예외 처리

@ExceptionHandler를 사용했을 때 예외 처리를 위한 코드에 중복이 발생하는 문제를 개선하는 방법을 알아보자.

특정 클래스에 @RestControllerAdvice 애너테이션을 추가하면 여러 Controller 클래스에서 @ExceptionHandler, @InitBinder, @ModelAttribute 가 추가된 메서드를 공유해서 사용할 수 있다.
즉, @RestControllerAdvice 애너테이션을 추가한 클래스를 이용하면 예외 처리를 공통화 할 수 있다는 것이다.

Exception 핸들러 메서드 구현

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

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

	// (2)
    @ExceptionHandler
    public ResponseEntity handleConstraintViolationException(
            ConstraintViolationException e) {
        // TODO should implement for validation

        return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
    }
}

위 코드는 MemberController에 추가했던 Exception 핸들러 메서드의 로직을 그대로 가져온 것이다.
@RestControllerAdvice 애너테이션을 이용해서 예외 처리를 공통화 하면 각 Controller마다 추가되는 @ExceptionHandler 로직에 대한 중복 코드를 제거하고, Controller의 코드를 단순화 할 수 있다.

ErrorResponse 수정

GlobalExceptionAdvice를 통해 Controller 클래스에서 발생하는 Request Body의 유효성 검증에 대한 에러는 유연한 처리가 가능해졌다.
그러나 URI 변수로 넘어오는 값의 유효성 검증에 대한 에러 처리는 아직 구현되지 않았다.
이 부분을 처리하기 전에 ErrorResponse 클래스가 ConstraintViolationException 에 대한 Error Response를 생성할 수 있도록 ErrorResponse 클래스를 수정한다.

// 코드 생략

기능이 늘어남에 따라 ErrorResponse 클래스의 구현 복잡도가 늘어날 수 있지만, 에러 유형에 따른 에러 정보 생성 역할을 분리함으로써 ErrorResponse를 사용하는 입장에서 한층 더 사용하기 편리해졌다.

of() 메서드

of() 메서드는 Java8의 API에서도 흔히 볼 수 있는 Naming Convention이다.
주로 객체 생성시에 어떤 값의 객체를 생성한다는 의미에서 of() 메서드를 사용한다.

Exception 핸들러 메서드 수정

@RestControllerAdvice
public class GlobalExceptionAdvice {
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        final ErrorResponse response = ErrorResponse.of(e.getBindingResult());

        return response;
    }

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

        return response;
    }
}

수정된 ErrorResponse 클래스를 사용할 수 있게 수정한 GlobalExceptionAdvice 클래스 코드이다.
이전 코드와 비교했을 때, Error Response 정보를 만드는 역할을 ErrorResponse 클래스가 대신 해주므로 코드가 간결해졌다.
또한 이전 코드에서는 Error Response 객체를 ResponseEntity로 래핑하여 리턴했었는데, 수정한 코드에서는 ResponseEntity가 사라지고 ErrorResponse 객체를 바로 리턴하고 있다.
@ResponseStatus 애너테이션을 이용해 HTTP Status를 HTTP Response에 포함하고 있다.

@RestControllerAdvice vs @ControllerAdvice

@RestControllerAdvice@ControllerAdvice의 차이점은 아래와 같다.

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody

@RestControllerAdvice 애너테이션은 @ControllerAdvice@ResponseBody의 기능을 포함한다.
JSON 형식의 데이터를 Response Body로 전송하기 위해 ResponseEntity로 데이터를 래핑할 필요가 없다.

Key Summary!

@RestControllerAdvice 애너테이션을 추가한 클래스를 사용하면 예외 처리를 공통화 할 수 있다.
또한, JSON 형식의 데이터를 Response Body로 전송하기 위해 ResponseEntity로 래핑할 필요가 없다.
@ResponseStatus 애너테이션으로 HTTP Status를 대신 표현할 수 있다.


참고 자료

post-custom-banner

0개의 댓글