스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 : BeanValidation

jkky98·2024년 8월 2일
0

Spring

목록 보기
18/77

짜증, 부담, 복잡

Validator 사용으로 컨트롤러 메서드에서의 코드 복잡도는 내려갔다. 대신 우리는 컨트롤러 클래스의 새로운 메서드로

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

를 추가해야했고 Validator Class를 직접 구성해야 했다. 귀찮음의 본질은 결국 다음의 검증로직이다.

// 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
            // rejectValue -> 필드에러 생성 해줌

        }
        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) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

결국 위의 코드는 우리가 작성해야 할 부분이고 Validator를 구성하여 책임 분리를 통해 컨트롤러 코드부분은 깔끔해졌지만 위의 검증로직 코드를 작성해야한다는 사실은 변하지 않는다.

스프링은 이러한 절차(Validator를 적고 Validator를 받을 새로운 객체를 들여오고...)를 줄이기 위해 애노테이션 기반의 기술로 이러한 복잡성 문제를 다시 해결해준다.

개인적으로 이번 강의에서 가장 마음의 안도가 왔던 부분이다. 이전의 V1~V6까지 거치면서도 100레벨의 복잡성을 버전을 거처가며 5씩 다운되는 느낌이었다면 애노테이션 기반의 해결책을 보며 50레벨이 한번에 다운되는 느낌이 들었다. 속으로 "와"를 외친 부분이었다. 처음부터 이 애노테이션 기술을 알았다면 이런 효용성과 경외감을 느끼지는 못했을 것 같다.(찔끔찔끔의 개선과 여전히 남아 느껴지는 복잡성이 주는 고통을 겪어야 느낄 수 있다는 말) 그래서 무슨 기술인데? 바로 들어가보자.

BeanValidation

검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

Bean Validation 사용을 위해 build.gradle의 다음을 추가한다. 그리고 Item객체를 다음과 같이 수정한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'
public class Item {
	private Long id;
 
	@NotBlank // 빈값 안댐
	private String itemName;
 
	@NotNull // null 안댐
	@Range(min = 1000, max = 1000000) // 숫자 값 범위 설정
	private Integer price;
    
	@NotNull // null 안댐
	@Max(9999) // 최대 9999까지
	private Integer quantity;
    //...
}

이전의 과정을 거쳤다면 위의 코드를 보자마자 검증이 어떻게 될 지 이해가 되고 눈에 잘 보인다. 아마 이런 기술을 지원해주길 무의식적으로 기대하고있던 것 같다. 필드에 검증 애노테이션을 붙여 검증을 진행하는 것이다(추상화).

(이런 부분에서 django Model의 필드객체와 닮아있는 것 같다.)

검증 애노테이션을 어떻게 컨트롤러가 인식하고 검증을 진행하는 지 컨트롤러 코드를 보자.

@PostMapping("/add")
    public String addItem2(@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()) {
            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}";
    }

컨트롤러 메서드에서 달라진 것이 없다. 오히려 @InitBinder부분(Validator 부르는 메서드)이 컨트롤러 클래스에서 사라졌다. 검증기를 불러오는 로직이 사라지고(스프링이 알아서 해주니까 지워짐) 오직 @Validated만을 통해 객체에 접근하여 검증내용을 보고 검증을 진행해준다.

객체의 필드에 검증 애노테이션을 달아놓아 자동적으로 글로벌 Validator인 Validator(LocalValidatorFactoryBean)가 스프링 빈의 ValidatorFactory에 구축되고 검증시 여기서 Validator를 꺼내와서, Validator의 validate메서드로 검증을 진행한다. 에러 발생시 에러내용을 Set<ConstraintViolation<Item>>에 넣어 반환해준다. 이를 bindingResult에서 활용한다고 보면 될 것 같다.

그렇기에 우리는 이전의 템플릿을 수정할 필요도 없고 컨트롤러에 추가해야할 작업도 없다.

  • 여기서 주의해야할 점은 직접 글로벌 Validator를 등록할 경우 스프링 부트는 Bean Validator를 글로벌로 등록하지 않으므로 애노테이션 기반 빈 검증기가 동작하지 않는다

BeanValidation - 에러 코드

예시

@NotBlank

  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank
    @Range
  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range

이전에 배운 내용처럼 레벨에 따라 에러코드가 생성되므로 이를 활용해서 직접 메세지를 등록하면 된다. 등록하지 않아도 기본으로 동작하는 디폴트 메세지가 에러 메세지로 반환된다.

Object 오류 처리

BeanValidation에서 애노테이션을 모두 필드에 붙이고 있다. 그렇다면 Object관련 오류(여러 필드를 조합한 검증 룰)같은 것은 어떻게 처리해야 할까

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

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000") 를 이용하여 검증을 수행하기도 했지만 springboot3 이상에서는 지원되지 않는다.(애초에 2시절에도 기능이 너무 작아 외면 받은 기술이다. - 사장된 기술)

  • 결론적으로는 Object오류 수준은 개발자가 직접 작성하는 것을 권장한다.(이정도는 니들이 해라 느낌)
@PostMapping("/add")
    public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        // 특정 필드가 아닌 복합 룰 검증
        // Object 에러
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

BeanValidation 한계

Item 객체에 검증 애노테이션을 붙이는 것은 굉장히 편리해보이나 더 깊이 생각해보면 한계점이 존재한다.

만약 등록(addItem)과 수정(editItem)에서의 검증기준이 다르다면?

만약 등록시에는 Max(9999) 검증을 적용하지만 수정시에는 어떤 검증도 하고싶지 않다면?

groups

위의 문제를 해결하기 위해 BeanValidation은 groups라는 기술을 지원한다. Item을 직접 사용하지 않고 등록용 인터페이스, 수정용 인터페이스를 만든다.

@Data
public class Item {
//    @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;

//    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
//    @Max(value = 9999, groups = {SaveCheck.class})
    private Integer quantity;

그리고 애노테이션 인자에 groups에 상황에 맞는 클래스를 전달해준다.

@PostMapping("/add")
    public String addItem2(@Validated(SaveCheck.class) @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);
            }
        }

그리고 컨트롤러에도 어떤 인터페이스가 적용되어야 할 지를 명시해준다.

이렇게 되니 item에서 마치 if문을 처리하듯 복잡도가 올라간다.
실제로는 groups보다는 다음의 방법을 더욱 많이 사용한다(실무 레벨)

Form 전송 객체 분리

groups는 논리적으로 조건문이 된다. 하나의 Item객체를 최대한 조건로직으로 잘 수정을 가해서 활용하는 것과 같다.

하지만 실제로는 Item객체 이상의 데이터들이 들어올 수 있기도 하고 여러 복잡한 경우가 생길 수 있다. 이럴 경우마다 모든 조건에 이를 반영하는 등의 작업은 매우 번거로울 것으로 예상된다. 그렇기에 하나의 상황(add, edit)에 대한 객체를 하나씩 만들어 놓는 것이 더 확장성 있고 덜 복잡한 선택이 될 것이다.

(django에서 폼 객체 하나만 써서 여러 view에서 조건문 로직을 통해 이용하는 것이 아니라 폼 객체 여러개를 만드는 것이 편하다고 배운 것과 일맥상통한 것 같다.)

객체지향적으로 생각해도 상황마다 별도의 객체를 이용하는 것이 더욱 확장성 있고 유지보수하기 좋은 선택이다.

// ItemSaveForm
@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

    @Max(value = 9999)
    @NotNull
    private Integer quantity;
}
===============================================
// ItemEditForm
@Data
public class ItemEditForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //수정에서 수량은 자유롭게
    private Integer quantity;
}

edit 컨트롤러 메서드에서는 ItemEditForm을 사용하고, add 컨트롤러 메서드에서는 ItemSaveForm을 사용하는 것이다.

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

@ModelAttribute에 "item" 이름으로 바인딩 한 이유는 객체 타입인 ItemEditForm으로부터 디폴트로 "itemEditForm"으로 key가 잡히기 때문에 템플릿을 수정하지 않기 위해 추가한 것이다.

비슷하게 addItem도 잘 수정해서 반영하면 문제없이 웹 서버가 동작함을 확인할 수 있다.

API 컨트롤러 검증

form - 쿼리 파라미터가 아닌 메세지 바디로 json 형식으로 전달될 경우도 우리의 코드로 커버가 "일부 상황을 제외하고" 가능하다.

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

실험을 위해 간단한 RestController를 제작한다. 실험 상황은 3 가지이다.

  1. 성공 흐름
  2. 타입 바인딩 자체가 실패할 경우(객체 구성에 실패하여, 검증 로직 수행안될 경우)
  3. 객체 구성에 성공했으나 검증 로직에서 탈락할 경우

PostMan을 통해 진행한다.

요청 url : http://localhost:8080/validation/api/items/add
요청 method : Post

메시지 바디(raw - json) : 
1. {"itemName":"hello", "price":"1000", "quantity": "9999"}
2. {"itemName":"hello", "price":"QQQ", "quantity": "9999"}
3. {"itemName":"hello", "price":"1000", "quantity": "10000"}

결과 :
1. {
    "itemName": "hello",
    "price": 1000,
    "quantity": 9999
}
2. {
    "timestamp": "2024-08-02T01:34:08.394+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/validation/api/items/add"
}
3. [
    {
        "codes": [
            "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.quantity",
                    "quantity"
                ],
                "arguments": null,
                "defaultMessage": "quantity",
                "code": "quantity"
            },
            9999
        ],
        "defaultMessage": "9999 이하여야 합니다",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 10000,
        "bindingFailure": false,
        "code": "Max"
    }
]

1, 3번은 콘솔에서 문제없이 "성공 로직 실행"을 출력했으나 2번 경우에 대해서 API 컨트롤러 자체가 호출이 되지 않았다.

폼을 통해 쿼리 파라미터로 전송 시 bindingResult가 타입 바인딩 에러도 처리해주던 것과 달리 API에서는 불가능했다.

이는 메세지 컨버터가 @ModelAttribute와 달리 JSON 데이터를 객체로 변화 시키기 위한 과정이 한번에 이루어지기 때문이다.

@ModelAttribute vs @RequestBody

HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드
에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.

위에서 발생한 400에러의 예외의 경우 API 예외처리 챕터에서 다시 다루도록 한다.

profile
자바집사의 거북이 수련법

0개의 댓글