반응형

검증 요구사항

1. 요구사항

• 타입 검증
    ◦ 가격, 수량에 문자가 들어가면 검증 오류 처리
• 필드 검증
    ◦ 상품명: 필수, 공백X
    ◦ 가격: 1000원 이상, 1백만원 이하
    ◦ 수량: 최대 9999
• 특정 필드의 범위를 넘어서는 검증
    ◦ 가격 * 수량의 합은 10,000원 이상

 

2. 검증 로직의 필요성

• 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것 

정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있음

 

• 참고: 클라이언트 검증, 서버 검증
 - 클라이언트 검증은 조작할 수 있으므로 보안에 취약함
 - 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해짐
 - 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
 - API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

 

 

검증 과정

1. 상품 저장 성공

ⓞ 사용자가 상품 등록페이지에 접근한다(HTTP GET /add)
① 사용자가 상품정보를 입력 후 서버로 전송한다(HTTP POST /add)
② 상품이 성공적으로 등록된 후 Location 정보로 상품정보 상세경로를 Redirect로 응답함
③ 클라이언트에서는 응답받은 정보에 있는 Location정보로 Redirect하여 신규 상세 페이지로 이동함

 

 

2. 상품 저장 실패

ⓞ 사용자가 상품 등록페이지에 접근한다(HTTP GET /add)
① 사용자가 상품정보를 입력 후 서버로 전송한다(HTTP POST /add)
② 상품의 유효성 검증이 실패하며 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동

 

※검증에서 실패하는 대표적인 경우
 - Null
 - TypeMissMatch
 - 비즈니스 요구사항에 맞지 않음
    ex) 상품의 가격은 1000원 이상이여야 하는데 500원으로 작성)

 

 

다양한 검증 방식

1. Map사용 (검증 직접 처리)

※ 서버에서 전달받은 데이터를 직접 검증하여 Map에 담아 RedirectAttributes에 담아 보내는 방법

1.1 상품 추가 controller 소스

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
	//검증 오류 결과를 담음 
	Map<String, String> errors = new HashMap<>();

	//검증 로직
	if(item.getItemName() == null){
		errors.put("itemName", "상품 이름은 필수입니다.");
	}
	//... 기타 검증 로직

	//검증 실패시 다시 입력 폼으로 이동해야 한다.
	if (!errors.isEmpty()) {
		log.info("errors = {}", errors);
		model.addAttribute("errors", errors);
		return "validation/v1/addForm";
	}

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

• 검증에 실패하면 errors라는 Map에 에러 내용을 담아서 model에 담아 타임리프로 반환
• @ModelAttribute 애노테이션이 붙은 Item 객체는 에러가 발생하여 다시 페이지 이동 시 그대로 다시 담겨져 전송되며 타임리프에서 이를 사용할 수 있음
• RedirectAttributes는 uri과 파라미터에 보존할 데이터를 Redirect 할 수 있음.

 

1.2 상품추가 thyemleaf 소스

•  글로벌 오류 메세지

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

※ Safe Navigation Operator
 - 여기에서 errors 가 null 이라면  errors.containsKey() 를 호출하는 순간 NullPointerException 이 발생
 - errors?.은 errors가 null 일때 NullPointerException이 발생하는 대신, null 을 반환하는 문법
 - th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않음

 

• 오류 메세지 적용

<div>
	<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
	<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>
 </div>

※ input의 필드 오류 처리

 - th:class를 사용하여 해당 필드에 오류가 있으면 기존에 있던 form-control 클래스에 field-error 클래스를 추가하여 반환하고 오류가 없으면 기존에 있던 form-control 클래스만 바인딩

 

※ input의 필드 오류 처리 개선

<input type="text" id="itemName" th:field="*{itemName}"
	th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" 
	class="form-control" placeholder="이름을 입력하세요">

 - classappend를 사용해서 해당 필드에 오류가 있으면 field-error 라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조

 - 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않음

 

1.3 문제점

 타입이 안맞는 경우(ex: Integer 타입 변수에 String 타입 값을 바인딩 하려는 경우) 컨트롤러까지 가지도 못하고  400 (Bad Request) 에러가 발생하며 오류 페이지를 띄움
잘못된 타입의 값 전달시에도 오류페이지를 보여주지 않고 잘못된 부분을 사용자에게 알려야함.  
해결책 : BindingResult 클래스를 이용해 타입이 잘못된 내용에도 오류 페이지를 내보내지 않도록 할 수 있음

 

 

2. BindingResult를 이용하여 검증 1

2.1 상품 추가 controller 소스

@PostMapping("/add")
// BindingResult가 Map<String, String> errors역할을 해줌
// BindingResult는 model에 자동으로 담아줌
// BindingResult의 위치는 @ModelAttribute Item item뒤에 와야함
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		
	// 검증 로직
	if (!StringUtils.hasText(item.getItemName())) {
		// 이전소스
		//Map<String, String> errors = new HashMap<>();
		//errors.put("itemName", "상품 이름은 필수 입니다.");

		// modelAttribute에 담길 Object명, 필드명, 메시지
		bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다."));
	}
	// 다른 로직
	// ... 생략 ...
	
	// 특정 필드가 아닌 복합 룰 검증
	if(item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if(resultPrice < 10000) {
			// 이전소스
			//errors.put("globalError", "가격 * 수량은 10,000원 이상이어야 합니다. 현재값 = "+resultPrice);
			bindingResult.addError(new ObjectError("item", "가격 * 수량은 10,000원 이상이어야 합니다. 현재값 = "+resultPrice));
		}
	}
	
	// 검증에 실패하면 다시 입력 폼으로
	if(bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		// 이전소스
		//model.addAttribute("errors",errors);
		return "validation/v2/addForm";
	}
	
	// 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 컨트롤러의 매핑 메서드에서 BindingResult를 매개변수로 받음으로써 타입 불일치에 대한 대응 가능
BindingResult 매개변수는 반드시 전송받을 객체(ex: @ModelAttribute Item item) 다음에 위치해야 함 

 bindingResult의 addError 메서드를 이용해 에러내용을 담을 수 있음
 - @ModelAttribute 필드(ex: name, price, quantity, ...)에러인 경우 FieldError객체를 이용해 담으면 됨.


 필드 에러 (FieldError) 생성자 요약

public FieldError(String objectName, String field, String defaultMessage) {}

 - objectName: @ModelAttribute 이름
 - field: 오류가 발생한 필드 이름
 - defaultMessage: 기본 오류 메세지

 

※ 글로벌 오류인 경우 ObjectError객체를 이용해 담으면 된다. 
ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage) {}

 - objectName : @ModelAttribute의 이름

 - defaultMessage : 오류 기본 메시지

 

 

2.2 상품추가 thyemleaf 소스

<form action="item.html" th:action th:object="${item}" method="post">
	<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>

${#fields}: BindingResult가 제공하는 검증 오류에 접근이 가능하다.
 th:errors: 해당 필드에 오류가 있는 경우 태그를 출력 (th:if 편의 버전)
 th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class정보를 추가

 

※ BindingResult를 사용할 경우 클라이언트에서 타입이 잘못된 내용이 전송되더라도 BindingResult에 그 오류내용(FieldError)을 담아서 컨트롤러를 정상 호출함

※  BindingResult의 내용은 자동으로 Model에 담겨지기 때문에 타임리프에서도 자연스럽게 사용 가능

 

2.3 문제점

 사용자가 잘못 입력해서 전송한 데이터가 남아있지 않음 (사용자 입력 값을 유지할 수 없음)

 사용자가 잘못 입력한 내용이 뭔지 잊을수도있고, 혹은 에러내용을 봐도 에러 내용이 자세하지 않으면 내가 어디가 어떻게 잘못 입력했는지 파악하기 힘들어짐  
 매번 에러 메세지를 하드코딩으로 입력해야하는것도 쉽지 않고 코드 중복이 심함
해결책 : 위에서 사용한 FieldError에 오버로딩 된 생성자가 존재함 

 

 

3. BindingResult를 이용하여 검증 2 (사용자가 입력한 값을 유지하는 방법)

3.1 FieldError에 오버로딩 된 생성자 분석

public FieldError(String objectName,               // 오류가 발생한 객체 이름
                  String field,                    // 오류 필드
                  @Nullable Object rejectedValue,  // 사용자가 입력한 값(거절된 값)
                  boolean bindingFailure,          // 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 
                  @Nullable String[] codes,        // 메세지 코드
                  @Nullable Object[] arguments,    // 메세지에서 사용하는 인자
                  @Nullable String defaultMessage) // 기본 오류 메세지.

//사용 예
new FieldError( "item", 
	        "itemName", 
	        item.getItemName(),	// 사용자가 입력 한 값 (거절된 값)
	        false,
	        null,
	        null,
	        "상품 이름은 필수입니다.")

 

3.2 controller 소스

@PostMapping("/add")
// BindingResult의 fieldError에 입력한 값 다시 넣어주는 오버로딩된 메서드 활용
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		
	// 검증 로직
	if (!StringUtils.hasText(item.getItemName())) {
		// feildError의 파라미터 : 오류가 발생한 객체이름, 오류 필드, 사용자가 입력한값(거절된값), 타입오류 같은 바인딩 실패인지 검증실패인지 구분값, 메시지코드, 메시지에서 사용하는 인자, 기본 오류 메시지
		bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다."));
	}

	// 다른 로직
	// ... 생략 ...
		
	// 검증에 실패하면 다시 입력 폼으로
	if(bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		return "validation/v2/addForm";
	}
	
	// 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

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

 - 사용자가 입력한 값 (거절된 값을 3번째 인자로 전달)

 

 

3. BindingResult를 이용하여 검증 3 (errors.proerties로 에러 메세지 관리)

3.1 FieldError에 오버로딩 된 생성자 분석

public FieldError(String objectName,               // 오류가 발생한 객체 이름
                  String field,                    // 오류 필드
                  @Nullable Object rejectedValue,  // 사용자가 입력한 값(거절된 값)
                  boolean bindingFailure,          // 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 
                  @Nullable String[] codes,        // 메세지 코드
                  @Nullable Object[] arguments,    // 메세지에서 사용하는 인자
                  @Nullable String defaultMessage) // 기본 오류 메세지.

//사용 예
new FieldError( "item", 
	        "price", 
	        item.getPrice(),	
	        false,
	        new String[]{"range.item.price"},    // 메세지 코드
	        new Object[]{1000, 1000000},         // 메세지에서 사용하는 인자
	        null)

 

3.2 메세지 코드 사용

스프링 부트 메세지 설정 추가 (application.properties) 파일

spring.messages.basename=messages,errors

 

resources/errors.properties 파일 추가

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

 

3.3 controller 소스

@PostMapping("/add")
// error.properties 기능 사용
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
	// 다른 로직
	// ... 생략 ...
		
	// 검증 로직
	if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
		// new Object[]{1000, 100000}는 properties 입력한 값의 {0} {1}의 치환 인자 값
		bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
	}
		
	// 다른 로직
	// ... 생략 ...

	// 검증에 실패하면 다시 입력 폼으로
	if(bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		return "validation/v2/addForm";
	}
	
	// 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[] "range.item.price"}, new Object[]{1000, 1000000}, null));

 - 사용할 메세지 코드 (거절된 값을 5번째 인자로 전달)

 - 사용할 메세지 코드에 넘길 인자 (거절된 값을 6번째 인자로 전달)

 

3.4 문제점

 소스 작성이 너무 번거롭고 에러하나 담는데 넣어야 할 속성도 너무 많음
 messages의 이름도 range.item.price을 매번 다 적는것도 번거로움
해결책 : BindingResult에서는 rejectValue(), reject() 메서드를 통해 FieldError, ObjectError을 직접 생성하지 않아도 되도록 해줌

 

 

4. BindingResult를 이용하여 검증 4 (rejectValue(), reject() 메서드)

4.1 사용 예시

//before
bindingResult.addError(new FieldError("item", "itemName",item.getItemName(), false, new String[]{"required.item.itemName"}, null, null))
bindingResult.addError(new FieldError("item", "price", item.getPrice(),false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null))

//after
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1_000_000}, null);



// before
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[] {10000, resultPrice}, null));

// after
bindingResult.reject("totalPriceMin", new Object[] {10000, resultPrice}, null);

 

4.2 rejectValue 메서드의 매개 변수

void rejectValue(@Nullable String field,         // 오류 필드명
		String errorCode,                // MessageResolver를 위한 오류 코드
		@Nullable Object[] errorArgs,    // 오류 메세지에서 {0}을 치환하기 위한 값
		@Nullable String defaultMessage);// 오류 메세지를 못찾을 경우 기본 메세지

 

4.3 reject 메서드 매개 변수

void reject(String errorCode,                    // MessageResolver를 위한 오류 코드
		@Nullable Object[] errorArgs,    // 오류 메세지에서 {0}을 치환하기 위한 값
		@Nullable String defaultMessage);// 오류 메세지를 못찾을 경우 기본 메세지

※ FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했으나 rejectValue()를 사용하고 부터는 오류 코드를 range 로 간단하게 입력함.

 

 field와 errorCode 매개변수를 가지고 errors.properties에서 메세지를 찾아낸다는 것인데, rejectValue()와 rejct()는 내부에서 MessageCodesResolver를 통해서 찾아냄.

 

 

MessageCodesResolver

1. MessageCodesResolver 인터페이스 분석

 스프링에서 제공하는 마커 인터페이스인 MessageCodesResolver는 다음과 같은 메서드가 정의되어 있음

public interface MessageCodesResolver {
	String[] resolveMessageCodes(String errorCode, String objectName);
	String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType);
}

※ 이 인터페이스의 기본 구현체로 DefaultMessageCodesResolver를 제공하는데 이를 이용해서 각종 메세지에 대한 대처가 쉽게 가능함.


2. MessageCodesResolver의 동작

 메세지 혹은 예외메세지는 특정 필드에 맞는 메세지가 있을수도 있지만 범용성이 높은 메세지도 있을 수 있음

 예를들어 required.item.itemName=상품 이름은 필수 입니다. 라고 디테일하게 에러 메세지를 작성할 수 있지만,

required=필수 값입니다. 라고 범용적인 메세지를 작성할수도 있음.

 범용성의 수준에따라 단계를 만들어두면 MessageCodesResolver는 범용성이 낮은순서에서 높은순서로 차례대로 찾으면서 처음 매칭되는 결과를 가져옴

 

 메세지 예시

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

#level 2
required: 필수 값 입니다.

 - MessageCodesResolver는 디테일한순서부터 차례대로 찾음

 - 만약 level1이 작성되어있지 않다면 level2의 required값을 찾아서 담음

 - 이렇게 작성하면 오류메세지에 대한 대응이 한결 편해짐

 

2. DefaultMessageCodesResolver의 기본 매세지 생성 규칙

※ 객체 오류와 필드 오류를 범용성 순으로 찾음.
2.1 객체 오류

객체 오류의 경우 다음 순서로 2가지 생성 
1.: code + "." + object name 
2.: code

예) 오류 코드: required, object name: item 
1.: required.item
2.: required


2.2 필드 오류
※ 필드 오류의 경우 다음 순서로4가지 메시지 코드 생성

1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int 
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

※ 구체적인 것에서 덜 구체적인 순으로 찾음

 

2.3 예시

 reject("totalPriceMin") ObjectError  발생 시
다음 2가지 오류 코드를 자동으로 생성

new String[]{"totalPriceMin.item", "totalPriceMin"}을 내부에서 만들어 메세지를 찾음
 - totalPriceMin.item
 - totalPriceMin

 

 rejectValue("itemName", "required") FieldError 발생 시 
다음 4가지 오류 코드를 자동으로 생성

new String[]{"required.item.itemName", "required.itemName", "required.java.lang.String", "required"} 를 내부에서 만들어 메세지를 찾음
 - required.item.itemName
 - required.itemName
 - required.java.lang.String
 - required


 오류 메시지 출력
 - 타임리프 화면을 렌더링 할 때 th:errors가 실행

 - 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾음

 - 없으면 디폴트 메시지 출력

 

 

3. MessageCodesResolver 사용해보기(테스트)

3.1 객체 오류 조회 해보는 테스트

@Test
void messageCodesResolverObject() {
	DefaultMessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
	String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
	for (String msg : messageCodes) {
		System.out.println(msg);
	}
}

 - required.item
 - required

 

 

3.2 필드 오류 조회 해보는 테스트

@Test
void messageCodesResolverField() {
	DefaultMessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
	String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
	for (String msg : messageCodes) {
		System.out.println(msg);
	}
}

 - required.item.itemName
 - required.itemName
 - required.java.lang.String
 - required

 

 

ValidationUtils

※ 유효성 검증을 더 편하게 작성할 수 있는 객체

//before
if (!StringUtils.hasText(item.getItemName())) { 
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다."); 
}

//after
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 너무 복잡한 검증은 힘들고 위 코드처럼 단순한 Empty나 공백처리같은 기능만 제공
 내부적으로 rejectValue를 호출하고 위에서 우리가 소개한 여러 방식들을 사용해서 에러를 담음
(MessageCodesResolver, new FiledError(), ...)

 

 

 

스프링에서 제공하는 기본 오류 메세지

※ 직접 정의한 오류 코드는 rejectValue()를 직접 호출해서 담아주지만, 스프링이 직접 검증 오류에 추가한 경우도 있음 (주로 타입 정보 불일치)

스프링에서 직접 검증 오류에 추가를 한 것으로 BindingResult에 FieldError가 다음과 같은 메세지코드가 생성되어 추가됨.

codes[
typeMismatch.item.price, 
typeMismatch.price, 
typeMismatch.java.lang.Integer, 
typeMismatch
]

※ 스프링은 타입 오류가 발생하면 자동으로 위 오류 코드들을 사용하게 됨

 errors.properties에는 해당 내용으로 정의한 메세지가 없기 때문에 스프링에서 정의한 기본 메세지가 출력됨

※ 하지만, 기본 메세지는 너무 장황하고 길어서 개발자가아닌 사용자에게 노출해서는 안됨
 errors.properties에 다음과 같이 메세지를 추가

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



Validator 분리

※ 검증 로직은 중복이 많고, 매번 필요할때마다 작성하는것은 비효율적이지만 중요도가 높은만큼 생략할수도 없음

※ 이런 검증 로직을 별도의 클래스로 분리해서 모듈화하면 재사용성이 높아지고 가독성또한 높아질 수 있음.

 

1. 분리 예제 1

1.1 validator 인터페이스 분석

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}

 인터페이스는 책임 사슬 패턴에서 주로보이는 메서드인 supports와 실제 검증을 수행하는 validate메서드를 정의하고있음.

Validator 인터페이스를 구현하면서 Item에 대한 검증로직을 구현

 

1.2 ItemValidator 객체 생성

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // isAssignableFrom을 사용하면, class와 자식 클래스까지 검증함
        // item == clazz
        // item == subItem
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;

        ValidationUtils.rejectIfEmpty(errors, "itemName", "required");

        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            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);
            }
        }
    }
}

 Item.class.isAssignableFrom(clazz): 해당 Validator 구현체는 Item 클래스에 대한 검증을 수행할 수 있음을 의미
 Errors errors : 매개변수타입인 Errors는 BindingResult클래스의 부모 타입이기 때문에 공변성이 성립함


※ itemValidator는 Component라 Component Scan으로 등록되었기 때문에 Dependency Injection을 받아서 컨트롤러에서 사용가능

 

1.3 controller에서 itemValidator사용 법

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

        ...

    @PostMapping("/add")
    public String addItemV5(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        itemValidator.validate(item, bindingResult);				

        //검증 실패시 다시 입력 폼으로 이동해야 한다.
        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "validation/v2/addForm";
        }
        
        ...
        
    }
}

※ 컨트롤러에 있던 검증 로직이 itemValidator.validate()메서드 호출로 검증이 가능

 

 

2. 분리 예제 2 - 에노테이션

※ 스프링에서는 Validator 인터페이스를 구현해서 검증로직을 만들면 추가적으로 애너테이션을 사용하여 검증을 수행할수도 있음

 WebDataBinder를 이용하는 것인데 이 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함하는 클래스

 객체에 내가 만든 검증기를 추가(add)하면 자동으로 검증기 적용이 가능

 

2.1 한 컨트롤러에만 적용 - 예제1

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

	private final ItemRepository itemRepository;
	private final ItemValidator itemValidator;

	// 컨트롤러가 호출 될때마다 항상 실행됨
	@InitBinder
	// item 객체에 파라미터 바인딩 해주고, 검증기를 가지고 검증을 해줌
	// Spring MVC가 내부에서 검증기를 적용
	public void init(WebDataBinder dataBinder) {
		dataBinder.addValidators(itemValidator);
	}
    
	...
    
	@PostMapping("/add")
	// @Validated : item에 대해서 자동으로 검증기가 수행이됨 
	//자동 검증
	public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

	//검증 실패시 다시 입력 폼으로 이동해야 한다.
	if (bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		return "validation/v2/addForm";
	}
	...
}
    
}

 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있음

 @InitBinder → 해당 컨트롤러에만 영향을 줌

@Validated 어노테이션으로  item에 대해서 자동으로 검증기가 수행됨

 

2.2 글로벌 설정 방법

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Override
	public Validator getValidator() {
		return new ItemValidator();
	}
}

 

2.3 @Validated, @Valid
 @Validated : org.springframework.validation.annotation.Validated가 스프링 전용 검증 애너테이션
 @Valid : javax.validation.@Valid는 자바 표준 검증 애너테이션

 둘 다 역할은 동일하지만, @Valid는 build.gradle에 다음과 같은 의존성을 추가해줘야 함

implementation 'org.springframework.boot:spring-boot-starter-validation'
반응형

+ Recent posts