컨트롤러의 중요한 역할 중 하나는 HTTP 요청
이 정상인지 검증하는 것이다.
최종적으로 서버 검증은 필수
클라이언트와 서버 간의 데이터 검증 과정은 성공했을 때와 실패했을 때로 구분된다.
상품 저장 성공 | 상품 저장 검증 실패 |
---|---|
Null
TypeMissMatch
비즈니스 요구사항에 맞지 않음
검증 실패의 대표적인 케이스인데, 이를 처리하는 방법은 다양하며 하나씩 알아보자.
검증을 하는 방식은 몹시 다양하다.
단순히 Map
에다가 에러 내용을 담아 모델에 담아서 반환하는 방식도 있고, BindingResult
를 사용하여 담아 보낼 수도 있고, Validator
라는 마커 인터페이스를 구현하는 방식도 있다.
서버에서 전달받은 데이터를 직접 검증하여 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}";
}
RedirectAttributes
는 Redirect
시 보존할 데이터를 담을 수 있다.
가장 간단한 방식으로 컬렉션 프레임워크만 쓸 줄 안다면 크게 어렵지 않게 구현할 수 있다.
하지만 타입이 안 맞는 경우 (ex: Integer 타입 변수에 String 타입 값을 바인딩 하려는 경우) 컨트롤러까지 가지 못하고 400 (Bad Request)
에러가 발생하며 오류 페이지를 띄운다.
❗ 타입 불일치에도 오류 페이지를 보여주지 않고 잘못된 부분을 사용자에게 고지할 수 있어야 한다.
다음에는 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}";
}
bindingResult
의 addError
메서드를 이용해 에러 내용을 담을 수 있다.
필드(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
를 제공한다. 이것은 오류 발생 시 오류 코드로 메시지를 찾기 위해 사용된다.
messages.properties
를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties
라는 별도의 파일로 관리해 보자.
먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게 하면 messages.properties
, errors.properties
두 파일을 모두 인식한다. (생략하면 messages.properties
를 기본으로 인식한다.)
application.properties
spring.messages.basename=messages, errors
src/main/resources/errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
errors_en.properties
파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.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
를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
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);// 오류 메시지를 못 찾을 경우 기본 메시지
여기서 field
와 errorCode
매개변수를 가지고 errors.properties
에서 메시지를 찾아낸다는 것인데, 스프링에서는 이를 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
메시지 혹은 예외 메시지는 특정 필드에 맞는 메시지가 있을 수도 있지만 한편으로는 범용성이 높은 메시지도 있을 수 있다. 예를 들어 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"
즉, 구체적인 것에서 덜 구체적인 것으로 차례대로 찾는다.
MessageCodesResolver
를 reject()
, rejectValue()
메서드에서 사용하기 때문에 우리는 편하게 field
와 errorCode
만 인수로 넘겨줌으로써 에러 내용을 담을 수 있는 것이다.
new String[]{"required.item.itemName", "required.itemName", "required.java.lang.String", "required"}
를 내부에서 만들어 메시지를 찾는다.new String[]{"totalPriceMin.item", "totalPriceMin"}
을 내부에서 만들어 메시지를 찾는다.스프링에서는 이를 위해 ValidationUtils라는 유틸 클래스를 제공하는데, 이를 사용해 유효성 검증을 여기서 한 번 더 편하게 작성할 수 있다.
// before
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
// after
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
rejectValue()
호출MessageCodesResolver
를 사용해서 검증 오류 코드로 메시지 코드들을 생성new FieldError()
를 생성하면서 메시지 코드들을 보관th:erros
에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출우리가 정의한 오류 코드는 rejectValue()
를 직접 호출해서 담아준다.
codes[typeMismatch.item.price,
typeMismatch.price,
typeMismatch.java.lang.Integer,
typeMismatch]
스프링은 타입 오류가 발생하면 자동으로 위 오류 코드들을 담게 되는데 errors.properties
에는 해당 내용으로 정의한 메시지가 없기 때문에 스프링에서 정의한 기본 메시지가 출력되는 것이다.
하지만, 기본 메시지는 너무 장황하고 길어서 개발자가 아닌 사용자에게 노출해서는 안 된다.
그래서 errors.properties
에 다음과 같이 메시지를 선언해 주자.
typeMismatch.java.lang.Integer=숫자를 입력해 주세요.
typeMismatch=타입 오류입니다.
지금까지 검증 로직을 최대한 모듈화하고 스프링에서 제공하는 여러 유틸 클래스나 리졸버를 통해 간략해 보았다. 하지만 그럼에도 검증 로직은 중복이 많고, 매번 필요할 때마다 작성하는 것은 비효율적이다. 하지만, 중요도가 높은 만큼 생략할 수도 없다.
그래서 이런 검증 로직을 별도의 클래스로 분리해서 이런 문제들을 해결해 보자. 중복이 발생할 경우 분리하여 모듈화하면 재사용성이 높아지고 가독성 또한 높아질 수 있다.
스프링에서는 검증에 필요한 Validator라는 인터페이스를 정의해두었다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
인터페이스는 책임 사슬 패턴에서 주로 보이는 메서드인 supports
와 실제 검증을 수행하는 validate
메서드를 정의하고 있다. 우리는 이러한 Validator
인터페이스를 구현하면서 Item에 대한 검증 로직을 구현해 볼 것이다.
@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 클래스의 부모 타입이기 때문에 공변성이 성립한다.
이렇게 구현한 itemValidator
는 Component
이기 때문에 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
인터페이스를 구현해서 검증 로직을 만들면 추가적으로 애노테이션을 사용하여 검증을 수행할 수도 있다. 바로 WebDataBinder
를 이용하는 것인데 이 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함하는 클래스다. 그렇기에 이 객체에 내가 만든 검증기를 추가(add) 하면 자동으로 검증기 적용이 가능해진다.
@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
를 통해 등록한 검증기는 해당 컨트롤러에서만 사용 가능하다.
(글로벌 설정은 별도로 해야 한다. )
이렇게 위와 같이 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";
}
...
}
@Validated
을 사용해서 Item
의 검증 로직을 수행해 준다.
WebDataBinder
가 @Validated
이 붙은 요소를 검증하는데 이때 WebDataBinder
가 가진 여러 검증기 중에서 어떤 검증기가 실행돼야 할지 찾기 위해 구분이 필요한데 이때 supports()
가 사용된다.
검증을 위해 사용하는 애노테이션으로 @Validated
를 사용했지만 @Valid
를 먼저 알고 있는 사람도 있다.
org.springframework.validation.annotation.Validated
가 스프링 전용 검증 애노테이션이라면 javax.validation.@Valid
는 자바 표준 검증 애노테이션이다.
둘 다 역할은 동일하지만, @Valid
는 다음과 같은 의존성을 추가해 줘야 한다.
(gradle 기준 build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-validation'