검증은 정말 중요하다. 예를들어, 웹페이지에서 아이디 비밀번호를 입력해서 로그인 해야하는데 입력하지 않고도 통과되면 안되듯이 꼭 검증을 해줘야 한다.

 

컨트롤러의 중요한 역할 중 하나인 검증은 클라이언트 검증과 서버 검증으로 나눌 수 있다.

 

클라이언트만 검증을 할 경우 버프스위트같은 프로그램 등으로 조작할 수 있으므로 보안에 매우 취약하다.

서버만 검증하게 된다면 즉각적인 고객 사용성이 부족해진다.

 

즉, 둘을 적절히 섞어 사용해야 하며 서버 검증은 필수적이다.

 

Map<String, String> errors = new HashMap<>();

검증 오류 결과를 보관할 errors라는 Map을 만들었다.

 

if (!StringUtils.hasText(item.getItemName())) {
 errors.put("itemName", "상품 이름은 필수입니다.");
}

만약 상품의 이름이 공백이라면 에러를 띄워야 하는데, 이 때 키값은 오류가 발생한 필드명으로 하였고 value값은 출력할 오류 문자열을 넣어주었다.

 

if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값= " + resultPrice);
 }
}

필드 하나만 다루지 않고 여러개의 필드를 같이 다뤄서 처리해야하는 경우는 필드 이름을 넣을 수 없으므로 globalError라는 키값을 사용했다.

 

if (!errors.isEmpty()) {
 model.addAttribute("errors", errors);
 return "validation/v1/addForm";
}

검증 실패시에는 모델에 errors를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.

 

<div th:if="${errors?.containsKey('globalError')}">
 <p class="field-error" th:text="${errors['globalError']}">전체 오류메시지</p>

만약 errors에 globalError가 있다면 출력하고 없으면 출력을 안한다.

 

여기서 errors?. 부분을 보면 errors가 null일때 보통은 NullPointException을 출력하는데 이것을 대신 null으로 반환하는 타임리프 문법이다. 즉, 없다면 if=null이 되며 출력되지 않는다.

 

이제부터 타입 오류처리와 뷰 템플릿에서의 중복 제거를 해보고 고객이 입력한 문자를 화면에 남기는 것을 해본다.

 

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
    if (!StringUtils.hasText(item.getItemName())) {
	 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
	}
    
    if (item.getPrice() != null && item.getQuantity() != null) {
 		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.addError(new ObjectError("item",
            "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
 		}
    }
}

BindingResult 파라미터 위치는 검증할 대상 바로 뒤에 위치해야 한다

 

필드 하나만 오류 처리할 경우 FieldError 객체를 생성하여 bindingResult에 담아주고,

여러 필드를 다룬다면 ObjectError 객체를 생성하여 bindingResult에 담아주면 된다.

 

파라미터 값은 첫번째로 @ModelAttribute 이름이 들어가고 두번째는 오류가 발생한 필드명 세번째는 오류 기본 메시지를 입력해주면 된다.

 

<div th:if="${#fields.hasGlobalErrors()}">
 <p class="field-error" th:each="err : ${#fields.globalErrors()}"
th:text="${err}">글로벌 오류 메시지</p>
</div>

<div>
 <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
 <input type="text" id="itemName" th:field="*{itemName}"
 th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
 <div class="field-error" th:errors="*{itemName}">
 상품명 오류
 </div>
 </div>

타임리프에서는 BindingResult에 대해 편리하게 검증 오류를 표현하는 기능을 제공한다.

 

여기서 #fields를 통해 BindingReuslt가 제공하는 검증 오류에 접근할 수 있고, th:errors로 필드에 오류가 있을 때만 출력하게 하며 th:errorclass로 th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

이렇게 하면 검증은 다 완료되었지만, 검증 실패 시 데이터가 싹 다 날라가버린다.

 

이를 방지하기 위한 방법을 살펴본다.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
 }

파라미터를 살펴보면

 

1. 오류가 발생한 객체 이름

2. 오류 발생 필드명

3. 사용자가 입력한 값(검증에 실패한 값)

4. 타입 오류인지 검증 실패인지 구분 해주는 값

5. 메시지 코드

6. 메시지에서 사용하는 인자

7. 기본 오류 메시지

 

순이다.

 

사용자의 입력한 값이 컨트롤러의 @ModelAttribute에 바인딩 되는 시점에 오류가 발생하게 되면 모델 객체에서 사용자가 입력한 값을 유지하기 힘들다.

 

예를 들어 문자열을 받아야하는데 숫자를 받아버리면 다른 타입이므로 보관할 방법이 없다. 이러한 이유 때문에 오류 발생시 사용자 입력 값을 별도로 보관해야한다. 그리고 보관한 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.

 

FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.

 

추가로 타임리프에서의 사용자 입력 값을 어떻게 유지하냐면

th:field="*{price}"

th:field로 지정해주면 오류 발생시 FieldError에서 보관한 값을 사용하여 출력한다.

 

타입오류로 바인딩에 실패하면 FieldError를 생성하여 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingReuslt에 담아 컨트롤러를 호출한다. 이렇게 된다면 타입 오류 발생시에도 오류 메시지를 정상 출력할 수 있다.

 

오류메시지에 대해 살펴보자

 

먼저 오류 메시지를 담아두는 errors.properties라는 파일을 만들고,

스프링 부트가 해당 메시지 파일을 인식할 수 있게

spring.messages.basename=messages,errors

 

errors.properties

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

 

errors를 추가해준다. 여기서 {0} {1}은 파라미터 값으로 이해하면 된다.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null,
null));}
 
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
 bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));}
 
 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
{9999}, null));}

 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.addError(new ObjectError("item", new String[]
{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));}}

사용시 위의 방식처럼 5번째 파라미터 값에 new String[]{"error message 키 값"} 으로 메시지 코드를 지정해 줄 수 있다.

여기서 메시지 코드가 배열 형식으로 여러 값을 전달할 수 있는데 순서대로 매칭되어서 처음 매칭되는 메시지가 사용된다.

 

6번째 파라미터 값에는 파라미터값이 있다면 파라미터 값을 new Object[]{0번째 파라미터 값, 1번째 파라미터값} 형식으로 사용한다.

 

하지만 이렇게 사용할 경우 파라미터값이 너무 많아 사용하기에 불편하다.

 

BindingResult가 제공하는 rejectValue(), reject()를 사용하면 간단히 검증할 수 있다.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.rejectValue("itemName", "required");}
 
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
 bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);}

 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);}

 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);}}

해당 코드는 위의 코드와 같은 코드이고 매우 간소화된 것을 볼 수 있다.

 

rejectValue, reject()의 파라미터는 (둘은 필드에러냐 오브젝트에러냐의 차이)

1. 오류 필드명

2. 오류 코드

3. 오류 메시지에서의 파라미터 값 ex) {0} {1}

4. 기본 메시지

 

로 구성되어 있다.

 

여기서 왜 여기서는 오류가 발생한 객체 이름을 안묻는지 의문이 들 수 있다.

 

그 이유는 BindingResult는 무조건 검증할 @ModelAttribute 객체 뒤에 위치해야 하므로 BindingResult는 검증할 객체 대상을 이미 알고있으므로 생략 가능하다.

 

오류 코드는 자세하게 만들 수도 있고, 간단하게 만들 수도 있다.

 

예를 들어,

required.item.itemName:상품 이름은 필수 입니다.

이렇게 자세하게 적용 객체와 필드를 지정해서 할 수도 있고,

required: 필수 값 입니다.

간단하게 적용할 수도 있는데, 우선순위는 구체적인 것이 더 높다.

 

우선순위를 자세히 알아보기 위해 MessageCodesResolver 인터페이스의 구현체인 DefaultMessageCodesResolver 구현체를 알아본다.

 

객체 오류시에

1. code + "." + 객체명 ex) required.item

2. code ex) required

 

필드 오류시에

1. code + "." + 객체 이름 + "." + 필드 ex) required.item.itemName

2. code + "." + 필드 ex) required.itemName

3. code + "." + 필드 타입 ex) required.String

4. code ex) required

 

순서이다. rejectValue()나 reject() 메서드는 내부에서 MessageCodesResolver를 사용하여 메시지 코드를 생성한다.

 

중요한 것은 구체적으로 작성한 것이 우선순위가 높고 추상적일수록 우선순위가 낮다.

 

즉, 이것들을 종합한 순서는 다음과 같다.

 

1. rejectValue() 호출

2. MessageCodesResolver를 사용해 검증 오류 코드로 메시지 코드들 생성

3. new FieldError()를 생성하며 메시지 코드들 보관

4. th:errors 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고 출력

 

그리고 타입 오류가 발생하면 스프링은 typeMismatch라는 오류코드를 출력하는데 이도 4가지의 메시지 코드가 출력되는데 몇가지만 고쳐서

typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

이런 형태로 메시지를 지정하여 출력할 수 있다.

 

이러한 검증 로직들이 모두 컨트롤러에 한꺼번에 들어가있으면 가독성도 안좋을 뿐더러 배보다 배꼽이 더 큰 꼴이 되어버린다. 그래서 별도의 클래스로 분리해주어야 한다.

 

ItemValidaotr.class

@Component
public class ItemValidator implements Validator {

 @Override
 public boolean supports(Class<?> clazz) {
 return Item.class.isAssignableFrom(clazz);}
 
 @Override
 public void validate(Object target, Errors errors) {
 Item item = (Item) target;
 ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName","required");
 
 if (item.getPrice() == null || item.getPrice() < 1000 ||
item.getPrice() > 1000000) {
 errors.rejectValue("price", "range", new Object[]{1000, 1000000},
null);}

 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 errors.rejectValue("quantity", "max", new Object[]{9999}, null);}

 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 errors.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);}}}}

Validator라는 인터페이스를 이용하면 먼저 supports()로 검증기가 지원하는지 여부를 확인하고, validate()로 검증 객체와 BindingResult를 받아 검증하는 로직을 작성한다.

 

private final ItemValidator itemValidator;

itemValidator.validate(item, bindingResult);

사용시에는 의존관계 주입을 해주고, validate() 메서드로 검증해주면 된다.

 

여기서 추가적으로 스프링의 도움을 받아보자

 

@InitBinder
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}

@InitBinder는 해당 컨트롤러에만 영향을 주고,

 

WebDataBinder는 스프링의 파라미터 바인딩 역할을 해주고 검증 기능도 내부에 포함하므로 검증기를 자동으로 적용할 수 있다.

 

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult 
bindingResult, RedirectAttributes redirectAttributes)

이렇게 validator를 직접 호출하는 부분을 제거하고, 파라미터로 @Validated를 입력하니 기존과 동일하게 작동하는 것을 확인할 수 있다.

 

@Validator는 검증기를 실행하라는 애노테이션으로 WebDataBinder에 등록된 검증기를 찾아 실행한다. 만약 여러 검증기가 등록 되어있다면, 어떤 검증기가 실행되어야 하는지 support() 메서드로 구분하여 처리한다.

'Spring > SpringMVC' 카테고리의 다른 글

스프링 로그인 - 쿠키, 세션  (0) 2023.02.12
스프링 검증 - Bean Validation  (0) 2023.02.11
메시지, 국제화  (0) 2023.02.05
타임리프 - 스프링 통합과 폼  (0) 2023.02.05
타임리프 기본 기능 모음  (0) 2023.02.04

+ Recent posts