라즈베리파이반

라즈베리파이 등 컴퓨터계열 게시판입니다.

제목스프링 레거시로 게시판 만들기 (9) : Bean Validation2022-10-31 02:50
작성자user icon Level 4

88x31.png


1. Validator


지난번 글에서는 컨트롤러에서 BindingResult 객체를 통해 검증하고 오류가 있으면 예외를 던져 메시지 출력 처리를 했습니다. 하지만 조금더 객체지향적인 프로그래밍을 하기 위해서는 컨트롤러에서 검증 로직을 분리하여 별도의 클래스로 관리하는 것이 코드를 유지보수하기에 좋을 수 있습니다.


스프링에서는 검증 로직을 위한 Validator 검증기 인터페이스를 지원합니다. validator 패키지 폴더를 생성하고 그 아래에 검증기 클래스를 작성하도록 하겠습니다. 검증기를 빈으로 등록하기 위해 우선 어플리케이션 컨텍스트에 validator 폴더를 스캔할 수 있도록 component-scan을 추가합니다.


/src/main/webapp/WEB-INF/spring/root-context.xml

...

<context:component-scan base-package="kro.rubisco.validator"></context:component-scan>

...


validator 패키지 아래에 ProductValidator 클래스를 작성해주세요.


/kro/rubisco/validator/ProductValidator.java

package kro.rubisco.validator;


import org.springframework.stereotype.Component;

import org.springframework.validation.Errors;

import org.springframework.validation.Validator;


import kro.rubisco.dto.ProductDTO;


@Component

public class ProductValidator implements Validator {


    @Override

    public boolean supports(Class<?> clazz) {

        return ProductDTO.class.isAssignableFrom(clazz);

    }


    @Override

    public void validate(Object target, Errors errors) {

        

        ProductDTO product = (ProductDTO) target;

        

        if(product.getCount() == null || product.getCount() < 10 || product.getCount() > 100){

            errors.rejectValue("count", "range", new Object[] {10L, 100L}, null);

        }

        

        if(product.getPrice() == null || product.getPrice() < 100 || product.getPrice() > 10_000_000){

            errors.rejectValue("price", "range", new Object[] {100L, 10_000_000L}, null);

        }

        

        if(product.getCount() != null && product.getPrice() != null) {

            

            Long total = product.getCount() * product.getPrice();

            

            if(total > 100_000_000) {

                errors.reject("totalPriceMax", new Object[] {100_000_000L, total}, null);

            }

        }

    }

}


@Component 어노테이션을 통해 컨테이너에 빈으로 등록하고, Validator 인터페이스를 상속받아 supports 메소드와 validate 메소드를 구현해야합니다.


supports는 해당 검증기가 검증하려는 객체를 지원하는지 확인하기 위한 메소드이고, validate는 검증을 수행하는 메소드 입니다.


supports에는 매개변수로 클래스가 주입되므로, 검증 객체의 타입인 ProductDTO와 동일한 클래스인지 확인하면 됩니다.


validate에는 컨트롤러에서 작성한 검증 로직을 그대로 가져오면 됩니다. 참고로 ErrorsBindingResult의 상위 인터페이스입니다.


이제 컨트롤러를 다음과 같이 수정해주세요.


/kro/rubisco/controller/TestController.java

package kro.rubisco.controller;


import java.util.Locale;


import org.springframework.context.MessageSource;

import org.springframework.stereotype.Controller;

import org.springframework.validation.BindException;

import org.springframework.validation.BindingResult;

import org.springframework.validation.annotation.Validated;

import org.springframework.web.bind.WebDataBinder;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.InitBinder;

import org.springframework.web.bind.annotation.ModelAttribute;

import org.springframework.web.bind.annotation.PostMapping;

import org.springframework.web.bind.annotation.RequestMapping;


import kro.rubisco.config.BindExceptionWithViewName;

import kro.rubisco.dto.ProductDTO;

import kro.rubisco.validator.ProductValidator;

import lombok.RequiredArgsConstructor;


@Controller

@RequiredArgsConstructor

@RequestMapping("/test")

public class TestController {

    

    private final MessageSource messageSource;

    private final ProductValidator productValidator;

    

    @InitBinder

    public void init(WebDataBinder dataBinder) {

        dataBinder.addValidators(productValidator);

    }


    @GetMapping()

    public void getTestView() {}

    

    @PostMapping()

    public String getTestView(

        @Validated @ModelAttribute("product") ProductDTO product, 

        BindingResult bindingResult, 

        Locale locale

    ) throws BindException {


        if(bindingResult.hasErrors()) {

            throw new BindExceptionWithViewName(bindingResult, "/test", messageSource, locale);

        }


        return "redirect:/test";

    }

}


ProductValidator를 생성자로 주입하고, @InitBinder 어노테이션을 통해 WebDataBinder를 초기화 해줍니다. WebDataBinder는 사용자의 요청을 객체에 바인딩 해주는 역할을 하며, 검증 기능도 내부에 포함되어 있습니다. addValidators를 통해 주입받은 ProductValidator를 추가합니다.


getTestView 메소드에는 ProductDTO 앞에 @Validated 어노테이션이 추가되었습니다. 해당 어노테이션이 추가되면 webDateBinder에 등록한 검증기를 찾아서 validate 메소드를 호출합니다. 이때 어떤 검증기가 호출될지는 supports 메소드를 통해 확인됩니다.


ProductDTO.class를 매개변수로 주고, true가 리턴되는 검증기에서 validate 를 호출합니다. 이때 ProductDTOBindingResult가 주입되어 검증을 수행합니다. 즉, 컨트롤러에서 수행한 검증 로직을 검증기에서 하게 된 것입니다.


컨트롤러에서는 BindingResult에 오류가 있는지 확인 후 오류가 있다면 예외를 던져줍니다.



2. Bean Validation


이번에는 Bean Validation에 대해 알아보겠습니다. Bean Validation은 특정 구현체가 아닌 Bean VAlidation 2.0(JSR-380)이라는 검증에 대한 표준을 정의한 인터페이스 모음입니다. 해당 인터페이스를 구현한 대표적인 구현체로 hibernate-validator가 있습니다.


Bean Validation을 사용하면 어노테이션을 통해 간단하게 검증을 수행할 수 있습니다. 우선 pom.xml에 maven repository를 참고하여 사용률이 높은 의존성을 추가해주도록 하겠습니다. 스프링 버전도 높이겠습니다.


pom.xml

...

    <properties>

        <java-version>1.8</java-version>

        <org.springframework-version>5.3.23</org.springframework-version>

        <org.aspectj-version>1.6.10</org.aspectj-version>

        <org.slf4j-version>1.6.6</org.slf4j-version>

    </properties>

...

    <dependencies>

    ...

        <!-- https://mvnrepository.com/artifact/javax.validation/validation-api -->

        <dependency>

            <groupId>javax.validation</groupId>

            <artifactId>validation-api</artifactId>

            <version>2.0.1.Final</version>

        </dependency>

        

        <!-- https://mvnrepository.com/artifact/org.hibernate.validator/hibernate-validator -->

        <dependency>

            <groupId>org.hibernate.validator</groupId>

            <artifactId>hibernate-validator</artifactId>

            <version>6.2.0.Final</version>

        </dependency>

    ...

    </dependencies>


1) 에러 코드 

 어노테이션 

 내용

 @AssertTrue

 @AssertFalse

 값이 true 또는 false인지 검사

 @DecimalMax

 @DecimalMin

 지정값보다 크거나 같은지 또는 작거나 같은지 검사

 @Max

 @Min

 지정값보다 크거나 같은지 또는 작거나 같은지 검사

 @Digits

 자리수 검사

 @Size

 배열이나 컬렉션의 크기가 범위내에 있는지 검사

 @range

 범위 내에 값이 존재하는지 검사

 @Null

 @NotNull

 null인지 또는 null이 아닌지 검사

 @Positive

 @PositiveOrZero

 양수인지 검사

 @Negative

 @NegativeOrZero

 음수인지 검사

 @Email

 이메일 형식에 맞는지 검사

 @Future

 @FutureOrPresent

 미래의 시간인지 검사

 @Past

 @PastOrPresent

 과거의 시간인지 검사

 @Pattern

 정규식 검사


ProductDTO를 수정하겠습니다.


/kro/rubisco/dto/ProductDTO.java

package kro.rubisco.dto;


import javax.validation.constraints.NotNull;


import org.hibernate.validator.constraints.Range;


import lombok.Data;


@Data

public class ProductDTO {

    

    @NotNull @Range(min = 10, max = 100)

    private Long count;

    

    @NotNull @Range(min = 100, max = 10_000_000)

    private Long price;

}


확인을 위해 error.properties 파일에 메시지를 지우고 컨트롤러를 다음과 같이 수정합니다.


/kro/rubisco/controller/TestController.java

package kro.rubisco.controller;


import java.util.Locale;


import org.springframework.context.MessageSource;

import org.springframework.stereotype.Controller;

import org.springframework.validation.BindException;

import org.springframework.validation.BindingResult;

import org.springframework.validation.annotation.Validated;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.ModelAttribute;

import org.springframework.web.bind.annotation.PostMapping;

import org.springframework.web.bind.annotation.RequestMapping;


import kro.rubisco.config.BindExceptionWithViewName;

import kro.rubisco.dto.ProductDTO;

import lombok.RequiredArgsConstructor;


@Controller

@RequiredArgsConstructor

@RequestMapping("/test")

public class TestController {

    

    private final MessageSource messageSource;


    @GetMapping()

    public void getTestView() {}

    

    @PostMapping()

    public String getTestView(

        @Validated @ModelAttribute("product") ProductDTO product, 

        BindingResult bindingResult, 

        Locale locale

    ) throws BindException {


        if(bindingResult.hasErrors()) {

            throw new BindExceptionWithViewName(bindingResult, "/test", messageSource, locale);

        }


        return "redirect:/test";

    }

}


검증기를 지웠습니다. 유효성 검사를 위해서는 ProductDTO 앞에 @Validated 또는 @Valid 어노테이션을 붙이면 ProductDTO의 어노테이션을 기준으로 검증이 수행됩니다.


@Validated와 @Valid는 기능이 동일하며, @Valid는 자바 표준, @Validated는 스프링 표준입니다. @Validated의 경우 그룹 기능을 사용할 수 있습니다.


Bean Validation을 사용하면 메시지 기본값이 자동으로 설정되며, message 인수를 통해 지정해 줄수도 있습니다.


/kro/rubisco/dto/ProductDTO.java

package kro.rubisco.dto;


import javax.validation.constraints.NotNull;


import org.hibernate.validator.constraints.Range;


import lombok.Data;


@Data

public class ProductDTO {

    

    @NotNull @Range(min = 10, max = 100, message = "제품의 수량은 10개 이상 100개 이하로 입력할 수 있습니다.")

    private Long count;

    

    @NotNull @Range(min = 100, max = 10_000_000, message = "제품의 가격은 100원 이상 10,000,000원 이하로 입력할 수 있습니다."))

    private Long price;

}


다국어 지원을 위해서는 dataSource에 입력하는 것을 권장합니다. 인수의 경우, {0}은 필드명이고, {1} 부터는 인수의 이름을 오름차순한 순서로 주입됩니다. 에러코드는 어노테이션 이름과 동일합니다. 메시지를 다음과 같이 작성해주세요.


/src/main/webapp/WEB-INF/message/error.properties

Range.product.count = 제품의 수량은 {2}개 이상 {1}개 이하로 입력할 수 있습니다. 

Range.product.price = 제품의 가격은 {2}원 이상 {1}원 이하로 입력할 수 있습니다.

totalPriceMax = 최대 예산은 {0}원 입니다. (현재 총액: {1}원)


max가 min보다 알파벳 순서상 앞서므로, max = {1}, min = {2}가 됩니다.


2) 객체 검사


객체 검사의 경우 @ScriptAssert 어노테이션을 사용하면 됩니다.


/kro/rubisco/dto/ProductDTO.java

package kro.rubisco.dto;


import javax.validation.constraints.NotNull;


import org.hibernate.validator.constraints.Range;

import org.hibernate.validator.constraints.ScriptAssert;


import lombok.Data;


@Data

@ScriptAssert(lang="javascript", script = "_this.count * _this.price < 100000000")

public class ProductDTO {

    

    @NotNull 

    @Range(min = 10, max = 100)

    private Long count;

    

    @NotNull @Range(min = 100, max = 10_000_000)

    private Long price;

}


에러코드는 ScriptAssert.product 또는 ScriptAssert가 됩니다. 그러나 제약사항이 많고 복잡하므로, 객체 검사의 경우 검증기를 사용할 것을 권장합니다.


3) groups 기능


객체의 필드를 메소드마다 다르게 검사해야할 경우가 있습니다. 예를 들어 게시글을 입력할때는 documentId가 필수적이지 않지만, 게시글을 수정하거나 삭제할때는 documentId가 필수값이 됩니다. 이러한 이유들로 DB의 결과 데이터를 담을 Entity와 사용자의 요청을 담을 DTO를 구분합니다. Entity는 변화가 거의 없고, DTO는 변화가 빈번하기 때문입니다.


DTO를 따로 작성하지 않더라도 필드 검증을 다르게 적용할 수 있는데, 바로 groups 기능입니다. 이 기능은 @Validated 어노테이션에서 사용가능합니다. 앞서 설명했듯이 이 어노테이션은 클래스를 비교하여 검증기를 선택할 수 있도록 합니다.


count는 update에만, price는 update와 insert를 할 때 검증이 된다고 가정합시다. 아래와 같이 DTO를 수정해주세요.


/kro/rubisco/dto/ProductDTO.java

package kro.rubisco.dto;


import javax.validation.constraints.NotNull;


import org.hibernate.validator.constraints.Range;


import kro.rubisco.validator.ProductInsertValidator;

import kro.rubisco.validator.ProductUpdateValidator;

import lombok.Data;


@Data

public class ProductDTO {

    

    @NotNull(groups = ProductUpdateValidator.class)

    @Range(min = 10, max = 100, groups = ProductUpdateValidator.class)

    private Long count;

    

    @NotNull(groups = {ProductUpdateValidator.class, ProductInsertValidator.class})

    @Range(

        min = 100, max = 10_000_000, 

        groups = {ProductUpdateValidator.class, ProductInsertValidator.class})

    private Long price;

}


ProductUpdateValidatorProductInsertValidator는 비어있는 인터페이스로, 단지 구분을 위해 껍데기를 생성해주었을 뿐입니다.


컨트롤러의 @Validated 어노테이션에는 다음과 같이 ProductInsertValidator.class를 입력해주세요.


/kro/rubisco/controller/TestController.java

package kro.rubisco.controller;


import java.util.Locale;


import org.springframework.context.MessageSource;

import org.springframework.stereotype.Controller;

import org.springframework.validation.BindException;

import org.springframework.validation.BindingResult;

import org.springframework.validation.annotation.Validated;

import org.springframework.web.bind.WebDataBinder;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.InitBinder;

import org.springframework.web.bind.annotation.ModelAttribute;

import org.springframework.web.bind.annotation.PostMapping;

import org.springframework.web.bind.annotation.RequestMapping;


import kro.rubisco.config.BindExceptionWithViewName;

import kro.rubisco.dto.ProductDTO;

import kro.rubisco.validator.ProductInsertValidator;

import kro.rubisco.validator.ProductValidator;

import lombok.RequiredArgsConstructor;


@Controller

@RequiredArgsConstructor

@RequestMapping("/test")

public class TestController {

    

    private final MessageSource messageSource;

    private final ProductValidator productValidator;

    

    @InitBinder

    public void init(WebDataBinder dataBinder) {

        dataBinder.addValidators(productValidator);

    }


    @GetMapping()

    public void getTestView() {}

    

    @PostMapping()

    public String getTestView(

        @Validated(ProductInsertValidator.class) @ModelAttribute("product") ProductDTO product, 

        BindingResult bindingResult, 

        Locale locale

    ) throws BindException {


        if(bindingResult.hasErrors()) {

            throw new BindExceptionWithViewName(bindingResult, "/test", messageSource, locale);

        }


        return "redirect:/test";

    }

}


실행시켜보면 count는 검증하지 않고 price 필드만 검증하는 것을 볼 수 있습니다. 하지만 메소드가 많을수록 코드가 복잡해지므로 Entity와 DTO를 분리하여 작성하는 것을 권장합니다.

#스프링 레거시# Validator# Bean Validation
댓글
자동등록방지
(자동등록방지 숫자를 입력해 주세요)