❯ 예외 처리
애플리케이션을 구현하다보면 여러가지 예외를 마주하게된다.
그 중 현재 진행중인 Spring MVC에서 겪을 수 있는 예외 중 가장 대표적인 것이
클라이언트 요청 데이터의 유효성 검증에서 발생하는 예외이다.
현재까지는 예외가 발생할 경우 정상적으로 작동하지 않았음을 나타낼뿐 그에 대한 구체적인 정보는 제공하지 않았다.
따라서 어떠한 예외가 왜, 어떤부분에서 발생했는지를 알려주기 위해 예외 처리를 할 수 있다.
❯ @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());
}
}
}
이번주에 진행된 내용은 코드를 직접 작성해보는 과정이 많았다.
이론공부와 실습을 같이 진행하니 재밌게 할 수 있었다. 특히 복잡한 코드를 @~~~ 한줄로 대신할 수 있는걸 보면서
이런걸 처음 구상하고 만든 사람들은 얼마나 대단한건가... 하는 생각도 들었다.
코드가 점점 길어지고 복잡해져서 어려운 부분이 있기는 하지만, 적어도 코드를 보고 해석할 수 있는 수준으로 실력을 올리고싶다.
'부트캠프 개발일기 > Spring MVC' 카테고리의 다른 글
46일차: Spring MVC(JDBC, Spring Data JDBC) (0) | 2023.04.18 |
---|---|
45일차: Spring MVC(비즈니스적인 예외 던지기, throw) (0) | 2023.04.17 |
43일차: Spring MVC(서비스 계층, Mapper) (0) | 2023.04.13 |
42일차: Spring MVC(DTO) (0) | 2023.04.12 |
41일차: Spring MVC(API 계층, Controller) (0) | 2023.04.11 |