🌿 스프링/스프링 MVC 2편

Validation

le2donguk 2026. 2. 14. 19:02

개요

우리가 흔히 사용하는 @Validated , BindingResult는 처음부터 존재했던 게 아니라 점진적으로 발전된 결과물이다.

그 과정을 따라가 보려고 한다 


V1 - 가장 원시적인 검증 방식 (Map 사용)

 @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 || item.getPrice() > 1000000) {
            errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용 합니다");
        }

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

        //특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                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}";
    }

 

V1에서는 검증 오류를 이렇게 직접 관리했다 

Map<String, String> errors = new HashMap<>();

 

 

Error 맵을 만들고 검증 실패가 나면  해당 맵에 에러  메시지와 어떤 필드에서 검증 실패가 났는지 기록했다

errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용 합니다");

 

그리고 다시 뷰로 보낼 때 model에 이 ErrorMap을 담아서 뷰에서 검증 결과를 보여줬다

model.addAttribute("errors", errors);
return "validation/v1/addForm";

 

 

이 방식의 문제점 

1. 검증 로직 자체가 실행이 안될 수 있다 

예를 들어 사용자가 price = abc를 입력하면 Item.price는 Integer 이기 때문에 Data Binding 단계에서 TypeDismatch 예외가 발생한다

 

실제 요청 흐름을 따라가 보면 

HTTP 요청
 ↓
DataBinding (문자 → 객체 변환)
 ↓
타입 변환 실패 ❌
 ↓
컨트롤러 호출 안됨 ❌
 ↓
400 에러 페이지

 

즉 우리가 만든 검증 로직 자체가 실행되지 않는다.

 

2. 사용자 입력값이 사라짐

사용자가 어떤 입력값을 입력해서 검증이 실패했는지 알 수가 없다 price = "qqq"를 입력했지만 예외가 발생하고 사용자가 "qqq"를 입력했다는 그런 입력 값이 사라져 사용자는 뭐가 문제인지 알 수 없게 된다 

 

참고
@ModelAttribute는 필드 중 하나가 바인딩이 실패해도 BindingResult랑 같이 쓰면 Binding실패를 허용해서 Controller까지 호출된다
반면에 @ResponseBody 나 @RestController는 필드중 하나라도 바인딩이 실패하면 예외가 발생한다 

V2 - BindingResult 도입

이러한 분제를 해결하기 위해 등장한 게 바로 BindingResult

 

핵심 변화

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

 

 

기존에 BindingResult가 없을 때는 

HTTP 요청
 ↓
DataBinding 실패 ❌
 ↓
컨트롤러 호출 안됨

 

이러한 문제가 있었지만 이제 BindingResult가 등장함으로 다음과 같이 흐름이 변하게 된다

HTTP 요청
 ↓
DataBinding 실패
 ↓
FieldError 객체 생성
 ↓
BindingResult 저장
 ↓
컨트롤러 호출됨 ✅

 

코드로 변화된 부분을 살펴보자면 에러를 처리하는 부분이 다음과 같이 객체를 생성하는 것으로 바뀌게 된다

 

기존

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

 

첫 번째 변화

FieldError객체와 와 BindingResult의 도입 

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

 

두 번째 변화

이후 사용자가 어떤 오류값을 입력해서 검증이 실패했는지 나타내는 reject value를 포함한 여러 기능이 추가되어 다음과 같이 바뀐다

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

 

FieldError의 생성자 파라미터의 의미는 입력하는 순서대로 다음과 같다

  • 어떤 Target 객체를 검증? → "item"
  • 그 객체의 어떤 필드? → "itemName"
  • 어떤 입력 잘못된 입력값을 입력했나? (reject value)  → "item.getName()"
  • 바인딩에 실패해서 발생한 오류인가? (bindingFailure) → false
  • Code(Message code) → null
  • Argument(Message Argument) → null
  • default Message → "상품 이름은 필수입니다 "

 


V2-3 메시지 코드 도입

여기선 이전 버전과 달라진 게 Message를 보여주는 부분이 하드코딩 된 부분에서 이전에 학습한 MessageSource로 바뀌었다

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

 

이전 부분에서 다음과 같은 부분이 변경되었다 

  • 어떤 Target 객체를 검증? → "item"
  • 그 객체의 어떤 필드? → "itemName"
  • 어떤 입력 잘못된 입력값을 입력했나? (reject value)  → "item.getName()"
  • 바인딩에 실패해서 발생한 오류인가? (bindingFailure) → false
  • Code(Message code) → null → new String []{"required.item.itemName"}
  • Argument(Message Argument) → null new Object []{"1,000원", "1,000,000원"}
  • default Message → "상품 이름은 필수입니다 "

 


V2-4 rejectValue()

bindResult.addError(new FieldError("~"~~,,,.....) 너무 길기 때문에 축약 버전이 등장하기 시작했다.

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

 

이제 필드 Error

bindingResult.rejectValue

 

Global Error는

bindingResult.reject

 

를 사용해서 간편하게 사용된다

 

여기서 주의할 점은 인자값으로 필드와 , MessageSource의 코드 값을 받는다

여기선 필드는 itemName , MessageSource의 코드는 required이다 

 

스프링은 해당 메시지 소스 코드를 총다음과 같은 방식으로 만들고 해당 메시지 소스 코드가 존재하지 않으면 

defaultMessage를 출력한다 

 

스프링은 메세지 파일에서 이 순서대로 메시지가 존재하는지 찾는다 여기서는 코드값을 required를 줬으므로 다음과 같이 메시지를 찾기 시작한다

required.item.itemName
required.itemName
required.java.lang.String
required

 

참고
이렇게 메시지를 관리하고 메세지를 찾고 하는 일은 MessageCodesResolver가 담당한다

 


V2-5 Validator 인터페이스 도입

사실 많이 편해졌어도 여전히 문제가 남아있다. 컨트롤러마다 하나하나 에러를 등록해줘야 하고 필드가 많아지고 , 컨트롤러가 많아지면 그 수만큼 같은 코드를 중복 작성해야 한다

또 컨트롤러는 컨트롤러의 역할에만 집중하고 검증 기는 검증의 역할에 집중해야 한다 즉 검증의 역할을 컨트롤러에서 분리해야 하는 문제도 여전히 존재한다

 

그래서 등장하게 된 게 Validator 인터페이스다 

@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // 파라미터로 넘어오는 clazz가 Item class를 지원 하냐
        // item == clazz
        // 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[]{"1,000원", "1,000,000원"}, null);
        }

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

        if (item.getQuantity() != null && item.getPrice() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            errors.reject("totalPriceMin",new Object[]{10000 , resultPrice},null);
        }

    }
}

 

 

Validator 인터페이스를 구현해서 

  • supports 메서드에 어떤 class를 검증할 건지 구현하고
  • validate 메서드에 검증 로직을 구현한다

이렇게 따로 분리를 하고 필요한 컨트롤러에서 가져다 쓰면 된다 

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

        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}";
    }

 

처음 코드와 비교했을 때 엄청나게 편리해졌다 하지만 개발자들은  더 더 편리하고 싶어 해서 더 발전한다

 


V2-6 WebDataBinder + @Validated

사실 Validator를 분리했지 그냥 클래스를 만들고 호출하는 것도 개발자의 몫이다 

위에 코드에서 

itemValidator.validate (item,bindingResult)

 

이렇게 간단해졌지만 여전히 저 똑같은 코드를 검증이 필요한 곳곳에 똑같이 호출해서 써야 한다 

그래서 @Validated 어노테이션이 등장하게 된다 

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


        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
//            log.info("errors={}", bindingResult);
            bindingResult.getAllErrors().forEach(e -> {
                log.info("error = {}", e);
            });
            return "validation/v2/addForm";
        }

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

 

이제는 호출도 필요 없이 검증을 원하는 Model 앞에@Validated를 붙이기만 하면 스프링이 알아서 Validator 인터페이스의 구현채를 돌면서 supports를 호출하면서 적절한 Validator를 찾아서 우리가 짠 검증 로직을 실행시켜준다 

 

이게 인터페이스에 의존, 유연한 확장, 다형성 을 따르는 스프링의 힘이다 

 

참고
이전에 BindingResult를 인자로 할 때 항상 Model 뒤에 선언했다 그 이유는 그렇게 해야 BidningResult가 Model을 검증할 객체로 인식하기 때문이다 그래서 여기서도 @Validated의 위치는 @ModelAttribute 앞에 있어야 한다 

 


V3 - Bean Validation + Group

Bean Validation

이제는 우리가 검증 코드를 짤 필요도 없다 어노테이션이 등장했다  이렇게 어노테이션으로 검증 규약을 하는 스펙..? 표준..? 을 BeanValidation이라고 한다 

이전에 Validator를 통해서 우리의 검증 로직을 만들었는데 이제 그럴 필요도 없어지고 다음과 같이 사용하면 된다

@Data
public class ItemSaveForm {
    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(9999)
    private Integer quantity;

}

 

어노테이션은 그냥 별 역할을 안 한다 어노테이션 말 그대로 주석이다 

이 주석이 붙어있으면 어떻게 동작하세요~~ 를 나타내는 그냥 구분자? 역할이라고 생각하면 된다

실제 내부 동작 과정은 위에 계속 설명한  과정을 통해서 이뤄진다

 

다시 한번 말하지만 어노테이션은 사용자(개발자)가 편하라고 만들어진 것이고 내부의 동작과정은 위에 설명한 대로 동작한다 

 


또 또.. 또.. 다른 문제 등장 

등록할 때의 validation 기준과 수정할 때의 validation 기준이 다르다 그니깐 Validation 기준이 서로 충돌할 수 있다 

 

예를 들어 

  • 등록 시에는 quantity 수량이 최대 9999까지 등록할 수 있지만 수정 시에는 수량을 무제한으로 변경할 수 있다
  • 등록 시에는 id에 값이 없어도 되지만 수정 시에는 id값이 필수

그래서 등장한 게 

 

Bean Validation Group

@Validated(SaveCheck.class)
@Validated(UpdateCheck.class)

 

@NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {SaveCheck.class,UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class,UpdateCheck.class})
    @Range(min = 1000, max = 1000000 , groups = {SaveCheck.class,UpdateCheck.class})
    private Integer price;

 

그룹을 지정해서 해당 그룹마다 다른 Validation기준을 가지게 한다 

그런데 사실 너무 복잡하다.. 그래서 잘 안 씀

그리고 Groups를 실제로 잘 이용하지 않는데 그 이유가 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다

 


(최종 버전 ) V4 - Form 객체 분리 

별건 아니고 여태까지 하나의 DTO를 써서 나타난 문제다

그래서

ItemSaveForm
ItemUpdateForm

 

이렇게 개별 DTO로 분리하고 각자의 맞는 Validaiton을적용하자