🌿 시작하기 앞서


스프링 부트 3.2.1 버전을 기준으로 작성됨
대부분이 실습코드이므로 학습위주로 정리함

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

참고: 클라이언트 검증, 서버 검증

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.

  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.

  • 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수

  • API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함


✔️ Validation V1


// 생으로 if문 걸어서 값 null 체크 및 값 체크 등
// Map에 error를 직접 담아서 모델에 보낸다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes
redirectAttributes, Model model) {

 //검증 오류 결과를 보관
 Map<String, String> errors = new HashMap<>();
 
 //검증 로직
 if (!StringUtils.hasText(item.getItemName())) {
 errors.put("itemName", "상품 이름은 필수입니다.");

 
 //특정 필드가 아닌 복합 룰 검증
 if (...) {
	if (...) {
 		errors.put("globalError", "...");
 	}
 }
 
 //검증에 실패하면 다시 입력 폼으로
 if (!errors.isEmpty()) {
 model.addAttribute("errors", errors);
 return "validation/v1/addForm";
 }
 
 //성공 로직

addForm.html

th:if="${errors?.containsKey('globalError')}"

th:text="${errors['globalError']}"
이런식으로 에러 코드를 넣어 있다면 에러를 출력

참고 Safe Navigation Operator
errors?. 은 errors가 null일 때 NullPointerException 대신 null을 반환하는 문법
th:if 에서 null은 실패로 처리 -> 오류 메시지 출력 x

필드 오류 처리

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">

값이 없으면 _(No-Operation)을 사용해서 동작 무시

정리

  • 검증 오류 발생시 입력 폼으로
  • 검증 오류들을 고객에게 안내 및 다시 입력
  • 검증 오류가 발생해도 입력 데이터 유지

문제점

  • 뷰 템플릿 중복 처리가 많음
  • 타입 오류 처리가 안됨 -> 400 에러
  • 타입 오류로 잘못 입력한 값도 입력 데이터를 유지해야함
  • 고객이 입력한 값을 별도로 관리해야함

✔️ Validation V2


스프링이 제공하는 검증 오류 처리
BindingResult

  • 스프링이 제공하는 검증 오류를 보관하는 객체
  • 오류 정보를 BindingResult에 담아서 컨트롤러 정상 호출

@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));
 		}
 	}
    
 if (bindingResult.hasErrors()) {
 log.info("errors={}", bindingResult);
 return "validation/v2/addForm";
 }
 //성공 로직

}

주의
BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.

/** Field Error 생성자 요약
* objectName : @ModelAttribute 이름
* field : 오류가 발생한 필드 이름
* defaultMessage : 오류 기본 메시지
*/
public FieldError(String objectName, String field, String defaultMessage) {}

/** 글로벌 오류 ObjectError
* objectName : @ModelAttribute 이름
* defaultMessage : 오류 기본 메시지
*/
public ObjectError(String objectName, String defaultMessage) {}

단점 : 오류 발생시 입력 데이터가 사라짐


✔️ FieldError, ObjectError


사용자 입력 데이터 유지

public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {

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

	//특정 필드 예외가 아닌 전체 예외
	//if (item.getPrice() != null && item.getQuantity() != null) {
	//	int resultPrice = item.getPrice() * item.getQuantity();
		
        //if (resultPrice < 10000) {
 			bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량 의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
 		}
 	}
    
 if (bindingResult.hasErrors()) {
 	//log.info("errors={}", bindingResult);
 	return "validation/v2/addForm";
 }
 
 //성공 로직

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 : 타입 오류 같은 바인딩 실패인지 검증 실패인지 구분 값 (false가 검증 실패)
* codes : 메시지 코드  String[]
* arguments : 메시지에서 사용하는 인자 Object[]
* defaultMessage : 기본 오류 메시지
  • FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공
  • th:field="*{price}"
    • 오류 발생시 FieldError에서 보관한 값 사용해서 출력
  • 타입오류로 바인딩 실패시 스프링이 FieldError를 생성 및 사용자 값 저장한다.
    • 해당 오류를 BindingResult에 담아서 컨트롤러 호출

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

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

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

// 필드 오류 처리 
<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
	<div class="field-error" th:errors="*{itemName}">
 		상품명 오류
	</div>
  • #fields: #fields 로 BindingResult 가 제공하는 검증 오류에 접근 가능

  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전

  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가


✔️ 오류 코드와 메시지 처리


application.properties


spring.messages.basename=messages,errors 추가

errors.properties



required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}
  • codes : String[] 로 여러개의 메시지 전달되어 순서대로 매칭
  • arguments : Object[]로 {0}, {1} 등 순서에 맞게 치환값 전달


rejectValue(), reject()

FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류 다루기 가능

 if (!StringUtils.hasText(item.getItemName())) {
 	bindingResult.rejectValue("itemName", "required");
 }
rejectValue()
void rejectValue(@Nullable String field, String errorCode,
				@Nullable Object[] errorArgs, @Nullable String defaultMessage);
                
* field : 오류 필드명
* errorCode : 오류 코드 (메시지에 등록된 코드 x )
* errorArgs : 오류 메시지에서 {0} 등을 치환하기 위한 값
* defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지 
원리

MessageCodesResolver 의 기능

  • 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 높은 우선순위를 갖는다.

  • 검증 오류 코드로 메시지 코드들을 생성한다.

  • MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.

  • 주로 다음과 함께 사용 ObjectError , FieldError

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

예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류
필드 오류의 경우 다음 순서로 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"
동작 방식
  • rejectValue(), reject() 내부에서 MessageCodesResolver 사용하여 메시지 코드 생성
  • 이를 통해 생성된 순서대로 오류 코드를 보관
오류 메시지 출력
  • 타임리프 화면을 렌더링 할 때 th:errors 가 실행

  • 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 조회

  • 없으면 디폴트 메시지를 출력


ValidationUtils

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

// 사용 후 
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

스프링이 직접 만든 오류 메시지 처리

스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용

// 4가지 메시지 코드 생성
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch

타입 에러를 일으키면

Failed to convert property value of type java.lang.String to required type
java.lang.Integer for property price; nested exception is
java.lang.NumberFormatException: For input string: "A"

이런 문구가 뜬다. 이걸 변경해보자

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

이렇게 하면 원하는 메시지를 출력 가능


✔️ Validator 분리


검증 로직을 별도로 분리

V1

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}
  • supports() : 해당 검증기를 지원하는지 여부 확인
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

하나의 검증 클래스를 만들고 Validator를 구현

@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.getPrice() != null && item.getQuantity() != null) {
 	int resultPrice = item.getPrice() * item.getQuantity();
 		if (resultPrice < 10000) {
 			errors.reject("totalPriceMin", new Object[]{10000, resultPrice},
null);
 			}
		}
	}
}
// 스프링 빈으로 주입받아서 직접 호출한 것임
private final ItemValidator itemValidator;

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

✔️ V2

WebDataBinder 사용
스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함

@InitBinder // 해당 컨트롤러에서 검증기 자동 적용
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

// 깔끔...

if (bindingResult.hasErrors()) {
	log.info("errors={}", bindingResult);
	return "validation/v2/addForm";
 }
 //성공 로직

@Validated @ModelAttribute Item item

  • 꼭 @Validated 를 써주어야 적용된다.
  • 검증 대상 앞에 붙여준다.

✅ 참고
@Validated 는 스프링 전용
@Valid 는 자바 표준 검증 애노테이션
둘다 사용 가능하다.



🔖 학습내용 출처

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

profile
열심히 살자

0개의 댓글

Powered by GraphCDN, the GraphQL CDN