@Validated 적용

jylee·2024년 1월 23일
0

그냥생각나는거

목록 보기
37/48

Item 객체

@Getter
@AllArgsConstructor
public class Item {
    private final String id;
    @Setter private String name;
    @Setter private Integer price;
    @Setter private Integer quantity;
    @Setter private String description;
    @Setter private Boolean open; //판매 여부
    @Setter private List<String> regions; //등록 지역
    @Setter private ItemType itemType;
    @Setter private DeliveryType deliveryType;
}

id는 setter로 변경할 수 없도록 설정

Item 등록 객체

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ItemAddForm {
    @NotBlank
    private String name;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    @NotNull
    @Max(9999)
    private Integer quantity;
    private String description;
    private Boolean open; //판매 여부
    private List<String> regions; //등록 지역
    private ItemType itemType;
    private DeliveryType deliveryType;
}

제약조건 애노테이션으로 설정

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다

@AllArgsConstructor으로 아이템 등록 페이지 처음 호출 시 빈 값을 가진 객체 전달하기 위해 사용
@NoArgsConstructor으로 등록 요청 시 객체 값 유지를 위해 사용. 등록 시 오류가 날 경우 사용자 입력 값 저장 용

등록폼 요청 GET /item

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

Controller에 작성된 @ModelAttribute 메소드

@ModelAttribute("regions")
public Map<String, String> regions() {
    Map<String, String> regions = new LinkedHashMap<>();
    regions.put("SEOUL", "서울");
    regions.put("BUSAN", "부산");
    regions.put("JEJU", "제주");
    return regions;
}

@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
    return ItemType.values();
}

@ModelAttribute("deliveryTypes")
public List<DeliveryType> deliveryTypes() {
    return List.of(DeliveryType.values());
}

메소드 단위에 @ModelAttribute가 작성되어 있는 경우
해당 컨트롤러에 요청이 들어오면 @ModelAttribute가 붙은 메소드의 반환 값이
자동으로 Model 객체에 담기게 된다.

등록폼 html

<form th:action th:object="${item}" method="post">
    <div th:if="${#fields.hasErrors()}">
        <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
    </div>
    <div>
        <label for="name" th:text="#{label.item.name}"></label>
        <input type="text" id="name" th:field="*{name}" th:errorclass="field-error"
               class="form-control" placeholder="이름을 입력하세요">
        <div class="field-error" th:errors="*{name}">상품명 오류</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" placeholder="가격을 입력하세요">
        <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" placeholder="수량을 입력하세요">
        <div class="field-error" th:errors="*{quantity}">수량 오류</div>
    </div>
    <div>
        <label for="description" th:text="#{label.item.description}"></label>
        <input type="text" id="description" th:field="*{description}"
               class="form-control" placeholder="설명 입력하세요">
    </div>

    <hr class="my-4">
    <!-- single checkbox -->
    <div>판매 여부</div>
    <div>
        <div class="form-check">
            <input type="checkbox" id="open" th:field="*{open}" class="form-check-input">
            <label for="open" class="form-check-label">판매 오픈</label>
        </div>
    </div>

    <hr class="my-4">
    <!-- multi checkbox -->
    <div>
        <div>등록 지역</div>
        <div th:each="region : ${regions}" class="form-check form-check-inline">
            <input type="checkbox" th:field="*{regions}" th:value="${region.key}"
                   class="form-check-input">
            <label th:for="${#ids.prev('regions')}"
                   th:text="${region.value}" class="form-check-label">서울</label>
        </div>
    </div>

    <hr class="my-4">
    <!-- radio button -->
    <div>
        <div>상품 종류</div>
        <div th:each="type : ${itemTypes}" class="form-check form-check-inline">
            <input type="radio" th:field="*{itemType}" th:value="${type.name()}"
                   class="form-check-input">
            <label th:for="${#ids.prev('itemType')}" th:text="${type.description}"
                   class="form-check-label">BOOK
            </label>
        </div>
    </div>

    <hr class="my-4">
    <div>
        <div>deliveryTypes</div>
        <select th:field="*{deliveryType}" class="form-select">
            <option value="">==배송 방식 선택==</option>
            <option th:each="type : ${deliveryTypes}" th:value="${type}"
                    th:text="${type.description}">FAST</option>
        </select>
    </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}"/>
        </div>
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                th:onclick="|location.href='@{items}'|" th:text="#{button.cancel}"/>
        </div>
    </div>
</form>

등록 POST /item

@PostMapping("/item")
public String add(
        @Validated @ModelAttribute("item") ItemAddForm itemAddForm,
        BindingResult bindingResult,
                  RedirectAttributes redirectAttributes) {
    // 특정 필드가 아닌 복합 룰 검증
    if (itemAddForm.getPrice() != null && itemAddForm.getQuantity() != null) {
        int resultPrice = itemAddForm.getPrice() * itemAddForm.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }
    // 검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "item/add";
    }

    String id = UUID.randomUUID().toString().substring(0, 8);
    Item item = new Item(
            id,
            itemAddForm.getName(),
            itemAddForm.getPrice(),
            itemAddForm.getQuantity(),
            itemAddForm.getDescription(),
            itemAddForm.getOpen(),
            itemAddForm.getRegions(),
            itemAddForm.getItemType(),
            itemAddForm.getDeliveryType()
            );
    itemRepository.addItem(item);

    redirectAttributes.addFlashAttribute("message", "등록 성공");
    redirectAttributes.addAttribute("id", id);
    return "redirect:/item/{id}";
}

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

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

@ModelAttribute("item") ItemAddForm itemAddForm으로 form 데이터가 파싱되어 들어올 때 @Validated 애노테이션이 달려있으므로 ItemAddForm의 각 field 작성된 애노테이션들에 form 데이터가 적합한지 검사한다.

  • BindingResult는 검증할 대상 바로 다음에 와야한다
  • BindingResult 는 Model에 자동으로 포함된다.

검증 순서

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

1-1. 성공하면 다음으로
1-2. 실패하면 typeMismatch 로 FieldError 추가

Validator 적용

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

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

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

profile
ㅎㅇ

0개의 댓글