컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것
참고 : 클라이언트 검증, 서버 검증
GET/add -> POST/add -> Redirect/items/{id} -> GET/items/{id}
사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.
GET/add -> POST/add -> 상품 저장 (검증 실패) -> Model(검증 오류 결과 포함) 상품 등록 폼에 전달
고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다.
Map<String, String> errors = new HashMap<>();
만약 검증시 오류가 발생하면 어떤 검증에성 오류가 발생했는지 정보를 담아둔다.
이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key
로 사용한다. 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
특정 필드를 넘어서는 오류를 처리해야 할 수도 있다. 이때는 필드 이름을 넣을 수 없으므로 globalError
라는 key
를 사용
만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 model
errors
를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.
Item
의 price
, quantity
같은 숫자 필드는 타입이 Integer
이므로 문자 타입으로 설정하는 것이 불가능하다. 숫자 타입에 문자가 들어오면 오류가 발생한다. 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.Item
의 price
에 문자를 입력하는 것처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다. 주의
BindingResult bindingResult
파라미터의 위치는 @ModelAttribute Item item
다음에 와야한다.
if(!StringUtils.hasText(item.getItemName())){
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
public FieldError(String objectName, String field, String defaultMessage){}
필드에 오류가 있으면 FieldError
객체를 생성해서 bindingResult
에 담아두면 된다.
objectName
: `@ModelAttribute 이름field
: 오류가 발생한 필드 이름defaultMessage
: 오류 기본 메시지bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
public ObjectError(String objectName, String defaultMessage){}
특정 필드를 넘어서는 오류가 있으면 ObjectError
객체를 생성해서 bindingResult
에 담아두면 된다.
objectName
: @ModelAttribute
의 이름defaultMessage
: 오류 기본 메시지타임리프는 스프링의 bindingResult
를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
#fields
: #fields
로 BindingResult
가 제공하는 검증 오류에 접근할 수 있다.th:errors
: 해당 필드에 오류가 있는 경우에 태그를 출력한다. `th:if 편의 버전이다.th:errorclass
: th:field
에서 지정한 필드에 오류가 있으면 class
정보를 추가한다.
BindingResult
가 있으면 @ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
예) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
BindingResult
가 없으면 -> 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.BindingResult
가 있으면 -> 오류 정보(FieldError
)를 BindingResult
에 담아서 컨트롤러를 정상 호출한다.@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError
생생해서 BindingResult
에 넣어준다.Validator
사용 -> 후에 설명주의
BindingResult
는 검증할 대상 바로 다음에 와야한다.BindingResult
는 Model에 자동으로 포함된다.BindingResult와 Errors
BindingResult
는 인터페이스이고, Errors
인터페이스를 상속받고 있다. Errors
인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult
는 여기에 더해서 추가적인 기능들을 제공한다.
// 필드 에러
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
FieldError
는 두가지 생성자를 제공한다.
파라미터 목록
objectName
: 오류가 발생한 객체 이름field
: 오류 필드rejectedValue
: 사용자가 입력한 값(거절된 값)bindingFailur
: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값codes
: 메시지 코드arguments
: 메시지에서 사용하는 인자defaultMessage
: 기본 오류 메시지ObjectError
도 유사하게 두가지 생성자를 제공한다.
타입 오류로 바인딩에 실패하면 스프링은 FieldError
를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult
에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
messages.properties
를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties
라는 별도의 파일로 관리해보자.
예제
//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, 100000}
를 사용해서 코드의 {0}
, {1}
로 치환할 값을 전달한다.rejectValue()
를 사용해서 오류 errors.properties
에 있는 코드를 직접 입력하지 않아도 실행
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드명errorCode
: 오류 코드 (메시지에 등록된 코드 아님)errorArgs
: 오류 메시지에서 {0}
을 치환하기 위한 값defaultMessage
: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
BindingResult
는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다. 따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price
오류 코드를 만들 때 세밀하게 작성하면 범용성이 떨어진다.
메시지 코드가 있으면 우선순위로 사용한다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
MessageCodesResolver
인터페이스이고 DefaultMessageCodesResolver
는 기본 구현체이다.ObjectError
, FieldError
객체 오류
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
rejectValue()
, reject()
는 내부에서 MessageCodesResolver
를 사용한다. 여기서 코드를 생성한다.FieldError
, ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관한다.구체적인 것에서 덜 구체적인 것으로!
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
rejectValue()
호출MessageCodesResolver
를 사용해서 검증 오류 코드로 메시지 코드들을 생성new FieldError()
를 생성하면서 메시지 코드들을 보관th:errors
에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출검증 오류 코드 종류
1. 개발자가 직접 설정한 오류 코드 -> rejectValue()
를 직접 호출
2. 스프링이 직접 검증 오류에 추구한 경우 (주로 타입 정보가 맞지 않음)
errors.properties
에 다음 내용 추가
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
결과적으로 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정
특정한 구현체가 아니라 검증 애노테이션과 여러 인터페이스의 모음이다.
@NotBlank
: 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull
: null
을 허용하지 않는다.
@Range(min = 1000, max = 100000)
: 범위 안의 값이어야 한다.
@Max(9999)
: 최대 9999까지만 허용한다.
spring-boot-starter-validation
라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다. 스프링 부트는 자동으로 글로벌 Validator로 등록한다. 이 Validator는 @NotNull
같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid
, @Validated
만 적용하면 된다.
검증 오류가 발생하면, FieldError
, ObjectError
를 생성해서 BindingResult
에 담아준다.
@Validated, @Valid 둘다 사용 가능
@Validated
는 내부에 groups라는 기능을 포함
@ModelAttribute
각각의 필드에 타입 변환 시도typeMismatch
로 FieldError
추가바인딩에 성공한 필드만 Bean Validation 적용
@ModelAttribute
-> 각각의 필드 타입 변환 시도 -> 변환에 성공한 필드만 BeanValidation 적용
Bean Validation을 적용하고 bindingResult
에 등록된 검증 오류 코드를 보자.
오류 코드가 애노테이션 이름으로 등록된다. 마치 typMismatch
와 유사
@NotBlank
@Range
messageSource
메시지 찾기message
속성 사용 -> @NotBlank(message = "공백! {0}")
Bean Validation에서 특정 필드 (FieldError
)가 아닌 해당 오브젝트 관련 오류 (ObjectError
)는 어떻게 처리? -> @ScriptAssert()
사용
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item {
//...
}
ScriptAssert.item
ScriptAssert
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
위의 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
저장용 groups
public interface SaveCheck {
}
수정용 groups
public interface UpdateCheck {
}
Item - groups 적용
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class,
UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
private Integer quantity;
Controller 적용
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
groups는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문에 잘 사용하지 않는다
실무에서 groups
를 잘 사용하지 않는 이유는 바로 등록시 폼 전달 데이터가 Item
도메인 객체와 맞지 않기 때문이다. (약관 정보 등이 추가로 넘어옴)
그래서 Item
을 직접 전달 받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. ItemSaveForm
이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute
로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item
을 생성한다.
HTML Form -> Item -> Controller -> Item -> Repository
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
이름은 의미있게 지으면 된다. ItemSave
라고 해도 되고, ItemSaveForm
, ItemSaveRequest
, ItemSaveDto
등으로 사용
ItemSaveForm
...
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
...
ItemUpdateForm
...
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
...
Controller
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
주의
@ModelAttribute("item")
에 item
이름을 넣어준 부분을 주의하자. 이것을 닿지 않으면 itemSaveForm
이름으로 MVC Model에 담기게 된다.
@Valid
, @Validated
는 HttpMessageConverter
(@RequestBody
)에도 적용할 수 있다.
참고
@ModelAttribute
: HTTP 요청 파라미터 (URL 쿼리 스트링, POST Form)를 다룰 때 사용한다. 각각의 필드 단위로 세밀하게 적용된다.
@RequestBody
: HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다. HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 진행되지 않고 예외 발생한다.