RE-Heat 개발자 일지

스프링 MVC 2편 - [4] 검증 1 - Validation(하편) 본문

백엔드/스프링

스프링 MVC 2편 - [4] 검증 1 - Validation(하편)

RE-Heat 2023. 7. 16. 20:53

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

인프런 김영한 님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.

 

 

[7] 오류 코드와 메시지 처리 1

오류 메시지가 일관적이지 않으면 사용자에게 혼란을 야기할 수 있다. 그리고 스프링에선 상편에서 나온 messages.properties처럼 오류 메시지를 구분하기 쉽게 관리할 수 있다.

 

 

errors.properties

① 에러 메시지를 따로 관리할 파일을 만든다.

② application.properties에

spring.messages.basename=messages, errors

을 추가해 messages.properties, errors.properties 두 파일을 모두 인식하게 만든다.(생략하면 messages.properties만 기본으로 인식한다.)

 

 

ValidationItemControllerV2 - addItemV3

@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) { //상품명 누락
        bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName", "required.default"}, null, null));
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
    }
    if (item.getQuantity() == null || item.getQuantity() > 9999) {
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
    }

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

 

FiledError (상품명 누락·가격 입력값 오류 등)

- objectName : "item" 
- field : "itemName"
- rejectedValue : item.getItemName()
- bindingFailure : 타입오류 같은 바인딩 실패인지, 검증실패인지 구분값 => false
- codes : 메시지 코드 => new String[]{"required.item.itemName", "required.default"}

    ▶ required.item.ItemName을 찾지 못하면 required.default를 찾아 오류 메시지로 보낸다.

- arguments : 메시지에서 사용하는 인자 => null

    ▶ 가격 범위가 필요할 땐 range.item.price의 {0} {1}에 들어갈 인자값을 넣는다.
- defaultMessage : 기본오류 메시지 => null (메시지 코드로 대체)

 

■ ObjectError

- objectName : "item" 
- codes : 메시지 코드 => new String[]{"totalPriceMin"}
- arguments : 메시지에서 사용하는 인자 => new Object[]{10000, resultPrice}
- defaultMessage : 기본오류 메시지 => null

 

 

실행화면 1

errors.properties값이 그대로 찍히는 걸 확인할 수 있다.

 

실행화면 2

required.item.itemName을 찾을 수 없으면 required.default를 찾는 것을 확인할 수 있다.

둘 다 존재하지 않으면 400번 오류 메시지로 넘어간다.

 

 

[8] 오류 코드와 메시지 처리 2

목표 : FiledError와 ObjectError의 생성자에 넣어야 할 파라미터 값이 많아 번거롭다. 더 간결하게 쓸 수 있지 않을까?

 

BindingResult는 검증해야 할 객체인 target의 바로 다음에 온다. 그러므로 굳이 타깃까지 적어줄 필요가 없다.

 

 

ValidationItemControllerV2-addItemV3

//@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());

 

=> log를 보면 bindingResult가 검증해야 할 객체를 알고 있다는 것을 알 수 있다.

 

그래서 스프링은 위 사실을 바탕으로 FiledError, ObjectError를 직접 생성하지 않고 깔끔하게 검증 오류를 다룰 수 있는 rejectValue()와 reject()를 제공한다.

 

 

ValidationItemControllerV2-addItemV4

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) { //상품명 누락
        bindingResult.rejectValue("itemName", "required");
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
    }
    if (item.getQuantity() == null || item.getQuantity() > 9999) {
        bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
    }

    //특정 필드가 아닌 복합 룰 검증
    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/v2/addForm";
    }

 

■ rejectValue() :FieldError 대체 

field : 오류 필드명 => "price"
errorCode : => "range" MessageCodeResolver가 range.item.price를 불러온다. 
errorArgs : 오류메시지 {0} {1}을 치환하기 위한 값 => new Object[]{1000, 1000000}
defaultMessage : 오류메시지를 찾을 수 없을 때 사용하는 기본 메시지 => null

 

■ reject() : ObjectError 대체

errorCode : => "totalPriceMin" errors.properties의 totalPriceMin을 불러온다.
errorArgs : 오류메시지 {0} {1}을 치환하기 위한 값 => new Object[]{10000, resultPrice}
defaultMessage : 오류메시지를 찾을 수 없을 때 사용하는 기본 메시지 => null

 

 

[9] 오류 코드와 메시지 처리 3

범용성 좋은 오류 코드와 세밀한 코드 필요할 때 쓰는 방법이 있을까?

=> 가장 좋은 방법은 범용성 높은 코드를 사용하다 자세하게 작성해야 할 때 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다. 

 

errors.properties 예시

#Level 1

required.item.itemName: 상품이름은 필수입니다.

 

#Level 2

required : 필수 값입니다.

 

이렇게 단계별로 적용하는 기능을 스프링은 MessageCodesResolver를 통해 지원한다.

 

 

[10] 오류 코드와 메시지 처리 4

 

MessageCodesRewolverTest

public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    void messageCodesResolverField(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        assertThat(messageCodes).containsExactly(
          "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

① MessageCodesResolver는 인터페이스, DefaultMessageCodesResolver는 구현체

    주로 ObjectError, FieldError와 함께 사용한다.

 

■ DefaultMessageCodesResolver 기본 메시지 생성 규칙

4가지 순서로 코드 생성

 - 필드 오류 [FieldError] : rejectValue("itemName", "required")

1. code + "." + object name + "." + field => required.item.itemName

2. code + "." + field => required.itemName

3. code + "." + field type => required.java.lang.String [타입 확인, 보통 스프링이 자동 생성]

4. code => required

 

- 객체 오류 [ObjectError] : reject("totalPriceMin")

1. code + "." + object name => totalPriceMin.item

2. code => totalPriceMin

 

동작방식

① rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용한다. 여기에서 메시지 코드를 생성

② FieldError, ObjectError 생성자를 확인하면 오류 코드를 여러 개(new String[]{"1", "2"}) 가질 수 있다.

bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName", "required.default"}, null, null));

그래서 MessageCodesResolver를 통해서 순서대로 오류 코드를 보관한다. 

그리고 th:errors에서 순서대로 오류 코드를 찾으므로 세밀한 순서대로 즉, 단계별로 적용할 수 있다.

 

[11] 오류 코드와 메시지 처리 5

모든 오류마다 메시지를 일일이 많드는 건 번거로운 일이다. 그래서 중요하지 않은 오류엔 범용성 있는 메시지, 정말 중요한 메시지는 구체적인 메시지를 보여주면 된다.

 

예시]

▶ 레벨 1

 

▶ 레벨 3

 

▶ 레벨 4

 

■ ValidationUtils

Empty, 공백 같은 단순한 기능만 제공한다.

상품명 누락에 썼던 코드를 ValdationUtils로 대체할 수 있다.

 


[12] 오류 코드와 메시지 처리 6

검증 오류 코드

① 개발자가 직접 설정한 오류 코드 rejectValue()를 직접 호출

② 스프링이 직접 검증 오류 추가(주로 타입 정보가 맞지 않을 때 사용)

 

입력 타입을 잘못 넣었을 때 로그를 확인하면 BindingResult의 FiledError가 담겨 있고 typeMismatch라는 오류코드가 생성된 것을 확인할 수 있다.

스프링은 타입 오류가 발생하면 typeMistmatch라는 오류 코드를 사용한다. 하지만, 그 코드가 상당히 개발자(?)스럽다.

개발자스러운 타입오류 메시지

그래서 사용자 친화적인 오류 메시지를 보내려면 errors.properties에 다음 내용을 추가하면 된다.

#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

 

[13] Validator 분리 1

목표 : 복잡한 검증 로직을 별도로 분리하자.

 

ItemValidator

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) { //Errors는 BindingResult의 부모 클래스
        Item item = (Item) target;

        if (!StringUtils.hasText(item.getItemName())) { //상품명 누락
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

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

① Spring이 제공하는 interface인 Validator를 상속받아 사용

② 메소드 설명

    supports(Class<?> claxx) : 해당 검증기를 지원하는지 여부 확인

    validate(Object target, Errors errors) : 타깃은 검증대상, Erros는 BindingResult의 부모 클래스

    

ValidationItemControllerV2 - addItemV5() : ItemValidator 직접 호출

@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //검증 로직
    itemValidator.validate(item, bindingResult);

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

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

itemValidator를 스프링 빈으로 주입받아서 직접 호출했다.

 

 

[14] Validator 분리 2

■ WebDataBinder를 이용하는 방법도 있다.

ValidationItemControllerV2

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

① WebDataBinder에 itemValidator외 다른 검증기도 추가할 수 있다.

② @InitBinder는 해당 컨트롤러에만 영향을 주며, 해당 컨트롤러가 호출되면 제일 먼저 @InitBinder가 붙은 init메소드가 호출된다.

 

ValidationItemControllerV2 - addItemV6

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={}" + bindingResult);
        return "validation/v2/addForm";
    }

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

① 직접 호출 대신 검증기를 실행하라는 @Validated를 넣는다.

    1] 이 애노테이션이 붙으면 WebDataBinder에 등록된 검증기를 찾아 실행하고 파라미터 바인딩을 해준다.

    2] 검증기가 여러 개면 어떤 검증기를 사용할지 구분이 필요하고 여기서 앞서 나왔던 supports()가 사용된다.

        supports(Item.class)가 호출되고 결괏값이 true이므로 ItemValidator의 validate()가 호출된다.

 

 

글로벌 설정 - 모든 컨트롤러에 적용

ItemServiceApplication

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
   public static void main(String[] args) {
      SpringApplication.run(ItemServiceApplication.class, args);
   }
   @Override
   public Validator getValidator() {
      return new ItemValidator();
   }
}

이러면 모든 컨트롤러에 다 적용할 수 있다. 단, 다음 챕터에서 쓰는 BeanValidator 파트에서 자동등록이 되지 않아 문제가 생기므로 제외