Search
Duplicate

4. 검증1 - Validation

목차

Previous

내가 주니어 개발자일때는 회사가 크지 않아 백엔드와 프론트엔드를 모두 맡아야하는 상황에서 이런 문제로 고민을 많이 했었다.
유효성 검증은 서버와 프론트중 어디서 해야하는가?
이번 강의에서도 서론에 언급하는 내용인데, 나 역시 했던 고민이기에 다시 한 번 생각해봤다.
뭔가 직접 성능테스트를 한 것도 아니면서 그 때의 나는 둘 다 유효성 검증을 하면 안전하겠지만 속도가 너무 느려지지 않을까 고민을 했다. (트래픽도 몹시 작은 프로젝트였으면서..)
그래서 처음엔 서버쪽에 검증로직을 넣어서 구현을 했는데, 그렇게 되니 고객입장에서는 즉각적인 반응성이나 서버에 무조건 전송을 한 뒤에 결과를 알 수 있기에 사용성이 떨어지는 상황이 되었다. 그래서 클라이언트에만 구현을 했더니 이번에는 사용자가 볼 때는 그럴싸해서 보기 좋았지만, 당장 내가 테스트목적으로 api를 직접 javascript의 ajax로 콜만해도 유효성 검증을 다 뛰어넘어버리는 상황이 생겨서 보안에 취약한 상황이 생겼다.
그래서 그 당시에 결론은 지금으로썬 너무 멍청하지만, 어짜피 제한된 인원(지점의 특정 직급만 해당한다.) 만 사용하는 프로그램이고, 폐쇄된 환경에서 제공되는 프로젝트이기에 클라이언트에만 열심히 유효성 검증 코드들을 넣어놨었다. 물론, 차후에 서버측에도 검증 코드를 넣어놓는 상황이 생겼으니 결국 둘 다 넣게 되었다.
결론은 서버와 클라이언트 양 측에 적절히 검증을 섞어서 사용하며 서버에서 최종 검증은 필수다.

검증 과정

클라이언트와 서버간의 데이터 검증 과정은 성공했을때와 실패했을때로 구분되는데, 이를 그림으로 보면 다음과 같다.
상품 저장 성공
1.
사용자가 상품 등록페이지에 접근한다(HTTP GET /add)
2.
사용자가 상품정보를 입력 후 서버로 전송한다(HTTP POST /add)
3.
상품이 성공적으로 등록된 후 Location 정보로 상품정보 상세경로를 Redirect로 응답한다.
4.
클라이언트에서는 응답받은 정보에 있는 Location정보로 Redirect하여 신규 상세 페이지로 이동한다.
상품 저장 검증 실패 : 사용자가 전송한 상품 정보가 유효성 검증에 실패했을때의 모습이다.
1.
사용자가 상품 등록페이지에 접근한다(HTTP GET /add)
2.
사용자가 상품정보를 입력 후 서버로 전송한다(HTTP POST /add)
3.
상품의 유효성 검증이 실패하며 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동한다.

어떻게 검증 오류결과를 담을까

검증에서 실패하는 경우는 대표적으로 다음과 같다.
Null
TypeMissMatch
비즈니스 요구사항에 맞지 않음
(ex: 상품의 가격은 1000원 이상이여야 하는데 500원으로 작성)
그밖에도 여러 문제가 있을 수 있지만, 대표적으로 위와같은 부분들이 검증에서 실패하는 대표적인 케이스인데, 이를 처리하는 방법은 다양하며, 이를 하나씩 알아보자.

다양한 검증 방식

검증을 하는 방식은 몹시 다양하다.
단순히 Map에다가 에러 내용을 담아 모델에 담아서 반환하는 방식도 있고, BindingResult를 사용하여 담아 보낼수도 있고, Validator 라는 마커 인터페이스를 구현하여 사용하는 방식도 있다. 이를 간략하게나마 하나씩 알아보고, 가장 최근에 사용중인 애노테이션 기반의 검증 방식까지 알아보자.

1. 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}"; }
Java
복사
⇒검증에 실패하면 errors라는 Map에 에러 내용을 담아서 이를 반환하는 식으로 구현을 했다.
@ModelAttribute 애노테이션이 붙은 Item 객체는 에러가 발생하여 다시 페이지 이동시 그대로 다시 담겨져 전송되며 타임리프에서 이를 사용할 수 있다.
RedirectAttributesRedirect시 보존할 데이터를 담을 수 있다.
가장 간단한 방식으로 콜렉션 프레임워크만 쓸 줄안다면 크게 어렵지 않게 구현할 수 있다.
하지만, 이 방법은 타입이 안맞는 경우(ex: Integer 타입 변수에 String 타입 값을 바인딩 하려는 경우) 컨트롤러까지 가지도못하고 400 (Bad Request) 에러가 발생하며 오류 페이지를 띄운다.
우리는 이런 잘못된 타입의 값 전달시에도 오류페이지를 보여주지 않고 잘못된 부분을 사용자에게 고지할 수 있어야 한다.
다음에는 BindingResult 클래스를 이용해 타입이 잘못된 내용에도 오류 페이지를 내보내지 않도록 해보자.

2. BindingResult를 이용하여 검증하기

컨트롤러의 매핑 메서드에서 BindingResult를 매개변수로 받음으로써 타입 불일치에 대한 대응도 가능해진다.
(중요!) BindingResult 매개변수는 무조건 전송받을 객체(ex: Item) 다음에 위치해야 한다.
⇒ ex: method(@ModelAttribute Item item, BindingResult bindingResult, ...){ ... }
이를 코드로 구현하면 대략 다음과 같다.
@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}"; }
Java
복사
bindingResultaddError 메서드를 이용해 에러내용을 담을 수 있다.
⇒ 필드(ex: name, price, quantity, ...)에러인 경우 FieldError객체를 이용해 담으면 된다.
필드 에러 (FieldError) 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}
Java
복사
objectName: @ModelAttribute 이름
field: 오류가 발생한 필드 이름
defaultMessage: 기본 오류 메세지
⇒ 글로벌 오류인 경우 ObjectError 객체를 이용해 담으면 된다.
ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
Java
복사
타임리프에서는 다음과 같이 사용하면 된다.
<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>
HTML
복사
#fields: BindingResult가 제공하는 검증 오류에 접근이 가능하다.
th:errors: 해당 필드에 오류가 있는 경우 태그를 출력한다.(th:if 편의 버전)
th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class정보를 추가한다.
BindingResult를 사용할 경우 클라이언트에서 타입이 잘못된 내용이 전송되더라도 BindingResult에서 그 내용을 가지고 있기에 @ModelAttribute에 타입 불일치로 매칭이 되지 않더라도 예외가 발생하지 않고 BindingResult에서 가지고 있다. 그래서 400(Bad Request) 오류가 발생하지 않는 것이다.
이러한 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, "상품 이름은 필수입니다.")
Java
복사
이 생성자를 사용하게 되면 오류 메세지를 더 자세하게 작성할 수 있는데, 여기서 codes, arguments 매개변수는 메세지 파일(ex: errors.properties)에서 읽어오는 것이기에 파일을 생성해줘야 한다.
기존의 messages라는 default name으로 사용해서 그 안에 error 내용까지 다 적으면 편할순 있지만 프로젝트가 커짐에따라 책임이 너무 과중해진다. 그렇기에 다음과 같이 새로운 properties를 생성하고 설정으로 지정해주면 된다.
스프링 부트 메세지 설정 추가
spring.messages.basename=messages,errors
YAML
복사
errors.properties
required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
YAML
복사
위 추가된 설정을 사용한 FieldError 객체 생성
new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null);
Java
복사
codes에는 배열로 여러 값을 전달할 수 있는데 순서대로 매칭하며 처음 매칭되는 메세지가 사용된다.
arguments는 Object 배열을 사용하며 전달한 값을 {0}, {1}에 맞춰 치환한다.
일단, 이런식으로 사용은 할 수 있다. 하지만, 너무 번거롭고 에러하나 담는데 넣어야 할 속성도 너무 많다.
그리고 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);
Java
복사
딱 봐도 after가 훨씬 간결하다. 그런데 errors.properties는 어디서 가져오는 것일까?
우선 rejectValue 메서드의 매개변수부터 살펴보자.
void rejectValue(@Nullable String field, //오류 필드명 String errorCode, //MessageResolver를 위한 오류 코드 @Nullable Object[] errorArgs, //오류 메세지에서 {0}을 치환하기 위한 값 @Nullable String defaultMessage);//오류 메세지를 못찾을 경우 기본 메세지
Java
복사
여기서 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); }
Java
복사
이 인터페이스의 기본 구현체로 DefaultMessageCodesResolver를 제공하는데 이를 이용해서 각종 메세지에 대한 대처가 쉽게 가능하다.

MessageCodesResolver의 동작

메세지 혹은 예외메세지는 특정 필드에 맞는 메세지가 있을수도있지만 한편으로는 범용성이 높은 메세지도 있을 수 있다. 예를들어 required.item.itemName=상품 이름은 필수 입니다. 라고 디테일하게 에러 메세지를 작성할 수 있지만, required=필수 값입니다. 라고 범용적인 메세지를 작성할수도 있다.
이처럼 범용성의 수준에따라 단계를 만들어두면 MessageCodesResolver는 범용성이 낮은순서에서 높은순서로 차례대로 찾으면서 처음 매칭되는 결과를 가져온다. 다음 메세지를 보자.
#level 1 required.item.itemName: 상품 이름은 필수입니다. #level 2 required: 필수 값 입니다.
Java
복사
이렇게 errors.properties가 작성되어 있다면 리졸버는 디테일한순서부터 차례대로 찾는다. 만약 level1이 작성되어있지 않다면 required값을 찾아서 담는 것이다. 이렇게 작성하면 오류메세지에 대한 대응이 한결 편해진다.
MessageCodesResolver는 다음과 같이 객체 오류와 필드 오류를 범용성 순으로 찾는다.
객체 오류
객체 오류의 경우 다음 순서로 2가지 생성 1.: code + "." + object name 2.: code 예) 오류 코드: required, object name: item 1.: required.item 2.: required
Java
복사
필드 오류
필드 오류의 경우 다음 순서로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"
Java
복사
즉, 구체적인 것에서 덜 구체적인 것으로 차례대로 찾는다.

MessageCodesResolver 사용해보기

MessageCodesResolver의 기본 구현체인 DefaultMessageCodesResolver를 이용해 코드를 작성해보자.
객체 오류 조회해보기
@Test void messageCodesResolverObject() { DefaultMessageCodesResolver codesResolver = new DefaultMessageCodesResolver(); String[] messageCodes = codesResolver.resolveMessageCodes("required", "item"); for (String msg : messageCodes) { System.out.println(msg); } }
Java
복사
실행 결과는 다음과 같이 나올 것이다.
필드 오류 조회해보기
@Test void messageCodesResolverField() { DefaultMessageCodesResolver codesResolver = new DefaultMessageCodesResolver(); String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class); for (String msg : messageCodes) { System.out.println(msg); } }
Java
복사
실행 결과는 다음과 같이 나올 것이다.

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

다시 얘기를 돌려 이러한 MessageCodesResolver를 reject(), 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");
Java
복사
너무 복잡한 검증은 힘들고 위 코드처럼 단순한 Empty나 공백처리같은 기능만 제공한다.(그게 어디야..)
내부적으로 rejectValue를 호출하고 위에서 우리가 소개한 여러 방식들을 사용해서 에러를 담는다.
(MessageCodesResolver, new FiledError(), ...)

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

우리가 정의한 오류 코드는 rejectValue()를 직접 호출해서 담아준다.
하지만, 스프링이 직접 검증 오류에 추가한 경우도 있는데(주로 타입 정보 불일치) 이런 경우 다음과 같은 메세지를 본 적이 있을 것이다.
숫자형 Input에 문자를 담아 전송한 경우 볼 수 있는 에러 내용
이는 스프링에서 직접 검증 오류에 추가를 한 것으로 BindingResult에 FieldError가 다음과 같은 메세지코드가 생성되어 추가되었기 때문이다.
codes[typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typeMismatch]
YAML
복사
스프링은 타입 오류가 발생하면 자동으로 위 오류 코드들을 담게되는데 errors.properties에는 해당 내용으로 정의한 메세지가 없기 때문에 스프링에서 정의한 기본 메세지가 출력되는 것이다.
하지만, 기본 메세지는 너무 장황하고 길어서 개발자가아닌 사용자에게 노출해서는 안된다.
그래서 errors.properties에 다음과 같이 메세지를 선언해주자.
typeMismatch.java.lang.Integer=숫자를 입력해주세요. typeMismatch=타입 오류입니다.
YAML
복사
이제 다시 실행하면 다음과같이 정상적으로 나올 것이다.

Validator 분리

지금까지 검증 로직을 최대한 모듈화하고 스프링에서 제공하는 여러 유틸클래스나 리졸버를 통해 간략화 시켜보았다. 하지만 그럼에도 검증 로직은 중복이 많고, 매번 필요할때마다 작성하는것은 비효율적이다. 하지만, 중요도가 높은만큼 생략할수도 없다.
그래서 이런 검증 로직을 별도의 클래스로 분리해서 이런 문제들을 해결해보자. 중복이 발생할 경우 분리하여 모듈화하면 재사용성이 높아지고 가독성또한 높아질 수 있다.
스프링에서는 검증에 필요한 Validator라는 인터페이스를 정의해두었다.
public interface Validator { boolean supports(Class<?> clazz); void validate(Object target, Errors errors); }
Java
복사
인터페이스는 책임 사슬 패턴에서 주로보이는 메서드인 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); } } } }
Java
복사
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"; } ... } }
Java
복사
컨트롤러에 있던 많은 검증 로직이 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); } }
Java
복사
addValidators()메서드를 사용해 검증기를 추가하면 해당 컨트롤러에서 검증기 자동 적용이 가능하다.
하지만, @InitBinder를 통해 등록한 검증기는 해당 컨트롤러에서만 사용가능하다.
(글로벌 설정은 별도로 해야 한다. )
이렇게 위와같이 WebDataBinder에 ItemValidator 검증기를 추가했다면 다음과같이 애너테이션으로 편하게 검증로직을 수행하고 에러내용을 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"; } ... }
Java
복사
@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'
Groovy
복사

정리

검증을 하는 방식은 정말 다양하기에 주니어 입장에서 고민할 점이 정말 많다.
서버와 클라이언트뿐 아니라 검증을 언제 어디서 어떻게 할지에 대해서 고민을 해봐야하는데, 스프링에서는 기본적으로 BindingResult 클래스를 이용하며 이를 하나하나 직접 FieldError, ObjectError를 만들어 담는 방식부터 이런 불편함을 줄이기위해 모듈화된 편의성 메서드들을 사용하는 방법까지 알아보았다.
그리고 마침내 검증 애너테이션을 이용하여 컨트롤러 내에서 검증로직 자체를 안보이게 하는 수준까지 알아보았다. 그래서 이 내용들을 그냥 간결하게 이제 내가 사용할 검증 애노테이션만 소개하는 정도로 할까 고민을 했었다.
하지만, 포스팅을 하다보니 느껴지는건 결국 내가 편하게 작성하는 검증 애너테이션의 내부 구조는 결국 BindingResult라는 객체에 에러 내용을 담고 이를 클라이언트로 보내주는 과정을 숨긴것이기에 제대로 사용하려면 해당 객체들에 대해서도 소개할 필요가 있다고 느꼈기에 모두 작성해보았다.
이제 다음 챕터에서는 Bean Validation을 학습해보자.

다음 챕터로

이전 챕터로