V4

์ €์žฅ์šฉ๊ณผ ์ˆ˜์ •์šฉ DTO๋ฅผ ๊ฐ๊ฐ ๋งŒ๋“ ๋‹ค.

Item

import lombok.Data;
import org.hibernate.validator.constraints.Range;

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

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() { }

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

์ €์žฅ์šฉ ๊ฐ์ฒด

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

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

์ˆ˜์ •์šฉ ๊ฐ์ฒด

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //์ˆ˜์ •์—์„œ๋Š” ์ˆ˜๋Ÿ‰์€ ์ž์œ ๋กญ๊ฒŒ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค.
    private Integer quantity;
}

์ปจํŠธ๋กค๋Ÿฌ

@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("itemId") 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.setItemName(form.getItemName());
    itemParam.setPrice(form.getPrice());
    itemParam.setQuantity(form.getQuantity());

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

Bean Validation - HTTP ๋ฉ”์‹œ์ง€ ์ปจ๋ฒ„ํ„ฐ

@Valid, @Validated๋Š” HttpMessageConverter(@RequestBody)์—๋„ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

์ปจํŠธ๋กค๋Ÿฌ

@Slf4j
@RestController
@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์˜ ๊ฒฝ์šฐ 3๊ฐ€์ง€ ๊ฒฝ์šฐ๋ฅผ ์ƒ๊ฐํ•ด์•ผ ํ•œ๋‹ค.

    • ์„ฑ๊ณต ์š”์ฒญ (์„ฑ๊ณต)

    • ์‹คํŒจ ์š”์ฒญ : JSON์„ ๊ฐ์ฒด๋กœ ์ƒ์„ฑํ•˜๋Š” ๊ฒƒ ์‹คํŒจ (JSON parser error)

    • ๊ฒ€์ฆ ์˜ค๋ฅ˜ ์š”์ฒญ : JSON์„ ๊ฐ์ฒด๋กœ ์ƒ์„ฑํ•˜๋Š” ๊ฒƒ์€ ์„ฑ๊ณตํ–ˆ์œผ๋‚˜ ๊ฒ€์ฆ์—์„œ ์‹คํŒจ

์‹คํŒจ ์š”์ฒญ : price์— ๋ฌธ์ž๋ฅผ ์ž…๋ ฅํ–ˆ์„ ๋•Œ

{
    "timestamp": "2024-01-20T15:52:55.588+00:00",
    "status": 400,
    "error": "Bad Request",
    "path": "/validation/api/items/add"
}

๊ฒ€์ฆ ์˜ค๋ฅ˜ ์š”์ฒญ : @Max์˜ ๋ฒ”์œ„๋ฅผ ์ดˆ๊ณผํ–ˆ์„ ๋•Œ

[
    {
        "codes": [
            "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.quantity",
                    "quantity"
                ],
                "arguments": null,
                "defaultMessage": "quantity",
                "code": "quantity"
            },
            9999
        ],
        "defaultMessage": "9999 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 10000,
        "bindingFailure": false,
        "code": "Max"
    }
]

bindingResult.getAllErrors()๋Š” objectError์™€ FieldError๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์Šคํ”„๋ง์€ ์ด ๊ฐ์ฒด๋ฅผ JSON์œผ๋กœ ๋ณ€ํ™˜ํ•ด์„œ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ์ „๋‹ฌํ•œ๋‹ค. ์‹ค์ œ ๊ฐœ๋ฐœํ•  ๋•Œ๋Š” ์ด ๊ฐ์ฒด๋“ค์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•˜์ง€ ๋ง๊ณ  ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋งŒ ๋ฝ‘์•„์„œ ๋ณ„๋„์˜ API ์ŠคํŽ™์„ ์ •์˜ํ•ด์•ผ ํ•œ๋‹ค.

@ModelAttribute vs @RequestBody

HTTP ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” @ModelAttribute๋Š” ํ•„๋“œ ๋‹จ์œ„๋กœ ์ •๊ตํ•˜๊ฒŒ ๋ฐ”์ธ๋”ฉ์ด ์ ์šฉ๋˜๊ธฐ ๋•Œ๋ฌธ์— ํŠน์ • ํ•„๋“œ๊ฐ€ ๋ฐ”์ธ๋”ฉ ๋˜์ง€ ์•Š์•„๋„ ๋‚˜๋จธ์ง€ ํ•„๋“œ๋Š” ์ •์ƒ ๋ฐ”์ธ๋”ฉ ๋˜๊ณ  Validator๋ฅผ ์‚ฌ์šฉํ•œ ๊ฒ€์ฆ๋„ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

@RequestBody๋Š” HttpMessageConverter ๋‹จ๊ณ„์—์„œ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ์ฒด๋กœ ๋ณ€๊ฒฝํ•˜์ง€ ๋ชปํ•˜๋ฉด ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ๋ชปํ•˜๊ณ  ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๊ธฐ ๋•Œ๋ฌธ์— Validator๋„ ์ ์šฉํ•  ์ˆ˜ ์—†๋‹ค.


์Šคํ”„๋ง๊ณผ Bean Validation

  • ์Šคํ”„๋ง์—์„œ๋Š” ์–ด๋…ธํ…Œ์ด์…˜ ๊ธฐ๋ฐ˜ ๊ฒ€์ฆ์„ ์œ„ํ•ด @Valid์™€ @Validated ์–ด๋…ธํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ ์‚ฌ์šฉ ๋ฐฉ์‹์— ์žˆ์–ด ์•ฝ๊ฐ„ ์ฐจ์ด๊ฐ€ ์žˆ๋‹ค.

  • @Valid๋Š” jakarta.validation์— ํฌํ•จ๋˜์–ด ์žˆ๊ณ , @Validated๋Š” org.springframework.validation.annotation์— ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉฐ @Valid๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” spring-boot-starter-validation ์˜์กด์„ฑ์ด ํ•„์š”ํ•˜๋‹ค.

  • ๋‘ ์–ด๋…ธํ…Œ์ด์…˜ ๋ชจ๋‘ ๊ฐ์ฒด ํƒ€์ž…์—๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ๋˜ํ•œ ๊ฒ€์ฆํ•  ๊ฐ์ฒด ๋ฐ”๋กœ ์•ž์— ์œ„์น˜ํ•ด์•ผ ํ•˜๋ฉฐ ๊ฒ€์ฆ๋œ ๊ฒฐ๊ณผ๋Š” BindingResult์— ๋‹ด๊ธด๋‹ค.

  • ๊ฒ€์ฆ์€ ๋ฐ”์ธ๋”ฉ์˜ ๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ ์ฒ˜๋ฆฌ ๊ณผ์ •์ด๋ฉฐ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋ฐ”์ธ๋”ฉ์— ์„ฑ๊ณตํ•œ ํ•„๋“œ๋Š” ๊ฒ€์ฆ์ด ์ด๋ฃจ์–ด์ง„๋‹ค.

  • ๋งŒ์•ฝ ํ•„๋“œ์˜ ํƒ€์ž… ๋ณ€ํ™˜์ด ์‹คํŒจํ•˜๋ฉด ์‹คํŒจ ๊ฒฐ๊ณผ๊ฐ€ FieldError ๊ฐ์ฒด์— ๋‹ด๊ธฐ๊ณ  BindingResult์— ๋ณด๊ด€๋œ๋‹ค.

  • ํƒ€์ž… ๋ณ€ํ™˜์— ์‹คํŒจํ•œ ํ•„๋“œ๋Š” ๊ธฐ๋ณธ ๊ฐ’์ด ์ €์žฅ๋œ ์ƒํƒœ์—์„œ ๊ฒ€์ฆ์ด ์ด๋ฃจ์–ด์ง€์ง€๋งŒ Validator ๊ตฌํ˜„์ฒด์— ๋”ฐ๋ผ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜๋„ ์žˆ๊ณ  ๊ธฐ๋ณธ ๊ฒ€์ฆ์ด ์ด๋ฃจ์–ด์งˆ ์ˆ˜ ์žˆ๋‹ค.

ํด๋ž˜์Šค ๊ตฌ์กฐ

img.png

๊ฒ€์ฆ ์ฒ˜๋ฆฌ ํ๋ฆ„๋„

img_1.png

์—ฌ๊ธฐ์„œ ConstraintValidator์—๋Š” ๋‹ค์–‘ํ•œ ๊ฒ€์ฆ๊ธฐ๋“ค์ด ์ •์˜๋˜์–ด ์žˆ๋‹ค.

img_2.png

์ปค์Šคํ…€ ๊ฒ€์ฆ ์• ๋…ธํ…Œ์ด์…˜ ๋งŒ๋“ค๊ธฐ

ConstraintValidator ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•˜์—ฌ ์ปค์Šคํ…€ ๊ฒ€์ฆ ์• ๋…ธํ…Œ์ด์…˜์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค.

img_3.png

์ฃผ์–ด์ง„ ์ œ์•ฝ ์กฐ๊ฑด A์— ๋Œ€ํ•ด ๊ฒ€์ฆ ๋Œ€์ƒ ํƒ€์ž… T๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ์ธํ„ฐํŽ˜์ด์Šค์ด๋‹ค.

  • isValid() : ๊ฒ€์ฆ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. ๊ฒ€์ฆ ๋Œ€์ƒ ๊ฐ์ฒด T๋Š” ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๊ณ  ๊ทธ๋Œ€๋กœ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค.

  • initialize() : ๊ฒ€์ฆ๊ธฐ์˜ isValid() ํ˜ธ์ถœ์„ ์ค€๋น„ํ•˜๊ธฐ ์œ„ํ•ด ์ดˆ๊ธฐํ™”ํ•œ๋‹ค. ๊ฒ€์ฆ์— ์‚ฌ์šฉ๋˜๊ธฐ ์ „์— ๋จผ์ € ํ˜ธ์ถœ ๋œ๋‹ค.

/*--------------์ปค์Šคํ…€ ์–ด๋…ธํ…Œ์ด์…˜--------------*/
@Documented
@Constraint(validatedBy = PasswordValidator.class) //๊ฒ€์ฆ๊ธฐ๋ฅผ ์ง€์ •
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPassword {
    String message() default "์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋‹ˆ๋‹ค. ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ตœ์†Œ 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•˜๊ณ , ๋Œ€๋ฌธ์ž, ์†Œ๋ฌธ์ž, ์ˆซ์ž๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.";
    int minLength() default 8;
    Class<?>[] groups() default {}; //ํ•„์ˆ˜
    Class<? extends Payload>[] payload() default {}; //ํ•„์ˆ˜
}

/*--------------์ปค์Šคํ…€ ๊ฒ€์ฆ๊ธฐ--------------*/
public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {

    private int minLength;
    
    @Override
    public void initialize(ValidPassword constraintAnnotation) {
        this.minLength = constraintAnnotation.minLength();
    }
    
    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (!StringUtils.hasText(password)) {
            return false;
        }
        
        boolean length = password.length() >= minLength;
        boolean upperCase = password.chars().anyMatch(Character::isUpperCase);
        boolean lowerCase = password.chars().anyMatch(Character::isLowerCase);
        boolean digit = password.chars().anyMatch(Character::isDigit);
        
        //์ตœ์†Œ ๊ธธ์ด, ๋Œ€๋ฌธ์ž, ์†Œ๋ฌธ์ž, ์ˆซ์ž๋ฅผ ํฌํ•จํ•˜๋Š”์ง€ ๊ฒ€์‚ฌ
        return length && upperCase && lowerCase && digit;
    }
}

Last updated