MVC 4 - 검증 및 오류 처리
기존에는 데이터에 직접 들어올 수 없는 값이 들어오면 바로 오류페이지로 넘어갔다. 이 경우는 사용자에게 불편을 초래하기 때문에 오류가 있을 경우 해당 오류의 이유와 이를 쉽게 고쳐서 다시 서비스를 이용할 수 있도록 하게끔 프로그래밍을 해야한다. 따라서 기존의 오류페이지가 아닌 데이터를 입력하는 페이지에서 사용자가 다시 데이터 입력을 시도할 수 있도록 프로그래밍 하고 이 검증 로직을 발전시키는 것이 이번 챕터의 목표.
V1
Map<String, String> errors = new HashMap<>();
컨트롤러에 error와 error에 관한 메세지를 처리하기 위한 Map 형식의 errors를 만든다.
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
이후 해당 오류가 발생할 수 있는 상황들을 조건문으로 작성하여 errors에 key = 필드, value = 오류 메세지의 형태로 저장한다.
비즈니스의 특정 조건을 만족시키는 복합룰의 경우는 조금 더 복잡한데,
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다.현재 값 = " + resultPrice);
}
}
이런 식으로 가격 * 수량의 10000원 이하일 경우 이 경우도 오류를 발생하도록 설정한다.
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
만약 요구사항을 충족시키지 못해 errors에 해당 필드와 오류메세지가 저장되어 있다면 입력폼으로 회귀하여 다시 입력을 진행하도록 한다.
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}"
th:text="${errors['itemName']}">
상품명 오류
</div>
뷰 템플릿에서 containsKey('필드명')을 사용해 만약 해당 필드의 키를 가진 오류메세지가 존재한다면 이를 출력하도록 설정. error?.은 errors가 null일 때, NullPointerException을 반환하는 대신 null 을 그대로 반환한다. 그리고 th:if에서 null은 false로 처리되므로 오류메세지가 출력되지 않는다.
참고로,
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
성공한다면, 중복 처리를 방지하기위해 PRG 패턴을 사용하여 해당 목록이 작성된 항목으로 이동한다.
POST를 이용하여 상품을 등록하고 단순히 홈 화면으로 이동 후, 새로고침을 하면 HTTP 메서드는 POST메서드를 계속 요청하기 때문에 데이터가 중복입력될 수 있다. 따라서 이 같은 상황을 방지하기 위해 상품상세화면으로 redirect를 시행하면 클라이언트는 GET을 이용하여 해당 페이지로 이동한다. 이를 통해 같은 데이터의 중복등록을 방지할 수 있다.
다만, 클라이언트가 해당 데이터가 입력된 것인지 아닌지에 대해서 판별할 수 있는 변화를 보기에는 힘들다. 따라서 추가적인 저장완료라는 상태를 변환시키기 위해 RedirectAttributes를 사용해야한다.
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
해당 코드를 삽입하면 URL이 "basic/items/3?status=true"와 같은 형식으로 상태가 변경된다. 저장완료의 메세지까지 작성하고 싶다면 템플릿에 if문을 사용해서 해당 메세지를 작성해주면 된다.
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
이러한 방식은 중복처리가 많고, 데이터 타입 값이 일치하지 않을 때, 고객이 잘못 입력한 데이터를 모두 초기화하여 해당 데이터를 처음부터 다시 입력해야하는 문제가 발생한다.
스프링이 제공하는 검증 오류 처리방법에는 3가지가 있다.
1.객체 타입 오류 -> 스프링이 자체적으로 new FieldError() 를 생성하여 처리한다.
2,비즈니스의 특정 요구사항 -> 개발자가 BindingResult에 삽입하여 처리한다
3.Validator
이 중 BindingResult를 V2에 활용해보자
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes)
기존의 ModelAttribute, RedirectAttributes말고 BindinfResult가 추가 되었다. 이는 검증 오류를 보관하는 객체로 해당 페이지의 오류를 야기시킨 데이터를 화면에 존재한 채로 클라이언트에게 제공할 수 있다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은필수입니다."));
}
위에 작성한 errors에 key와 value를 입력한 형태와 거의 똑같다. 여기서 FieldError는 두 가지 생성자를 제공하는데 ,
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object, rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable, Object[] arguments, @Nullable String defaultMessage)
파라미터 목록
objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
에러 메세지들을 기재하는 에러 프로퍼티
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
이 때, BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError를 직접 생성하지 않고 검증 오류를 다룰 수 있다. 해당 메서드들은 오류 필드, 오류코드, 오류메세지 인자, 기본 오류 메세지가 필요하지만 BindingResult는 객체를 명시하지 않아도 객체 뒤에서 바로 입력되기 때문에 자신의 target을 알고 있다. 즉 위의 코드는 필드이름과 오류메세지만 가지고 오류를 알려준다. 오류메세지와 관련해서는 에러 프로퍼티를 활용(에러 프로퍼티도 메세지로 활용할 수 있도록 추가)
오류 메세지에 세밀한 정의가 있다면, 예를들어 required.item.itemName이 정의되어있다면 해당 에러 프로퍼티를 찾아서 출력하겠지만 그렇지 않고 requierd만 있다면 메세지 프로퍼티에서 required를 찾아 출력한다.
상세한 에러 프로퍼티 이름은 메세지 리졸버가 자동적으로 해주는데 rejectValue메서드는 해당 기능이 내재되어있다. 즉 첫 단어만 적어주면 해당 객체의 이름, 필드명을 가지고 자동적으로 에러 프로퍼티를 찾아 오류 메세지를 출력한다는 점.
따라서 개발자는 클라이언트에게 상세한 오류메세지를 알려주고 싶다면 해당 오류메세지를 에 프로퍼티에서 따로 작성하고 그렇지 않으면 기본적인 오류메세지들만 작성해도 된다.
참고로 비즈니스 조건이 아닌 입력 타입의 경우 오류가 발생하면 스프링이 알아서 검증하여 typeMisMatch 오류를 발생하고 오류메세지도 스프링이 생성한 메세지가 출력된다.
지금까지 과정들을 돌이켜보면 컨트롤러에서 너무 많은 역할을 하고 있다는 것을 알 수 있다. 이러한 문제점을 해결하기 ㅜ이해 검증로직을 따로 클래스 분리하여 컨트롤러는 해당 로직만 호출하는 것이 코드의 유연성을 높일 것이다.
이렇게 분리된 검증로직 클래스에 Component를 사용하여 빈으로 등록하고 사용하는 것이 효율적이다. 다만 이러한 부분을 더 축약시켜 Bean Validation을 이용하면 이전과는 비교할 수 없을 정도로 축약된 형태의 검증로직을 구현할 수 있다.
V3
public class Item {
private Long id;
@NotBlank(message = "공백! {0}")
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
각 필드에 붙인 검증 Annotation. 직관적이기에 자세한 뜻은 생략.
해당 오류가 발생했을 경우 메세지는 기존과 같이 에러 프로퍼티에서 작성해주면 된다.
Bean Validation
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.
BeanValidation 메시지 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
return "validation/v3/addForm";
}
}
@Validated Annotation이 추가로 붙은 후 검증오류처리
진짜 보고 이게 맞나 싶었다. 그 동안 작성한 코드를 이런 식으로 축약해서 사용한다는 점은 너무 놀라웠다.
1, 스프링 부트에 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
2. 이후 LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다. 검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.
필드에러에서는 간편한 모습을 보여주었으나 가격 * 수량이 10000원 이상 넘어야하는 검증 오류는 어떻게 처리해야할까? 이 경우는 @ScriptAssert()를 사용할 수 있으나 다른 객체에서의 정보도 필요하고 복잡한 상황이 많기 때문에 이러한 오브젝트 오류는 따로 작성하여 Controller에 넣어주는 게 더 나을 수도 있다.
이러한 상황에서 등록과 수정이 각각 검증로직이 다르다면 Bean Validator는 한계에 부딪힌다 이를 위해서 groups와 객체분리를 이용하면 이러한 한계를 보완할 수 있다.
두 내용 모두 꽤 쉽기도 하고 Dto를 작성하면서 익숙해졌기에 자세한 설명은 생략하고 필요할 때 해당 pdf를 다시 보는 식으로 진행하는 것이 좋을 것 같다.