[MVC2] 4. 검증1 - Validation

kiwonkim·2021년 10월 22일
0

이전 포스팅

스프링의 메시지 기능에 알아보았다.

  1. 스프링 부트는 MessageSource 클래스를 자동으로 빈으로 등록한다.

  2. application.properties 에서 메시지 설정 파일들(properties)의 베이스 이름을 설정한다. 기본값은 messages이다.

  3. 베이스 이름을 바탕으로 properties 파일을 생성하고, 메시지를 설정한다. (ex. messages.properties)

  4. MessageSource.getMessage로 메시지를 가져오거나 타임리프의 메시지 표현식 #{...} 으로 메시지를 가져온다.

  5. 타임리프도 getMessage를 쓰는 것과 마찬가지이며, 이때 Locale은 스프링이 요청 헤더를 바탕으로 설정한다.

사용자가 Form 값에 이상한 데이터를 넣으면 어떻게 될까? 예를 들어 Integer로 선언한 age 필드에 문자열을 넣으면 서버 에러가 발생할 것이다. 서버 에러를 방지하고 수정이 필요하다고 사용자에게 알려주기 위해 Form 값 입력 검증이 필요하다.



검증 V1 - 직접 구현

검증 목적

상품저장 성공시 수행되는 로직이다.

  1. 서버에게 상품추가 페이지를 요청 후 받고.
  2. 상품저장 Form에 값을 입력한 후 POST 요청을 수행.
  3. 서버는 상품저장을 수행하고 상품상세 페이지로 Redirect
  4. 사용자는 Redirect 된 상품상세 페이지로 GET요청을 하게된다.

상품저장 실패시 수행시킬 로직이다.

  1. 서버에게 상품추가 페이지를 요청 후 받고.
  2. 상품저장 Form에 값을 입력한 후 POST 요청을 수행.
  3. 서버는 잘못된 값이 Form에 들어온 것을 파악하고, 검증 오류결과를 Model에 담아 상품추가 페이지를 다시 전달한다.

즉 우리는 Form 데이터에 잘못된 값이 존재할 때
1. 서버가 에러로 종료되지 않고.
2. 클라이언트가 기존에 입력한 값은 유지시키며.
3. 잘못입력한 이유는 클라이언트가 알 수 있도록 전달해주고 싶다.


ErrorMap 추가

@PostMapping("/add") // /add 로 POST -> 아이템 추가 수행
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {

        //ModelAttribute 필드는 모델에 자동으로 추가됨. model.addAttribute("item", item)이 수행됨.

        //검증 오류 결과를 보관
        Map<String, String> errorMap = new HashMap<>();

        //Form 입력 검증 로직
        if (!StringUtils.hasText(item.getItemName())) { //아이템 이름 검증
            errorMap.put("itemName", "상품 이름은 필수입니다."); //key : itemName. value:"상품 이름은 필수입니다."
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 100000){ //아이템 가격 검증
            errorMap.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) { //아이템 수량 검증
            errorMap.put("quantity", "수량은 최대 9999개 까지 허용합니다.");
        }
        if (item.getPrice() != null && item.getQuantity() != null) { //아이템 수량 검증
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                errorMap.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
            }
        }

        // 검증에 실패하면 다시 입력폼으로 이동
        if (!errorMap.isEmpty()) { //에러맵에 값이 존재하면
            log.info("errorMap = {} ", errorMap);
            model.addAttribute("errorMap", errorMap);
            return "validation/v1/addForm";
        }

        //검증 성공시 수행로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v1/items/{itemId}";
    }

Controller에서 Form 으로 입력받은 필드마다 검증을 수행한다. 검증시 문제가 발생하면 errorMap에 필드와 에러메시지를 Key value 형태로 저장하고. 이를 모델에 담아 다시 addForm을 전달해준다.


뷰에서 errorMap 출력

<div>
<label for="price" th:text="#{label.item.price}">가격</label>
	<input type="text" id="price" th:field="*{price}"
		th:class="${errorMap?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
		class="form-control" placeholder="가격을 입력하세요">
        <div class="field-error" th:if="${errorMap?.containsKey('price')}" th:text="${errorMap['price']}">
                가격 오류
	</div>
</div>

뷰에서는 위와같이 렌더링한다. errorMap?. 는 SpringEL이 제공하는 Safe Navigation Operator이다. 에러가 존재하는 시점이 아닌 처음 addForm 을 받은 상황에서는 errorMap이 Model에 없으므로 해당 객체가 없어 . 사용시 NullPointerException 이 발생하게 된다. 하지만 ?. 로 설정하면 errorMap이 존재하지 않으면 Null을 반환해 NullPointerException이 발생하지 않는다.


검증 수행과정

전체 검증 수행과정이다.

  1. 클라이언트가 Form을 받아 데이터를 입력한다.
  2. 이때 컨트롤러는 검증에러가 발생하면 errorMap에 저장하게 되는데. "에러 발생 필드":"메시지" 형태로 값을 저장한다.
  3. errorMap과 item을 Model에 담아 다시 Form을 사용자에게 전달한다.
  4. 검증 오류 후 전달받은 Form에는 이전에 입력한 데이터와 errorMap이 반영되어있다.


특징과 한계

에러가 존재하는 필드를 직접 HashMap에 담아 에러메시지를 출력할 수 있게 되었다. 그러나 타입 에러를 처리할 수 없다. 필드의 다른 타입이 들어오는 경우 객체에 담는 과정에서 에러가 발생해, 컨트롤러까지 도달하지 못하고 서버가 종료된다.


검증 V2 - BindingResult 활용

BindingResult란

스프링이 제공하는 검증오류 처리방법의 핵심이다. 컨트롤러에서 검증을 원하는 객체의 다음 파라미터로 넘겨주면, BindingResult는 그 안에 해당 객체에서 발생한 에러 객체를 저장한다.

FieldError란

필드에서 에러가 발생시, 에러가 발생한 필드와 출력할 메시지를 저장하기 위한 객체이다. 에러가 발생하면 FieldError 객체를 생성하여 이를 저장한뒤 BindingResult.addError 로 BindingResult에 FieldError를 저장한다.

ObjectError란

필드가 아닌 비즈니스 로직에 의한 글로벌 에러가 존재할 수 있다. (ex. 상품 수 + 상품 가격 = 1000 이상). 이런 경우 에러 발생시 FieldError 객체에 담을 수 없는데, 이는 ObjectError 객체에 저장한 뒤 BindingReuslt 에 저장한다.

타입 불일치 Error 처리

사용자가 Form에서 나이에 String을 입력했다고 가정해보자. Dto에서는 int age로 선언되어 있을 것이고, String과 타입 불일치가 발생해 컨트롤러를 불러오지도 못하고 객체 생성과정에서 서버에러가 발생할 것이다. 그런데 이 때 파라미터에 BindingResult가 존재하면 스프링은 FieldError로 타입 불일치 필드를 자동으로 저장하고 컨트롤러를 호출해준다.

FieldError의 생성자

//생성자1
public FieldError(String objectName, String field, String defaultMessage) {}

ex) FieldError("item", "price", "가격오류입니다.")

//생성자2
public(String objectName, String field, Object rejectedValue, boolean bindingFailure,
		String[] codes, Object[] arguments, String defaultMessage)
        
ex) FieldError("item", "price", "item.getPrice()", null, null, "가격오류 발생. 입력 값은 유지합니다.");

생성자1은 오류가 발생한 객체 이름, 필드, 메시지가 담긴다.
생성자2는 객체 이름, 필드, 거절된 값, 타입오류 여부, 메시지 코드, 메시지 사용 인자, 메시지가 담긴다.
똑같이 에러 발생시 저장하는 용도를 수행하나. 사용자가 입력한 값을 저장할 때 생성자2를 사용하며 메시지 코드와 메시지 사용 인자는 에러 메시지를 properties 로 생성할 때 사용한다.

서버 오류는 스프링이 방지해준다 해도, 사용자가 기존에 입력한 값은 타입 불일치로 객체에 값을 저장할 수 없다. 이때 생성자2를 사용하여 거절된 값을 저장해 반환 뷰 렌더링에 활용한다. 즉 거절된 값을 FieldError가 들고있도록 하는 것이다.

BindingResult 에 검증 오류 추가하는 방법

  1. @ModelAttribute 이나 Dto를 넣어줄 때 바인딩이 실패하면 스프링이 자동으로 FieldError 생성해서 넣어줌.
  2. 개발자가 직접 if문 써서 검증 오류 상황에 bindingResult.addError(new FieldError(~~~)) 로 넣어주는 것.
  3. Vaildator 사용. @Valid 와 객체에 Validator 적용.

타임리프 스프링 검증 통합 기능

타임리프는 스프링의 BindingResult를 활용해 편리한 검증 오류 표현기능을 제공한다.

  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력. th:if의 편의버전.
  • th:errorclass : th:field 에 지정한 필드에 오류가 있으면 class 정보를 추가.
  • th:field : th:field는 오류가 발생하지 않으면 모델에서 get필드명 으로 값을 가져와 사용하나. 오류가 발생한 경우 FieldError 에서 보관하고 있는 값을 가져와 사용한다.
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}"
	th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
	<div class="field-error" th:errors="*{itemName}">
		가격 오류
	</div>
</div>

위의 코드를 보면 위에서 th:object 로 form 태그 입력을 "item" 와 매핑하였기에. th:field 에서 item의 price필드와 연결된다.
th:errorclass 는 BindingResult에 price를 필드로하는 FieldError 가 존재할 경우 class에 field-error 를 추가해준다.
th:errors 또한 price를 필드로하는 FieldError가 존재하면 해당 태그를 출력한다. 즉 가격 오류 내용이 출력된다.
th:field는 해당 field의 FieldError 가 존재하지 않을 경우 get필드명으로 값을 가져와 Form 칸을 채워주고. FieldError 가 존재하면 FieldError에서 값을 가져와 Form 칸을 채워준다.


특징과 한계

HashMap을 생성하는 것이 아닌 스프링이 제공하는 BindingResult를 활용하였다. 에러 조건에 충족할 시 FieldError와 ObjectError 객체를 생성하고 addError로 BindingReuslt 에 넣어주었다. 이 때 디폴트 메시지를 일일이 설정해주어야하는데, 디폴트 메시지의 중복과 수정문제가 발생한다.


검증 V3 - 에러 메시지 활용

메시지 도입의 필요성

V2에서 BindingResult 의 도입으로 타입 에러 발생시 자동으로 FieldError에 담아주어 처리가 가능해졌고, 스프링과 타임리프의 연동성으로 BindingResult 의 FieldError 들을 매우 편리하게 뷰에 반영할 수 있게 되었다. 그런데 "상품 가격을 필수입니다" 같은 에러메시지를 하드코딩하면 수정시 매우 불편하다. 이를 메시지 처리할 수는 없을까?


errors.properties 생성

//application.properties
spring.messages.basename=errors,messages

//errors.properties
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

error.properties 를 생성하였다. 이 때 application.properties에 basename을 등록해야함에 유의하자.


FieldError 생성자의 오류코드

error.properties로 에러메시지를 코드와 메시지로 저장해놓았다. 그럼 오류메시지와 위의 코드를 어떤식으로 매핑할까?

public(String objectName, String field, Object rejectedValue, boolean bindingFailure,
		String[] codes, Object[] arguments, String defaultMessage)

위의 생성자를 살펴보자. String[] codes가 들어간다. codes는 메시지에 사용할 오류코드들의 배열을 의미하며, 앞에서부터 탐색하여 존재하는 코드의 메시지를 가져온다. Object[] arguments는 메시지에 들어갈 파라미터 배열을 의미한다.

new FieldError("item", "itemName", item.gerItemName() ,false, new String[]{"required.item.itemName"}, null, null)

위처럼 코드를 입력하면 defaultMessage 대신 errors.properties의 required.item.itemName 의 메시지를 가져와 오류메시지로 저장되며, 저장된 오류메시지는 뷰의 th:errors에 서 출력된다.


reject와 rejectValue

FieldError와 ObjectError는 필드도 많고 만들기 복잡하다. BindingResult는 이를 대신 만들어서 삽입하는 reject와 rejectValue메서드를 제공한다.

void rejectValue(String field, String errorCode, Object[] errorArgs, String defaultMessage);

BindingResult는 파라미터에서 객체 뒤에 오므로 객체를 이미 알고있다. 따라서 필드, 에러코드, 메시지파라미터, 기본메시지만 사용한다.

bidingResult.rejectValue("itemName", "required" null, null);

위와 같이 사용하면 된다. errorsproperties의 메시지코드는 required.item.itemName인데 rejectValue는 어떻게 required만 가지고 메시지를 가져올 수 있는 것일까?


메시지 코드 설계

오류코드 설계

메시지 코드는 required와 같이 간단하게 만들면 범용성이 좋아 여러군데서 사용할 수 있다. 그러나 세밀하게 사용하기가 어렵다. 세밀하게 required.item.itemName 으로 모든 오류마다 설정하면 관리가 매우 힘들 것이다. 따라서 세밀한 것에 우선순위를 두고, 해당 코드의 메시지가 없다면 범용성 넓은 메시지를 점차 찾도록 하면 된다. 이처럼 하면 추가적인 오류코드가 필요할 때도 properties 파일만 고치면 되므로 확장이 용이하다.


MessageCodesResolver

MessageCodesResolver는 파라미터로 받은 오류코드로 메시지 코드들을 생성한다.

String[] messageCodes = codesResolver.resolveMessageCodes(
"required", "item", "itemName", String.class);

/*
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
*/

"오류 코드, 객체이름, 필드이름, 타입이름"을 파라미터로 넣으면.
1. 오류코드.객체이름.필드이름
2. 오류코드.필드이름
3. 오류코드.타입이름
4. 오류코드
4개의 메시지 코드를 생성해서 배열로 반환해준다.


reject와 rejectValue의 원리

FieldErorr에서는 required.item.itemName 처럼 메시지코드를 세세하게 써준 것과 달리. rejectValue에서는 오류코드를 required만 넣어주면 알아서 메시지 코드를 찾아서 사용하였다. 이는 FieldError는 파라미터로 메시지코드의 배열을 사용하지만 rejectValue는 파라미터로 오류코드를 사용하기 때문이다. rejectValue는 오류코드를 사용해 MessageCodesResolver를 호출하여 메시지코드 배열을 받고, 이를 FieldError의 파라미터로 넘겨 호출한다. 그러면 FieldError에서 우선순위 높은 메시지 코드부터 순회하며, 존재하면 해당 메시지를 가져오는 것이다.


타입에러시 오류코드

검증 오류에는 비즈니스 로직 오류와, 타입 오류 두가지가 존재한다. 타입 오류시 스프링은 자동으로 FieldError를 생성하여 BindingResult에 저장한다고 하였다. 이때 오류코드로 "typeMismatch" 가 넘어간다. 즉
1. typeMismatch.객체명.필드명
2. typeMismatch.필드명
3. typeMismatch.타입
4. typeMismatch
네 개의 메시지코드가 저장된 배열을 파라미터로 FieldError가 호출된다. 위의 메시지코드에 맞게 properties 파일에서 메시지를 설정하면 된다.


검증 V4 - Validator 분리

Validator 분리

현재 Controller 에는 검증 수행 + 검증 모두 성공 후 로직. 두가지가 공존한다. 검증 수행 부분을 따로 빼서 관리하면 유지보수가 용이할 것이다.

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        //target의 클래스가 Item 이거나 Item의 자식인지 검사
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        //target은 Object이므로 Item으로 캐스팅 후 사용해야함.
        
        //errors는 bingingResult의 부모클래스.
		
        /* 
        검증 로직
        */
        }

    }
}

위의 코드를 보면 Validator 를 상속한 클래스를 생성하고, supports와 validate를 오버라이딩 한다. validate 에서 캐스팅 후 검증 로직을 수행한다. 그 후 @Component로 빈으로 등록한다.


Controller에 WebDataBinder 적용

public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
        dataBinder.addValidators(personValidator);
    }
    
    @PostMapping("/add") // /add 로 POST -> 아이템 추가 수행
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        // 검증에 실패하면 다시 입력폼으로 이동
        if (bindingResult.hasErrors()) {
            log.info("errorMap = {} ", bindingResult);
            return "validation/v2/addForm";
        }
        
        /* 
        성공 로직
        */
}

등록한 검증기 빈을 주입받아 vaildate 메서드를 호출해도 되나, @InitBinder에서 WebDataBinder에 검증기를 추가하여 사용하면 @Validated가 붙은 자동으로 검증기를 적용해준다. 이때 어떤 추가된 검증기 중에 어떤 검증기를 사용할 지는 오버라이딩한 supports 메서드에 의해 정해진다.

0개의 댓글