공부/Spring

검증2 - Bean Validation

Stair 2025. 4. 3. 10:20
반응형

Bean Validation을 알아보기 전에 이전에 보았던 Validation을 참고하자.

https://surrealcode.tistory.com/135

 

검증1 - Validation

검증 요구사항기존에 만든 상품 관리 시스템은 허술한 부분이 많다. id가 없어도 등록이 되거나, int값을 받는 곳에 String을 넣게되면, 4xx번대 에러가 발생하는 등 문제가 많았다. 이 부분을 해결

surrealcode.tistory.com

 

BeanValidation

검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다. 다음 코드를 보자

public class Item {
    private Long id;

    @NotBlank
    private String ItemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(9999)
    private Integer quantity;
}

 

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation이다.

Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

 

Bean Validation이란

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고, 그 구현체로 하이버네이트가 있는 것과 같다.

 

Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.

표준 스펙 및 자세한 사항은 다음을 확인하자.

https://hibernate.org/validator/

 

 

 

Bean Validation - 시작

Bean Validation 기능을 어떻게 사용하는지 코드로 알아보자. 먼저 스프링과 통합하지 않고, 순수한 BeanValidation 사용법부터 테스트 코드로 알아보자..

 

 

Bean Validation 의존관계 추가

Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다. build.gradle에 추가해주자.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 

Bean Validation을 사용해보자.

 

기존 Item클래스에 Bean Validation을 적용시키자.

@Data
public class Item {

    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

검증 애노테이션

@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.

@NotNull : null을 허용하지 않는다.

@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.

@Max(9999) : 최대 9999까지만 허용한다.

 

참고 : import 된 클래스를 보면 

import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

 

javax인것도 있고 , hivernate인것도 있다.

 

javax.validation으로 시작하면 특정 구현에 관계 없이 제공되는 표준 인터페이스이고,

org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할때만 제공되는 검증 기능이다. 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 된다.

 

다음은 검증기를 생성해보고 사용해보자.

public class BeanValidationTest {

    @Test
    void beanValidation() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        Item item = new Item();
        item.setItemName(" ");
        item.setPrice(0);
        item.setQuantity(10000);

        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation = " + violation);
            System.out.println("violation.getMessage() = " + violation.getMessage());
        }
    }
}

 

스프링과 통합하면 직접 이런 코드를 작성하지는 않기 때문에, 이렇게 사용하는 구나 참고정도만 하자.

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

 

검증기를 생성했으면 실행을 해보도록 한다. 검증 대상(Item)을 직접 검증기에 넣고 그 결과를 받는다. Set에는 ConstraintViolation이라는 검증 오류가 담긴다. 따라서 결과가 비어있으면 검증 오류가 없는 것이다.

Set<ConstraintViolation<Item>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
    System.out.println("violation = " + violation);
    System.out.println("violation.getMessage() = " + violation.getMessage());
}

 

 

실행 결과를 보자.

violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=classhello.itemservice.domain.item.Item,messageTemplate='{javax.validation.constraints.NotBlank.message}'} violation.getMessage() = 공백일 수 없습니다
violation = ConstraintViolationImpl{interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=classhello.itemservice.domain.item.Item,messageTemplate='{org.hibernate.validator.constraints.Range.message}'} violation.getMessage() = 1000에서 1000000 사이여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=classhello.itemservice.domain.item.Item,messageTemplate='{javax.validation.constraints.Max.message}'}violation.getMessage() = 9999 이하여야 합니다

 

ConstraintViolation 출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보 등 다양한 정보를 확인할 수 있다.

 

** 참고 : 에러메세지를 아직 만들지 않은 상태에서도 에러 메시지가 나온 이유는 기본 메시지 번들을 사용하기 때문이다.

검증 애너테이션이 적용된 필드에서 검증이 실패하면, 기본 메시지 번들에서 해당 에너테이션에 매핑된 메시지를 가져와 반환한다. 기본 메시지는 Hibernate Validator 내부에 기본 제공되는 ValidationMessages.properties 파일에 정의되어 있다.

 

 

정리 : 이렇게 빈 검증기(Bean Validation)를 직접 사용하는 방법을 알아보았다.

 

 

 

Bean Validation - 스프링 적용

 

public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            log.info("errors ={}", bindingResult);
            return "validation/v3/addForm";
        }

        //성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }

}

 

기존에 등록했던 ItemValidator를 제거해두자. 오류 검증기가 중복으로 적용된다.

private final ItemValidator itemValidator;

@InitBinder
public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(itemValidator);
}

 

실행해보면 애노테이션 기반의 BeanValidation이 정상 동작하는 것을 확인할 수 있다.

(참고 : 이전에 했던 글로벌 검증 기능은 빠져있는데, 뒤에서 다시 알아보도록 하자.)

 

 

스프링 부트가 spring-boot-starter-validation라이브러리를 넣으면 자동으로 Bean Validation를 인지하고 스프링에 통합한다.

LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated만 적용하면 된다.

검증 오류가 발생하면, FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

 

* 참고 : 검증시엔 @Validated, @Valid 둘 다 사용 가능하다.

javax.validation.@Valid를 사용하려면 build.gradle 의존 관계 추가가 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-validation' @Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다. 둘 중 아무거나 사용해도 동일하게 작동하지만, @Validated는 내부에 groups라는 기능을 포함하고 있다.

 

 

검증 순서

1. @ModelAttribute 각각의 필드에 타입 변환 시도

    1.1 성공하면 2번으로

    1.2 실패하면 typeMismatch로 FieldError추가

2. Validator 적용

 

BeanValidator는 바인딩에 실패한 필드는 BeanValidator를 적용하지 않는다. 생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.(일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)

 

@ModelAttribute -> 각각의 필드 타입 변환을 시도한다. -> 변환에 성공한 필드만 BeanValidation을 적용한다.

 

ex)

- itemName에 문자"A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation

- price에 문자 "A"입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 -> price 필드는 BeanValidation 적용 X

 

 

 

Bean Validation - 에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?

itemName을 빈 공백으로 하여 저장한 뒤, 로그를 확인해보자.

 

Field error in object 'item' on field 'itemName': rejected value []; codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [공백일 수 없습니다]

 

Bean Validation을 적용하고, bindingResult에 등록된 검증 오류 코드를 보자.

오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch와 유사하다.

 

NotBlank라는 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.

1. NotBlank.item.itemName

2. NotBlank.itemName

3. NotBlank.java.lang.String

4. NotBlank

 

위 오류 코드를 기반으로 errors.properties에 메시지를 등록해보자.

#Bean Validation 추가

NotBlank.item.itmeName=상품 이름을 적어주세요.

NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

{0}은 필드명이고, {1},{2}...는 각 애노테이션마다 다르다

 

실행시켜보면 방금 등록한 메시지가 정상 적용되는 것을 확인할 수 있다.

 

BeanValidation 메시지 찾는 순서는 다음과 같다.

1. 생성된 메시지 코드 순서대로 messageSource에서 미시지 찾기

2. 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백! {0}"}

3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.

 

애노테이션의 message 사용 예

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

 

 

Bean Validation - 오브젝트 오류

Bean Validation에서 특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?

다음과 같이 @ScriptAssert()를 사용하면 된다.

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000원 넘게 입력해주세요")
public class Item {
...
}

 

실행해보면 메시지 코드도 다음과 같이 생성된다.

 

메시지 코드

- ScriptAssert.item

- ScriptAssert

 

그런데 실제 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응하기가 어렵다.

 

따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert를 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }   
    ...
}

 

@ScriptAssert 부분은 제거해주도록 하자.

 

 

 

Bean Validation - 수정에 적용

상품 수정에도 빈 검증(Bean Validation)을 적용해보자.

 

수정에도 검증 기능을 추가하자.

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {

    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    if (bindingResult.hasErrors()) {
        log.info("errors ={}", bindingResult);
        return "validation/v3/editForm";
    }

    itemRepository.update(itemId, item);
    return "redirect:/validation/v3/items/{itemId}";
}

 

- edit() : Item 모델 객체에 @Validated를 추가하자.

- 검증 오류가 발생하면 editForm으로 이동하는 코드를 추가한다.

 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error{
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.updateItem}">상품 수정</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="id" th:text="#{label.item.id}">상품 ID</label>
            <input type="text" id="id" th:field="*{id}" class="form-control" readonly>
        </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">
            <div class="field-error" th:errors="*{itemName}">
                상품명 오류
            </div>
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}"
                   th:errorclass="field-error" class="form-control">
            <div class="field-error" th:errors="*{price}">
                가격 오류
            </div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}"
                   th:errorclass="field-error" class="form-control">
            <div class="field-error" th:errors="*{quantity}" >
                수량 오류
            </div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">저장</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='item.html'"
                        th:onclick="|location.href='@{/validation/v3/items/{itemId}(itemId=${item.id})}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

editForm은 addForm을 참고하여 만들자.

- .field-error css를 추가한다.

- 글로벌 오류 메시지를 추가한다.

- 상품명, 가격, 수량 필드에 검증 기능을 추가한다.

 

프로그램을 실행해서 수정시에도 검증이 잘 되는지 확인해보자.

 

 

 

Bean Validation - 한계

 

데이터를 등록할때와 수정할 때는 요구사항이 다를 수 있다.

 

등록시 기존 요구사항

- 타입 검증

    - 가격, 수량에 문자가 들어가면 검증 오류 처리

- 필드 검증

    - 상품명 :필수, 공백X

    - 가격 : 1000원 이상, 백만원 이하

    - 수량 : 최대 9999

- 특정 필드의 범위를 넘어서는 검증

    - 가격 * 수량의 합은 10000원 이상

 

수정시 요구사항

- 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만, 수정시에는 수량을 무제한으로 변경할 수 있다.

- 등록시에는 id에 값이 없어도 되지만, 수정시에는 id값이 필수이다.

 

 

위 수정 요구사항을 만족시키기 위해서 Item 클래스를 변경하여보자.

public class Item {

    @NotNull //수정 요구사항 추가
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
//    @Max(9999) //수정 요구사항 추가
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

id를 NotNull로 잡았고, quantity의 @Max어노테이션을 삭제하였다.

 

참고 : 현재 구조에서는 수정시 item의 값은 항상 들어오도록 로직이 구성되어있다. 그래서 검증하지 않아도 된다고 생각할 수 있지만, HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로, 서버에서 항상 검증 해야한다. 예를 들어 HTTP 요청을 변경해서 item의 id 값을 삭제하고 요청할 수도 있다. 따라서 최종 검증은 서버에서 진행하는 것이 안전하다.

 

위 코드인 상태에서 수정을 실행하면 정상 동작한다.

**하지만 등록시에 문제가 발생한다. 등록 시에는 id값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는다.

 

등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다

'id' : rejected value [null];

왜냐하면 등록시에는 id에 값이 없다. 따라서 @NotNull id를 적용한 것 때문에 검증에 실패하고, 다시 폼 화면으로 넘어온다.

결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.

 

결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다. 이 문제를 어떻게 해결할 수 있을까?

 

 

 

Bean Validation - groups

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.

 

방법 2가지

- BeanValidation의 groups 기능을 사용한다.

- Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.

 

BeanValidation groups 기능 사용

이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.

예를 들어 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

 

우선 SaveCheck, UpdateCheck 인터페이스르 만들어준다.

package hello.itemservice.domain.item;

public interface SaveCheck {
}
package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

아이템이 위 인터페이스를 적용시켜주어야 한다.

수정시에만 적용되는 값엔 UpdateCheck.class를, 등록시에만 적용되는 값엔 SaveCheck.class를, 양쪽 다 해당 사항 있는 경우엔 둘 다 적어준다.

public class Item {

    @NotNull(groups = UpdateCheck.class) //수정 요구사항 추가
    private Long id;

    @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;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

이제 컨트롤러 등록폼, 수정폼에 적용시켜주도록 한다.

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors ={}", bindingResult);
        return "validation/v3/addForm";
    }

    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v3/items/{itemId}";
}

 

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    if (bindingResult.hasErrors()) {
        log.info("errors ={}", bindingResult);
        return "validation/v3/editForm";
    }

    itemRepository.update(itemId, item);
    return "redirect:/validation/v3/items/{itemId}";
}

 

 

** 참고 : @Valid에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated를 사용해야 한다.

 

정리 : groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하면 Item은 물론이고, 전반적인 복잡도가 증가한다. 사실 groups 기능은 실제로 잘 사용되지 않는다.

실무에서는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

 

 

Form 전송 객체 분리 - 소개

validationItemV4Controller

 

실무에서는 groups를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.

소위 "Hello World"예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 들어맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.

그래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다.

이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용하여 Item을 생성한다.

 

폼 데이터 전달에 Item 도메인 객체 사용

- HTML Form -> Item -> Controller -> Item -> Repository

    - 장점 : Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 중간에 Item을 만드는 과정이 없어 간단하다.

    - 단점 : 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.

 

폼 데이터 전달을 위한 별도의 객체 사용

- HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

    - 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달받을 수 있다. 보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.

    - 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

 

수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면, 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있다. 예를 들면, 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 전달받는것이 좋다.

 

Item도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 본 것 처럼 실무에서는 Item의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.

 

따라서 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups를 적용할 일은 드물다.

 

이름 짓는 방법

이름은 의미있게 지으면 된다. ItemSave라고 해도 되고, ItemSaveForm, ItemSaveRequest, ItemSaveDto 등으로 사용해도 된다. 중요한 것은 일관성이다.

 

등록, 수정용 뷰 템플릿이 유사하면 합쳐도 괜찮은지?

뷰 템플릿 파일의 등록과 수정을 합치는게 좋을지 고민이 될 수 있다. 각각 장단점이 있지만, 어설프게 합치면 수 많은 분기문 때문에 나중에 유지보수할 때 어려움을 겪을 수 있다.

이런 어설픈 분기문들이 보이기 시작하면 분리해야한다.

--> 왠만하면 분리하자.

 

 

 

Form 전송 객체 분리 - 개발

이전까지 사용했던 Item은 사용하지 않을 예정이므로 검증 코드를 제거해도 된다.

@Data
//@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000원 넘게 입력해주세요")
public class Item {

//    @NotNull(groups = UpdateCheck.class) //수정 요구사항 추가
    private Long id;

//    @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;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

 

ItemSaveForm - ITEM 저장용 폼

Save에 쓰일 저장용 객체를 위한 폼을 만든다.

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

}

 

ItemsaveForm은 이전 요구사항처럼 range가 1000~1000000이어야 하고, quantity가 NotNull이어야 하며 Max 9999까지 저장이 가능하다.

또한 save시에는 Id가 따로 필요하지 않기 때문에 id는 없어도 괜찮다.

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    private Integer quantity;

}

 

반면 update시엔 save와 달리 id를 필요로 하며, quantity를 자유롭게 변경할수도 있다. Null 또한 가능하다.

 

이제 컨트롤러를 수정해보자.

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        //특정 필드가 아닌 복합 룰 검증
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            log.info("errors ={}", bindingResult);
            return "validation/v4/addForm";
        }

        //성공 로직
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setPrice(form.getPrice());
        item.setQuantity(form.getQuantity());

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v4/items/{itemId}";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

        //특정 필드가 아닌 복합 룰 검증
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        if (bindingResult.hasErrors()) {
            log.info("errors ={}", bindingResult);
            return "validation/v4/editForm";
        }

        Item itemParam = new Item();
        itemParam.setId(form.getId());
        itemParam.setItemName(form.getItemName());
        itemParam.setPrice(form.getPrice());
        itemParam.setQuantity(form.getQuantity());


        itemRepository.update(itemId, itemParam);
        return "redirect:/validation/v4/items/{itemId}";
    }

 

Item 대신에 ItemSaveForm과 ItemUpdateForm을 전달받는다. 그리고, @Validated로 검증도 수행하고, BindingResult로 검증 결과도 받는다.

 

** 주의 : @ModelAttribute("item")에 item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm의 경우 규칙에 의해 itemSaveForm이라는 이름으로, ItemUpdateForm의 경우 itemUpdateForm이라는 이름으로 MVC 모델에 담기게 되며 뷰 템플릿에서 이 부분을 찾을 수 없다. 이렇게 되면 th:object 이름도 변경해주어야 한다.

 

이후 Repository에는 item객체에 맞게 데이터를 저장해줘야하기 때문에 Form 객체를 Item으로 변환시켜주어야 한다.

//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);

 

 

정리 : Form 전송 객체를 분리하여 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확하게 분리하였다.

 

 

 

Bean Validation - HTTP 메시지 컨버터

@Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.

 

참고

@ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰때 사용한다.

@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

 

 

**@RestController : @Controller + @ResponseBody

@ResponseBody : 메서드의 반환 값을 HTTP 응답 본문으로 직접 전송한다.(뷰를 거치지 않고 데이터 자체를 반환함)

 

 

ValidationItemApiController를 생성해보자.

@Slf4j
@RestController // @Controller + @ResponseBody
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

 

API 컨트롤러를 호출하고, 바인딩이 실패시 검증 오류 로그를 남딘다.

바인딩 성공시엔 성공 로그를 남기는 간단한 컨트롤러이다.

 

그럼 이제 PostMan으로 찔러보자.

 

API의 경우 3가지 경우를 나누어서 생각해야 한다.

1. 성공 요청 : 성공

2. 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패함

3. 검증 오류 요청 : JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

 

 

1. 성공할 경우

2025-04-03 10:07:31.548  INFO 13792 --- [nio-8080-exec-3] h.i.w.v.ValidationItemApiController      : API 컨트롤러 호출
2025-04-03 10:07:31.549  INFO 13792 --- [nio-8080-exec-3] h.i.w.v.ValidationItemApiController      : 성공 로직 실행

 

 

2. 실패할 경우(JSON을 객체로 생성하는 것 자체가 실패)

2025-04-03 10:10:51.930  WARN 13792 --- [nio-8080-exec-5] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "AAA": not a valid Integer value; nested exception is cohttp://m.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "AAA": not a valid Integer value
 at [Source: (PushbackInputStream); line: 1, column: 34] (through reference chain: hello.itemservice.web.validation.form.ItemSaveForm["price"])]

 

JSON을 객체로 생성하는 것 자체가 실패할 경우엔 ItemSaveForm 객체로 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 따라서 로그가 찍히지 않은다.

물론 Validator도 실행되지 않는다.

 

 

3. 검증 오류 요청(JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패할 경우)

2025-04-03 10:12:55.678  INFO 13792 --- [nio-8080-exec-8] h.i.w.v.ValidationItemApiController      : API 컨트롤러 호출
2025-04-03 10:12:55.679  INFO 13792 --- [nio-8080-exec-8] h.i.w.v.ValidationItemApiController      : 검증 오류 발생 errors=org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value [10000]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]

 

return bindingResult.getAllErrors();는 ObjectError와 FieldError를 반환한다. 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예쇼ㅣ로 보여주기 위해 검증 오류 객체들을 그대로 반환하였다. 하지만 실제 개발할때는 필요한 데이터만 뽑아 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

 

로그를 확인해보면, Validation이 정상적으로 수행된 것을 확인할 수 있다.

 

@ModelAttribute vs @ RequestBody

HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도, 나머지 필드는 정상 처리할 수 있었다.

HttpMessageConverter는 @ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid, @Validated가 적용된다.

 

- @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도, 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.

-@RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진해오디지 않고, 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

반응형

'공부 > Spring' 카테고리의 다른 글

검증1 - Validation  (0) 2025.04.01
메시지, 국제화  (0) 2025.03.30
타임리프 - 스프링 통합과 폼  (0) 2025.03.29
타임리프 - 기본 기능  (0) 2025.03.27
스프링 MVC 웹 페이지 만들기  (0) 2025.03.25