스프링 MVC 2편 (4), 검증 1 - Validation

2025. 12. 21. 16:48Spring RoadMap/Spring MVC

백엔드 웹 개발 핵심 기술, 스프링 MVC - 검증(Validation)

웹 애플리케이션에서 검증(Validation)은 타협할 수 없는 부분이다. 클라이언트(Front-end)에서 유효성 검사를 하더라도, 보안과 데이터 무결성을 위해 서버(Back-end)에서의 검증은 필수다.

이번 글에서는 스프링 MVC가 제공하는 검증 메커니즘이 어떻게 발전해왔는지, 그 내부 원리(BindingResult, FieldError, MessageCodesResolver)를 코드로 상세히 뜯어본다.

1. 검증 요구사항

 

  1. 사용자가 상품 등록 폼에서 정상 범위의 데이터 입력
  2. 서버에서 검증 로직 통과하고 상품 저장
  3. 상품 상세 화면으로 리다이렉트

  1. 사용자가 검증 범위를 넘어서는 데이터 입력
  2. 서버 검증 로직 실패
  3. 사용자에게 상품 등록 폼을 다시 보여주고 어떤 값을 잘못 입력했는지 알림

우리가 만들 상품 관리 시스템에 다음과 같은 검증 로직을 추가한다고 가정하자.

  • 타입 검증: 가격, 수량에 문자가 들어가면 검증 오류 처리.
  • 필드 검증:
    • 상품명: 필수, 공백 X.
    • 가격: 1,000원 이상 ~ 1,000,000원 이하.
    • 수량: 최대 9,999개.
  • 글로벌 검증(복합 룰): 가격 * 수량의 합은 10,000원 이상이어야 함.

2. V1: 단순히 Map을 사용한 검증 (무식한 방법)

가장 먼저 떠오르는 방법은 검증 오류가 발생할 때마다 Map에 오류 정보를 담아 뷰로 넘기는 것이다.

ValidationItemControllerV1

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

    // 1. 검증 오류 결과를 보관할 객체 생성
    Map<String, String> errors = new HashMap<>();

    // 2. 필드 검증 로직
    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 까지 허용합니다.");
    }

    // 3. 특정 필드가 아닌 복합 룰 검증 (글로벌 오류)
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
        }
    }

    // 4. 검증 실패 시 다시 입력 폼으로 이동
    if (!errors.isEmpty()) {
        log.info("errors = {}", errors);
        model.addAttribute("errors", errors);
        return "validation/v1/addForm";
    }

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

 

글로벌 오류 메시지

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

 

필드 오류 처리

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">

 

직접 처리의 문제점

  1. 타입 오류 처리 불가: Item 객체의 price는 Integer다. 만약 사용자가 "A"를 입력하면 컨트롤러에 진입하기도 전에 400 Bad Request 예외가 터져버린다. 즉, 우리가 만든 errors 맵 로직은 실행조차 되지 않는다.
  2. 값 유지의 번거로움: 오류가 발생해서 폼으로 돌아갔을 때, 사용자가 입력했던 데이터가 다 날아간다(물론 @ModelAttribute가 어느 정도 해주지만 타입 오류 시에는 불가능하다).
  3. 뷰 템플릿 복잡도 증가: 뷰에서 Map을 직접 꺼내서 처리해야 하므로 코드가 지저분해진다.

3. V2: BindingResult 사용 (스프링의 해결사)

 

스프링은 검증 오류를 보관하는 전용 객체인 BindingResult를 제공한다.

중요한 규칙: BindingResult 파라미터는 반드시 검증할 객체(@ModelAttribute) 바로 뒤에 와야 한다.

 

ValidationItemControllerV2

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

    // 1. 필드 검증
    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 까지 허용합니다."));
    }

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

    // 3. 검증 실패 로직
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        // model.addAttribute("errors", errors); // BindingResult는 자동으로 뷰에 넘어간다. 생략 가능.
        return "validation/v2/addForm";
    }

    // 성공 로직 생략...
}

BindingResult의 특징

  • 타입 오류 방어: @ModelAttribute 바인딩 시 타입 오류가 발생해도 컨트롤러가 호출된다! 스프링이 오류 정보를 BindingResult에 담아서 컨트롤러를 정상적으로 실행시켜 준다.
  • FieldError: 필드에 오류가 있을 때 사용. new FieldError(객체명, 필드명, 메시지)
  • ObjectError: 특정 필드가 아닌 글로벌 오류일 때 사용. new ObjectError(객체명, 메시지)

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

  • @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 포함
  • 개발자가 직접 적용
  • Validator 사용

4. V3: 사용자 입력 값 유지 (FieldError의 2번째 생성자)

 

V2의 문제점은 오류가 발생했을 때 사용자가 입력했던 잘못된 값(예: 가격에 'qqq')이 사라진다는 점이다. 이를 해결하기 위해 FieldError의 두 번째 생성자를 사용한다.

 

ValidationItemControllerV2 - addItemV2

if (!StringUtils.hasText(item.getItemName())) {
    // rejectedValue: 사용자가 입력한 거절된 값
    // bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 로직 실패인지 구분 (여기선 검증 로직 실패니 false)
    bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}

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

 

FieldError 생성자 파라미터 상세

  • objectName: 오류가 발생한 객체 이름
  • field: 오류 필드
  • rejectedValue: 사용자가 입력한 값 (거절된 값) -> 이것을 넣어줘야 뷰에서 다시 보여줄 수 있다.
  • bindingFailure: 타입 오류면 true, 검증 오류면 false
  • codes: 메시지 코드 (뒤에서 설명)
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지

스프링의 바인딩이 실패(타입 오류)하면, 스프링은 내부적으로 이 생성자를 이용해 BindingResult에 사용자의 입력 값을 담아둔다. 그래서 우리가 타임리프의 th:field를 쓰면 오류가 나도 값이 유지되는 것이다.


5. V4: 오류 메시지의 체계적 관리 (MessageCodesResolver)

 

하드코딩된 오류 메시지("상품 이름은 필수입니다")를 소스코드에서 제거하고, errors.properties 파일로 관리해보자.

 

application.properties

spring.messages.basename=messages,errors

 

errors.properties

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

 

ValidationItemControllerV2 - addItemV3 (rejectValue 사용)

 

FieldError를 직접 생성하는 것은 너무 번거롭다(생성자 파라미터가 너무 많다). BindingResult는 이미 본인이 검증해야 할 객체(item)를 알고 있다. 따라서 rejectValue() 메서드를 사용하면 코드가 획기적으로 줄어든다.

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

    // 1. 필드 검증 - rejectValue() 사용
    if (!StringUtils.hasText(item.getItemName())) {
        // field, errorCode, errorArgs, defaultMessage
        bindingResult.rejectValue("itemName", "required", null, null);
    }
    
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
    }
    
    // ... 나머지 생략 ...

    // 2. 글로벌 오류 - 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);
        }
    }

    if (bindingResult.hasErrors()) {
        return "validation/v2/addForm";
    }
    // ... 성공 로직 ...
}

핵심 원리: MessageCodesResolver bindingResult.rejectValue("itemName", "required")를 호출하면 내부적으로 MessageCodesResolver가 동작하여 다음과 같은 순서로 메시지 코드를 생성한다. (구체적인 것에서 덜 구체적인 순서로)

  1. required.item.itemName (코드 + 객체명 + 필드명)
  2. required.itemName (코드 + 필드명)
  3. required.java.lang.String (코드 + 타입)
  4. required (코드)

스프링은 이 순서대로 errors.properties를 뒤져서 메시지를 찾는다. 개발자는 범용적인 메시지(required)를 정의해두고, 특별히 중요한 필드만 구체적인 메시지(required.item.itemName)를 정의하면 된다.


6. V5: 검증 로직의 분리 (Validator 인터페이스)

컨트롤러에 검증 로직이 가득 차 있어서, 컨트롤러의 본분인 "요청을 받아 응답을 준다"는 역할이 흐려지고 있다. 검증 로직을 별도 클래스로 분리하자.

 

ItemValidator

@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        // Item 클래스와 그 자식 클래스를 검증할 수 있는지 확인
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;

        // 기존 컨트롤러에 있던 검증 로직을 그대로 복사해옴
        // BindingResult는 Errors의 자식이므로 그대로 사용 가능
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        
        // ... (나머지 로직 동일) ...
    }
}

 

ValidationItemControllerV2 - addItemV5 (Validator 적용)

@Controller
@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()) {
            return "validation/v2/addForm";
        }

        // 성공 로직...
    }
}

이제 컨트롤러는 검증 로직이 어떻게 돌아가는지 알 필요가 없다. "검증해줘"라고 던지고, "결과가 어때?"하고 확인만 하면 된다.


7. V6: 검증 자동화 (@Validated)

검증기를 매번 itemValidator.validate()로 직접 호출하는 것도 귀찮다. 스프링은 애노테이션 하나로 이를 자동화해준다.

 

WebDataBinder 설정 (컨트롤러 내)

@InitBinder
public void init(WebDataBinder dataBinder) {
    // 이 컨트롤러로 요청이 올 때마다 검증기를 자동으로 적용
    dataBinder.addValidators(itemValidator);
}

 

ValidationItemControllerV2 - addItemV6 (최종)

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

    // 코드 한 줄 없이 검증 끝!
    // @Validated가 있으면 앞서 등록한 itemValidator.supports()를 체크하고,
    // 맞으면 validate()를 실행해서 bindingResult에 오류를 담아준다.

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

    // 성공 로직...
    return "redirect:/validation/v2/items/{itemId}";
}
  • @Validated: "이 객체를 검증하라"는 뜻이다. (@Valid도 사용 가능하다. @Valid는 자바 표준, @Validated는 스프링 전용이며 그룹 검증 기능이 추가된다.)
  • 이 애노테이션이 붙으면 WebDataBinder에 등록된 검증기를 찾아서 실행한다. 여러 검증기가 등록되어 있다면 supports() 메서드로 어떤 검증기를 쓸지 결정한다.

정리

 

우리는 검증 로직을 다음과 같은 단계로 발전시켰다.

  1. Map 사용: 타입 변환 오류 처리가 안 되고 코드가 복잡함.
  2. BindingResult: 스프링이 제공하는 검증 오류 보관소. 타입 오류도 안전하게 처리.
  3. FieldError: 사용자가 입력한 거절된 값을 저장하여 화면에 다시 보여줌.
  4. rejectValue + MessageCodesResolver: 복잡한 생성자 대신 에러 코드(required)만으로 메시지를 체계적으로 관리.
  5. Validator 분리: 컨트롤러에서 복잡한 검증 로직을 제거.
  6. @Validated: 애노테이션 하나로 검증 실행을 자동화.

이 과정을 통해 컨트롤러 코드는 매우 깔끔해졌고, 검증 로직은 재사용 가능하며 체계적인 구조를 갖추게 되었다. 다음 섹션에서는 검증 로직을 더 간편하게 만드는 Bean Validation(애노테이션 기반 검증)에 대해 알아본다.