본문 바로가기
spring

MVC-2 message, validation

by 오우지 2022. 2. 11.

메시지

 

화면에 보이는 문구가 마음에 안든다고 전부 고쳐달라 하면 어떻게 해야할까? HTML파일에 메시지가 하드코딩 돼 있기 때문에 상품명, 가격, 수량 같은 label에 있는 단어를 변경하려면 화면들을 다 찾아가면서 변경해야 한다. 

다양한 메시지를 한 곳에서 관리하는 기능을 메시지 기능이라고 한다.

 

예를 들어 messages.properties라는 메시지 관리 파일을 만들고

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

각 HTML들은 해당 데이터를 key 값으로 불러서 사용한다.

<label for="itemName" th:text="#{item.itemName}"></label>

국제화

메시지 파일을 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다.

 

국가를 인식하는 HTTP 'accept-language' 헤더 값을 사용하거나 사용자가 직접 언어를 선택하도록 하고 쿠키 등을 사용해서 처리하면 된다.

 

messages_en.properties

item=Item
item.id=Item ID
item.itemName=Item Name
item.price=price
item.quantity=quantity

messages_ko.properties

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

메시지와 국제화 기능을 직접 구현할 수도 있지만 스프링은 기본적인 메시지와 국제화 기능을 제공한다.

스프링 메시지 소스 설정

스프링의 기본적 메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource를 스프링 빈으로 등록하면 되는데 MessageSource는 인터페이스이기 때문에 구현체인 ResourcBundleMessageSource를 스프링 빈으로 등록하면 된다.

 

하지만 그 마저도 스프링 부트는 메시지 소스를 자동으로 스프링 빈으로 등록한다.

spring.messages.basename=messages,config.i18n.messages

이름을 바꾸고 싶다면

spring.message.basename={}로 등록하면 된다.

별도의 설정을 하지 않으면 messages라는 이름으로 기본 등록된다. 따라서 message_en.properties, message_ko.properties 파일만 등록하면 자동으로 인식된다.

 

 

package hello.itemservice.message;
  import org.junit.jupiter.api.Test;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.boot.test.context.SpringBootTest;
  import org.springframework.context.MessageSource;
  import static org.assertj.core.api.Assertions.*;
  
  @SpringBootTest
  public class MessageSourceTest {
  
      @Autowired
      MessageSource ms;
      
      @Test
      void helloMessage() {
		String result = ms.getMessage("hello", null, null); 
        	assertThat(result).isEqualTo("안녕");
	} 
}

getMessage의 code에는 가져올 변수 값을 넣고 나머지는 null을 입력한다. locale 정보가 없으면 basename에서 설정한 기본 이름 메시지 파일을 조회한다. 

이때 해당하는 메시지가 없는 경우 NoSuchMessageException이 발생하는데 이때 defaultMessage 항목을 사용하면 기본 메시지가 반환된다. 

 

추가정보

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#application-properties

 

 

 

국제화 파일 선택

Locale 정보를 기반으로 국제화 파일을 선택한다. Localeen_US 의 경우 messages_en_US -> messages_en -> messages 순서로 찾는다. 먼저 구체적인 것, 그 다음 디폴트를 찾는다.

스프링은 기본적으로 Accept-Language 헤더 값을 이용해서 언어를 선택하지만 선택 방식을 변경할 수 있도록 LocaleResolver라는 인터페이스를 제공한다.

 

Validation

1. 클라이언트 검증은 조작할 수 있으므로 보안에 취약

2. 서버만으로 검증하면 고객 사용성이 부족해진다.

3. 둘의 적절한 믹스가 필요, 최종 서버 검증은 필수

 

@PostMapping("/add")
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {

        //검증 오류 결과를 보관
        HashMap<String, Object> errors = new HashMap<>();

        //검증 로직
        if(!StringUtils.hasText(item.getItemName())){
            errors.put("itemName", "상품 이름은 필수입니다.");
        }
        if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
            errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
        }
        if(item.getQuantity() == null || item.getQuantity() > 9999){
            errors.put("quantity", "수량은 최대 9,999 까지 허용합니다");
        }

        if(item.getPrice() == null && item.getQuantity() == null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice < 10000){
                errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        if(hasError(errors)){
            log.info("errors = {}", errors);
            model.addAttribute("errors", errors);
            return "validation/v1/addForm";
        }

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

HashMap에 오류 발생 정보를 담아둔다. 특정 필드를 넘어서는 오류에 대해서는 globalError라는 key를 사용한다.

 

<div th:if="${errors?.containsKey('globalError')}">
	<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

타임리프의 th:if"#{errors?

여기서 ?의 의미는 null 값이 들어있을 경우 NPE가 아닌 null이라는 값이 대신 나올 수 있게 해준다.

 

정리

1. 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.

2. 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 해준다.

3. 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.

 

남은 문제점

1. 뷰 템플릿 중복 처리가 많다.

2. 타입 오류 처리가 안된다. 숫자 타입에 문자가 들어오면 400 에러가 난다. 또한 오류가 발생해도 고객이 어떤 입력에서 오류가 발생했는지 알아채기 힘들다.

3. 고객이 입력한 값도 별도로 관리가 되어야 한다.

 

 

BindingResult1

스프링이 제공하는 검증 오류 처리 방법이다.

public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        //검증 오류 결과를 보관
        HashMap<String, Object> errors = new HashMap<>();

        //검증 로직
        if(!StringUtils.hasText(item.getItemName())){
            bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
        }
        if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
            bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if(item.getQuantity() == null || item.getQuantity() > 9999){
            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다"));
        }

        if(item.getPrice() != null && item.getQuantity() != null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice < 10000){
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        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}";
    }

BindingResult의 파라미터 위치는 @ModelAttribute Item item 다음에 와야 한다.

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해 bindingResult에 담아두면 된다.

 

타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.

#fields로 접근 가능하다.

th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력한다.

th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

 

BindingResult2

스프링이 제공하는 검증 오류를 보관하는 객체다. 이전에는 필드에서 에러가 나면 400 에러를 뱉었지만 BindingResult를 사용하면 컨트롤러는 호출된다.

 

BindingResult에 검증 오류를 적용하는 3가지 방법

1. 스프링이 알아서

2. 개발자가

3. Validator 사용

 

FieldError는 두가지 생성자를 제공한다.

public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)

objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지

 

이때 메시지 처리처럼 error메시지 파일을 만들어서 공통처리를 할 수 있다.

new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}

codes: required.item.itemName을 사용해서 메시지 코드를 지정한다. 메시지 코드는 배열로 전달할 수 있고 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. 실행해보면 MessageSource를 찾아서 메시지를 조회하는 것을 확인할 수 있다.

 

 

FieldError, ObjectError는 입력해줘야 할 것이 너무 많아 다루기 번거롭다. 오류 코드도 좀 더 자동화할 수 있다.

BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 사용하지 않아도 검증 오류를 다룰 수 있다.

 

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

field: 오류 필드명

errorCode: 오류코드(메시지 등록 코드가 아닌 messageResolver를 위한 오류 코드)

errorArgs: 오류 메시지에서 {0}을 치환하기 위한 값

defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

BindingResult는 어떤 객체를 대상으로 하는지 알고 있기 때문에 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price를 사용했다.

 

FieldError()는 오류 코드를 range.item.price와 같이 모두 입력했지만 rejectValue()를 사용하고 나서는 range로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다.

 

 

오류 코드 처리

오류 코드를 만들 때 자세히 만들 수도 있고 단순하게 만들 수도 있다. 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만 세밀하게 작성하기 어렵고 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가 세밀하게 작성해야 하는 경우에 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.

 

만약 required를 오류 코드로 사용한다면 다음과 같이 메시지를 선택해서 사용한다. 하지만 required.item.itemName과 같이 객체와 필드명을 조합한 메시지가 있으면 이 메시지를 높은 우선순위로 사용한다.

#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.

이처럼 범용성 있게 잘 개발해두면 메시지 추가 만으로 편리하게 오류 메시지를 관리할 수 있다.

 

스프링은 MessageCodesResolver라는 것으로 기능을 지원한다.

 

MessageCodesResolver

우선 MessageCodesResolver를 알아보자.

public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println(messageCode);
        }

        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    void messageCodesResolverField(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
    }
}

 

 

검증 오류 코드로 메시지 코드들을 생성한다.

MessageCodesResolver 는 인터페이스고 DefaultMessageCodesResolver는 기본 구현체다.

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

 

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

 

객체 오류의 경우 다음 순서로 2가지 생성

객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required

 

필드 오류의 경우 4가지 메시지 코드 생성

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch

 

동작 방식

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

FieldError, ObjectError의 생성자를 보면 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.

MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.

 

오류 메시지 출력

타임리프 화면을 렌더링 할 때 th:errors가 실행된다. 만약 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾고 없으면 디폴트 메시지를 출력한다.

 

오류 코드 관리 전략

1. 핵심적인 것에서 덜 구체적인 것으로 만들어 줘야 한다.

2. 모든 오류 코드에 대해서 메시지를 각각 정의하면 관리하기 힘들때 때문에 중요하지 않은 메시지는 requried같은 메시지로 끝내고 중요한 것은 구체적으로 적어서 사용하는 방식이 효과적이다.

 

ValidationUtils

validationUtils은 vaildation을 확인할 때 if 쓰는 것을 줄여준다.

if(!StringUtils.hasText(item.getItemName())){
        bindingResult.rejectValue("itemName", "required");
    }
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

1. rejectValue()호출

2. MessageCodesResolver를 사용해 검증 오류 코드로 메시지 코드들을 생성

3. new FieldError()를 생성하면서 메시지 코드들을 보관

4. th:errors에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고 노출

 

 

 

스프링이 직접 만든 오류 메시지 처리

검증 오류 코드는 2가지로 나눌 수 있다.

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

2. 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보)

 

price 필드에 문자를 입력하고 로그를 확인해보면 BindingResult에 FieldError가 담겨있고 메시지 코드들이 생성된 것을 확인할 수 있다.

 

codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch];

아직 errors.properties에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.

error.properties에 다음 내용을 추가하면 에러가 정확하게 뜬다.

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

 

Validator 분리 1

지금까지의 검증 과정은 컨트롤러에 검증 로직이 있기 때문에 코드가 지저분하다. 이제 복잡한 검증 로직을 별도로 분리하려고 한다.

 

ItemValidator를 만든다.

@Component
public class ItemValidator implements Validator {


    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        //item == clazz
        //item == subItem - 자식 클래스까지 커버 가능하다.


    }

    @Override
    public void validate(Object target, Errors errors) {
        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);
            }
        }
    }
}

스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.

public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}

supports(): 해당 검증기를 지원하는 여부 확인

validate(Object target, Errors erros): 검증 대상 객체와 BindingResult

 

실행해보면 기존과 완전히 동일하게 동작하는 것을 확인할 수 있다. 검증과 관련된 부분이 깔끔해졌다.

하지만 스프링 인터페이스를 받았기 때문에 코드를 작성해주지 않아도 스프링이 자동으로 적용해주는 방법이 있다. 이제 그것에 대해 알아보자.

Validator 분리 2

스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다. Validator 인터페이스를 사용해 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

 

WebDataBinder를 통해서 사용하기

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

이렇게 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에 검증기를 자동으로 적용할 수 있다.

@InitBinder -> 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야 한다.

 

@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}";
    }

validator를 직접 호출하는 부분이 사라지고 검증 대상 앞에 @Validated가 붙었다.

 

하지만 여러 검증기를 등록한다면 어떤 검증기가 실행되어야 할지 구분해야 한다. 이때 supports()가 사용된다.

아까 위에서 supports()에서 Item 객체인지 확인하는 부분이 있었는데 이게 true가 반환된다면 해당 validator가 실행된다.

 

모든 컨트롤러에 적용되도록 글로벌 설정을 할 수도 있는데 다음과 같이 작동하면 된다.

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

 

'spring' 카테고리의 다른 글

mvc2 - 쿠키, 세션 로그인  (0) 2022.02.14
HTTP 웹 기본지식 - 2  (0) 2022.02.12
HTTP 웹 기본지식 - 1  (0) 2022.02.04
스프링 기본편 복습 - 2  (0) 2022.01.28
스프링 기본편 복습 - 1  (0) 2022.01.23