관리 메뉴

나구리의 개발공부기록

검증1 - Validation, 오류 코드와 메시지 처리1 ~ 6, Validator 분리1 ~ 2 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

검증1 - Validation, 오류 코드와 메시지 처리1 ~ 6, Validator 분리1 ~ 2

소소한나구리 2024. 9. 4. 14:18

  출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/GMo43


1. 오류 코드와 메시지 처리1

1) 에러 메시지 파일 생성

  • 오류 메시지가 항목마다, 기능마다 모두 다르다면 규모가 큰 애플리케이션에서 수정시 매우 번거올 수 있으므로 일관성 있게 오류 메시지를 다루는 것이 좋음
  • FieldError와 ObjectError의 생성자는 codes, arguments를 제공하는데 이것은 오류 발생 시 오류 코드로 메시지를 찾기위해 사용되며 이것을 활용
  • 기존 messages.properties에 error.item 이런식으로 등록해도 되지만 오류 메시지를 구분하기 쉽게 별도의 파일로 관리하는것이 좋을 수 있음
  • 참고로 errors_en.properties 이런식으로 파일을 생성하면 오류 메시지도 국제화 처리가 가능함

(1) errors.properties 생성 (~/resources/)

  • required.item.itemName 처럼 이름이 규칙이 있어 보이는 것은 떡밥임
  • 에러코드.오브젝트명.필드명
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

(2) application.properties 추가

spring.messages.basename=messages, errors

2) 코드 수정

(1) ValidationItemControllerV2 - addItemV3() 추가

  • codes의 값은 String[]로 받음 -> 배열의 값을 순차적으로 적용 해서 계속 못찾으면 defaultMessage값이 출력됨
  • codes에도 없고 defaultMessage에도 없으면 당연히 에러페이지가 뜸(500에러)
  • arguments의 값은 Object[]로 받음 -> 작성된 코드의 {0}, {1}을 순서대로 치환 적용
// 오류 메시지 처리1
@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,
                        // codes의 값은 String 배열로 받음 -> 순차적으로 적용됨
                        new String[]{"required.item.itemName"}, null, null));
    }

    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.addError(
                new FieldError("item", "price", item.getPrice(), false,
                        // arguments값은 Object[]로 받음 -> 순서대로 적용
                        new String[] {"range.item.price"}, new Object[] {1000, 1000000},null));

    }

    if (item.getQuantity() == null || item.getQuantity() > 9999 || item.getQuantity() < 1) {
        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));
        }
    }
    // ... 이하 코드들
}

2. 오류 코드와 메시지 처리2

1) 오류 코드 자동화

(1) 로그 확인

  • 파라미터도 많고 뭔가 다루기가 번거로운 FieldError, ObjectError를 조금더 자동화 하기
  • 컨트롤러에서 BindingResult는 검증해야할 객체인 target 바로 다음에 위치함 -> BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있음!
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());

// 아무값이나 저장해서 보면 결과가 나온다
objectName=item
target=Item(id=null, itemName=상품, price=12345, quantity=1)

 

(2) rejectValue(), reject()

void rejectValue(@Nullable String field, String errorCode,
     @Nullable Object[] errorArgs, @Nullable String defaultMessage);
     
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String, defaultMessage);
  • BindingResult가 제공하는 rejectValue() - Field, reject() - Object를 사용하면 FieldError, ObjectError를 직접 생성하지 않고 검증 오류를 다룰 수 있음
  • field: 오류 필드명
  • errorCode: 오류 코드 (messages에 등록된 코드가 아닌 messageResolver를 위한 오류 코드 -> 뒤에서 설명)
  • errorArgs: 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

2) 코드 수정

(1) ValidationItemcontrollerV2 - addItemV4() 추가

  • rejectValue(), reject()에서는 오류코드를 range, required처럼 매우 간단하게 입력 했음에도 오류 메시지가 잘 출력됨
  • 이부분을 이해하려면 MessageCodesResolver를 이해해야 함
// 오류 메시지 처리2
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {

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

    // 필드에러는 rejectValue
    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 || item.getQuantity() < 1) {
        bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
    }

    // 오브젝트 에러는 reject
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }
	// ... 이하 코드   
}

3. 오류 코드와 메시지 처리3 - 오류코드의 설계

1) 오류 코드의 설계

(1) 설계

  • 오류 메시지는 자세히 만들수도 단순하게 만들수도 있음
  • 단순하게 만들면 범용성이 좋아서 여러 곳에서 사용할 수 있지만 메시지를 세밀하게 작성하기는 어려움
  • 자세히 만든 오류 메시지는 범용성이 떨어짐
  • 가장 좋은 방법은 범용성으로 사용하다가 세밀하게 작성해야 하는 경우 세밀한 내용이 적용하도록 단계를 두는 방법임
# 디테일한 메시지를 높은 우선순위로 사용
required.item.itemName: 상품 이름은 필수 입니다
...

# 범용적인 메시지를 낮은 우선순위로 사용
required: 필수 값 입니다.

 

(2) MessageCodesResolver

  • 앞서 에러를 출력하는 메서드의 기능이 new String[]로 {"required.item.itemName", "required"} 오류 코드를 배열로 받게 된 것처럼 우선순위가 적용 된다면 추가적인 수정 요청이 오더라도 코드는 고치지 않고 properties 메시지만 관리하면 문제가 쉽게 해결 됨 -> 이렇게 범용성 있게 개발하는 것이 개발을 잘하는 것임
  • 이러한 기능을 스프링이 지원하는데 그것이 바로 MessageCodesResolver임

4. 오류 코드와 메시지 처리4

1) 테스트 코드로 MessageCodesResolver를 알아보기

(1) MessageCodesResolverTest

  • test하위에 itemservice.validation 경로에 작성
  • MessageCodesResolver: 검증 오류 코드로 메시지코드를 생성
  • MessageCodesResolver는 인터페이스이며 DefaultMessageCodesResolver는 기본 구현체임
  • ObjectError, FieldError와 함께 사용
public class MessageCodesResolverTest {

    // MessageCodesResolver - 인터페이스
    // DefaultMessageCodesResolver - 구현체
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    // ObjectError 테스트
    @Test
    void messageCodesResolverObject() {
        // 에러코드명, 오브젝트명 입력
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
            // 출력 결과
            // messageCode = required.item  - 1순위
            // messageCode = required       - 2순위

            // 이렇게 우선순위가 디테일한 것부터 우선순위가 적용되는 것을 rejectValue(), reject()가 해주고 있는 것
        }
        // 검증
        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    // Field 테스트
    @Test
    void messageCodesResolverField() {
        // 필드명, 필드의 타입 추가
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);

        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
            // 출력 결과
            // messageCode = required.item.itemName     - 1순위
            // messageCode = required.itemName          - 2순위
            // messageCode = required.java.lang.String  - 3순위(에러코드명.타입)
            // messageCode = required                   - 4순위
        }
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required");
    }

}

 

(1) DefaultMessageCodesResolver의 기본 메시지 생성 규칙

오류 종류 순서
객체 오류 1 : code + "." + object name
2 : code

ex)
오류 코드 : required
object name : item

1 : required.item
2 : required
필드 오류 1 : code + "." + object name + "." + field
2 : code + "." + field
3 : code + "." + filed type
4 : code

ex)
오류 코드 : typeMismatch
object name : user
field : age
field type : int

1 : typeMismatch.user.age
2 : typeMismatch.age
3 : typeMismatch.int
4 : typeMismatch

 

(2) 동작 방식

  • rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용하여 메시지 코드들을 생성함
  • BindingResult의 로그를 통해서 확인해보면 MessageCodesResolver를 통해 생성된 순서대로 오류 코드를 보관하는 것을 확인할 수 있음
  • 타임리프 화면을 렌더링 할 때 th:errors가 실행되고 오류가 있다면 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾고 없으면 디폴트 메시지를 출력함


5. 오류 코드와 메시지 처리5

1) 오류 코드 관리 전략

  • 핵심은 구체적인 것에서 덜 구체적인 것으로 만드는 것 -> 정말 중요한 메시지 (구체적), 중요하지 않은 메시지(범용성있게)
  • 이렇게 복잡하게 사용하는 이유는 모든 오류 코드에 대해 메시지를 다 정의하면 개발자 입장에서 관리하기가 너무 어려움

2) 오류 코드 전략 도입해보기

(1) errors.properties의 기존 코드는 주석처리하고 새롭게 작성

  • Level1 ~ 4 단계를 순차적으로 주석을 적용하면서 테스트를 해보면 각 레벨단계의 오류 메시지가 출력됨
  • Level4 까지 전부 주석처리하면 디폴트 메시지를 사용함
  • 구체적인 것에서 덜 구체적인 순서대로 찾기 때문에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 됨
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

# == Object Error ==
# 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} 까지 허용합니다.

2) ValidationUtils

(1) 검증 유틸리티

  • if문으로 되어있던 검증로직을 1줄로 가능 - Empty, 공백 처리하는 if문을 간단하게 해주는 유틸리티
// ValidationUtils 사용 전 
if (!StringUtilshasText(item.getTimeName())) {
	bindingResult.rejectValue("itemName", "required");
}

// ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 


6. 오류 코드와 메시지 처리6

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

(1) 기본 메시지

  • price 필드에 문자를 입력해보면 스프링이 만든 에러 메시지가 출력되는데, 내가 직접 만들지 않아도 스프링은 타입오류가 발생하면 typeMismatch라는 오류코드를 사용해서 4가지 메시지 코드를 생성함
  • 이렇게 오류 메시지가 길게 나온것은 메시지 코드를 입력해준 것이 없기 때문에 스프링이 기본으로 제공하는 메시지가 출력된 것임
  • 해당 메시지는 스프링이 만든 것이기 때문에 컨트롤러 코드를 수정할 수 없는데 생성된 메시지 코드를 보고 직접 properties에 등록해주면 원하는 메시지로 등록할 수 있음
  • log에 찍힌 메시지 코드를 보면 기존에 배웠던 형식과 똑같이 생성된 것을 확인 할 수 있음
typeMismatch.item.price,
typeMismatch.price,
typeMismatch.java.lang.Integer,
typeMismatch

 

(2) 스프링이 제공하는 기본 메시지를 직접 지정

  • errors.properties에 스프링이 만들어낸 메시지 코드를 입력해서 적용하면 내가 지정한 오류 메시지가 출력됨
  • 적용 순서는 배웠던 내용과 동일함
  • 보통 bindingResult 에러처리는 컨트롤러 코드 앞에서 처리해 오류가 발생한 즉시 반환되도록 하긴하지만, 개발자가 판단에 따라서 오류를 2개 다 보여줄지, 1개만 보여줄지, if문으로 처리할지 정하면 됨
# 타입 에러 추가
typeMismatch.java.lang.Integer = 숫자를 입력해주세요.
typeMismatch = 타입 오류 입니다.

7. Validator 분리1

1) Validator 분리

(1) 검증 로직 분리

  • 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크기때문에 별도의 클래스로 역할을 분리하는 것이 좋음
  • 분리한 검증로직은 재사용 할 수도 있음
  • 잘 재사용하진 않지만 비슷한 검증 로직이 있다면 가능함

(2) ItemValidator 클래스 생성

  • 스프링은 검증을 체계적으로 제공하기위해 Validator 인터페이스를 제공함
  • supports(): 해당 검증기를 지원하는 여부
  • validate(Object target, Errors errors): 검증 대상 객체와 BindingResult
// 스프링이 지원하는 Validator를 구현해서 사용
@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> aclass) {
        return Item.class.isAssignableFrom(aclass);
        // isAssignableFrom(): 자식 클래스까지 커버가 됨
        // item == aclass
        // 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 || item.getQuantity() < 1) {
            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);
            }
        }

    }
}

 

(3) ValidationItemControllerV2 - addItemV5() 추가

  • ItemValidator를 Component로 스프링 빈에 등록해서 Autowired로 불러와서 직접 호출
  • Validator 인터페이스를 사용하지 않고 new ItemValidator()로 객체를 생성하여 사용해도 되지만 이렇게 스프링 기능을 사용하는 이유가 있음 -> Bean Validation의 강의에서 나옴
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    // 생성자는 하나고 파라미터가 2개인 상태 -> Autowired로 주입
    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    // ... 기존 코드 생략

    // 검증로직을 ItemValidator라는 클래스로 분리
    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {

        // 기존 검증로직은 모두 지우고 ItemValidator 클래스를 불러오기
        // validate(target, errors)
        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}";
    }

    // ... 기존 코드 생략
}

8. Validator 분리2

1) WebDataBinder를 통해서 사용하기

(1) 스프링의 추가적인 도움 받기

  • Validator 인터페이스를 사용하면 스프링의 추가적인 도움을 받을 수 있음
  • WebDataBinder는 스프링의 파라미터 바인딩 역할을 해주고 검증 기능도 내부에 포함됨

(2) ValidationItemControllerV2 - init() 추가

  • WebDataBinder에 검증기를 추가하면 해당 컨트롤러(클래스)에서는 검증기를 자동으로 적용할 수 있음
  • @InitBinder는 해당 컨트롤러에만 영향을 주며 전체 컨트롤러에 적용하는 설정은 별도로 해야함
public class ValidationItemControllerV2 {

    // 생성자는 하나고 파라미터가 2개인 상태 -> Autowired로 주입
    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;
    
    @InitBinder
    public void init(WebDataBinder dataBinder) {
        log.info("init binder {}", dataBinder);
        dataBinder.addValidators(itemValidator);
    }
    //...
}

 

(3) ValidationItemControllerV2 - addItemV6()

  • 검증 대상 앞에 @Validated만 넣어주고 기존에 ItemValidator를 불러오는 코드는 삭제하면 끝
  • addItemV5와 동일하게 동작됨
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {
                        
    // itemValidator.validate(item, bindingResult); 삭제

    // ... 기존 코드 유지
}

 

(4) @Validated 동작 방식

  • 검증기를 실행하라는 애노테이션
  • 해당 애노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아 실행
  • 여러 검증기가 등록되었을 때 사용되는 것이 ItemValidator에서 오버라이딩하여 구현한 supports()임
  • 각 검증기에 등록된 클래스 타입이 호출되고 결과가 true이면 각 검증기의 validate()가 호출되는 것

(5) 글로벌 설정 - 모든 컨트롤러에 다 적용

  • 해당 프로젝트의 애플리케이션에 WebMvcConfigurer를 구현
  • getValidator()를 오버라이딩하면 프로젝트의 전체 컨트롤러에 @Validated 애노테이션을 넣어서 검증을 사용할 수 있음
  • 글로벌 설정을 하면 이후에 설명하는 BeanValidator가 자동으로 등록되지 않으니 BeanValidator강의를 진행할 때는 글로벌 설정 부분은 모두 주석처리 후 진행해야함
  • 또한 글로벌 설정을 직접 사용하는 경우는 드묾
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
}
	@Override
	public Validator getValidator() {
		return new ItemValidator();
	}
}

 

** 참고

  • 검증시 @Valid도 사용가능한데 build.gradle 의존관계 추가가 필요함
  • implementation 'org.springframework.boot:spring-boot-starter-validation'

  • @Validated는 스프링 전용 검증 애노테이션이고 @Valid는 자바 표준 검증 애노테이션임