spring 검증(validation, bean validation)
웹 서비스를 만들다보면 회원가입, 로그인, 회원정보수정, 상품등록, 상품수정 등 많은 폼 입력을 만든다.
이때, 각 input에 대한 검증을 해야한다. 이 검증을 spring에서 쉽게 할 수 있게 도와준다.
예를 들어, 그래픽카드를 등록하고 수정하는 기능이 있는 웹을 만들 것이다. 등록과 수정의 요구사항이 다르다고 가정한다.
그래픽카드 도메인은 다음과 같다.
@Getter @Setter
public class GraphicsCard {
private Long id;
private GraphicsCardGroup group;
private String name;
private Long quantity;
private Long price;
}
id는 Repository가 자동으로 1부터 지정해주는 값이다.
group는 다음과 같다.
public enum GraphicsCardGroup {
RTX5090, RTX5080, RTX5070, RTX4090, RTX4080, RTX4070;
}
name은 이름, quantity는 수량, price는 하나의 값이다.
HomeController는 다음과 같다.
@Controller
@RequiredArgsConstructor
@Slf4j
public class HomeController {
private final GraphicsCardService graphicsCardService;
// 어떤 컨트롤러든 이 모델을 Model에 추가한다.
@ModelAttribute("GraphicsCardGroups")
public GraphicsCardGroup[] graphicsCardGroups() {
return GraphicsCardGroup.values();
}
@GetMapping("/")
public String home(Model model) {
List<GraphicsCard> result = graphicsCardService.findAll();
model.addAttribute("graphicsCards", result);
return "index";
}
@GetMapping("/newForm")
public String newForm(Model model) {
model.addAttribute("graphicsCard", new GraphicsCard());
return "newForm";
}
@PostMapping("/newForm")
public String addForm(@ModelAttribute("graphicsCard") @Validated AddGraphicsCardForm addGraphicsCardForm, BindingResult bindingResult, Model model) {
if (addGraphicsCardForm.getQuantity() != null && addGraphicsCardForm.getPrice() != null) {
long totalPrice = addGraphicsCardForm.getQuantity() * addGraphicsCardForm.getPrice();
if(totalPrice < 10000) {
bindingResult.reject("totalPriceMin", "수량x가격인 총 가격이 10000 이상이어야 합니다.");
}
}
if (bindingResult.hasErrors()) {
log.info(bindingResult.getAllErrors().toString());
return "newForm";
}
GraphicsCard graphicsCard = new GraphicsCard();
graphicsCard.setGroup(addGraphicsCardForm.getGroup());
graphicsCard.setName(addGraphicsCardForm.getName());
graphicsCard.setQuantity(addGraphicsCardForm.getQuantity());
graphicsCard.setPrice(addGraphicsCardForm.getPrice());
graphicsCardService.join(graphicsCard);
return "redirect:/";
}
@GetMapping("/editForm/{id}")
public String editForm(@PathVariable Long id, Model model) {
model.addAttribute("graphicsCard", graphicsCardService.findOne(id));
return "editForm";
}
@PostMapping("/editForm/{id}")
public String editFormCommit(@PathVariable Long id, @Validated @ModelAttribute("graphicsCard") EditGraphicsCardForm editGraphicsCardForm, BindingResult bindingResult, Model model) {
if (editGraphicsCardForm.getQuantity() != null && editGraphicsCardForm.getPrice() != null) {
long totalPrice = editGraphicsCardForm.getQuantity() * editGraphicsCardForm.getPrice();
if(totalPrice < 1000) {
bindingResult.reject("totalPriceMin", "수량x가격인 총 가격이 1000 이상이어야 합니다.");
}
}
if (bindingResult.hasErrors()) {
return "editForm";
}
GraphicsCard graphicsCard = new GraphicsCard();
graphicsCard.setId(id);
graphicsCard.setGroup(editGraphicsCardForm.getGroup());
graphicsCard.setName(editGraphicsCardForm.getName());
graphicsCard.setQuantity(editGraphicsCardForm.getQuantity());
graphicsCard.setPrice(editGraphicsCardForm.getPrice());
graphicsCardService.update(id, graphicsCard);
return "redirect:/";
}
}
먼저 index는 모든 그래픽카드 정보들을 보여준다. index.html이다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container-sm">
<table class="table">
<tr>
<th>id</th>
<th>group</th>
<th>이름</th>
<th>수량</th>
<th>가격</th>
<th>수정</th>
</tr>
<tr th:each="graphicsCard : ${graphicsCards}">
<td th:text="${graphicsCard.id}"></td>
<td th:text="${graphicsCard.group}"></td>
<td th:text="${graphicsCard.name}"></td>
<td th:text="${graphicsCard.quantity}"></td>
<td th:text="${graphicsCard.price}"></td>
<td><button class="btn btn-primary" th:onclick="|location.href='@{|/editForm/${graphicsCard.id}|}'|">수정하기</button></td>
</tr>
</table>
<button type="button" class="btn btn-primary" onclick="location.href='/newForm'">새 그래픽카드 상품 등록</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
th:each로 각 그래픽카드들의 정보를 보여준다.
새 그래픽카드 상품 등록부터 살펴보자. newForm.html이다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<style>
.field-error {
color: red;
border-color: red;
}
</style>
<body>
<div class="container-sm">
<form method="post" th:object="${graphicsCard}">
<div th:if="${#fields.hasGlobalErrors()}">
<p th:class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류</p>
</div>
<div>
<label class="form-label" for="group">그래픽카드 종류</label>
<select th:field="*{group}" id="group" class="form-select" th:errorclass="field-error">
<option th:each="GraphicsCardGroup : ${GraphicsCardGroups}" th:value="${GraphicsCardGroup}"
th:text="${GraphicsCardGroup}"></option>
</select>
<p class="field-error" th:errors="*{group}"></p>
</div>
<label class="form-label" for="name">name:</label>
<input class="form-control" type="text" id="name" th:field="*{name}" th:errorclass="field-error"><br>
<p class="field-error" th:errors="*{name}"></p>
<label class="form-label" for="quantity">quantity:</label>
<input class="form-control" type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error"><br>
<p class="field-error" th:errors="*{quantity}"></p>
<label class="form-label" for="price">price:</label>
<input class="form-control" type="text" id="price" th:field="*{price}" th:errorclass="field-error"><br>
<p class="field-error" th:errors="*{price}"></p>
<button type="submit" class="btn btn-primary">등록</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
컨트롤러에서 model.addAttribute("graphicsCard", new GraphicsCard());로 미리 빈 graphicsCard객체를 생성해둔다. th:object와 th:field를 사용하기 위해서다.
th:object로 객체를 선택한다.
그리고 하위 요소에 th:field를 사용하면 id, name, value속성을 자동으로 만들어준다.
th:errors를 사용하면 해당 필드의 오류가 있는 경우 태그에 message를 출력한다. th:if의 편의 버전이다.
th:errorclass를 사용하면 th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
글로벌오류는 ${#fields.hasGlobalErrors()}로 판단하고 ${#fields.globalErrors()}를 루프돌려서 표현한다.
여기서 등록을 하면 이제 검증이 필요한 것이다.
검증을 위해 AddGraphicsCardForm 클래스를 따로 만들어준다.
@Getter @Setter
public class AddGraphicsCardForm {
@NotNull
private GraphicsCardGroup group;
@Length(min = 1, max = 20)
private String name;
@NotNull
@Range(min = 1, max = 10)
private Long quantity;
@NotNull
@Range(min = 0, max = 100000)
private Long price;
}
GraphicsCard 클래스에는 @Getter @Setter만 있고 속성에는 아무런 annotation이 없었지만, 이 폼에서는 여러가지 annotation이 있다. @NotNull은 null값이 될 수 없다는 뜻이고, @Length는 최소 최대 길이를 설정, Range는 값의 범위를 설정하는 어노테이션이다. 이러한 어노테이션들은 Bean validation에 사용되는 것이다.
여러가지 bean validation annotation은 다음 사이트에서 확인 가능하다: https://docs.jboss.org/hibernate/validator/8.0/reference/en-US/html_single/#section-builtin-constraints
PostMapping를 살펴보자.
@PostMapping("/newForm")
public String addForm(@ModelAttribute("graphicsCard") @Validated AddGraphicsCardForm addGraphicsCardForm, BindingResult bindingResult, Model model) {
if (addGraphicsCardForm.getQuantity() != null && addGraphicsCardForm.getPrice() != null) {
long totalPrice = addGraphicsCardForm.getQuantity() * addGraphicsCardForm.getPrice();
if(totalPrice < 10000) {
bindingResult.reject("totalPriceMin", "수량x가격인 총 가격이 10000 이상이어야 합니다.");
}
}
if (bindingResult.hasErrors()) {
log.info(bindingResult.getAllErrors().toString());
return "newForm";
}
GraphicsCard graphicsCard = new GraphicsCard();
graphicsCard.setGroup(addGraphicsCardForm.getGroup());
graphicsCard.setName(addGraphicsCardForm.getName());
graphicsCard.setQuantity(addGraphicsCardForm.getQuantity());
graphicsCard.setPrice(addGraphicsCardForm.getPrice());
graphicsCardService.join(graphicsCard);
return "redirect:/";
}
@Validated는 해당 객체의 BeanValidation을 해준다. 이때, BindingResult가 @ModelAttribute객체 바로 다음 매개변수에 있어야 한다. 객체에 오류가 있다면 bindingResult에 오류가 담긴다.
@ModelAttribute("graphicsCard")에서 graphicsCard는 model.addAttribute("graphicsCard", addGraphicsCardForm);을 뜻한다. 따로 이름을 지정하지 않으면 model.addAttribute("addGraphicsCardForm", addGraphicsCardForm);로 되어 newForm.html이 렌더링될 때, graphicsCard라는 객체가 없어 오류가 생긴다.
bindingResult.reject로 글로벌 오류를 담을 수 있다. 여기서는 수량x가격인 총 가격이 10000원 이상이어야 한다는 제약을 걸었다.
에러가 있다면 th:errors와 th:errorclass가 동작해 빨간색 오류를 나타낸다.
오류 메시지를 설정하지 않는다면 영어로 기본 메시지가 나온다.
좀 더 친절하게 오류 메시지를 알려주고싶다면 application.properties에 spring.messages.basename=messages, errors라고 errors를 추가후 resources 패키지에 errors.properties를 만든다.
내가 만든 errors.properties
NotNull={0} 값을 입력해야 합니다.
Length={0} 길이가 {2}이상 {1}이하여야 합니다.
Range={0} 값이 {2}이상 {1}이하여야 합니다.
typeMismatch=타입이 맞지 않습니다.
오류 메시지 작성 방법은 다음과 같다.
log.info로 bindingResult의 오류들을 콘솔에 남기고 오류를 본다.
그러면 codes와 arguments를 확인해 작성하면 된다.
Field error in object 'graphicsCard' on field 'name': rejected value []; codes [Length.graphicsCard.name,Length.name,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [graphicsCard.name,name]; arguments []; default message [name],20,1]; default message [길이가 1에서 20 사이여야 합니다]
위 에러를 보면 name값을 입력하지 않았을 때 나오는 오류이다.
name에 현재 @Length(min = 1, max = 20)의 제한이 있으므로
codes를 보면 [Length.graphicsCard.name,Length.name,Length.java.lang.String,Length]이다. 오른쪽부터 왼쪽으로 자세해지는데, 이 값들을 errors.properties의 = 왼쪽에 넣으면 된다.
그리고 arguments를 보면 마지막에 default message [name],20,1라고 적혀힜다. 이것을 errors.properties의 = 오른쪽에 {인덱스}를 넣으면 넣을 수 있다.
정보 수정하기 창은 그래픽카드 등록과 검증이 다르기 때문에 따로 editGraphicsCardForm 클래스를 만들어 bean validation을 받으면 된다.
@Getter @Setter
public class EditGraphicsCardForm {
@NotNull
private Long id;
@NotNull
private GraphicsCardGroup group;
@Length(min = 1, max = 20)
private String name;
@NotNull
@Range(min = 1, max = 100)
private Long quantity;
@NotNull
@Range(min = 0, max = 1000000)
private Long price;
}
수량과 가격의 max가 늘었다.
@PostMapping("/editForm/{id}")
public String editFormCommit(@PathVariable Long id, @Validated @ModelAttribute("graphicsCard") EditGraphicsCardForm editGraphicsCardForm, BindingResult bindingResult, Model model) {
if (editGraphicsCardForm.getQuantity() != null && editGraphicsCardForm.getPrice() != null) {
long totalPrice = editGraphicsCardForm.getQuantity() * editGraphicsCardForm.getPrice();
if(totalPrice < 1000) {
bindingResult.reject("totalPriceMin", "수량x가격인 총 가격이 1000 이상이어야 합니다.");
}
}
if (bindingResult.hasErrors()) {
return "editForm";
}
GraphicsCard graphicsCard = new GraphicsCard();
graphicsCard.setId(id);
graphicsCard.setGroup(editGraphicsCardForm.getGroup());
graphicsCard.setName(editGraphicsCardForm.getName());
graphicsCard.setQuantity(editGraphicsCardForm.getQuantity());
graphicsCard.setPrice(editGraphicsCardForm.getPrice());
graphicsCardService.update(id, graphicsCard);
return "redirect:/";
}
컨트롤러를 보면 수량x가격인 총 가격이 1000이상으로 기준이 등록 10000원보다 쉬워졌다.
등록 폼과 같이 @Validated로 객체의 BeanValidation을 해주고 @ModelAttribute("graphicsCard")로 모델 이름을 바꿔주는 작업은 똑같다.
시연영상