본문 바로가기
관리자

Programming-[Backend]/Spring

[스프링 웹MVC-2] 7. 검증(Validation) - 오류코드, 메시지 처리

728x90
반응형

 

이제 검증된 내용을 앞서 배운 메시지로 공용화하여 처리하는 방법을 배운다. 이렇게 공용화함으로써 유지보수에 유리하고, 계층적인 오류 및 메시지 관리가 가능하게 된다.

 

살펴볼 순서는 다음과 같다.

 


1. FieldError, ObjectError 객체의 codes, arguments 파라미터 알아보기

2. bindingResult의 rejectValue, reject 사용해보기

3. 범용적 메시지 처리

4. 처리 원리 : MessageCodesResolver

5. 오류 메시지 계층화 하기, ValidationUtils

6. 스프링이 만든 오류 처리 : typeMismatch


 

1. FieldError, ObjectError 객체의 codes, arguments 파라미터

 

 

 

앞선 글에서 FieldErrors와 ObjectErrors의 파라미터로 codes, arguments를 받을 수 있었다. 여기서 codes는 messages.properties와 같은 메시지의 경로값을 의미하고, arguments는 해당 메시지에서 쓰일 인자들을 의미하게 된다. 실습으로 알아보자.

 

 

errors.properties를 추가한다.

 

1
2
3
4
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값= {1}
cs

 

 

그리고 해당 파일을 참조할 수 있도록 application.properties 내에 basename으로 추가한다.

spring.messages.basename=messages, errors

 

 

ControllerV2 파일에서 addItemV3를 추가한다(addItemV2는 @PostMapping 주석처리).

 

Codes

codes는 String 배열로 작성할 수 있다. 여러 개를 받을 수 있다는 뜻인데, 이것은 뒤에서 다룰 오류 메시지의 우선순위대로 작성하게 된다.

 

Arguments

arguments는 메시지에 필요한 인자들을 Object 배열로 순서대로 넣어주면 된다.

 

마지막으로, 앞선 글에서 작성했던 defaultMessage 파라미터는 사용할 일이 없으므로 null로 처리해주었다. 만약 codes에 걸리는 에러가 없을 때 기본 메시지를 전달하고 싶다면 defaultMessage를 설정해주면 된다. 혹시라도, codes에도 걸리지 않고, defaultMessage도 없다면 서버는 Whitelabel Error page를 출력하게 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
//      bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
      bindingResult.addError(new FieldError("item""itemName", item.getItemName(), falsenew String[]{"required.item.itemName"}, nullnull));
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
      bindingResult.addError(new FieldError("item""price", item.getPrice(), falsenew String[]{"range.item.price"}, new Object[]{10001000000}null));
    }
    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
      bindingResult.addError(new FieldError("item""quantity", item.getQuantity(), falsenew String[]{"max.item.quantity"}, new Object[]{9999}null));
    }
 
    if (item.getPrice() != null && item.getQuantity() != null) {
      int totalPrice = item.getQuantity() * item.getPrice();
      if (totalPrice < 10000) {
        bindingResult.addError(new ObjectError("item"new String[]{"totalPriceMin"}, new Object[]{"10000", totalPrice}null));
      }
    }
cs

 

 

 


 

2. bindingResult의 rejectValue, reject 사용해보기

 

 

@ModelAttribute Item item 코드 바로 뒤에 BindingResult를 파라미터로 작성해야 된다는 것을 배웠다. 이것은 사실, BindingResult가 이미 item 객체를 참조하고 있다는 것을 내포하는 의미이다. 다시 말해서, FieldError와 ObjectError에서 첫번째 파라미터로 사용되는 ObjectName은 작성할 필요가 없다는 것을 의미한다.

 

getObjectName(), getTarget()

실제로 bindingResult.getObjectName(), bindingResult.getTarget() 메서드로 객체 이름과 실제 객체의 정보를 확인할 수 있다.

 

1
2
 log.info("objectName = {}" ,bindingResult.getObjectName());
 log.info("target={}", bindingResult.getTarget());
cs

 

 

 

rejectValue(), reject()

이제 ObjectName을 생략하고, 각 Error 객체를 새롭게 생성하지 않는, BindingResult 객체의 rejectValue, reject 메서드를 사용해본다. addItemV4() 메서드를 만들자.

 

rejectValue() 메서드로 FieldError를 대체하고, reject() 메서드로 ObjectError를 대체한 것을 알 수 있다. 그리고 ObjectName도 생략되었다. 다만 rejectValue의 두 번째 파라미터, reject의 첫 번째 파라미터인 codes가 맨 앞 단어들만 작성된 것을 볼 수 있다. 이것은 오류메시지를 계층화하여 표현하는 MessageCodesResolver가 처리해주기 때문인데, 해당 내용은 뒷 장에서 살펴본다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@PostMapping("/add")
  public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
 
    log.info("objectName = {}" ,bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());
 
    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
      bindingResult.rejectValue("itemName""required"nullnull);
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
      bindingResult.rejectValue("price""range"new Object[]{10001000000}, null);
    }
    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
      bindingResult.rejectValue("quantity""max"new Object[]{9999}, null);
 
    }
    if (item.getPrice() != null && item.getQuantity() != null) {
      int totalPrice = item.getQuantity() * item.getPrice();
      if (totalPrice < 10000) {
     
        bindingResult.reject("totalPriceMin"new Object[]{10000}, null);
      }
    }
cs

 

 

 


 

 

3. 범용적 메시지 처리 : 메시지 계층화

 

 

errors.properties에 범용적인 에러 메시지를 추가해보자. 이렇게 범용적인 메시지를 추가하고, 상황에 따라서 범용적인 메시지를 사용하는 부분이 있고, 구체적인 메시지를 사용하는 부분이 생길 수 있다. 예를 들어서 회원 가입을 하는데, 회원의 이름값을 입력하지 않는 경우가 있을 수도 있는데, 회원 가입을 하는 상황이라 맥락이 정확하니, 그냥 "required=필수값입니다." 라는 메시지를 적용할 수도 있는 것이다.

 

그래서 메시지를 범용적, 구체적인 부분들로 계층화해놓고, bindingResult에서 사용할 때 우선순위를 매기게 된다.

 

1
2
3
4
5
6
7
8
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값= {1}
 
required=필수값 입니다.
range=범위는 {0} ~ {1} 까지 허용합니다.
max=최대 {0} 까지 허용합니다.
cs

 

bindingResult에서 codes 파라미터는 String[]{}의 문자열의 배열 형태로 나타낸다고 했는데, 우선 순위대로 작성한다면 다음과 같이 작성될 수 있을 것이다.

String[]{"required.item.itemName", "required"}

 

즉, 구체적인 메시지를 우선적으로 적용하되, 구체적인 메시지가 아닌 범용적으로 적용해야 될 부분은 범용적인 메시지가 작동하도록 설정할 수 있는 것이다. 이를 좀 더 단순화한 것이 위에서의 rejectValue(), reject() 메서드에서 적용된 방식이라 할 수 있다. 좀 더 정확한 이해를 위해서는 다음 장에서의 MessageCodesResolver를 이해할 필요가 있다.

 

 


 

 

 

4. MessageCodesResovler

 

 

MessageCodesResovler는 메시지를 작성한 errors.properties를 계층화하여 배열로 반환해준다. 테스트 코드를 작성하여 실행하면서 이해해보자. 테스트 파일을 만들고, MessageCodesResolver를 만든다.

 

첫번째 테스트의 resolverMessageCodes()의 파라미터로는 "required" 에러코드, "item" ObjectName을 추가한다. 코드와는 다르지만, messageCodes 내부의 요소들을 반복문으로 출력해보면 아래와 같은 결과가 나온다.

 

두번째 테스트에서는 순서대로 파라미터를 에러코드, objectName, fieldName, 타입 클래스로 넣어준다. 이렇게 하면 출력시 아래와 같은 결과가 나온다.

 

 

 

java/hello/itemservice/validation/MessageCodesResolverTest.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
    Assertions.assertThat(messageCodes).containsExactly(
        "required.item.itemName",
        "required.itemName",
        "required.java.lang.String",
        "required");
  }
}
 
cs

 

순서대로 ObjectError, FieldError를 만들어주는 것이다.

 

즉, BindingResult의 rejectValue(), reject() 메서드는 MessageCodesResolver를 적용하여 에러 메시지 properties에 있는 코드들을 해석하고, 구체화된 코드 -> 범용화된 코드의 우선순위별로 ObjectError, FieldError를 만들어주는 것이다. 따라서 위에서 rejectValue()와 reject() 메서드를 적용할 때 errorCode 파라미터로 가장 최상위의 "required" 만 입력하면 됬었다.

 

에러메시지를 계층화하는 우선순위를 정리하자면 다음과 같다.

 

ObjectError

1. ErrorCode.ObjectName

2. ErrorCode

 

FieldError

1. ErrorCode.ObjectName.FieldName

2. ErrorCode.FieldName

3. ErrorCode.typeName

4. ErrorCode

 

 


 

 

5. 오류 메시지 계층화 하기, ValidationUtils

 

 

오류 메시지 계층화 관리

 

이제 이해가 됬다면, 오류 메시지를 실제로 계층화하여 적용하는 실습을 해보자. 다음 코드를 복사하여 errors.properties에 붙여넣는다. Level1 ~ Level4 까지 구성되어 있으며, 구체적인 범위 -> 범용적인 범위로 계층화되어 있다.

 

 

resources/errors.properties

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
 
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
 
 
#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
 
#Level2 - 생략
 
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 숫자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
 
#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
 
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
cs

 

컨트롤러에서는 rejectValue(), reject() 메서드를 활용하였고, codes는 1개만 파라미터로 입력했었다.

 

bindingResult.rejectValue("itemName", "required", null, null);

 

4. MessageCodesResolver에서 살펴본 바와 같이, 우선순위를 두고 에러메시지를 갖고 오게 되므로 에러가 나면 Level1의 에러메시지를 출력하게 된다. 그런데 만약 변경이 필요하다면 Level1의 코드는 주석처리하고 Level3의 코드가 적용되도록 하면 각 컨트롤러나 서비스 코드를 변경할 필요없이 메시지 코드만 수정함으로써 효율적인 관리가 가능해진다.

 

 

ValidationUtils

 

잘 쓰지 않는 문법인데, StringUtils 문법을 대체할 수 있는 객체가 ValidationUtils 이다. 검증 코드에서 if문과 StringUtils 내부의 메서드를 ValidationUtils.rejectIfEmptyOrWhitespace 문법으로 사용할 수 있다.

 

1
2
3
4
5
if (!StringUtils.hasText(item.getItemName())) {
      bindingResult.rejectValue("itemName""required"nullnull);
    }
// = ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
    
cs

 

 

 


 

6. 스프링이 만든 오류 처리 : typeMismatch

 

 

스프링이 만드는 typeMismatch 에러를 개발자가 직접 작성한 메시지로 처리해보자. 스프링은 입력값의 타입이 안맞을 때, typeMismatch 에러를 출력한다. 그런데 이 코드는 사용자들이 알아보기 어렵고 너무 긴 메시지라서 화면상 보기도 좋지 않다.

 

 

처리하는 방법은 간단하다. 에러 메시지를 작성했던 errors.properties에 typeMismatch 코드를 추가하는 것이다.

 

 

resources/errors.properties

 

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

 

단순하지만, 원리를 생각해보면 Errors(FieldErros, ObjectErrors)를 만들고, code와 objectName 등의 변수에 맞게 에러메시지를 출력하도록한 개념이 적용되었다는 것을 인지하는 것은 중요하다. 공통으로 사용할 부분은 변수로 처리하고, 변경 포인트는 외부에 두고, 계층화 한다는 개념은 마치 스프링의 맨 처음에 배웠던 설정 부분과 구현 부분은 따로 관리한다는 객체지향적 개념과 비슷하다고 느껴진다.

 

 

 


 

 

 

7. Validator 분리 및 스프링 Validator 활용

 

 

 

Validator 분리하기

 

이때까지 컨트롤러 메서드 내부에 많은 검증 코드를 넣어서 컨트롤러의 주역할이 검증인 것 마냥 작성되었다. 이 부분을 ItemValidator라는 클래스를 만들고, 따로 빼서 관리하도록 한다. org.springframwork.validation의 Validator를 implements 받는다. 그러면 supports와 validate 메서드를 오버라이드 해야한다.

 

supports는 파라미터로 받아오는 클래스(변수명 clazz)가 특정 클래스를 받아줄 수 있는지 또는, 그 클래스의 자식 타입 클래스를 받아주는지를 검사하는 메서드이다. 뒤에서 자세히 다룬다.

 

validate는 검증대상을 target으로 받고, Errors 객체를 받는다. 따라서 target을 우리가 검증하고자하는 Item 타입으로 캐스팅 해주고, 검증 로직에서의 기존 bindingResult 객체는 errors 객체로 바꿔주도록 한다.

 

컨트롤러에서 불러와서 사용해야 하기 때문에, @Component 어노테이션을 붙여주었다.

 

 

java/hello/itemservice/web/validation/ItemValidator.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Component
public class ItemValidator implements Validator {
 
  @Override
  public boolean supports(Class<?> clazz) {
    return Item.class.isAssignableFrom(clazz);
    //item 클래스를 상속받는 clazz가 오더라도 허용
  }
 
  @Override
  public void validate(Object target, Errors errors) {
    Item item = (Item) target;
 
    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
      errors.rejectValue("itemName""required"nullnull);
    }
    // = ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
 
 
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
      errors.rejectValue("price""range"new Object[]{10001000000}, null);
    }
    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
      errors.rejectValue("quantity""max"new Object[]{9999}, null);
 
    }
    if (item.getPrice() != null && item.getQuantity() != null) {
      int totalPrice = item.getQuantity() * item.getPrice();
      if (totalPrice < 10000) {
        errors.reject("totalPriceMin"new Object[]{10000, totalPrice}, null);
      }
    }
  }
}
cs

 

 

 

이제 Controller에서 ItemValidator를 불러와서 사용하면 된다. private final로 ItemValidator를 불러오고, validate 메서드만 적용하면 된다. 컨트롤러에서는 bindingResult를 사용했기 때문에, 파라미터로 넘기면 ItemValidator에서는 Errors라는 bindingResult의 부모 객체를 받아오도록 작성해두었기 때문에 그대로 적용이 가능하다.

 

 

java/hello/itemservice/web/validation/ValidationItemControllerV2.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
@Slf4j
public class ValidationItemControllerV2 {
 
  private final ItemRepository itemRepository;
 
  private final ItemValidator itemValidator;
 
  ... 중략
 
@PostMapping("/add")
  public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
 
    itemValidator.validate(item, bindingResult);
 
    log.info("bindingResult ={}", bindingResult);
    //검증 로직 실패 시, 다시 입력 폼으로
    //이중 부정이므로 리팩토링으로 hasError 등으로 바꾸는게 좋다.
    if (bindingResult.hasErrors()) {
      return "validation/v2/addForm";
    }
 
    //검증 로직 통과 시, 상품 저장
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status"true);
    return "redirect:/validation/v1/items/{itemId}";
  }
cs

 

 

사실 ItemValidator를 그냥 선언하고, 검증에 필요한 코드만 빼서 바로 불러와도 된다. 굳이 스프링이 제공하는 Validator를 implements 할 필요가 없는 것이다. 그러나 Validator를 상속받은 이유는 다음 섹션에서의 Validator의 기능을 활용하기 위해 상속받은 것이다.

 

 

 

 

스프링 Validator 활용하기

 

컨트롤러 코드에 @InitBinder, WebDataBinder를 추가한다. 이렇게 적용하면, @Controller 내부의 모든 메서드가 시작하기 전에 WebDataBinder에 추가한 검증기인 Validator를 실행해준다. addValidators 메서드로 검증기를 추가할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
@Slf4j
public class ValidationItemControllerV2 {
 
  private final ItemRepository itemRepository;
  private final ItemValidator itemValidator;
 
  @InitBinder
  public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(itemValidator);
  }
 
... 중략
cs

 

다만, 적용을 위해서는 메서드의 @ModelAttribute 앞에 @Validated 어노테이션을 추가로 달아주어야 한다. 이렇게 하면 앞서 메서드 내부에 작성했었던 itemValidator.validate(item, bindingResult) 코드를 생략할 수 있다. 이외에 javax 표준의 @Valid 어노테이션을 사용할 수도 있다.

 

1
2
3
4
5
@PostMapping("/add")
  public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
 
...
 
cs

 

 

supports 메서드

Validator의 supports 메서드는 바로 이 기능을 위해 제공되는 메서드이다. WebDataBinder에 여러개의 검증기를 addValidators 메서드를 이용하여 넣을 수 있는데, Item 객체 앞의 @Validated와 연결되어 supports메서드로 itemValidator 검증기가 Item 객체와 관련되어있는지 검사하는 방식인 것이다.

 

 

글로벌 설정

 

스프링부트 실행 부분에 WebMvcConfigurer를 상속받으면 모든 컨트롤러에 검증기를 적용하게 된다. 거의 쓸일이 없지만, 가능하다는 것 정도는 알아두자.

 

 

java/hello/itemservice/ItemServiceApplication.java

 

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
 
    public static void main(String[] args) {
        SpringApplication.run(ItemServiceApplication.class, args);
    }
 
    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }
}
cs

 

 


참조

 

1. 인프런_스프링 MVC 2편 - 백엔드 웹개발 핵심 기술_김영한 님 강의

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

 

728x90
반응형