검증 요구사항
- 지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
- 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
- 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
- 컨트롤러에서 이런 HTTP 요청이 정상인지 검증하는 작업이 매우 중요하다.
요구사항 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
클라이언트 검증와 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
프로젝트 설정 (v1)
- 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
- 프로젝트 선택
- Project
- Language
- Spring Boot
- Project Metadata
- Group
- Artifact
- Name
- Package name
- Packaging
- Java
- Dependencies
- Spring Web
- Thymeleaf
- Lombok
추가 설정
- 이전 프로젝트인
message
에서 일부 소스를 가져오자.- .java 파일의 패키지는 message 부분을 validation으로 변경하자.
- 가져올 목록
- src/main
- java/hello/itemservice
- domain/item
- Item.java
- ItemRepository.java
- web/item/basic
- resources
- static
- templates/basic
- addForm.html
- editForm.html
- item.html
- items.html
- messages.properties
- messages_en.properties
- 이번에는 프로젝트는 버전별로 검증 단계가 변화하는 과정을 확인할 것이다.
/basic
을 일괄 변경으로 /validation/v1
으로 변경하자.
BasicItemController
의 이름을 ValidationItemControllerV1
으로 변경하자.ValidationItemControllerV1
에서 basic/
을 validation/v1
으로 변경하자.resources/templates/basic
에 있는 html 파일들을 resources/templates/validation/v1
폴더 생성 후 이동하자.
검증 직접 처리 - 정의
- 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.
- 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다.
- 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.
검증 직접 처리 - 개발
컨트롤러
ValidationItemControllerV1
에서 addItemV1 ~ addItemV6까지 제거하자.- 대신에 아래 코드를 추가하자.
- 검증시 오류가 발생하면 errors 에 담아둔다.
- 이 때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다.
- 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
- 특정 필드를 넘어서는 오류를 처리해야 할 수도 있다.
- 이때는 필드 이름을 넣을 수 없으므로 globalError라는 key를 사용한다.
- 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해서는
model에 errors를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.
HTML
resources/templates/validation/v1/addForm.html
을 아래와 같이 수정하자.th:text="${errors['globalError']}">
처럼 에러 메시지를 표기하자.
Safe Navigation Operator
- 만약
th:text="${errors['globalError']}">
에서 error가 null이면 어떻게 될까?- 등록폼에 진입한 시점에는 errors 가 없다.
- 그래서
errors.containsKey()
를 호출하는 순간 NullPointerException
이 발생한다.
- 그래서
errors.containsKey('quantity')
를 errors?.containsKey('quantity')
처럼 변경해서 사용한다.?
를 .
앞에 붙이게 되면 대상 객체가 null이 아닐 때만 동작하게 된다.
프로젝트 설정 (v2)
- 이번에는 다음 스텝으로 넘어가보자.
ValidationItemControllerV1
를 그대로 복사해서 ValidationItemControllerV2
를 만들자.ValidationItemControllerV2
에서 클래스명을 제외하고 모든 v1
을 v2
로 변경하자.resources/templates/validation
경로에서 v1
폴더에 있는 모든 파일을 v2
폴더를 생성한 후 복사 및 붙여넣기를 해주자.
BindingResult (1)
- 스프링이 제공하는 검증 오류 처리 방법을 알아보자.
- 스프링에서는
BindingResult
을 통해 오류를 검증한다.
컨트롤러
ValidationItemControllerV2
에서 기존의 addItem
을 주석 처리한 후에 아래의 코드를 추가하자.- 여기서 주의할 점은
BindingResult bindingResult
파라미터의 위치는 @ModelAttribute Item item
다음에 와야 한다.
FieldError
public FieldError(String objectName, String field, String defaultMessage) {}
- 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
- 파라미터
- objectName
- field
- defaultMessage
ObjectError
public ObjectError(String objectName, String defaultMessage) {}
- 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
- 파라미터
HTML
resources/templates/validation/v2/addForm.html
에서 form 내부의 오류 메시지 영역을 아래와 같이 수정하자.
타임리프 스프링 검증 오류 통합 기능
- 타임리프는 스프링의
BindingResult
를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다. - 종류
#fields
#fields
로 BindingResult
가 제공하는 검증 오류에 접근할 수 있다.
th:errors
- 해당 필드에 오류가 있는 경우에 태그를 출력한다.
th:if
의 편의 버전이다.
th:errorclass
th:field
에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
- 메뉴얼
BindingResult (2)
BindingResult
는 스프링이 제공하는 검증 오류를 보관하는 객체이다.BindingResult
가 있으면 @ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!
@ModelAttribute
에 바인딩 시 타입 오류가 발생하면?
BindingResult
가 없는 경우- 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
BindingResult
가 있는 경우- 오류 정보(FieldError)를
BindingResult
에 담아서 컨트롤러를 정상 호출한다.
BindingResult에 검증 오류를 적용하는 3가지 방법
- @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
- 개발자가 직접 넣어준다.
- Validator 사용
BindingResult 사용 시 알아야 사항
- 메서드의 파라미터로 사용할 때
BindingResult
는 검증할 대상 바로 다음에 와야한다. - 만약에
Item
에 대해 검증하려고 한다면 @ModelAttribute Item item
바로 다음에 BindingResult
가 와야 한다. BindingResult
는 Model에 자동으로 포함된다
BindingResult와 Errors
BindingResult
는 인터페이스이고, Errors
인터페이스를 상속받고 있다.- 실제 넘어오는 구현체는
BeanPropertyBindingResult
이다. - 그런데
BeanPropertyBindingResult
는 BindingResult
와 Errors
둘다 구현하고 있다. - 그래서
BindingResult
대신에 Errors
를 사용해도 된다.
Errors
인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.BindingResult
는 여기에 더해서 추가적인 기능들을 제공한다.addError()
도 BindingResult
가 제공한다.
- 주로 관례상 BindingResult 를 많이 사용한다.
- 패키지
BindingResult
org.springframework.validation.BindingResult
Errors
org.springframework.validation.Errors
FieldError, ObjectError
- 이번에는 데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라지는 현상을 고쳐보자.
컨트롤러
ValidationItemControllerV2
에서 addItemV1
을 주석 처리하고 아래의 코드를 추가하자.
FieldError 생성자
- FieldError는 두 가지 생성자를 제공한다.
- 파라미터 종류
- objectName
- field
- rejectedValue
- bindingFailure
- 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes
- arguments
- defaultMessage
- ObjectError 도 유사하게 두 가지 생성자를 제공한다.
- 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
- 만약에 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.
- 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
- 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
- FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
- FieldError에서 rejectedValue가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
- bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.
- 여기서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.
타임리프의 사용자 입력 값 유지
- 타임리프의 th:field 는 매우 똑똑하게 동작한다.
- 정상 상황에는 모델 객체의 값을 사용한다.
- 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.
th:field="*{price}"
같은 경우를 의미한다.
스프링의 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다.
- 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.
- 즉, 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
오류 코드와 메시지 처리1
- 이번에는 오류 메시지를 관리하는 방법을 알아보자.
- 기존의
messages.properties
를 사용해도 되긴 하다.- 하지만 쉬운 관리를 위해
errors.properties
를 생성하자.
errors.properties
도 스프링이 알아서 인식할 수 있게 application.properties
를 수정하자.spring.messages.basename=messages,errors
- 참고로
spring.messages.basename
으로 정의한 이름들은 모두 국제화가 가능하다.- 즉,
errors_en.properties
라고 만들면 영문 버전이 되는 것이다.
메세지 추가
errors.properties
추가 후 아래 내용을 추가하자.
컨트롤러
ValidationItemControllerV2
에서 addItemV2
을 주석 처리하고 아래의 코드를 추가하자.
- FieldError에 배열을 넘기면
errors.properties
에 정의된 변수 영역에 값을 치환할 수 있다.
오류 코드와 메시지 처리2
- FieldError, ObjectError는 다루기 너무 번거롭다.
- 그러면 이번에는
BindingResult
가 제공하는 rejectValue()
와 reject()
를 사용해보자.
컨트롤러
ValidationItemControllerV2
에서 addItemV3
을 주석 처리하고 아래의 코드를 추가하자.
- errors.properties에 있는 코드를 직접 입력하지 않았는데 오류 메시지가 정상 출력된다.
rejectValue()
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- 파라미터 종류
- field
- errorCode
- 오류 코드
- 이 오류 코드는 메시지에 등록된 코드가 아니다.
- messageResolver라는 것을 위한 오류 코드이다.
- errorArgs
- defaultMessage
- 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
BindingResult
는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.- 그래서
target(item)
에 대한 정보는 없어도 된다.
축약된 오류 코드
FieldError()
를 직접 다룰 때는 오류 코드를 range.item.price
와 같이 모두 입력했다.- 그런데
rejectValue()
를 사용하고 부터는 오류 코드를 range로 간단하게 입력했다. - 그래도 오류 메시지를 잘 찾아서 출력한다.
MessageCodesResolver
를 통한 규칙이 이를 가능하게 한다.
오류 코드와 메시지 처리3
- 오류 코드는 다양하게 만들 수 있다.
- 자세한 버전
range.item.price =상품의 가격 범위 오류 입니다.
- 간단한 버전
- 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있다.
- 그렇다고 너무 자세하게 만들면 범용성이 떨어진다.
- 그래서 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
required.item.itemName
와 required
를 비교하면 당연히 required.item.itemName
가 세밀한 메시지 코드다.- 스프링은 메시지 코드의 우선순위에 따라 자동으로 알맞는 메시지를 가져온다.
- 이는 스프링이
MessageCodesResolver
을 통해 제공하는 기능이다. xxx.yyy.zzz
처럼 .
이 많이 들어갈 수록 세밀하다고 판단하여 높은 우선순위로 보여지는 메시지가 된다.
메시지 추가
errors.properties
에 아래와 같이 추가하자.- 코드가 겹치니 첫번째
required.item.itemName
는 주석 처리하자.
오류 코드와 메시지 처리4
- 테스트를 통해
MessageCodesResolver
에 대해 알아보자.
테스트 생성
MessageCodesResolver
- 검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver
는 인터페이스이고, 기본 구현체는 DefaultMessageCodesResolver
이다.- 주로 ObjectError와 FieldError를 함께 사용한다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
- 객체 오류
- code +
.
+ object name - code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
- 필드 오류
- code +
.
+ object name + .
+ field - code +
.
+ field - code +
.
+ field type - code
예) 오류 코드: typeMismatch, object name “user”, field “age”, field type: int
- “typeMismatch.user.age”
- “typeMismatch.age”
- “typeMismatch.int”
- “typeMismatch”
동작 방식
- rejectValue()와 reject()는 내부에서
MessageCodesResolver
를 사용한다. FieldError
와 ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관한다.
오류 코드와 메시지 처리5
- 오류 코드는 관리하기 위한 전략이 필요하다.
- 핵심은 구체적인 것을 먼저 만들고 덜 구체적인 것을 후순위로 만드는 것이다.
왜 이렇게 복잡하게 사용해야 할까?
- 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
- 극단적인 예시를 든다면 필드가 1,000건이 있다면 오류 메시지도 1,000건을 정의해야 한다.
- 그래서 크게 중요하지 않은 메시지는 범용성 있는 requried같은 메시지로 간단하게 만든다.
- 정말 중요한 메시지만 구체적으로 적어서 사용하는 방식이 더 효과적이다.
메시지 추가
- 이번에는
errors.properties
에 이런 오류 코드 전략을 도입해보자.
- 객체 오류와 필드 오류를 나누고, 또 그 안에서 다시 범용성에 따라 레벨을 나누어두었다.
- 만약
itemName
의 경우에는 required
검증 오류 메시지가 발생하면 아래 순서대로 메시지가 생성된다.- required.item.itemName
- required.itemName
- required.java.lang.String
- required
- 이렇게 생성된 메시지 코드를 기반으로 순서대로
MessageSource
에서 구체적인 것에서 덜 구체적인 순서대로 메시지에서 찾는다.- 메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾는다.
- 즉, 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을
재활용
할 수 있다.
오류 코드와 메시지 처리6
- 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드 →
rejectValue()
를 직접 호출 - 스프링이 직접 검증 오류에 추가한 경우
ValidationUtils
- ValidationUtils를 사용하면 아래와 같이 바꿀 수 있다.
Validator 분리1
- 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다.
- 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다.
Validator 인터페이스
- 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.
supports()
는 해당 검증기를 지원하는 여부를 확인한다.validate(Object target, Errors errors)
는 검증 대상 객체와 BindingResult
를 통해 검증을 진행한다.
Validator 구현
Item.class.isAssignableFrom
를 통해 Item 클래스만 해당 검사기를 사용할 수 있게 하였다.
Validator 적용
ValidationItemControllerV2
에서 addItemV4
을 주석 처리하고 아래의 코드를 추가하자.
- 유효성 검사기를 별도 클래스로 분리하여 컨트롤러의 검증 부분의 양이 엄청나게 줄었다.
- 게다가 Item 클래스에 대해서는 해당 유효성 검사기는 재사용할 수 있다.
Validator 분리2
- 스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다.
- 그런데 앞에서는 검증기를 직접 불러서 사용했다.
- 실제로 이렇게 사용해도 된다.
- 그런데 Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder를 통해서 사용하기
WebDataBinder
는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.ValidationItemControllerV2
에 아래 코드를 추가하자.
WebDataBinder 적용
ValidationItemControllerV2
에서 addItemV5
을 주석 처리하고 아래의 코드를 추가하자.
addItemV6의 동작 방식
@Validated
는 검증기를 실행하라는 애노테이션이다.@Validated
애노테이션이 붙으면 앞서 WebDataBinder
에 등록한 검증기를 찾아서 실행한다.- 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다.
- 이 때 supports() 가 사용된다.
- 여기서는 supports(Item.class) 호출되고, 결과가 true이므로 ItemValidator의 validate()가 호출된다.
글로벌 설정
@SpringBootApplication
애노테이션이 있는 메인 클래스에 implements WebMvcConfigurer
를 추가한다.- 아래 코드를 추가한다.
글로벌 설정 시 주의사항
- 글로벌 설정을 하면
BeanValidator
라는 것이 자동 등록되지 않는다. - 게다가 글로벌 설정을 직접 사용하는 경우는 드물다.
@Validated와 @Valid
- 검증시
@Validated
와 @Valid
둘 다 사용가능하다. - 다만
@Valid
를 사용하려면 build.gradle 의존관계 추가가 필요하다.implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated
는 스프링 전용 검증 애노테이션이고, @Valid
는 자바 표준 검증 애노테이션이다.
출처