개발자로 후회없는 삶 살기

spring PART.빈 검증 본문

[백엔드]/[spring | 학습기록]

spring PART.빈 검증

몽이장쥰 2023. 4. 21. 23:36

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

본론

- 빈 검증 소개

검증 코드 if문을 직접 지저분하게 짰는데 매우 번거롭습니다. 근데 잘 생각해보면 특정 필드에 대한 검증 로직은 대부분 그 특정 필드가 비었는지 아닌지, 범위를 넘는지 아닌지와 같이 매우 일반적인 로직입니다. 그래서 편리하게 해보자는 것에서 빈 검증이 나왔습니다.

@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는 null도 안되고 빈 값도 안되고 공백이 있으면 안 된다는 것, Range는 최소 ~ 최대 이렇듯이 검증 제약 조건을 @로 넣어버리는 것입니다. @만 넣으면 검증이 자동으로 됩니다. 이것을 bean validation이라고 합니다. 이렇게 하면 if문 로직을 다 지울 수 있습니다. 이것을 사용하는 법과 동작하는 법을 알아보겠습니다.

 

-> 사용법

 

스프링없이 순수 빈 검증을 사용해보자 Item 도메인 클래스에 @를 넣습니다. 

 

 

@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());
    }
}

사용하는 방법은 테스트 코드로 확인해봅니다. 사용하려면 검증기가 필요합니다. factory에서 검증기를 꺼내고 검증에 오류가 발생하는 set을 합니다. 그리고 검증기의 validate를 하면 violations를 반환하는데 비어있으면 오류가 없는 것이고 뭐가 채워져있으면 위반한 것입니다.

 

> 오류 메세지는 기본으로 제공하는 것으로 원하면 바꿀 수 있습니다. 검증기가 필요하고 이전에 검증기에 if문을 구현하고 item, 과 br을 넣어서 br에 오류가 담기고 뷰에 전달하는 것이었습니다. 나중에 검증기를 스프링과 통합해서 사용할 것이므로 이런게 있구나하고 알기만 하면 됩니다.

 

- 빈 검증 스프링 적용

@Controller
@RequestMapping("/validation/v2/items")
@Slf4j
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    
//    private final ItemValidator itemValidator;
//
//    @InitBinder
//    public void init(WebDataBinder dataBinder) {
//        log.info("init binder {}", dataBinder);
//        dataBinder.addValidators(itemValidator);
//    }

기존에 우리가 만든 if문이 담긴 검증기를 지웁니다.

 

근데도 저장 폼의 검증 기능이 잘 동작합니다. 지금 Item 객체의 @ 빈 검증이 적용이 된 것입니다. add postmapping에 @validated를 넣어서 자동으로 빈 검증기를 인지하고 스프링에 통합합니다. 그래서 사용할 수 있는 것입니다. > 그래들에 라이브러리를 설치하면 글로벌 검증기가 다운이 됩니다. 이 검증기는 @NotNull같은 것을 처리하는 검증기입니다. @validated가 등록된 검증기를 인지해서 @기반 검증을 할 수 있는 것입니다. 

 

> 이전에 검증기를 만들고 @InitBinder를 붙이고 @validated를 하면 뭐 만든 검증기에 item을 넣고 br을 넣는 것 없이 그냥 바로 검증기를 사용할 수 있었는데 그거와 같은 개념입니다. 결론은 validated로 검지기를 부르고 br에 검증 결과가 담겨서 뷰로 넘어간다고 생각하면 됩니다.

 

> 앞 장에서 만든 수많은 내용들은 지금을 위해서 준비한 것이었습니다. 진짜 다 필요없이 @validated만 넣으면 글로벌 검증기를 불러서 Item을 보니 @기반 검증 어노테이션이 붙은 필드를 검증하는 것입니다. 쉽게 생각하면 검증기를 구현하고 if문을 넣어서 @Validated가 붙은 맵핑 메서드에 적고 item과 br을 인자로 넣어 br에 오류 정보를 담는 것이라고 할 수 있습니다.

 

-> 검증 순서

ModelAttribute로 각 필드에 값을 넣고 > 성공하면 다음 필드를 봅니다. 실패하면 fieldError를 추가합니다. 빈 검증은 바인딩에 성공한 필드만 빈 검증을 합니다. 타입 오류로 값이 들어오지 않은 필드는 검증을 하지 않고 일단 타입이 맞아서 바인딩이 성공하고 난 후에 검증을 합니다. 검증을 했는데 범위를 넘는등 검증을 실패하면 fiedError나 ObjError를 추가합니다.

> 그래서 예전에 if문을 직접 구현했을 때는 qqq를 넣으면 typemismatch 오류가 나면서 바인딩이 안되어서 타입에러와 price에 null이 들어간 오류가 둘 다 br에 추가됐었는데 이제는 qqq를 넣으면 typemismatch 오류 메세지만 뜹니다.

 

- 빈 검증 에러코드, 에러메세지

기본으로 제공하는 오류 메세지를 바꿔보고 싶습니다. 상품명에 값을 입력하지 않고 저장하면 notBlack 오류가 추가가 됩니다.

 

메세지를 보면 NotBlank를 code 파라미터로 rejectvalue 필드에러에 넣었을 때와 똑같은 mcr 메세지를 보입니다.

 

> 그러면 우리는 에러 메세지 파일에 NotNull, NotBlank, Range를 오류 코드로 시작하여 키로 등록하고 메세지를 원하는 대로 바꾸면 됩니다.

 

파라미터 {0}은 보통 필드명을 말하고 {1}, {2}는 item에 Range에 적은 min, max, Max(9999)입니다.

 

물론 디테일하게도 바꿀 수 있습니다.

 

> 메세지 파일에서 메세지를 못 찾으면 @NotBlank 어노테이션에 붙은 message를 쓰고 여기도 없으면 라이브러리가 제공하는 기본값을 씁니다.

 

- 오브젝트 오류

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 이상입니다.")
public class Item {

빈 검증이 @가 Item의 필드에 붙어서 필드 관련이 아닌 obj 오류는 더 필요합니다. @ScriptAssert를 써야합니다.  lang은 js, script에 검증 조건을 넣습니다.

 

> 실행해보면 된다. message도 바꿀 수 있습니다. 그런데 이렇게 하는 것은 제약 조건이 너무 많습니다.

 

@PostMapping("/add")
public String addItemV6(@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[]{1000, resultPrice}, null);
        }
    }

실제 사용하기 에는 기능이 너무 약합니다. 실무에서는 하나의 객체에서만 값을 가져오지 않고 db에서도 가져올 수도 있습니다. 따라서 obj 에러는 이전에 자바 코드로 만든 직접 만든 에러 코드를 넣는 게 좋습니다.

 

- 수정 빈 검증

@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[]{1000, resultPrice}, null);
        }
    }

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

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

수정 폼에도 빈 검증을 적용해보자 edit form에 넘어올 때 빈 검증을 하면 되는 것입니다. add form에 적은 코드를 그대로 edit 맵핑 메서드에 적용하면 됩니다.

 

이제 수정 뷰도 바꿔주면 됩니다.

 

- 한계점

새로운 요구사항이 생겼습니다. 수정을 할 때는 등록할 때에 비해 검증을 좀 완화하자는 것입니다. 수량을 무한대로 풀어두고 수정시에는 id값이 필수입니다.

@NotNull // 수정
private Long id;
@NotBlank(message = "공백 X")
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
//    @Max(9999) 수정
private Integer quantity;

간단합니다. Item 클래스에서 id에 NotNull 넣고quantity의 Max를 지우면 됩니다. 하지만 이렇게 하면 등록시에 id를 받아야하는 필수조건을 충족하지 못합니다. 수정을 수정했더니 등록에 문제가 생긴 상황입니다.

 

> 빈 검증은 등록과 수정의 2가지 검증 조건에 충돌이 발생합니다. 근데 @는 Item에 걸어서 어떻게 할 수가 없습니다. 등록과 수정은 구분해서 빈 검증을 해야합니다. 동일한 모델 객체를 등록할 떄와 수정할 때 다른 검증을 하게 해야합니다.

 

 

-> 방법 2가지

1) 빈 검증의 groups 기능 : A 그룹에서는 A 검증을 B에서는 B 검증을 합니다.  

2) ModelAttribute 받는 바인딩 객체를 분리 : 이런 것을 폼 전송 객체라고 하는데 별도의 모델 객체를 만들자 아이템 저장용 폼과 수정 용 폼으로 쪼갭니다.

 

- groups

클래스 그룹을 만들어야 합니다. SaveCheck, updateCheck라는 표시하는 인터페이스가 필요합니다. save는 저장용 그룹을 표시하고 update는 수정용 그룹을 표시합니다. 

 

@Data
public class Item {
    @NotNull(groups = UpdateCheck.class) // 수정
    private Long id;
    @NotBlank(message = "공백 X", 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(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;
    }
}

> @에 groups = UpdateCheck.class라고 합니다. 이러면 수정 요구사항만 적용 되는 것입니다. {UpdateCheck.class, SaveCheck.class} 하면 두 요구사항이 모두 적용되는 것입니다. id는 수정에서 꼭 넣어야하고 save는 안넣어도 돼서 groups에 UpdateCheck를 넣고 수량은 등록할 때만 검증을 하고 수정할 떄는 안 넣어서 SaveCheck를 넣습니다.

 

public String addItemV7(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model)

public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult)

> 적용을 하려면 맵핑 메서드에서 validated(체크할 것)을 넣어주면 됩니다. 이 validated가 먹힐 때 savecheck인 애만 적용된다는 것입니다.

 

-> 실행

결과를 보면 등록할 때는 최대치에 걸립니다. 수정할 떄는 9999 제한을 넘어도 됩니다. 하지만 너무 복잡하고 실무에서는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용합니다. 지금 예제야 간단하지 실제 세상에서는 등록하고 수정할 때 필드가 아예 다릅니다.

 

- form 전송 객체 분리

간단한 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞지만 실무에서는 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어옵니다. 그래서 보통 Item을 직접 전달받는 것이 아니라 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달합니다. 예를들어 itemsaveform이라는 폼을 전달받을 전용 객체를 만들어서 @ModelAttribute를 사용합니다. 이것을 사용해서 컨트롤러에서 폼 데이터를 전달받고, 이후 필요한 데이터를 뺴서 Item을 생성합니다.

 

-> 2가지 패턴
1. 폼 데이터 전달에 Item 도메인 객체를 사용

Item 도메인 객체를 컨트롤러에서 바로 받아서 레포에 저장할 수 있어서 Item을 만드는 과정이 없어서 간단합니다. 하지만 간단한 경우에만 적용할 수 있습니다.

2. 별도의 객체 사용

ItemSaveForm을 컨트롤러에서 받아서 Item을 생성하고 레포에 넘깁니다. 이렇게 하면 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있습니다. 보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 충돌되지 않습니다.회원을 등록할 때, 수정할 때 항목 자체가 다릅니다. 하지만 단점은 Item 객체를 생성하고 변환하는 과정이 추가가 됩니다.

+ 실무에서는 Item의 데이터만 넘오는 것이 아니라 무수한 추가 데이터가 넘어옵니다. 그리고 Item을 생성하는데 필요한 추가 데이터를 DB나 다른 곳에서 찾아와야 할 수도 있습니다. 따라서 html form으로 넘어오는 데이터만 가지고 Item 데이터를 만들 수 있다는 생각은 접어야합니다.

 

- 개발시작
-> item

item 객체를 처음처음 원복을 합니다. Item은 이제 등록, 수정 폼을 받을 때 사용되는 게 아니고 등록 폼 전용 별도의 객체로 받은 데이터로부터 생성될 때 사용할 것입니다.

 

-> 등록 폼 객체

// 등록
@Data
public class ItemSaveForm {
    @NotBlank(message = "공백 X")
    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 = 1000000)
    private Integer price;

    private Integer quantity;
}

새로운 폼 패키지와 ItemSaveForm 클래스를 만듭니다. 등록할 때 필요한 상품명, 가격, 양을 적고 빈 검증을 붙입니다. 생성자는 필요없습니다.

 

-> 수정 폼 객체

등록 폼에서는 id가 없어도 되는데 수정 폼에서는 빈 검증에 id가 NotNull이고 수량은 max가 없습니다. 

 

=> 컨트롤러

이제 add, edit 맵핑 메서드에서 이 전용 폼들을 사용하게 바꿔야합니다.

 

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

// 이후
Item item = new Item(form.getItemName(), form.getPrice(), form.getQuantity());

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

-> 등록

ModelAttribute에 Item이 아니라 ItemSaveForm을 넘기고 이름은 form을 합니다. ModelAttribute에 모델에 담기는 이름을 item으로 해야 html을 안 바꿔도 됩니다. 이렇게 하면 html 입력 폼의 데이터가 ItemSaveForm에 들어옵니다. 

이렇게 하면 save에 form이 들어갑니다. 여기서 form이 넘어가면 안되고 Item을 만들어서 form의 데이터를 넣어 save해야합니다.

-> 수정

똑같이 ItemUpdateForm을 받고 Item을 생성하고 Form에서 값을 꺼낸 후 update에 넣습니다.

+ 이렇게 하면 폼 객체를 따로 만들어서 @기반 검증도 따로 각각 적용할 수 있었고 그 검증 폼은 폼 전용이니 Item객체가 필요해서 Item에 set하고 사용했습니다.

 

- HTTP 메세지 컨버터

@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("검증 오류 발생 = {}", bindingResult);
            return bindingResult.getAllErrors();
        }
        
        log.info("성공 로직 실행");
        return form;
    }
}

http 메시지 바디에 읽고 쓰는 게 메세지 컨버터였습니다. 빈 검증은 Http 메세지 컨버터가 동작하는 RequestBody에도 적용할 수 있습니다. api로 json 왔다갔다 할 때 빈 검증이 어떻게 쓰이나 보겠습니다.

새로운 컨트롤러를 만들고 RestController로 합니다. RequestBody로 ItemSaveForm을 받고 validated를 할 거라서 br까지 넣는다. if문으로 json api에 에러가 있으면 br이 가지고 있는 모든 obj, 필드에러를 다 반환합니다. 이 리스트를 반환하면 json으로 화면에 보여줄 것입니다. RestController이므로 return에 객체나 리스트 넣으면 다 json으로 변환되어서 나간다.@ResponseBody 효과입니다. 성공하면 그냥 테스트 삼아 form을 반환합니다.

 

-> 실행

조건에 맞는 요청을 하니 성공합니다

 

가격에 qqq를 넣으면 예외가 뜨면서 컨트롤러 호출이 안됩니다. 왜냐하면 api 방식이 json이 객체로 바뀌어야합니다. 바뀌고 검증을 하는 것이 순서입니다.

 

HTTP 메세지 컨버터가 이 ItemSaveForm 객체를 만들어야 컨트롤러를 호출할 수 있는데 이것 조차도 못 만들었습니다. 컨버터가 json을 보고 읽어서 객체로 만드려고 했는데 만들지 못했으니 검증을 못하는 것입니다. 따라서 컨트롤러 자체가 호출이 안되고 예외가 터져버립니다. 컨트롤러로 못 넘어옵니다.

 

-> api 요청 경우의 수

1) 성공 요청 : 성공, 2) 실패 요청 : json 객체를 생성하는 것 자체 실패, 3) json을 객체로 생성하는 것은 성공했는데 검증에서 실패한 경우

 

 

> 3)번을 해보자 등록할 때 수량을 9999개 초과하면 안되는데 10000개를 주면 쭉 json으로 뭐라고 나옵니다.  json을 객체로 만드는데는 성공하고 검증을 하는데 오류가 발생해서 br에 오류가 추가된 것입니다. 

 

-> 정리

br.getAllErrors는 obj에러와 필드에러를 반환합니다. 스프링이 이 객체를 json으로 변환해서 클라에게 전달합니다. 지금은 검증 오류 자체를 반환한 것인데 실제로는 json에서 필요한 데이터를 뽑아서 별도의 객체를 만들고 api 스펙에 정의 해서 반환해야합니다.

'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글

spring PART.필터  (0) 2023.04.24
spring PART.로그인, 세션관리  (0) 2023.04.23
spring PART.검증  (0) 2023.04.20
spring PART.메시지, 국제화  (0) 2023.04.19
spring PART.타임리프 기본 기능, 스프링 통합  (0) 2023.04.16
Comments