[Spring] 도메인 객체 검증하는 방법 -> Validation
스프링은 애플리케이션 전 계층에서 도메인 객체를 검증할 수 있는 인터페이스를 제공한다. 스프링의 bean validation을 통해 controller의 파라미터를 비즈니스 로직을 추가하지 않고 검증할 수 있는지 알아보자.
interface Validator
Spring은 도메인 객체를 검증할 수 있도록 Validator 인터페이스를 도입했다. Validator 인터페이스는 객체를 검증하는데 실패하면 Errors 객체에 에러를 등록한다.
Validator 인터페이스는 아래의 두가지 메서드를 가지고 있다.
- supports(Class): 매개변수로 전달된 클래스를 검증할 수 있는지 여부를 반환
- validate(Object, org.springframework.validation.Errors): 매개변수로 전달된 객체를 검증하고 실패하면 Errors 객체에 에러를 등록한다.
아래 코드는 Person 객체가 어떤 식으로 Validator 인터페이스를 구현하는지 보여준다.
@Getter @Setter
public class Person {
private String name;
private int age;
}
public class PersonValidator implements Validator {
public boolean supports(Class clazz) {
return Person.class.equals(clazz);
}
public void validate(Object obj, Errors e) {
ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
Person p = (Person) obj;
if (p.getAge() < 0) {
e.rejectValue("age", "negative.value");
} else if (p.getAge() > 110) {
e.rejectValue("age", "too.old");
}
}
}
validate 함수를 보면, 검증에 실패한 경우 Errors 객체의 rejectValue 함수를 호출하는 것을 볼 수 있다.
rejectValue의 파라미터는 필드이름, 에러코드로 구성된다.
ValidationUtils 클래스를 이용하여 필드 검증을 하는데, 값이 비어있거나 공백문자가 있는 경우를 쉽게 확인할 수 있다.
위 코드를 테스트 해보자.
@Autowired
private PersonValidator personValidator;
@GetMapping("/person/validate")
public boolean directlyValidatePerson(@ModelAttribute Person person, BindingResult result) {
logger.debug("validate directly. {}", person);
personValidator.validate(person, result);
return !result.hasErrors();
}
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = { WebAppConfig.class, ValidatorConfig.class })
public class PersonValidateControllerTest {
@Autowired
private PersonValidator personValidator;
private MockMvc mockMvc;
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.standaloneSetup(new PersonValidateController(personValidator)).build();
}
@Test
public void directlyValidateEmptyNameTest() throws Exception {
this.mockMvc.perform(get("/person/validate")
.param("name", "")
.param("age", "25")
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("false"));
}
@Test
public void directlyValidateWrongAgeTest() throws Exception {
// 음수 나이
this.mockMvc.perform(get("/person/validate")
.param("name", "test")
.param("age", "-1"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("false"));
// 100살을 초과하는 나이
this.mockMvc.perform(get("/person/validate")
.param("name", "test")
.param("age", "101"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("false"));
}
}
directlyValidatePerson 메서드를 보면 파라미터에 Person 객체 외에 BindingResult 객체가 있다.
BindingResult 객체는 모델의 검증 작업에서 나타난 에러를 저장하는 역할을 하며, 이를 이용해 메서드 실행을 분기할 수 있다.
MessageSource
위 Validator의 validate 함수 내에서 검증 실패시 reject 함수를 호출하는 것을 살펴봤다. 이 때 reject 함수 파라미터 중 에러코드를 지정했는데, 이는 에러메시지와 관련이 있다.
에러 메시지는 보통 messages.properties와 같은 properties 파일에서 읽어오도록 구현한다. Spring에서는 MessageSource를 이용해 properties 파일로부터 에러 메시지를 가져오도록 할 수 있다.
ValidationUtils.rejectIfEmpty(errors, "name", "name.empty");
위 코드에서 name.empty는 에러코드로 messages.properties 파일에 존재하는 키 값이다.
messages.properties에는 다음과 같이 선언이 되어있다.
name.empty=The name is empty
이 값을 가져오기 위해 MessageSource를 사용하는데, MessageSource의 구현에는 두가지 방법이 있다.
- StaticMessageSource: 코드로 메시지를 등록한다.
- ResourceBundleMessageSource: 리소스 파일로부터 읽어와 등록한다.
properties 파일을 읽어와 에러 메시지를 읽어올 것이므로 ResourceBundleMessageSource 클래스를 이용하자.
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource resourceBundleMessageSource = new ResourceBundleMessageSource();
resourceBundleMessageSource.setBasename("messages");
resourceBundleMessageSource.setDefaultEncoding("UTF-8");
return resourceBundleMessageSource;
}
@Autowired
private MessageSource messageSource;
@GetMapping("/person/validate")
public List<String> validateAndGetErrorMessages(@ModelAttribute Person person, BindingResult result) {
logger.debug("validated. {}", person);
personValidator.validate(person, result);
return result.getFieldErrors().stream()
.map(e -> messageSource.getMessage(e, Locale.getDefault()))
.collect(Collectors.toList());
}
@Test
public void validateAndGetBindingResultTest() throws Exception {
mockMvc.perform(get("/person/validate")
.param("name", "")
.param("age", "99")
.andDo(print())
.andExpect(status().isOk());
}
이 테스트에 대한 결과는 아래와 같다.
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-type: "application/json;charset=UTF-8"]
Content Type = application/json;charset=UTF-8
Body = ["The name is empty"]
Forwarded URL = null
Redirected URL = null
Cookies = []
Spring Validation
Spring 3 부터 Bean Validation API를 제공한다. Spring Bean Validation은 Validator 인터페이스를 이용하여 직접 도메인 객체를 검증하는 방법을 표준화하고 어노테이션을 이용해 표현할 수 있도록 도와준다.
Bean Validation 명세는 구현체로 Hibernate Validator를 지원한다.
Hibernate Validator는 자주 쓰이는 몇가지 검증 어노테이션을 built-in으로 제공한다.
@Getter @Setter
public class Car {
@NotBlank(message = "The manufacturer must not be empty.")
private String manufacturer;
@Range(min = 0, max = 10, message = "The seat count must be between 0 ~ 10")
private int seatCount;
@Range(min = 0, max = 300, message = "The speed must be between 0 ~ 300")
private int topSpeed;
}
세가지 필드를 가지고 있는 간단한 도메인 클래스이다. 검증 조건을 어노테이션으로 선언하여 표현하였다. 이를 검증하는 Validator를 가지고 와야하는데 Bean Validation API에서 LocalValidatorFactoryBean 객체를 기본으로 제공해줘 이를 이용해 Validator 인터페이스의 bean을 생성할 수 있다. 이 객체는 org.springframework.validation.Validator 인터페이스 뿐만 아니라, javax.validation.ValidatorFactory, javax.validation.Validator 인터페이스를 모두 구현하고 있다. 이 bean을 이용해 애플리케이션 전 계층에서 객체를 검증할 수 있다. 아래는 Validator를 등록하는 과정이다.
@Bean
public Validator jsrValidator() {
return new LocalValidatorFactoryBean();
}
이 Validator를 통해 Car 객체를 검증하는 컨트롤러 메서드를 작성하자.
@Autowired
@Qualifier("jsrValidator")
private Validator validator;
@GetMapping("/car/validate")
public boolean directlyValidateCar(@ModelAttribute Car car, BindingResult result) {
logger.debug("validate directly. {}", car);
validator.validate(car, result);
logger.debug("errors: {}", result.getFiledErrors());
return !result.hasErrors();
}
@Test
public void directlyValidateTest() throws Exception {
this.mockMvc.perform(get("/car/validate")
.characterEncoding("utf-8")
.param("manufacturer", "kook")
.param("seatCount", "4")
.param("topSpeed", "200"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("true"));
}
@Test
public void directlyValidateInvalidParamTest() throws Exception {
this.mockMvc.perform(get("/car/validate")
.characterEncoding("utf-8")
.param("manufacturer", "kook")
.param("seatCount", "4")
.param("topSpeed", "301"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("false"));
this.mockMvc.perform(get("/car/validate")
.characterEncoding("utf-8")
.param("manufacturer", "kook")
.param("seatCount", "-1")
.param("topSpeed", "301"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("false"));
this.mockMvc.perform(get("/car/validate")
.characterEncoding("utf-8")
.param("manufacturer", "")
.param("seatCount", "4")
.param("topSpeed", "301"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("false"));
}
위 코드에서 검증 후, 검증 에러의 로그를 찍도록 했는데 Car 클래스의 검증 어노테이션의 message 속성으로 정의한 에러메시지가 표시된다.
Spring MVC 3 Validation
Spring3부터 Spring MVC 컨트롤러 메서드의 파라미터를 자동으로 검증하는 어노테이션의 @Valid를 제공해준다. 지금까지는 직접 도메인 객체를 검증하는 방법이었지만, @Valid 어노테이션을 사용하면 검증하는 로직이 없어도 자동으로 검증해준다.
@Autowired
private MessageSource messageSource;
@GetMapping("/validate")
public boolean automaticallyValidateCar(@ModelAttribute @Valid Car car) {
logger.debug("validate automatically. {}", car);
return true;
}
@ExceptionHandler({BindException.class})
public ResponseEntity<String> paramValidateError(BindException ex) {
logger.error(ex.getMessage());
return ResponseEntity.badRequest()
.body(messageResource.getMessage(ex.getFielderror(), Locale.getDefault()));
}
위 코드에서 컨트롤러 메서드에서 BindingResult 객체를 받지 않는 것을 볼 수 있다. @Valid 애노테이션을 붙인 컨트롤러 메서드도 마찬가지로 검증 에러를 BindingResult 객체에 저장을 하는데, 만약 컨트롤러 메서드가 BindingResult 객체를 받지 않는다면 검증에러 발생시, BindException을 내보낸다.
이 예외는 HTTP 상태코드 400으로 처리되는데, 위 코드에서는 BindException을 400으로 처리는 그대로 하지만, body에 에러메시지를 담아서 내보내기 위해 ExceptionHandler 메서드를 정의하였다.
출처
https://lazymankook.tistory.com/86
'web > Spring' 카테고리의 다른 글
[Spring JPA] Entity의 equals와 hashCode (0) | 2023.02.28 |
---|---|
[Spring Security] SecurityConfig 리팩토링 - WebSecurityConfigurerAdapter 상속 제거, Resource Filter Chain 설정 (0) | 2023.01.16 |
[Spring] MapStruct - Entity와 DTO 매핑하기 (0) | 2022.11.07 |
[Spring] 페이징 성능 개선하기 3-2. 첫 페이지 조회 결과 캐시하기 (0) | 2022.07.21 |
[Spring] 페이징 성능 개선하기 3-1. 검색 버튼 사용시 페이지 건수 고정하기 (0) | 2022.07.20 |
댓글 개