스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 공부하고 정리하는 포스트입니다.


Bean Validation

Bean Validation 이란?

  • 특정한 구현체가 아닌 Bean Validation 2.0(JSR-380)이라는 기술 표준
    • 검증 애노테이션과 여러 인터페이스의 모음
    • 마치 표준 기술인 JPA가 있고 그 구현체로 Hibernate가 있는 것과 같은 논리
  • Bean Validation을 구현한 기술 중 일반적으로 사용하는 구현체 → Hibernate Validation
    • 이름이 Hibernate일 뿐이지 ORM과는 무관
  • 검증 기능을 매번 코드로 작성하는 것은 번거로운 일, 특히 특정 필드에 대한 검증 로직은 대부분 같은 내용이 반복됨
    → 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 Bean Validation

📌 검증 애노테이션

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null을 허용하지 않는다.
  • @Range(min, max) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용

Bean Validation - 시작

  • build.gradle에 의존관계 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'

Bean Validation을 직접 사용하는 방법

@Test
void beanValidation() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    
    Item item = new Item();
    item.setItemName(" ");
    item.setPrice(0);
    item.setQuantity(10000);
    
    Set<ConstraintViolation<Item>> validate = validator.validate(item);
    for (ConstraintViolation<Item> violation : validate) {
        System.out.println("violation = " + violation);
        System.out.println("violation.getMessage() = " + violation.getMessage());
    }
}
  • 임의로 검증기를 생성하고, 검증 대상을 넣어 결과를 받는 테스트코드 → Bean Validation을 직접 사용하는 방법
    → 스프링에 이미 통합되어 있음. 따라서, 이렇게 할 필요 없음

스프링 MVC는 어떻게 Bean Validator를 사용하는가?

  • spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

  • LocalValidatorFactoryBean을 글로벌 Validator로 등록 → @NotNull 같은 애노테이션을 보고 검증을 수행
    • 글로벌 Validator가 적용되어 있기 때문에 @Valid, @Validated만 적용하면 됨
    • 검증 오류 발생 시 FieldError, ObjectError를 생성해서 BindingResult에 담아줌
  • 다음과 같이 직접 글로벌 Validator를 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않음 → 애노테이션 기반의 빈 검증기가 동작하지 않으므로 다음 부분은 제거
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
    // 글로벌 검증기 추가
    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }

    //...
}

📌 참고

@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다. 둘 중 아무거나 사용해도 동일하게 작동하지만, @Validated는 내부에 groups라는 기능을 포함하고 있다.


필드 검증

  • Controller 계층에 다음과 같은 코드를 추가
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
                      BindingResult bindingResult,
                      RedirectAttributes redirectAttributes,
                      Model model) {

    //...

}
  • 검증 순서
    1. @ModelAttribute 각각의 필드에 타입 변환 시도
      • 성공 시 다음으로
      • 실패 시 typeMissmatchFieldError 추가
    2. Validator 적용
  • 바인딩에 성공한 필드만 Bean Validation을 적용
    • @ModelAttribute → 각각의 필드에 타입 변환 시도 → 변환에 성공한 필드만 Bean Validation 적용

Bean Validation - 에러 코드

  • Bean Validation을 사용하면 message.properties를 설정하거나 작성하지 않아도 기본 메시지가 출력됨
    → 해당 라이브러리에서 지정한 기본 메시지
    → 임의로 변경하려면?

  • Bean Validation 적용 후 bindingResult에 등록된 검증 오류를 보면 오류 코드가 애노테이션 이름으로 등록됨
    typeMissmatch와 유사

  • NotBlank라는 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성
  • @NotBlank
    • NotBlank.item.itemName
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • NotBlank
  • @Range
    • Range.item.price
    • Range.price
    • Range.java.lang.Integer
    • Range
  • 메시지 코드에 직접 등록하면 적용됨
NotBlank={0} 공백은 유효하지 않습니다.
Range={0}, {2}~{1}만 허용됩니다.
Max={0}, 최대{1}까지만 허용됩니다.
  • {0}은 필드명, {1}, {2}, … 은 각 애노테이션마다 다름 → 일반적으로 arguments

  • 다음과 같이 직접 애노테이션에 message 속성으로 지정하는 것도 가능

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

Bean Validation 메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 → @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 → “공백일 수 없습니다”.

Bean Validation - 오브젝트 오류

특정 필드가 아닌 해당 오브젝트 관련 오류(ObjectError)

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
    //...
}
  • @ScriptAssert()를 사용
    • ScriptAssert.itemScriptAssert 순으로 메시지 코드 생성
    • 실제로 사용해보면 제약이 많고 복잡함. 검증 기능이 객체의 범위를 넘어서는 경우에 대응이 어려움.
  • 따라서, 오브젝트 오류(글로벌 오류)의 경우 관련 부분만 직접 자바 코드로 작성하는 것이 더 좋은 방법
    • 코드 중복이 걱정되면 메소드를 분리하도록 하자
  • 요약
    • 간단한 필드 검증에는 Bean Validation을 이용하여 검증 애노테이션을 활용하자
    • 복잡한 객체 검증에는 제약이 많은 애노테이션보다 직접 코드로 구현하자
      → 코드의 재사용성이 높다면 모듈화를 진행하자

Bean Validation의 한계

  • 예를 들어, 데이터를 등록(POST)할 때와 수정(Fetch, PUT)할 때는 요구사항이 다름 → 변경된 제약조건은 기존에 작성된 Entity에 적용이 불가능
    • 등록 시에는 최대 갯수 제한이 있지만, 수정 시에는 수량을 무제한으로 변경 가능
    • 등록 시에는 id에 값이 없어도 되지만, 수정 시에는 id 값이 필수
  • 수정에 맞춰 id 필드에 @NotNull 검증 애노테이션을 붙이고 수량 필드에 @Max 애노테이션을 지우면
    → 수정은 동작하지만, 등록 시에 오류가 발생

따라서, 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다. 이 문제를 어떻게 해결해야하는가?

Bean Validation - groups를 사용해 검증 분리

  • 이러한 문제를 해결하기위해 Bean Validation은 groups라는 기능을 제공
    • 수행할 기능을 그룹으로 나누어 적용
    • 하지만 이 방법은 복잡도가 상승하기 때문에 실제로 잘 사용하지 않음

Form 전송 객체 분리를 이용한 검증 분리

  • groups 방식은 등록 시 폼에서 전달하는 데이터가 도메인 객체와 정확하게 일치하지 않음
    • 실무에서는 해당 정보와 관계없는 수 많은 부가 데이터도 함께 넘어옴
  • 보통 해당 객체를 직접 전달받는 것이 아닌, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달함
    → 예를 들어, XxxSaveForm이라는 폼을 전달받는 전용 객체를 만들어 @ModelAttribute로 사용
    → 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해 Xxx를 생성

  • 폼 데이터 전달에 Xxx 도메인 객체 사용: HTML Form → Xxx → Controller → Xxx → Repository
    • 장점: Xxx 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 중간에 Xxx을 만드는 과정이 없어서 간단.
    • 단점: 간단한 경우에만 적용 가능. 수정 시 검증이 중복될 수 있고, groups를 사용해야 함.
  • 폼 데이터 전달을 위한 별도의 객체 사용: HTML Form → XxxSaveForm → Controller → Xxx 생성 → Repository
    • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달받을 수 있음.
      보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않음.
    • 단점: 폼 데이터를 기반으로 컨트롤러에서 Xxx 객체를 생성하는 변환 과정이 추가됨.

Bean Validation - HTTP 메시지 컨버터

  • Form 방식을 이용한 검증에서, @Valid, @ValidatedHttpMessageConverter(@RequestBody)에도 적용 가능

  • API의 경우 3가지 경우를 나누어 생각해야함
    • 성공 요청 : 성공
    • 실패 요청 : JSON 객체를 생성하는 것 자체가 실패함
    • 검증 오류 요청 : JSON 객체로 생성하는 것은 성공했고, 검증에서 실패함
  • 잘못된 값을 전달하여 실패하게 만들어보자.
    → 검증 실패 시 HttpMessageConverter에서 요청 JSON을 객체로 생성하는데 실패
    → 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생. 또한 Validator도 실행되지 않음.

@ModelAttribute vs @RequestBody

  • HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용됨
  • 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있음

  • HttpMessageConverter@ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용
    → 따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid, @Validated가 적용됨.

  • @ModelAttribute
    • 필드 단위로 정교하게 바인딩이 적용.
    • 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있음.
  • @RequestBody
    • HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생함.
    • 컨트롤러도 호출되지 않고, Validator도 적용할 수 없음.

📌 참고

HttpMessageConverter 단계에서 실패하면 예외가 발생