<스프링 MVC 2편> 강의를 들으면서 배운 내용을 투두리스트에 적용해보고 있다. 이번에는 검증에 대해 배워서 Validator를 이용하여 검증 및 에러 메시지 국제화 기능을 추가해보았다.

이전에 다국어 기능을 만들어놓았기 때문에 messages, messages_en properties 파일이 이미 있는 상태였다. 하지만 에러 메시지는 별도로 관리하기 위해 파일을 따로 만들기로 했다.
erros, errors_en properties 파일을 resources 밑에 만들어 준 뒤 application.yml에서 설정을 추가해준다.
spring.message.basename: messages, errors
별도 설정이 없으면 스프링은 기본적으로 MessageSource를 messages라는 이름을 가진 파일에서 찾는다. errors 파일을 따로 만들어줬기 때문에 스프링이 찾을 수 있도록 basename 설정을 추가해준다.
required.taskForm.name=제목을 입력하세요.
range.taskForm.name=제목은 24자까지입니다.
unique.taskForm.name=동일한 제목의 할 일이 이미 존재합니다.
required.taskForm.priority=우선순위를 선택하세요.
errorCode는 필수 값에 대한 것은 "required", 값 범위에 관한 에러의 경우 "range", 유일성에 관한 에러의 경우 "unique"로 정해주었다. errorCode 뿐 아니라 objectName, field도 모두 지정하여 가장 우선순위가 높은 메시지 코드를 만들어 주었다.
검증 로직은 컨트롤러에 넣을 수도 있지만 가독성을 위해 TaskValidator라는 클래스를 별도로 만들어 분리하였다.
@Component
@RequiredArgsConstructor
public class TaskValidator implements Validator {
private final TaskService taskService;
@Override
public boolean supports(Class<?> clazz) {
return TaskForm.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
TaskForm form = (TaskForm) target;
if(!StringUtils.hasText(form.getName())) {
errors.rejectValue("name", "required");
}
if (form.getName().length() > 24) {
errors.rejectValue("name", "range");
}
if (taskService.checkDuplicateTaskName(form.getName())) { //제목 중복 체크
errors.rejectValue("name", "unique");
}
if (form.getPriority() == null) {
errors.rejectValue("priority", "required");
}
}
}
TaskValidator
@Component 애노테이션을 달아 스프링이 스캔을 할 수 있게 해준다.Validator 인터페이스를 상속한다. 이 인터페이스는 여러 Validator를 관리하여 사용할 때마다 검증 메서드(validate())를 호출하지 않아도 간편하게 검증이 가능하도록 해준다.Validator의 메서드 supports(), validate()를 오버라이드 해준다.supports()
TaskValidator가 TaskForm이라는 클래스에 대한 검증을 지원하는지 체크하기 위해서 support() 메서드에 TaskForm을 파라미터로 넘기면 true가 반환될 것이다. 이렇게 TaskValidator가 TaskForm에 대한 검증을 지원하는 것을 판별할 수 있다.validate()
검증 로직이다. 검증 대상(target)은 해당 객체로 캐스팅해주어야 한다.
BindingResult 대신 Errors를 사용한다. (Errors는 BindingResult의 부모 인터페이스)
검증 로직에 따라 에러가 존재하는 경우 errors에 rejectValue()(필드 에러) 또는 reject()(글로벌 에러)로 에러를 추가해준다.
@Controller
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
private final TaskValidator taskValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(taskValidator);
}
...
}
TaskValidator 의존성을 추가해준다.@InitBinderWebDataBinder를 초기화하는 메서드이다.WebDataBinder는 HTML form 데이터를 검증하고 에러를 표시하는 field marker에 대한 기능을 포함한다.validate() 메서드를 일일히 호출하지 않아도 검증 기능을 사용할 수 있다. 여러 개의 Validator들을 등록할 수도 있는데, 어떤 Validator가 어떤 객체에 대한 검증을 지원하는지는 supports() 메서드를 통해 판별한다. @PostMapping("/tasks/new")
public String addTask(@Validated TaskForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//검증 에러가 있는 경우
if (bindingResult.hasErrors()) {
return "task/addTaskForm";
}
//검증 통과
taskService.add(form.getName(), form.getPriority());
redirectAttributes.addAttribute("status", true);
return "redirect:/tasks";
}
@Validated: WebDataBinder에 등록한 Validator를 활성화하는 애노테이션으로, 대상 메서드의 파라미터에서 검증 대상 객체 앞에 붙인다.
에러가 있는 경우: TaskValidator를 통해 BindingResult에 에러 정보가 포함되고, 조건문이 참이기 때문에 다시 View를 호출한다.
검증을 통과한 경우: 사용자가 입력한 값을 가지고 새로운 task를 저장하고 목록을 보여주기 위해 리다이렉트한다.
<form action="/tasks/new" th:object="${taskForm}" method="post">
<p><label th:for="name" th:text="#{label.title}">제목</label></p>
<input type="text" th:field="*{name}">
<p th:errors="*{name}">Incorrect data</p>
th:errors="*{name}"th:errors: 지정한 필드에 에러가 존재하는 경우에 해당 태그를 표시한다. th:if를 사용할 때 길어지는 코드를 단축해준 것이다.*{name}: 위에서 th:object="${taskForm}"로 대상 객체가 지정되었기 때문에 *{}를 사용하여 필드명 만으로 접근할 수 있다. ${taskForm.name}과 동일하다. <div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}"
th:text="${err}">글로벌 오류 메시지</p>
</div>
th:if="${#fields.hasGlobalErrors()를 통해 글로벌 에러에 접근할 수 있다.th:each="err : ${#fields.globalErrors()}"와 같이 반복문으로 처리한다..field-error {
border-color: #dc3545;
color: #dc3545;
}
.field-error-text {
margin-top: 5px;
}
<input type="text" th:field="*{name}" th:errorclass="field-error">
<p th:errors="*{name}" th:class="field-error-text" th:errorclass="field-error">Incorrect data</p>
th:errorclass는 에러가 발생한 경우에 클래스를 추가해준다. 이것을 사용하면 에러가 발생했을 경우 스타일을 적용할 수 있다.
나의 투두리스트에서는 [할 일 등록] 폼 외에도 [할 일 수정] 폼에서 똑같은 검증을 적용해야 한다. 그런데 수정 폼을 다루는 editTaskForm()의 파라미터에는 검증 대상 객체인 TaskForm 객체 파라미터가 없어서 @Validated를 어디에 붙여야 하는지 고민했는데, 왜인지 이 애노테이션이 없어도 동작했다.
@GetMapping("/tasks/{taskId}/edit")
public String editTaskForm(@PathVariable Long taskId, Model model) {
Task task = taskService.findOne(taskId);
TaskForm form = new TaskForm();
form.setName(task.getName());
form.setPriority(task.getPriority());
model.addAttribute("taskForm", form);
return "task/editTaskForm";
}
왜 동작하는 걸까...?
아마도 @InitBinder에 등록된 TaskValidator가 같은 컨트롤러의 addTaskForm()에서 한 번 활성화 됐기에 다른 메서드에서도 사용할 수 있는 것 같긴 하다. @InitBinder가 없는 Bean Validation으로 Validator를 적용했을 때는 @Validated가 없으면 동작하지 않는다.