문준영
새벽 코딩
문준영
전체 방문자
오늘
어제
  • 분류 전체보기
    • 웹 개발
    • JAVA
      • 기본 문법 내용 정리
      • 함수 내용 정리
      • 쉽게 배우는 자바 프로그래밍 문제 풀이
    • HTML
      • HTML
      • CSS
      • 문제풀이
    • JavaScript
    • MYSQL
    • C
      • 기본 문법 내용 정리
      • 백준 알고리즘 (c언어)
      • 자료구조
    • Python
      • 참고 알고리즘
      • 기본 문법 내용 정리
      • 자료구조 내용 정리
      • 백준 알고리즘 (파이썬)
    • 깃허브
    • 멀티잇 풀스택

티스토리

hELLO · Designed By 정상우.
문준영

새벽 코딩

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

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

2024. 5. 30. 13:54
검증 

여태까지 만든 웹 애플리케이션은 폼 입력 시 숫자칸에 문자를 입력했을 때 검증 오류가 발생하면서 400 오류화면이 보여진다. 이렇게 되면 사용자는 코드에러에 당황할 뿐만 아니라 처음부터 폼에 값을 다시 입력해야한다.

웹 서비스는 폼 입력 시 오류가 발생하면 어떤 부분이 오류가 났는지, 고객이 입력한 데이터를 유지한 상태에서 알려줘야한다.

 

검증 V1 ~ V6 까지 단계적으로 검증을 하는 방법을 정리하였다.

 

검증 V1 - @Model 

상품 저장 검증 실패

그림 처럼 검증 오류 결과가 포함된 결과를 모델에 담아 전달한다.

 

ValidationItemControllerV1 - addItem()

@Model을 통해 에러내용을 모델에 담아 반환한다.

errors에 값이 들어있으면 검증에 실패하였으므로 입력 폼으로 돌아간다.

@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 (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
 errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
 }
 
 if (item.getQuantity() == null || item.getQuantity() > 9999) {
 errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
 }
 //특정 필드가 아닌 복합 룰 검증
 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
     errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재
    값 = " + resultPrice);
 }
 }
 //검증에 실패하면 다시 입력 폼으로
 if (!errors.isEmpty()) {
 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/v1/items/{itemId}";
}
  • HashMap을 통해 String, String 형식으로 put()을 통해 오류 정보를 담는다.
  • StringUtils.hasText(String) : null , 공백, 길이, 등 검증을 담고 있는 함수

addForm.html

타임리프를 통해 모델에 넣은 값들을 if문을 통해 검사하고 출력한다. 

<form action="item.html" th:action th:object="${item}" method="post">
 <div th:if="${errors?.containsKey('globalError')}">
     <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
 </div>
 <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>
 
 <div>
     <label for="price" th:text="#{label.item.price}">가격</label>
     <input type="text" id="price" th:field="*{price}" 
     th:class="${errors?.containsKey('price')} ? 'form-control  field-error' : 'form-control'"
     class="form-control" placeholder="가격을 입력하세요">
 	<div class="field-error" th:if="${errors?.containsKey('price')}"th:text="${errors['price']}">
     가격 오류
     </div>
 </div>
  ...
</form>
  • 등록 폼 진입 시점에는 errors가 비어있다. 따라서 errors.containsKey()를 호출하면 null 오류가 뜨므로 erros? 로 입력해야 한다.
  • 조건문을 통해 th:class에 값을 변경한다.
    • 조건에 맞으면 class를 추가하는 classappend를 사용해도 된다.
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" class="form-control">

 

 

검증 V1 정리

  • 검증 오류 시 오류 내용을 사용자에게 전달해줄 수 있다. - Model을 통해
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다. - @ModelAttribute시 자동으로 Model에 등록된다.

검증 V1 문제점

  • 뷰 템플릿에서 중복처리가 많아진다.
  • 타입 오류 처리가 불가하다. 숫자 필드 입력칸에 문자 입력 시 해당 값을 저장할 수가 없음

 

검증 V2 - BindingResult

ValidationItemControllerV2 - addItem()

BindingResult의 addError를 통해 에러를 추가한다. 상황에 맞게 FiledError와 ObjectError를 담아둔다.

addError시 FieldError또는 ObjectError에 입력한 값을 유지하려면 rejectedValue을 넣어야 한다.

@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.getPrice() < 1000 || item.getPrice() >1000000) {
 	bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
 }
 if (item.getQuantity() == null || item.getQuantity() >= 10000) {
 	bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
 }
 //특정 필드 예외가 아닌 전체 예외
 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()) {
     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는 반드시 @ModelAttribute Item 뒤에 와야한다.
  • addError()을 통해 에러를 추가한다.

 

addForm.html

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

<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">
         <div class="field-error" th:errors="*{itemName}">
            상품명 오류
         </div>
     </div>
     <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="*{price}">
            가격 오류
     	</div>
     </div>
     ...
 </form>
  • #fields : BindingResult가 제공하는 오류에 접근
  • th:field: 정상 상황에서는 모델 객체 값을 사용하지만, 오류 발생 시 FiledError에서 보관한 값을 사용한다.
  • th:errors : 해당 필드에 오류가 있는 경우 태그를 출력 if문과 유사
  • th:errorclass : th:field를 통해 지정 필드에 오류가 있으면 class를 추가한다.

 

BindingResult

스프링이 제공하는 검증 오류를 보관하는 객체로 검증 오류 발생 시 오류가 보관된다.

타입 오류가 발생하면 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다. 

 

BindingResult에서 검증 오류를 적용하는 방법

  1. @ModelAttribute의 객체에 바인딩이 실패하는 경우 스프링이 자동으로 FiledError를 생성하여 넣어줌
  2. 개발자가 직접 Field를 생성해서 넣음
  3. Validator을 통해 처리

 

FiledError 1 (입력 값이 유지되지 않음)

public FieldError(String objectName, String field, String defaultMessage) {}
  • objectName: @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 메세지

 

FiledError 2 (입력 값을 유지)

public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable 
Object[] arguments, @Nullable String defaultMessage) {}
  • objectName: @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • rejectedValue : 사용자가 입력한 값(잘못 입력한 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분하는 값
  • codes : 메세지 코드
  • arguments: 메세지 인자
  • defaultMessage : 오류 메세지

FiledError 2  입력 예시

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

 

오류 발생시 사용자 입력 값 유지

사용자가 입력한 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에서 입력한 값을 유지하기 어렵다. 그래서 오류가 발생한 시점에 사용자의 입력값을 보관하는 별도의 방법이 필요하다.

FiledError는 오류 발생시 사용자 입력값을 저장하는 기능을 제공한다. FiledError의 rejectedValue가 오류 발생 시 사용자 입력값을 저장하는 필드이다.

 

ObjectError 1

public ObjectError(String objectName, String defaultMessage) {}

 

ObjectError 2

public ObjectError(String objectName,rejectedValue,@Nullable String[] codes, @Nullable 
Object[] arguments, @Nullable String defaultMessage) {}

 

검증 V2 정리

  • 타입 오류로 바인딩에 실패해도 오류 메세지를 정상적으로 출력할 수 있다. - 바인딩 실패시 FieldError를 생성하고 BindingResult에 담아서 컨트롤러를 호출하기 때문
  • BindingResult를 통해 뷰 템플릿을 효율적으로 사용이 가능해졌다. - th:error를 통해 코드를 간략화시켰다.

검증 V2 문제점

  • addError()시 반복되는 new FiledError, new ObjectError 코드가 발생한다.
  • 오류 메세지 변경 시 모든 오류 메세지를 변경해야 하는 번거로움이 생긴다. 
  • 컨트롤러에서 오류 검증까지 해야하기 때문에 코드가 길고 복잡해진다.

 

검증 V3 - 오류 코드와 메세지 처리 1

FiledError 나 ObjectError의 생성자는 codes, arguments를 제공한다. 이러한 파라미터는 오류 발생 시 오류 코드로 메세지를 찾는 용도로 사용한다.

 

erros 메세지 파일 생성

  1. erros.properties 파일 생성
  2. application.properties에 properties 파일을 모두 추가한다.
spring.messages.basename=messages,errors

 

erros.properties

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

 

ValidationItemControllerV3 - addItem()

@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
 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));
 	}
 }
 if (bindingResult.hasErrors()) {
 	return "validation/v2/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v2/items/{itemId}";
}
  • FieldError나 ObjectError 생성자에 codes 파라미터를 추가하여 메세지 코드를 지정
  • arguments는 넣을 인자 값들 이다. 

 

검증 V3 정리

  • 국제화를 통해 편리하게 메세지를 조회 - codes 와 argument 파라미터를 통해 

 

검증 V3 문제점

  • addError()시 반복되는 new FiledError, new ObjectError 코드가 발생한다. 
  • 컨트롤러에서 오류 검증까지 해야하기 때문에 코드가 길고 복잡해진다.

 

검증 V4 - 오류 코드와 메세지 처리 2

위에서 설명한 것처럼 컨트롤러에서 BindingResult는 반드시 검증해야 할 객체 바로 뒤에 입력한다고 했다. 즉 BindingResult는 이미 본인이 검증해야할 객체를 알고 있는 것이다.

 

ValidationItemControllerV4 - addItem()

rejectValue() 또는 reject()을 통해 기존 코드를 단순화하였다.

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
 
 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);
     }
 }
 if (bindingResult.hasErrors()) {
	 return "validation/v2/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v2/items/{itemId}";
}
  • BindingReresult가 제공하는 reject(), rejectValue() 를 사용하면 FieldError와 ObjectError를 직접 생성하지 않아도 된다.

rejectValue()

void rejectValue(@Nullable String field, String errorCode, 
	@Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field : 오류 필드명
  • errorCode : 오류 코드(messageResolver을 위한 오류코드)
  • errorArgs : 메세지 인자

reject()

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

 

축약된 오류 코드

FiledError를 사용할 때는 required.item.itemName 과 같이 오류코드를 모두 입력해야 했다. 하지만 rejectValue()를 사용하면 오류 코드를 required로 간단하게 입력했다.  이 이유는 MessageCodeResolover에 있다. 

 

검증 V4 정리

  • rejectValue() 또는 reject() 함수를 통해 코드가 간략해졌다.

 

검증 V4 문제점

  • 컨트롤러에서 오류 검증까지 해야하기 때문에 코드가 길고 복잡해진다.

 

MessageCodeResolover

오류 코드를 만들 때는  required.item.itemName 처럼 자세히 만들수도 있고 required 처럼 단순하게 만들 수 있다.

단순하게 만들면 범용성이 좋아 여러곳에서 사용할 수 있지만 메세지를 세밀하게 작성하기 어렵다.

가장 좋은 방법은 범용성으로 사용하다가 세밀하게 작성해야 하는 부분은 세밀한 내용이 적용되게 메세지에 단계를 두는 것이다.

 

MessageCodeResolver 예시

예시 처럼 객체 오류의 경우 2가지가 필드 오류의 경우 4가지의 메세지 코드가 생성된다.

public class MessageCodesResolverTest {
 MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
 @Test
 void messageCodesResolverObject() {
     String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
     assertThat(messageCodes).containsExactly("required.item", "required");
 }
 @Test
 void messageCodesResolverField() {
     String[] messageCodes = codesResolver.resolveMessageCodes("required","item", "itemName", String.class);
     assertThat(messageCodes).containsExactly(
     "required.item.itemName",
     "required.itemName",
     "required.java.lang.String",
     "required"
     );
     }
}

 

DefalutMessageCodesResolver의 기본 메세지 생성 규칙 (구체적인 것에서 덜 구체적인 것으로)

객체 오류의 경우 2가지 메세지 코드가 생성된다.

  1.: code + "." + object name
  2.: code

 

필드 오류의 경우 4가지 메시지 코드가 생성된다.

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

 

동작 방식

rejectValue() 나 reject()는 내부에서 바로 이 MessageCodesResolver를 사용한다. 여기서 메세지 코드를 생성하는 것이다.

타임 리프 화면을 렌더링할 때 th:errors가 실행되고 이때 오류가 있다면 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾는다.

 

동작 순서 정리 

  1. rejectValue() 호출
  2. MessageCodesResolver을 통해 검증 오류 코드로 메세지 코드들을 생성
  3. new FieldError()를 생성하여 메세지 코드 보관
  4. th:errors 에서 메시지 코드들로 메세지를 순서대로 메세지에서 찾고 반환

타입 오류 

타입 오류시 스프링이 직접 오류를 추가해준다고 하였다. 오류시 로그를 확인하면 다음과 같이 메세지 코드가 생성된 것을 확인할 수 있다.

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

스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용하고 이 오류 코드가 MessageCodesResolver을 통하여 4가지의 메세지 코드로 생성되었다. error.properties에 메세지 코드가 없기 때문에 기본 메세지가 출력된다. 메세지 설정 시 [ error.properties에  typeMismatch.java.lang.Integer=숫자를 입력해주세요. typeMismatch=타입 오류입니다.] 처럼 추가하면 된다.

 

ValidationUtils

null이나 공백을 확인한 다음 rejcetValue() 를 적용해준다.

 

ValidationUtils 적용 전

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

ValidationUtils 적용 후

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

 

 

검증 V5 - Validator 분리 1

검증 V1~ V4까지 많은 문제점들을 해결하였다. 하지만 검증을 설정하면서 컨트롤러에 너무 많은 부담이 되기 시작했다. 이러한 경우 별도의 클래스로 역할을 분리하는 것이 좋다.

 

ItemValidator

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

 

스프링이 제공하는 검증 인터페이스

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supports() : 해당 검증기를 지원하는 대상 여부를 확인하는 함수
  • validate() : 검증 타겟과 bindingResult를 입력하는 함수 

 

ValidationItemControllerV5 - addItem()

검증하는 부분을 Validator을 통해 분리하여 코드가 간결해지고 컨트롤러에 부담이 줄었다.

private final ItemValidator itemValidator;

@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
 itemValidator.validate(item, bindingResult);
 
 if (bindingResult.hasErrors()) {
 	return "validation/v2/addForm";
 }
 //성공 로직
 Item savedItem = itemRepository.save(item);
 redirectAttributes.addAttribute("itemId", savedItem.getId());
 redirectAttributes.addAttribute("status", true);
 return "redirect:/validation/v2/items/{itemId}";
}

 

 

검증 V6 - Validator 2

스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서이다.  해당 인터페이스를 통해 WebDataBinder를 사용할 수 있다. WebDataBinder은 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함된다. 

 

WebDataBinder 추가

@InitBinder
public void init(WebDataBinder dataBinder) {
     log.info("init binder {}", dataBinder);
     dataBinder.addValidators(itemValidator);
     dataBinder.addValidators(UserValidator);
     ...
}
  • @InitBinder : 해당 컨트롤러에만 영향을 준다.
  • 검증기의 대상 여부는 supports()를 통해서 대상을 찾는다.

ValidationItemControllerV6 - addItem()

WebDataBinder을 사용하면 Validator을 직접 호출하지 않아도 된다. 대신 검증 대상 앞에 @Validator을 붙이면 된다.

@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}";
}
  • @Validated : 검증기를 실행하라는 애노테이션이다. 해당 애노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행한다.

 

검증 V5 ~ V6 정리

  • 컨트롤러에 검증 로직을 분리하여 코드가 간결해졌다.

 

Bean Validation

검증 기능을 V1~ V6 까지 단계적으로 완성시켰다. 하지만 검증 기능을 매번 이렇게 작성하는 것은 상당히 번거롭다.

검증 기능은 대부분 빈 값인지 아닌지, 특정 크기를 초과했는지 안했는지 처럼 단순한 로직으로 구성되어 있다. 이러한 검증 로직을 공통화하고 표준화한 것이 Bean Validation 이다. 애노테이션 하나로 대부분의 검증을 편리하게 사용할 수 있다.

 

Bean Validation 사용

build.gradle 에서 의존 관계 추가

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

 

Bean Validation 사용 전

이름 공백을 검증하는 코드

 if(!StringUtils.hasText(item.getItemName())){errors.rejectValue("itemName","required");}

가격이 1000이상 10000 미만인지 검증하는 코드

 if(item.getPrice()==null || item.getPrice()<1000 || item.getPrice()>10000){
         errors.rejectValue("price","range",new Object[]{1000,10000},null);}

 

Bean Validation 사용 후

이름 공백을 검증하는 코드

@NotBlank
private String itemName;

공백 검증 및 가격이 1000이상 10000 미만인지 검증하는 코드

@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;

 

전체 Item 코드

아래의 코드처럼 생성자에 검증 애노테이션을 붙이면 검증이 끝난다.

@Data
public class Item {
     private Long id;
     @NotBlank
     private String itemName;
     
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     
     @NotNull
     @Max(9999)
     private Integer quantity;
     
     public Item() {}
     
     public Item(String itemName, Integer price, Integer quantity) {
         this.itemName = itemName;
         this.price = price;
         this.quantity = quantity;
     }
}

 

 

Bean Validation 특징

  • 라이브러리 추가 시 스프링 부트가 자동으로 Bean Validator을 인지하고 스프링에 통합하여 자동으로 글로벌 Validator을 등록한다.
  • 글로벌 Validator이 적용되어 있기 때문에 @Valid , @Validated가 있으면 자동으로 검증 로직을 수행해준다.
    • 검증 오류 발생 시 -> FieldError 또는 ObjectError 생성 -> BindingResult에 넣어줌
  • 바인딩에 성공한 필드만 Bean Validation이 적용된다. 
    • ex) price에 문자 'Q' 입력 -> 타입 변환 실패 -> typeMismatch error -> price 필드에 Bean Validation 적용 X
  • typeMismatch 처럼 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메세지 코드가 순서대로 생성된다,
    • ex) @NotBlank [NotBlank.item.itemName, NotBlank.itemName, NotBlank.java.lang.String, NotBlank ]

 

Bean Validation 메세지

위와 마찬가지로  Bean Validation의 error 메세지를 작성할 수 있다.

#Bean Validation 추가
NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

메세지를 찾는 순서는

  1. 생성된 메세지 코드 순서대로 MessageSource에서 메세지 찾기
  2. 애노테이션 message 속성 사용 ex) @NotBlank(message="공백입니당.")
  3. 라이브러리가 기본으로 제공하는 기본값 사용하기 ex)공백일수 없습니다.

 

Bean Validation 오브젝트 오류

Field Error의 경우에는 애노테이션을 통해 검증을 수행할 수 있었다. 오브젝트 또한 @ScriptAssert 같은 애노테이션으로 오브젝트 검증을 수행할 수 있지만 제약이 많고 복잡하기 때문에 오브젝트 관련 부분만 직접 자바 코드를 사용하는 것을 권장한다.

ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
 //...
}

 

 

Bean Validation의 한계

생성자에 다양한 애노테이션을 붙여서 검증을 깔끔하게 수행하였다. 하지만 만약 데이터를 수정할 때의 검증 요구사항과 등록할 때의 요구사항이 다르다면 어떻게 해야할까? 바로 groups라는 기능을 사용하면 된다.

 

1. 각각의 그룹을 생성한다.

 

저장용 groups 생성

public interface SaveCheck {}

 

수정용 groups 생성

public interface UpdateCheck {}

 

 

2. Bean Validation의 애노테이션에 groups를 적용한다.

@Data
public class Item {
     @NotNull(groups = UpdateCheck.class) //수정시에만 적용
     private Long id;
     
     @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 모두 사용
     private String itemName;
    
     @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
     @Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
     private Integer quantity;

}

 

 

3. ValidationItemController - addItem()에 적용

Validated 괄화 안에 적용할 그룹을 입력하면 된다. 등록시 - SaveCheck , 수정시 - UpdateCheck

 

ValidationItemController - addItem()에 적용

@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, 
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
 //...
}

ValidationItemController - editItem()에 적용

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) 
@ModelAttribute Item item, BindingResult bindingResult) {
 //...
}

 

 

이렇게 groups 기능을 사용하면 검증 요구사항에 맞게 검증을 수행할 수 있다. 하지만 이에 따라 Item 객체에 복잡도가 올라간다. 실무에서는 groups 기능을 잘 사용하지 않는데 그 이유는 등록용 폼 객체와 수정용 폼 객체를 아예 분리해서 사용하기 때문이다.

 

Form 전송 객체 분리

실무에서는 groups 기능을 잘 사용하지 않는다. 그 이유는 등록시 폼에서 전달하는 데이터가 Item 객체와 딱 맞지 않기 때문이다. 등록 시에는 회원 정보, 동의 사항 등 수많은 데이터가 넘겨오지만 수정 시에는 이러한 부분들이 빠지기 때문에  보통 Item 객체를 직접 전달하는 것이 아니라 복잡한 폼의 데이터를 전달할 별도의 객체를 만들어서 전달한다.

 

폼 데이터 전달에 item 객체 사용

HTML Form -> Item -> Controller -> Item -> Repository

 

폼 데이터 전달에 별도의 객체 사용

HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

 

폼 데이터를 직접 전달하면 간단하지만 수정시 검증이 중복될 수있고, groups 기능을 사용해야 한다.

폼 데이터를 별도의 객체를 통해 사용하면 검증 중복없이 검증 기능을 사용할 수 있다.

따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고 등록/수정용 폼 객체를 나누면 등록, 수정이 완전히 분리가 되어 검증 기능을 효율적으로 사용할 수 있다.

 

Item 저장 Form

@Data
public class ItemSaveForm {
     @NotBlank
     private String itemName;
     
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     
     @NotNull
     @Max(value = 9999)
     private Integer quantity;
}

 

Item 수정 Form

@Data
public class ItemUpdateForm {
     @NotNull
     private Long id;
     
     @NotBlank
     private String itemName;
     
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     
     //수정에서는 수량은 자유롭게 변경할 수 있다.
     private Integer quantity;
}

 

ValidationItemController - addItem()에 적용

itemSaveForm 으로 전달받았다.

@PostMapping("/add")
 public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, 
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
  ...
 }

 

ValidationItemController - editItem()에 적용

itemUpdateForm 으로 전달받았다.

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated 
 @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
 //....
}

@ModelAttribute("item") 부분에서 이름을 넣는 item 부분을 주의해야한다. 이것을 넣지 않으면 별도의 객체 폼 이름(itemSaveForm)으로 html에 전달되게 된다.

 

Form 전송 객체 분리 시 additem 이나 editItem 함수에 폼 객체를 item 객체로 변환하는 부분들을 추가해야한다.

Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);

 

HTTP 메세지 컨버터

검증 기능은 @RequsetBody 부분 또한 사용할 수 있다. @RequestBody는 보통 API JSON 요청을 다룰 때 사용한다.

 

ValidationApiController

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
     @PostMapping("/add")
     public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
         log.info("API 컨트롤러 호출");
         if (bindingResult.hasErrors()) {
         	log.info("검증 오류 발생 errors={}", bindingResult);
         	return bindingResult.getAllErrors();
         }
         log.info("성공 로직 실행");
         return form;
         }
}
  • getAllErros() 는 ObjectError 또는 FieldError를 반환한다. 이 객체를 JSON으로 변환해서 클라이언트에 전달한다.

 

성공 시

 출력 결과 : API 컨트롤러 호출 성공 로직 실행

{"itemName":"hello", "price":1000, "quantity": 10}

 

실패 시 

JSON 객체로 생성하는 것 자체가 실패한 경우에는 예외가 발생한다.  

출력 결과 : Integer 에러

{"itemName":"hello", "price":"A", "quantity": 10}

 

 

@ModelAttribute vs @RequestBody

  • @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩되고, Validator를 사용한 검증도 적용할 수 있다.
  • @RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 바로 예외가 발생하고 다음 단계가 적용되지 않아 Validator도 적용할 수 없다.

 

'웹 개발' 카테고리의 다른 글

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 예외 처리  (0) 2024.05.30
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 로그인 처리  (0) 2024.05.30
intellij 단축키  (0) 2024.04.16
스프링 DB 1편 - 자바 예외 처리  (0) 2024.03.04
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술  (2) 2024.02.27
    '웹 개발' 카테고리의 다른 글
    • 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 예외 처리
    • 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 로그인 처리
    • intellij 단축키
    • 스프링 DB 1편 - 자바 예외 처리
    문준영
    문준영
    공부한 내용 정리!

    티스토리툴바