본문 바로가기
Spring/MVC 2편

검증1 - Validation

by JHyun0302 2023. 8. 6.
728x90

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

 

◎ 참고클라이언트 검증서버 검증

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

 

 

글로벌 오류 메시지

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

 

  • 오류 메시지는 errors 에 내용이 있을 때만 출력하면 된다.
  • 타임리프의 th:if 를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력할 수 있다.

 

참고 Safe Navigation Operator

  • errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법이다.
  • th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

 

 

필드 오류 처리

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" class="form-control">
  • classappend 를 사용해서 해당 필드에 오류가 있으면 field-error 라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조한다.
  • 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않는다.

 

 

 

 


필드 오류 - FieldError

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

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

 

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.

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

 

 

글로벌 오류 - ObjectError

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

//ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}

 

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

 

 

 

글로벌 오류 처리

<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 정보를 추가한다.

 

 


BindingResult

 

스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.

BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!

 

 

  • ex) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
  • BindingResult 가 있으면 오류 정보( FieldError )BindingResult 에 담아서 컨트롤러를 정상 호출한다.

 

BindingResult에 검증 오류를 적용하는 3가지 방법 

  1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다
  2. 개발자가 직접 넣어준다.
  3. Validator 사용 

 

※ 주의

  • BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다.
  • 예를 들어서 @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 한다.
  • BindingResult 는 Model에 자동으로 포함된다.

 

 

 


FieldError, ObjectError

 

 

목표

  • 사용자 입력 오류 메시지가 화면에 남도록 하자.
        ex) 가격을 1000원 미만으로 설정시 입력한 값이 남아있어야 한다.
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 : 기본 오류 메시지

ex) new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")

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

 

  • 여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
  • bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.
  • 여기서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.

 

th:field="*{price}"

 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력

 

 

 


오류 코드와 메시지 처리1

 

errors 메시지 파일 생성
messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 생성

 

 

application.properties

spring.messages.basename=messages,errors

 

errors.properties

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

 

 

 

 

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000}
  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다.
                   메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

 

 

 


오류 코드와 메시지 처리2

 

rejectValue() , reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않아도 된다.

 

 

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

 

  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. messageResolver를 위한 오류 코드이다.)
  • errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

 bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)


축약된 오류 코드

  • FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다.
  • 그런데 rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다.
  • 이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다.

 

 

 


오류 코드와 메시지 처리3

 

자세한 오류 코드

  • required.item.itemName : 상품 이름은 필수 입니다.
  • range.item.price : 상품의 가격 범위 오류 입니다.

 

단순한 오류 코드

  • required : 필수 값 입니다.
  • range : 범위 오류 입니다.

 

객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.

 

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

#Level2
required: 필수 값 입니다.

 

 

스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원한다

 

 

 


오류 코드와 메시지 처리4

 

 

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
  • 주로 다음과 함께 사용 ObjectError , FieldError

 

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

 

객체 오류

객체 오류의 경우 다음 순서로 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"

 

 

 

FieldError 

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

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

 

ObjectError

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

totalPriceMin.item
totalPriceMin

 

 

오류 메시지 출력

타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.

 

 

 

 

 


오류 코드와 메시지 처리5

 

 

핵심은 구체적인 것에서! 덜 구체적인 것으로!

 

errors.properties

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

#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다. 
range.item.price=가격은 {0} ~ {1} 까지 허용합니다. 
max.item.quantity=수량은 최대 {0} 까지 허용합니다.

#Level2 - 생략

#Level3
required.java.lang.String = 필수 문자입니다. 
required.java.lang.Integer = 필수 숫자입니다. 
min.java.lang.String = {0} 이상의 문자를 입력해주세요. 
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요. 
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요. 
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요. 
max.java.lang.String = {0} 까지의 문자를 허용합니다. 
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.

#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

 

  • 이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource 에서 메시지에서 찾는다.
  • 구체적인 것에서 덜 구체적인 순서대로 찾는다. 메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾는다.

 

 

 

ValidationUtils

// ValidationUtils 사용 전

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


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

 

 

 

★ 정리

  1. rejectValue() 호출
  2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
  3. new FieldError() 를 생성하면서 메시지 코드들을 보관
  4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출

 

 

 

 


오류 코드와 메시지 처리6

 

 

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

  • 스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다.
  • 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것

 

  1. typeMismatch.item.price
  2. typeMismatch.price
  3. typeMismatch.java.lang.Integer
  4. typeMismatch

 

error.properties

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

 

◎ 결과 :소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다.

 

 

 

 

 

 


Validator 분리1

 

◎ 목표 : 복잡한 검증 로직을 별도로 분리하자.

 

@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);
            }
        }
    } 
}

 

  • supports() {} : 해당 검증기를 지원하는 여부 확인(뒤에서 설명)
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

 

private final ItemValidator itemValidator;

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

	itemValidator.validate(item, bindingResult);
	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}";
}

 

◎ 정리 : 검증과 관련된 부분이 깔끔하게 분리되었다.

 

 

 

 

 

 


Validator 분리2

 

 

 

WebDataBinder를 통해서 사용하기
WebDataBinder 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.

 

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

 

  • 이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
  • @InitBinder → 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.

 

 

글로벌 설정 - 모든 컨트롤러에 다 적용

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
    public static void main(String[] args) {
        SpringApplication.run(ItemServiceApplication.class, args);
    }
    
    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }
}

 

★ 주의: 글로벌 설정을 하면 다음에 설명할 BeanValidator가 자동 등록되지 않는다. 참고로 글로벌 설정을 직접 사용하는 경우는 드물다.

 

 

 

 

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    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}";
}

 

validator를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에 @Validated 가 붙었다.

 

 

 

 

 

◎ 참고

검증시 @Validated @Valid 둘다 사용가능하다.

javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.

 

반응형

'Spring > MVC 2편' 카테고리의 다른 글

로그인 처리1 - 쿠키, 세션  (0) 2023.08.07
검증2 - Bean Validation  (0) 2023.08.07
메시지, 국제화  (0) 2023.08.06
타임리프 - 스프링 통합과 폼  (0) 2023.08.06
타임리프 - 기본 기능  (0) 2023.08.05