본문 바로가기
Spring MVC

[MVC] 검증2 - Bean Validation(1)

by 개발 Blog 2025. 7. 13.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

Bean Validation - 시작

Bean Validation 기능을 스프링 없이 순수하게 사용하는 방법을 테스트 코드로 확인해 본다.

 

1. 의존관계 추가

build.gradle에 다음 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

이 의존성을 추가하면 다음과 같은 라이브러리가 포함된다.

  • jakarta.validation-api : Bean Validation 인터페이스
  • hibernate-validator : 실제 구현체

2. 도메인 객체에 검증 애노테이션 적용

@Data
public class Item {
  private Long id;

  @NotBlank
  private String itemName;

  @NotNull
  @Range(min = 1000, max = 1000000)
  private Integer price;

  @NotNull
  @Max(9999)
  private Integer quantity;

  public Item() {}
  public Item(String itemName, Integer price, Integer quantity) {
    this.itemName = itemName;
    this.price = price;
    this.quantity = quantity;
  }
}

 

3. 사용된 검증 애노테이션

  • @NotBlank : 빈 문자열이나 공백만 있는 값 불가
  • @NotNull : null 불가
  • @Range(min, max) : 지정한 범위 내의 숫자만 허용 (Hibernate 전용)
  • @Max(값) : 최대값 제한

참고

  • javax.validation 으로 시작하는 애노테이션은 Bean Validation 표준 인터페이스
  • org.hibernate.validator는 Hibernate 구현체에서 제공하는 애노테이션
    실무에서는 Hibernate Validator를 많이 사용하므로 자유롭게 사용해도 된다.

4. Bean Validation 테스트 코드

public class BeanValidationTest {
  @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>> violations = validator.validate(item);

    for (ConstraintViolation<Item> violation : violations) {
      System.out.println("violation=" + violation);
      System.out.println("violation.message=" + violation.getMessage());
    }
  }
}

 

5. 검증 동작 방식

1. ValidatorFactory를 통해 검증기 생성

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

2. validate() 호출로 검증 실행

Set<ConstraintViolation<Item>> violations = validator.validate(item);

3. 결과는 ConstraintViolation 객체의 집합으로 반환되며, 각 항목에는 오류 메시지와 필드 정보 등이 포함됨

 

6. 실행 결과 예시

violation.message=공백일 수 없습니다
violation.message=9999 이하여야 합니다
violation.message=1000에서 1000000 사이여야 합니다

각 오류는 어떤 필드에서 어떤 문제인지 메시지로 나타난다.

 

Bean Validation - 스프링 적용

스프링 MVC에 Bean Validation을 통합하여, 검증 애노테이션 기반의 자동 검증 기능을 사용하는 방법을 다룬다.

컨트롤러 수정: ValidationItemControllerV3

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {
  if (bindingResult.hasErrors()) {
    log.info("errors={}", bindingResult);
    return "validation/v3/addForm";
  }

  Item savedItem = itemRepository.save(item);
  redirectAttributes.addAttribute("itemId", savedItem.getId());
  redirectAttributes.addAttribute("status", true);
  return "redirect:/validation/v3/items/{itemId}";
}
  • @Validated 애노테이션만 붙이면 스프링이 자동으로 Bean Validator를 실행한다.
  • 기존의 addItemV1() ~ addItemV5() 제거
  • 기존의 addItemV6() → addItem()으로 변경

불필요한 코드 제거

기존의 수동 Validator 적용 로직 제거

private final ItemValidator itemValidator;

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

이 코드를 그대로 둘 경우, ItemValidator와 BeanValidator가 중복 적용되므로 반드시 제거해야 한다.

 

스프링의 Bean Validation 적용 방식

  • spring-boot-starter-validation 의존성을 등록하면, 스프링 부트는 자동으로 Bean Validator를 통합한다.
  • LocalValidatorFactoryBean이 글로벌 Validator로 자동 등록됨
  • 컨트롤러 메서드에서 @Validated, @Valid만 붙이면 자동으로 검증 수행

글로벌 Validator 수동 등록 주의

다음과 같이 직접 글로벌 Validator를 수동 등록하면, 스프링 부트의 자동 Bean Validator 등록이 비활성화된다.

@Override
public Validator getValidator() {
  return new ItemValidator(); // 이 코드 제거할 것
}

이런 경우 @Validated, @Valid가 동작하지 않으므로 절대 등록하지 말 것

 

@Valid vs @Validated

  • @Validated : 스프링 전용, groups 기능 지원
  • @Valid : 자바 표준, 단순 검증
  • 둘 다 사용 가능하며 기능상 큰 차이는 없음

검증 순서

  1. @ModelAttribute의 각 필드에 타입 변환 시도
  2. 타입 변환 성공 시 → Bean Validation 적용
  3. 타입 변환 실패 시 → FieldError 추가, Bean Validation 미적용

예시

  • itemName에 "A" 입력 → 문자열 타입 변환 성공 → Bean Validation 적용
  • price에 "A" 입력 → 숫자 타입 변환 실패 → typeMismatch 오류만 발생, Bean Validation 미적용

Bean Validation - 에러 코드

Bean Validation을 적용하면 검증 실패 시 오류 메시지가 자동으로 생성되고, 해당 메시지는 BindingResult에 등록된다. 이 메시지는 애노테이션 이름을 기반으로 오류 코드가 구성된다.

 

오류 코드 생성 방식 예시

MessageCodesResolver가 다음과 같이 다양한 형태의 메시지 코드를 자동 생성하여 순차적으로 메시지를 찾는다.

@NotBlank 적용 시

NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

@Range 적용 시

Range.item.price
Range.price
Range.java.lang.Integer
Range

 

메시지 커스터마이징

기본 메시지를 커스터마이징 하려면 errors.properties 또는 messages.properties 파일에 메시지를 등록한다.

예시: errors.properties

# Bean Validation 커스텀 메시지
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
  • {0} : 필드명
  • {1}, {2} 등은 애노테이션마다 의미가 다름 (예: @Range(min, max) → {1}=max, {2}=min)

메시지 적용 우선순위

1. messageSource에 등록된 메시지 코드 순서대로 탐색

2. 애노테이션에 명시한 message 속성 사용

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

3. 라이브러리 기본 메시지 사용

  • 예: 공백일 수 없습니다.

정리

  • 오류 코드는 애노테이션 이름 기반으로 자동 생성된다.
  • errors.properties에 메시지를 등록하면 원하는 메시지로 덮어쓸 수 있다.
  • 메시지 탐색은 코드 우선 → 애노테이션 직접 지정 → 기본 메시지 순으로 이루어진다.
  • 필드명, 최대값, 최소값 등을 메시지 포맷에 활용할 수 있다.

Bean Validation - 오브젝트 오류

오브젝트 오류(ObjectError)란?

  • 특정 필드에 국한되지 않고, 객체 전체에 대한 제약 조건 위반을 나타낼 때 사용
  • 예: 가격 × 수량 >= 10,000처럼 필드 간의 조합을 검증해야 할 때

방법 1: @ScriptAssert 사용 (권장하지 않음)

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
  // ...
}

메시지 코드

  • ScriptAssert.item
  • ScriptAssert

단점

  • 복잡하고 제약이 많음
  • 유지보수 어려움
  • 필드 간의 유연한 조건 처리에 적합하지 않음

방법 2: 자바 코드에서 직접 ObjectError 추가 (권장)

@ScriptAssert 대신 컨트롤러에서 직접 글로벌 오류를 추가한다.

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

  // 객체 전체에 대한 조건 검증
  if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
      bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
    }
  }

  if (bindingResult.hasErrors()) {
    return "validation/v3/addForm";
  }

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

 

메시지 등록 예 (errors.properties)

totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

정리

  • 오브젝트 오류는 필드 단위로 처리할 수 없는 복합 조건에 사용
  • @ScriptAssert는 사용성이 떨어지고 유지보수 어려움
  • 실무에서는 BindingResult.reject()를 통해 글로벌 오류를 코드로 직접 추가하는 방식이 일반적
  • 메시지 코드 등록을 통해 사용자 친화적인 오류 메시지 제공 가능

Bean Validation - 수정에 적용

1. 컨트롤러 코드 수정

ValidationItemControllerV3의 edit() 메서드에 @Validated를 추가하고, 글로벌 오류 처리도 적용한다.

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
                   @Validated @ModelAttribute Item item,
                   BindingResult bindingResult) {

  // 가격 * 수량 >= 10000 검증
  if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
      bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
    }
  }

  if (bindingResult.hasErrors()) {
    return "validation/v3/editForm";
  }

  itemRepository.update(itemId, item);
  return "redirect:/validation/v3/items/{itemId}";
}
  • @Validated를 추가하여 필드 검증 활성화
  • 글로벌 검증 로직도 등록과 동일하게 적용

2. 템플릿 수정: editForm.html

변경 사항 요약

  • 글로벌 오류 메시지 영역 추가
  • 각 필드에 th:errorclass="field-error" 속성 적용
  • .field-error 스타일 정의
  • 오류 메시지를 th:errors, th:each 등을 통해 출력
<div th:if="${#fields.hasGlobalErrors()}">
  <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">
    글로벌 오류 메시지
  </p>
</div>

 

각 필드 예시 (상품명)

<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>

 

3. 메시지 등록 예 (errors.properties)

totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}