Java

spring 검증(validation, bean validation)

blackbearwow 2025. 4. 11. 02:10

웹 서비스를 만들다보면 회원가입, 로그인, 회원정보수정, 상품등록, 상품수정 등 많은 폼 입력을 만든다. 

이때, 각 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가 늘었다.

editForm.html

@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")로 모델 이름을 바꿔주는 작업은 똑같다. 

 

시연영상