쿠릉쿠릉 쾅쾅
쿠릉쿠릉 쾅쾅
쿠릉쿠릉 쾅쾅
250x250
전체 방문자
오늘
어제
  • 분류 전체보기
    • HTML CSS
    • 잡담
    • 프로그래밍 꿀팁 사이트
    • 코딩 도서
    • [자바]
      • 디자인 패턴
      • 자바의 정석 - 3판
      • 자바
      • 자바 문법
    • git
    • [TDD]
    • 개발 서적 독후감
      • 클린 코더
      • 토비 스프링3
      • 객체지향의 사실과 오해
      • 모던 자바 인 액션
      • 엘레강트 오브젝트
    • CS
      • 운영체제
      • HTTP
    • [SQL]
      • SQL 기초
      • 혼자공부하는SQL
    • [ Spring ]
      • REST API
      • Spring Toy
      • Spring 에러
      • Spring
      • Spring 입문
      • Spring 핵심 원리
      • SpringMVC 1편
      • SpringMVC 2편
      • Spring Boot를 이용한 RESTful We..
      • Batch
    • [JPA]
      • JPA
      • JPA 에러
      • JPA 프로그래밍 - 기본편
      • 스프링 부트와 JPA 활용 1 - 웹 애플리케이..
      • 실전! 스프링 부트와 JPA 활용2 - API 개..
      • 실전! 스프링 데이터 JPA
      • 실전! Querydsl
    • 인텔리제이
    • [DB]
      • DB
      • H2
    • Gradle
    • 면접
    • [알고리즘]
      • 알고리즘
      • 자료구조
      • 자바 알고리즘 공부
    • [프로젝트]
    • 쿠릉식 객체지향 사고
    • 리눅스

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 깃허브
  • MVC
  • 재귀
  • 스프링
  • JPA
  • 백준
  • Git
  • Spring
  • 스프링부트
  • 함수형인터페이스
  • SQL
  • springboot
  • querydsl
  • REST API
  • 자바
  • 자료구조
  • http
  • 알고리즘
  • GitHub
  • java

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
쿠릉쿠릉 쾅쾅

쿠릉쿠릉 쾅쾅

[Spring] 검증1 - Validation
[ Spring ]/SpringMVC 2편

[Spring] 검증1 - Validation

2022. 3. 7. 19:19
728x90

 

 

 

검증 - Validation

  • 컨트롤러의 중요한 역할 중 하나가 HTTP 요청이 정상인지 검증하는 것이다.
  • 검증 요구사항 예)
    • 타입 검증
      • 예) 가격, 수량 입력에 숫자인지 아닌지 체크
    • 필드 검증
      • 예) 상품명에 빈 공백인 경우
      • 예) 수량 최대치가 넘어갔는지 안넘어갔는지
    • 특정 필드의 범위 검증
      • 예) 가격 * 수량의 합이 1000원 이상인지 아닌지 

 

🔍 클라이언트 검증 vs 서버 검증

  • 클라이언트 검증은 외부 조작이 가능해서 보안에 취약하다.
  • 클라이언트 검증 없이 서버 검증만 진행할 경우 즉각적인 고객 사용성이 부족해진다.
    • 서버 검증은 클라이언트 검증보다 시간이 오래 걸린다.
  • API방식을 사용할 경우 API 스펙을 잘 정의해서 검증 오류를 API 응답결과에 잘 남겨야 한다.
  • 결국 클라이언트 검증과 서버 검증을 같이 적절히 사용하되, 최종 검증은 서버 검증으로 해야한다.

 

🔍 검증 과정

💡 검증 성공

  • 사용자가 상품 등록 페이지에 접근한다. (HTTP GET / add)
  • 사용자가 상품 정보를 입력 후 서버로 접속한다. (HTTP POST / add)
  • 상품이 성공적으로 등록된 후 Location 정보로 상품정보 상세경로를 Redirect로 응답한다.
  • 클라이언트에서는 응답받은 정보에 있는 Location 정보로 Redirect하여 신규 상세 페이지로 이동한다. 
  • 결론 : 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면 서버에서는 검증 로직이 통과하고, 상품을 저장하고 상품 상세 화면으로 redirect한다.

 

💡 검증 실패

  • 사용자가 상품 등록 페이지에 접근한다. (HTTP GET / add)
  • 사용자가 상품 정보를 입력 후 서버로 전송한다. (HTTP POST / add)
  • 상품의 유효성 검증이 실패하여 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동해야 한다.
  • 검증에 실패의 주 원인은 Null / TypeMissMatch / 비즈니스 요구사항 부적합 등이 있다. 

 

 


 

 

다양한 검증 방법

  • 검증 방식은 다양하다.
    • Map에다가 에러 내용을 담아서 모델에 담아서 반환하는 방식
    • BindingResult를 사용하여 모델에 담아서 반환하는 방식
    • Validator 라는 마커 인터페이스를 구현하는 방법

 

🔍 Map

  • 클라이언트에서 전달받은 데이터를 서버가 직접 검증하여 Map에 담아서 RedirectAttributes에 담아 보내는 방법

📌 컨트롤러

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

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

    // 검증  로직
    if(!StringUtils.hasText(item.getItemName())) {
        errors.put("itemName", "상품 이름은 필수입니다.");
    }

    if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
        errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
    }

    if(item.getQuantity()==null || 9999<=item.getQuantity()) {
        errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
    }

    if(item.getPrice()!=null && item.getQuantity()!=null) {
        int resultPrice = item.getPrice() * item.getQuantity();

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

    // 검증 실패하면 다시 입력 폼으로
    if(!errors.isEmpty()) {
        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}";
}
  • 검증에 실패하면 errors 라는 Map에 에러 내용을 담아서 뷰에 반환한다.
  •  @ModelAttribute  애노테이션이 붙은 Item 객체는 에러가 발생하면 다시 페이지 이동시 Item 필드 값들이 전부 다시 모델에 담겨서 뷰에 전송된다. 
  • RedirectAttributes는 Redirect시 보존할 데이터를 담을 수 있다.
  • StringUtils
    • String을 다루는데 편리한 기능을 제공하는 추상 클래스다.
    • 스프링에서 지원한다.
    • 주로 static 메서드로 구성되어 있다.
    •  hastText()  메서드는 파라미터가 문자열인지 확인해서 true 또는 fasle를 반환한다.  

 

💡 검증 실패시 타임리프에서 검증 실패 메시지 출력하기

📌 HTML (전체 오류 메시지)

<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
  • 오류 메시지는 errors 객체에 내용이 있을 때만 출력하면 된다.
  •  th:if  를 사용하여 조건에 만족할 때만 HTML 태그를 렌더링 할 수 있다.
  •  errors?.  에서  .?  는 errors 객체가 null일 때 NullPointerException이 발생하는 대신, null을 반환하도록 하는 문법이다. null이 반환되면 실패로 처리되기 때문에 렌더링을 하지 않는다.

 

📌 HTML (오류 처리 메시지)

<div class="field-error" th:if="${errors?.containsKey('itemName')}" 
     th:text="${errors['itemName']}">
    상품명 오류
</div>

 

📌 HTML (오류 처리시 입력폼 색상 적용)

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" 
       class="form-control">
  •  th:classappend를  사용하여 특정 조건일 때 클래스 속성명을 추가할 수 있다.
  • 여기서는 errors 객체에 값이 있을 경우 해당하는 입력폼에 class 속성 명을 추가하여 입력폼 색상을 바꿨다.

 

 

🔍 BindingResult

  • 컨트롤러의 매핑 메서드에 BindingResult를 매개변수로 받음으로써 타입 불일치에 대한 대응도 가능하다.
  • BindingResult 파라미터는 무조건 뷰에게 전달할 객체의 다음번 째 파라미터에 위치해야한다.
  • BindingResult를 사용할 경우 클라이언트에서 타입이 잘못된 내용이 전송되더라도 BindingResult에서 그 내용을 가지고 있기에 @ModelAttibute에 타입 불일치로 매칭이 되지 않더라도 예외가 발생하지 않고 BindingResult에서 가지고 있다.
  • 그래서 400 오류가 발생하지 않는다.
  • BindingResult에 담긴 값은 자동으로 Model에 담겨진다.
  • BindingResult는 인터페이스다. Errors 인터페이스를 상속받고 있다.
  • 그래서 컨트롤러에 넘어오는 구현체는 BeanPropertyBindingResult 클래스다.
  • 주로 BindingResult를 사용한다.

 

예시

@PostMapping("/add")
myMethod(@ModelAttribute Item item, BindingResult bindingResult, ...) {
    ...
}
  •  BindingResult bindingResult  파라미터 위치는  @ModelAttribute Item item  바로 다음에 와야 한다.

 

💡 필드값 오류메시지 처리1 - FieldError 

public FieldError(String objectName, String field, String defaultMessage) {}
  • FieldError에는 생성자가 2개 존재한다. 위의 생성자는 클라이언트에서 에러가 발생하여 페이지로 다시 이동할 때 값을 다시 보여주지 못한다. 
  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담는다.
  • objectName
    •  @ModelAttirbute 이름
    • 뷰에게 전달할 객체 이름
  • field
    • 오류가 발생한 필드 이름
  • defaultMessage
    • 기본 오류 메시지

 

💡 글로벌 오류 메시지 처리 - ObjectError

public ObjectError(String objectName, String defaultMessage) {}
  • 특정 객체의 필드값이 아닌 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담는다. 
  • objectName
    •  @ModelAttirbute 이름
    • 뷰에게 전달할 객체 이름
  • defaultMessage
    • 기본 오류 메시지

 

💡  BindingResult 사용

📌 컨트롤러

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 검증 로직
    if(!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
    }

    if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
        bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
    }

    if(item.getQuantity()==null || 9999<=item.getQuantity()) {
        bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999까지 허용합니다."));
    }

    if(item.getPrice()!=null && item.getQuantity()!=null) {
        int resultPrice = item.getPrice() * item.getQuantity();

        if(resultPrice<10_000) {
            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}";
}

 

📌 컨트롤러 (필드 오류)

if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
  • 객체 필드값에 오류가 있으면 FieldError객체를 생성해서 bindingResult에 담아두면 된다.
  •  new FieldError("item", "itemName", "상품 이름은 필수입니다.") 
    • objectName : "item"
      •  @ModelAttibute  이름
      • 뷰에게 전달할 객체 이름
    • field : "itemName"
      • 오류가 발생한 필드 이름
      • 여기에 적힌 변수 명으로 타임리프에서  th:errors 를 통해 접근할 수 있다.
      • 예)  th:errors="*{itemName}" 
      • 예)  th:errors="${item.itemName}" 
    • defaultMessage : "상품 이름은 필수입니다."
      • 기본 오류 메시지

 

📌 컨트롤러 (글로벌 오류)

if(resultPrice<10_000) {
    bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
  • 특정 객체의 필드값이 아닌 오류가 있으면 ObjectError 객체를 생성해서 bindidngResult의 파라미터로 전달하면 된다.
  • new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice) 
    • ObjectName : "item"
      •  @ModelAttibute  이름
      • 뷰에게 전달할 객체 이름
    • defaultMessage : "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice
      • 기본 오류 메시지

 

📌 HTML (필드 오류 메시지)

<input type="text" id="itemName" th:field="${item.itemName}"
       class="form-control"
       placeholder="이름을 입력하세요"
       th:errorclass="field-error">
       
<div class="field-error" th:errors="${item.itemName}">
    상품명 오류
</div>
  •  th:errors 
    • 해당 필드에 오류가 있는 경우에 태그를 출력한다.
    •  th:if 의 편의 버전이다.
  •  th:errorclass 
    •  th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

📌 HTML (글로벌 오류 메시지)

<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error"
       th:each="err : ${fields.globalErrors()}"
       th:text="${err}">글로벌 오류 메시지</p>
</div>

 

  •  #fields 
    •  #fields 로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.

 

💡 필드값 오류메시지 처리2 - FieldError

public FieldError(String objectName,                  // 오류가 발생한 객체 이름 
                  String field,                       // 오류 필드
                  @Nullable Object rejectedValue,     // 사용자가 입력한 값 (거절된 값)
                  boolean bindingFailure,             //타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
                  @Nullable String[] codes,           // 메시지 코드
                  @Nullable Object[] arguments,       // 메시지에서 사용하는 인자
                  @Nullable String defaultMeesage)    // 기본 오류 메시지
  • 위의 생성자를 사용하면 오류 메시지를 더 자세히 작성할 수 있다.
  • 여기서 codes, arguments 파라미터는 메시지 파일에서 읽어오는 것이기에 파일을 생성해줘야한다.
  • rejectedValue 파라미터는 오류 발생시 사용자 입력값을 저장한다.

 

📌 컨트롤러

@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 검증 로직
    if(!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
    }

    if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
        bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
    }

    if(item.getQuantity()==null || 9999<=item.getQuantity()) {
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999까지 허용합니다."));
    }

    if(item.getPrice()!=null && item.getQuantity()!=null) {
        int resultPrice = item.getPrice() * item.getQuantity();

        if(resultPrice<10_000) {
            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}";
}
  • FieldError 객체는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
  • 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값 rejectedValue 파라미터에 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시 사용자의 오류 메시지를 정상 출력할 수 있다.

 

📌 HTML (필드 오류 메시지 처리)

<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
       class="form-control"
       placeholder="이름을 입력하세요"
       th:errorclass="field-error">
<div class="field-error" th:errors="${item.itemName}">
    상품명 오류
</div>
  •  th:field 는 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다. 

 

 


 

 

오류 코드와 메시지 처리 

🔍 방법 1

  • 오류 메시지도 메시지 기능을 사용하여 한 곳에서 관리할 수 있다.
  • FiiledError, ObjectError의 생성자는  errorCode, arguments를 제공한다.
  • 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

 

💡 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 : 메시지 코드
    • 배열로 여러 값을 전달할 수 있는데 순차적으로 매칭된다. 처음 매칭되는 메시지가 사용된다.
      • 첫 번째 인자 값이 없을 경우 두 번째 인자값을 매칭하고,,, 이런식으로 매칭이 될 때 까지 배열 인자를 매칭한다.
      • 만약에 끝까지 매칭되는 값이 없을 경우 defaultMessage 파라미터 인자 값을 출력한다.
    • errors.properties 파일에 적혀 있는 key 값을 적으면 된다.
  • arguments : 메시지에서 사용하는 인자
    • Object 배열을 사용하며 메세지의 파라미터에게 전달할 값을 {0}, {1} 이렇게 인덱스 값에 맞춰서 매핑하여 전달한다. 
  • defaultMessage : 기본 오류 메시지

 

사용 예

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

 

💡 오류 메시지 설정 파일 생성

📌 application.properties (스프링 부트 메시지 설정 추가)

spring.messages.basename=messages, errors

 

📌 errors.properties (src/main/resoucres/errors.peroperties)

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.

 

📌 컨트롤러

@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 검증 로직
    if(!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
    }

    if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
        bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));
    }

    if(item.getQuantity()==null || 9999<=item.getQuantity()) {
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[] {9_999}, null));
    }

    if(item.getPrice()!=null && item.getQuantity()!=null) {
        int resultPrice = item.getPrice() * item.getQuantity();

        if(resultPrice<10_000) {
            bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10_000 * resultPrice}, null));
        }
    }

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

 

 

🔍 방법2

  • FieldError, ObjectError 객체는 다루기 너무 번거롭다.
  • 오류 코드를 자동화 할 수 있도록 해보자.
  • 컨트롤러에서 BindingResult는 검증해야할 객체인 target 바로 다음 번째 파라미터로 위치한다.
  • 따라서 BindingResult는 이미 본인이 검증해야할 객체인 target에 대해 알고 있다.
  • 그 부분을 활용하면 된다.
  • BindingResult가 제공하는  rejectValue()  와  reject()  메서드를 사용하면 FieldError, ObjectError 객체를 직접 생성하지 않고 깔끔하게 검증 오류를 다룰 수 있다.

 

📌 컨트롤러

    @PostMapping("/add")
    public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

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

        // 검증 로직
        if(!StringUtils.hasText(item.getItemName())) {
            bindingResult.rejectValue("itemName", "required");
        }

        if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
            bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }

        if(item.getQuantity()==null || 9999<=item.getQuantity()) {
            bindingResult.rejectValue("quantity", "max", new Object[]{9_999}, null);
        }

        if(item.getPrice()!=null && item.getQuantity()!=null) {
            int resultPrice = item.getPrice() * item.getQuantity();

            if(resultPrice<10_000) {
                bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }

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

 

💡 필드값 오류 메시지 처리 -  rejectValue() 

void rejectValue(@Nullable String field,
                 String errorCode,
                 @Nullable Object[] errorArgs,
                 @Nullable String defaultMessage);
  • filed : 오류명
  • errorCode : 오류 코드
    • 이 오류 코드는 메시지에서 등록된 코드가 아니라 messageResolver를 위한 오류 코드다.
  • errorArgs : 오류 메시지에서 {0}, {1} 등을 치환하기 위환 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
  • field와 errorCode 파라미터를 가지고 errors.properties에서 메시지를 찾아낸다.
    • 스프링은 MessageCodeResolver를 통해서 찾아낸다.

사용 예

// FieldError 객체 사용
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));


// rejectValue() 메서드 사용
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
  •  errorCode.객체.field 
    • 예) range.item.price

 

💡 글로벌 오류 메시지 처리 -  reject() 

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

사용예

// ObjectError 객체 사용
 bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10_000 * resultPrice}, null));


// reject() 메서드 사용
bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);

 

 

🔍 오류 메시지 설계 방법

📌 errors.properties

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

#Level2
required: 필수 값 입니다

 

  • 오류 메시지를 Level1처럼 상세하게 작성할 수 있고 Level2처럼 범용성 좋게 간단하게 작성할 수도 있다.
  •  reject() 메서드,  rejectValue() 메서드는 오류 메시지 설청 파일에서 key값을 매핑할 때 구체적인 key값일수록 우선순위를 갖는다.

 

 

🔍  MessageCodesResolver

  • 스프링에서는 오류 메시지 코드 관리를 위해 MessageCodesResolver 인터페이스의 구현체인 DefaultMessageCodesResolver 클래스를 기본으로 사용한다.
  • MessageCodesResolver는 오류 메시지 코드를 생성한다.
    • 예) required.item.itemName, required.itemName, required
  •  rejectValue() 메서드와  reject() 메서드는 내부에서 MessageCodesResolver를 사용한다. 

 

📌 MessageCodeResovler 인터페이스의  resolveMessageCodes()  메서드

String[] resolveMessageCodes(String errorCode, String objectName);


String[] resolveMessageCodes(String errorCode,
        String objectName,
        String field,
        @Nullable Class<?> fieldType);

 

💡  DefaultMessageCodesResolver  클래스의 기본 메시지 생성 규칙

  • MessageCodesResolver를 통해서 우선순위가 높은 오류 코드부터 생성하여 보관한다.
  • 오류코드는 구체적일수록 우선순위가 높다. 
  • 타임리프는 렌더링 할 때  th:errors 가 실행된다. 이 때 오류가 있다면 생성된 오류 메시지 코드를 우선순위가 높은 순서대로 매핑한다. 만약에 매핑된 오류 코드가 없으면 defaultMessage를 출력한다.

 

✔ 객체 오류 코드 생성 규칙

객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code

예) 오류 코드: required, object name: item
1.: required.item
2.: required

 

✔ 필드 오류 코드 생성 규칙

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"

 

 

💡 오류 코드 검증 (MessageCodesResolver 사용)

package hello.itemservice.validation;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;

public class MessageCodeResolverTest {

    MessageCodesResolver codeResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodeResolverObject() {
        String[] messageCodes = codeResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    void messageCodeResolverField() {
        String[] messageCodes = codeResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        Assertions.assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
    
}

 

📌 객체 오류 코드 검증

@Test
void messageCodeResolverObject() {
    String[] messageCodes = codeResolver.resolveMessageCodes("required", "item");
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
    Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
}
  •  reject("totalPriceMin") 
  • 2가지 오류 코드를 자동으로 생성한다.
    • totalPriceMin.item
    • totalPriceMi

 

📌 필드 오류 코드 검증

@Test
void messageCodeResolverField() {
    String[] messageCodes = codeResolver.resolveMessageCodes("required", "item", "itemName", String.class);
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
    Assertions.assertThat(messageCodes).containsExactly(
            "required.item.itemName",
            "required.itemName",
            "required.java.lang.String",
            "required"
    );
}
  •  rejectValue("itemName", "required") 
  • 4가지 오류 코드를 자동으로 생성한다.
    • required.item.itemNam
    • required.itemName
    • required.java.lang.String
    • required

 

 


 

 

오류 코드 관리 전략

  • MessageCodeResolver는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다.
  • 크게 중요하지 않는 메시지는 범용성 있게 required 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
  • 이렇게 생성된 메시지 코드를 기반으로 우선순위가 높은 순서대로 오류 코드를 MessageSource에서 메시지를 찾는다.

 

사용 예)

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

#==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} 까지 허용합니다.
  • 레벨이 낮을수록 더 구체적이고 우선순위가 높다.

 

 

🔍 ValidationUtils

💡 ValidationUtils 사용 전

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

 

💡 ValidationUtils 사용 후

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
  • Empty, 공백 같은 단순한 기능을 제공한다.
  • ValidationUtils 사용 전 코드를 한 줄로 줄 일 수 있다.

 

 

🧷 정리

  •  rejectValue0 메서드 또는  reject()  호출한다.
  • MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성한다.
  •  new FieldError()  또는  new ObejctError()  객체를 생성하여 메시지 코드들을 보관한다.
  •  th:errors  에서 메시지 코드들로 메시지를 우선순위대로 메시지 설정 파일에서 찾고 출력한다.

 

 


 

 

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

  • 검증 오류 코드는 2가지로 나뉜다.
    • 개발자가 직접 설정한 오류 코드 
      •  rejectValue() 메서드를 직접 호출한다.
    • 스프링이 직접 검증 오류에 추가한 경우
      • 주로 타입 정보가 맞지 않을 때
      • 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver를 통해 4가지 메시지 코드가 생성된다.
      • 예)
      • typeMismatch.item.price
      • typeMismatch.price
      • typeMismatch.java.lang.Integer
      • typeMismatch

 

 


 

Validator 분리

  • 오류 검증하는 코드 부분을 분리하여 모듈화할 수 있다.
  • Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 기능을 사용할 수 있다.

 

📌 Validator 인터페이스 (오류를 검증하는데 스프링에서 지원하는 기능)

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  •  supports() {}  : 해당 검증기를 지원하는 여부 확인
  •  validate(Object target, Errors errors)  : 검증 대상 객체와 BindingResult 
    • target : 검증 부분
      • 예) item
    • errors :  검증 오류가 발생할 경우 오류 코드 보관. BindingResult의 부모 객체다.

 

 

🔍 Validator 사용

📌 Validator 모듈 (ItemValidator)

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;


@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) {
        Item item = (Item) target;

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

        if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }

        if(item.getQuantity()==null || 9999<=item.getQuantity()) {
            errors.rejectValue("quantity", "max", new Object[]{9_999}, null);
        }

        if(item.getPrice()!=null && item.getQuantity()!=null) {
            int resultPrice = item.getPrice() * item.getQuantity();

            if(resultPrice<10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}
  • 검증 부분 모듈화
  •  Item.class.isAssignableFrom(clazz)  : 해당 Validator 구현체는 Item 클래스에 대한 검증을 수행할 수 있음을 의미한다.

 

📌 컨트롤러

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

    private final ItemValidator itemValidator;

    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        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를 생성자 주입을 통해 의존관계를 형성한다.

 

 

🔍 Validator 분리 - 애노테이션

  • WebDataBinder 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능을 한다.
  •  @Validated  /  @Valid  둘다 사용 가능하다.
  • javax.validation.@Valid를 사용하려면 build.gradle 의존관계 추가가 필요하다.
  •  @Validated 는 스프링 전용 검증 애노테이션이이다.
  •  @Valid 는 자바 표준 검증 애노테이션이다.

 

📌 WebDataBinder 검증기

@Controller
@Slf4j
@RequestMapping("/validation/v2/items")
@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);
    }
    
}
  •  dataBinder.addValidators()  메서드를 사용해 검증기를 추가하면 해당 컨트롤러에서 검증기를 자동으로 적용된다.
  •  @initBinder 애노테이션은 해당 컨트롤러만 영향을 준다.

 

📌 컨트롤러

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 검증 실패하면 다시 입력 폼으로
    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  애노테이션이 붙었다.
  •  @Validated  애노테이션은 검증기를 실행하라는 애노테이션이다.
  • 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
  • 만약에 여러 검증기를 등록했다면, 그 중 어떤 검증기가 실행되어야할지 구분이 필요한데 이 때  supports()  메서드가 사용된다.
  •  supports(Item.class) 가 호출되고 결과가 true일 경우 ItemValidator의  validate() 메서드가 호출된다.

 

💡 검증기 글로벌 설정 - 모든 컨트롤러에 다 적용

package hello.itemservice;

import hello.itemservice.web.validation.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer{

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Override
	public Validator getValidator() {
		return new ItemValidator();
	}
}
  • 검증기를 글로벌 설정으로 하려면 @SpringBootApplication 설정 클래스에 WebMvcConfigurer 인터페이스를 구현받고  getValidator()  메서드를 호출해서 검증기를 리턴하면 된다.
  • 글로벌 검증기를 사용하면 컨트롤러에  @InitBinder  애노테이션을 제거해도 된다.
  • 글로벌 설정은 BeanValidator가 자동으로 등록되지 않는다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


👀 참교자료

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/community?page=2&type=question&limit=20 

 

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

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

www.inflearn.com

 

https://amagrammer91.tistory.com/37

 

[Spring Framework] StringUtils

spring-core.jar 에 org.springframework.util 패키지 밑에는 개발에 도움이 되는 여러 클래스들이 있다. 그 중에 StringUtils라는 추상클래스가 있는데 이 클래스는 String을 다루는데 편리한 기능들이 몇개 있..

amagrammer91.tistory.com

 

https://catsbi.oopy.io/f6bc86a1-d19d-4647-bd12-b2d1d7db1b4b

 

4. 검증1 - Validation

목차

catsbi.oopy.io

 

728x90

'[ Spring ] > SpringMVC 2편' 카테고리의 다른 글

[Spring] 로그인 처리1 - 쿠키, 세션  (0) 2022.03.12
[Spring] 검증2 - Bean Validation  (0) 2022.03.11
[Spring] 메시지, 국제화  (0) 2022.03.06
[Spring] 타임리프 - 스프링 통합과 폼  (0) 2022.03.05
[Spring] Thymeleaf 타임리프 - 기본 기능  (0) 2022.03.02
    '[ Spring ]/SpringMVC 2편' 카테고리의 다른 글
    • [Spring] 로그인 처리1 - 쿠키, 세션
    • [Spring] 검증2 - Bean Validation
    • [Spring] 메시지, 국제화
    • [Spring] 타임리프 - 스프링 통합과 폼
    쿠릉쿠릉 쾅쾅
    쿠릉쿠릉 쾅쾅
    깃허브 주소 : https://github.com/kureung

    티스토리툴바