[Spring MVC] 스프링 MVC - Validation

홍정완·2022년 11월 27일
0

Spring

목록 보기
29/32
post-thumbnail

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


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

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



검증 과정


클라이언트와 서버 간의 데이터 검증 과정은 성공했을 때와 실패했을 때로 구분된다.


상품 저장 성공상품 저장 검증 실패



검증에서 실패하는 경우는 대표적으로 다음과 같다.

  • Null
  • TypeMissMatch
  • 비즈니스 요구사항에 맞지 않음

검증 실패의 대표적인 케이스인데, 이를 처리하는 방법은 다양하며 하나씩 알아보자.



다양한 검증 방식


검증을 하는 방식은 몹시 다양하다.

단순히 Map에다가 에러 내용을 담아 모델에 담아서 반환하는 방식도 있고, BindingResult를 사용하여 담아 보낼 수도 있고, Validator라는 마커 인터페이스를 구현하는 방식도 있다.



Map 사용

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


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

  • RedirectAttributesRedirect시 보존할 데이터를 담을 수 있다.

  • 가장 간단한 방식으로 컬렉션 프레임워크만 쓸 줄 안다면 크게 어렵지 않게 구현할 수 있다.

  • 하지만 타입이 안 맞는 경우 (ex: Integer 타입 변수에 String 타입 값을 바인딩 하려는 경우) 컨트롤러까지 가지 못하고 400 (Bad Request) 에러가 발생하며 오류 페이지를 띄운다.


  • 타입 불일치에도 오류 페이지를 보여주지 않고 잘못된 부분을 사용자에게 고지할 수 있어야 한다.

  • 다음에는 BindingResult 클래스를 이용해 타입이 잘못된 경우에도 오류 페이지를 내보내지 않도록 해보자.



BindingResult 사용

BindingResult를 이용한 검증은 스프링이 제공하는 검증 오류 처리 방법이다.

컨트롤러의 매핑 메서드에서 BindingResult를 매개변수로 받음으로써 타입 불일치에 대한 대응도 가능해진다.

BindingResult 매개변수는 무조건 전송받을 객체(ex: Item) 다음에 위치해야 한다.


@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    
    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
    }
    
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
        bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000까지 허용합니다."));
    }
    
    if (item.getQuantity() == null || item.getQuantity() >= 9999) {
        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()) {
        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}";
}

bindingResultaddError 메서드를 이용해 에러 내용을 담을 수 있다.

필드(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) {}



타임리프에서는 다음과 같이 사용하면 된다.

<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에서 그 내용을 가지고 있기에 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

  • 이러한 BindingResult의 내용은 자동으로 Model에 담기기 때문에 타임리프에서도 자연스럽게 사용할 수 있다.


여기까지만 해도 잘못된 내용에 대한 오류 페이지도 내보내지 않을 수 있고, 에러 내용을 담아 다시 전송할 수도 있다. 하지만, 아직 해결해야 할 문제가 있다.


  • 사용자 입력 값을 유지할 수 없다는 것이다.
  • 에러 메시지를 하드코딩으로 작성하고 코드 중복이 있다.

그래서 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,
	            "상품 이름은 필수입니다.")

FieldError, ObjectError의 생성자는 codes, arguments를 제공한다. 이것은 오류 발생 시 오류 코드로 메시지를 찾기 위해 사용된다.


errors 메시지 파일 생성

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


먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게 하면 messages.properties, errors.properties 두 파일을 모두 인식한다. (생략하면 messages.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}

✅ 참고

  • errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.



  • 위 추가된 설정을 사용한 FieldError 객체 생성
new FieldError("item",
			   "price", 
			    item.getPrice(),
				false, 
				new String[]{"range.item.price"}, 
				new Object[]{1000, 1000000}, 
				null);

  • codes : required.item.itemName를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.

  • arguments : Object[]{1000, 1000000}를 사용해서 코드의 {0}, {1}로 치환할 값을 전달한다.

  • 실행해 보면 메시지, 국제화에서 학습한 MessageSource를 찾아서 메시지를 조회하는 것을 확인할 수 있다.


일단, 이런 식으로 사용은 할 수 있다. 하지만, 너무 번거롭고 에러 하나 담는데 넣어야 할 속성도 너무 많다.
그리고 messages의 이름도 range.item.price을 매번 다 적는 것도 번거롭다.

BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.



BindingResult의 rejectValue(), reject() 메서드

rejectValue(), reject()를 사용해서 기존 코드를 단순화해보자.


// 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);

딱 봐도 after가 훨씬 간결하다. 그런데 errors.properties는 어디서 가져오는 것일까 ❓


우선 rejectValue 메서드의 매개변수부터 살펴보자.

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

여기서 fielderrorCode 매개변수를 가지고 errors.properties에서 메시지를 찾아낸다는 것인데, 스프링에서는 이를 MessageCodesResolver를 통해서 찾아낸다.



MessageCodesResolver


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


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

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

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

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



MessageCodesResolver의 동작

메시지 혹은 예외 메시지는 특정 필드에 맞는 메시지가 있을 수도 있지만 한편으로는 범용성이 높은 메시지도 있을 수 있다. 예를 들어 required.item.itemName=상품 이름은 필수입니다.라고 디테일하게 에러 메시지를 작성할 수 있지만, required=필수 값입니다.라고 범용적인 메시지를 작성할 수도 있다.

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


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

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

이렇게 errors.properties가 작성되어 있다면 리졸버는 디테일한 순서부터 차례대로 찾는다. 만약 level 1이 작성되어 있지 않다면 required값을 찾아서 담는 것이다. 이렇게 작성하면 오류 메시지에 대한 대응이 한결 편해진다.


MessageCodesResolver는 다음과 같이 객체 오류와 필드 오류를 범용성 순으로 찾는다.


객체 오류

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

즉, 구체적인 것에서 덜 구체적인 것으로 차례대로 찾는다.



reject(), rejectValue()는 MessageCodesResolver를 사용한다.

MessageCodesResolverreject(), rejectValue() 메서드에서 사용하기 때문에 우리는 편하게 fielderrorCode만 인수로 넘겨줌으로써 에러 내용을 담을 수 있는 것이다.


FieldError는 rejectValue("itemName", "required")

  • new String[]{"required.item.itemName", "required.itemName", "required.java.lang.String", "required"}를 내부에서 만들어 메시지를 찾는다.

ObjectError는 reject("totalPriceMin")

  • new String[]{"totalPriceMin.item", "totalPriceMin"}을 내부에서 만들어 메시지를 찾는다.



✅ ValidationUtils

스프링에서는 이를 위해 ValidationUtils라는 유틸 클래스를 제공하는데, 이를 사용해 유효성 검증을 여기서 한 번 더 편하게 작성할 수 있다.


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

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

🚩 정리

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



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

우리가 정의한 오류 코드는 rejectValue()를 직접 호출해서 담아준다.


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

스프링은 타입 오류가 발생하면 자동으로 위 오류 코드들을 담게 되는데 errors.properties에는 해당 내용으로 정의한 메시지가 없기 때문에 스프링에서 정의한 기본 메시지가 출력되는 것이다.


하지만, 기본 메시지는 너무 장황하고 길어서 개발자가 아닌 사용자에게 노출해서는 안 된다.
그래서 errors.properties에 다음과 같이 메시지를 선언해 주자.

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



Validator 분리


지금까지 검증 로직을 최대한 모듈화하고 스프링에서 제공하는 여러 유틸 클래스나 리졸버를 통해 간략해 보았다. 하지만 그럼에도 검증 로직은 중복이 많고, 매번 필요할 때마다 작성하는 것은 비효율적이다. 하지만, 중요도가 높은 만큼 생략할 수도 없다.

그래서 이런 검증 로직을 별도의 클래스로 분리해서 이런 문제들을 해결해 보자. 중복이 발생할 경우 분리하여 모듈화하면 재사용성이 높아지고 가독성 또한 높아질 수 있다.


스프링에서는 검증에 필요한 Validator라는 인터페이스를 정의해두었다.

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

인터페이스는 책임 사슬 패턴에서 주로 보이는 메서드인 supports와 실제 검증을 수행하는 validate 메서드를 정의하고 있다. 우리는 이러한 Validator 인터페이스를 구현하면서 Item에 대한 검증 로직을 구현해 볼 것이다.


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;

        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 클래스의 부모 타입이기 때문에 공변성이 성립한다.


이렇게 구현한 itemValidatorComponent이기 때문에 Component Scan으로 등록되었기 때문에 Dependency Injection을 받아서 컨트롤러에서 다음과 같이 사용할 수 있다.


@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 메서드 호출로 검증이 가능해졌다.



Validator 분리 ②

스프링에서는 Validator 인터페이스를 구현해서 검증 로직을 만들면 추가적으로 애노테이션을 사용하여 검증을 수행할 수도 있다. 바로 WebDataBinder를 이용하는 것인데 이 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함하는 클래스다. 그렇기에 이 객체에 내가 만든 검증기를 추가(add) 하면 자동으로 검증기 적용이 가능해진다.


WebDataBinder에 검증기(Validator) 추가

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
		private final ItemValidator itemValidator;
		
		@InitBinder
		public void init(WebDataBinder dataBinder){
		    dataBinder.addValidators(itemValidator);
		}
}

  • addValidators()를 사용해 검증기를 추가하면 해당 컨트롤러에서 검증기 자동 적용이 가능하다.

  • 하지만, @InitBinder를 통해 등록한 검증기는 해당 컨트롤러에서만 사용 가능하다.
    (글로벌 설정은 별도로 해야 한다. )


이렇게 위와 같이 WebDataBinderItemValidator 검증기를 추가했다면 다음과 같이 애노테이션으로 편하게 검증 로직을 수행하고 에러 내용을 BindingResult에 담을 수 있다.


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

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

  • @Validated을 사용해서 Item의 검증 로직을 수행해 준다.

  • WebDataBinder@Validated이 붙은 요소를 검증하는데 이때 WebDataBinder가 가진 여러 검증기 중에서 어떤 검증기가 실행돼야 할지 찾기 위해 구분이 필요한데 이때 supports()가 사용된다.



@Validated, @Valid

검증을 위해 사용하는 애노테이션으로 @Validated를 사용했지만 @Valid를 먼저 알고 있는 사람도 있다.

org.springframework.validation.annotation.Validated스프링 전용 검증 애노테이션이라면 javax.validation.@Valid자바 표준 검증 애노테이션이다.

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


implementation 'org.springframework.boot:spring-boot-starter-validation'
profile
습관이 전부다.

0개의 댓글