들어가며
API를 개발할때 API별로 파라미터 validation 체크를 해야한다. validation 체크를 하는 방법은 여러가지로 많겠지만, Spring Boot Starter Validation을 사용해서 여러가지 방법을 적용해보자.
의존성 추가
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation' // validation
예제코드 준비
ValidationController.java
package com.sample.api.controllers;
import com.sample.api.commons.Output;
import com.sample.api.dto.ValidationParamDto;
import com.sample.api.service.ValidationService;
import io.swagger.annotations.Api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = {"ValidationController"})
@RestController
@RequestMapping("/validation")
@RequiredArgsConstructor
@Slf4j
public class ValidationController {
private final Output output;
private final ValidationService validationService;
@PostMapping(value = "/test")
public ResponseEntity<?> test(@ModelAttribute ValidationParamDto validationParamDto) {
return output.send(validationParamDto);
}
}
ValidationParamDto.java
package com.sample.api.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ValidationParamDto {
private String userId;
private String userName;
private String addr;
private String memo;
}
[1] Custom Annotation 적용 방법
Controller가 수행되기 전, Interceptor 수행 단계에서 validation을 체크한다.
UserIdDuplicatedTarget.java
package com.sample.api.dto.validator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Target({ FIELD }) // 해당 어노테이션은 필드에만 선언 가능
@Retention(RUNTIME) // 해당 어노테이션이 유지되는 시간은 런타임까지 유효
@Constraint(validatedBy = UserIdDuplicatedValidator.class)
public @interface UserIdDuplicatedTarget {
/**
* 유효하지 않을 경우 반환할 메시지
* @return
*/
String message() default "{userid.invalid}";
/**
* 유효성 검증이 진행될 그룹
* @return
*/
Class<?>[] groups() default { };
/**
* 유효성 검증 시에 전달할 메타 정보
* @return
*/
Class<? extends Payload>[] payload() default { };
}
UserIdDuplicatedValidator.java
package com.sample.api.dto.validator;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class UserIdDuplicatedValidator implements ConstraintValidator<UserIdDuplicatedTarget, String> {
@Override
public boolean isValid(String userId, ConstraintValidatorContext constraintValidatorContext) {
if ("test".equals(userId)) {
return false;
}
return true;
}
}
ValidationParamDto.java
package com.sample.api.dto;
import com.sample.api.dto.validator.UserIdDuplicatedTarget;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ValidationParamDto {
@UserIdDuplicatedTarget
private String userId;
private String userName;
private String addr;
private String memo;
}
1) 추가한 @UserIdDuplicatedTarget 어노테이션을 달아보자.
@UserIdDuplicatedTarget
private String userId;
이렇게 하면 UserIdDuplicatedValidator.java의 isValid() 메서드를 수행하여 해당 로직을 수행한다.
■ 공통 Exception 처리에 메서드 추가
https://devfunny.tistory.com/318
/**
* validation error
* @param exception
* @return
*/
@ExceptionHandler({BindException.class})
public ResponseEntity<?> errorValid(BindException exception) {
List<ValidationItem> items = new ArrayList<>();
for (ObjectError error : exception.getAllErrors()) {
FieldError fieldError = (FieldError) error;
items.add(new ValidationItem(fieldError.getField(), fieldError.getDefaultMessage()));
}
return output.send(items, HttpStatus.BAD_REQUEST, EnumMessage.VALID_PARAMETER.getMessage());
}
ValidationController.java
@ModelAttribute 옆에 @Valid 어노테이션을 추가해야한다. 추가하지 않으면 validation 체크를 하지 않는다.
package com.sample.api.controllers;
import com.sample.api.commons.Output;
import com.sample.api.dto.ValidationParamDto;
import com.sample.api.service.ValidationService;
import io.swagger.annotations.Api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@Api(tags = {"ValidationController"})
@RestController
@RequestMapping("/validation")
@RequiredArgsConstructor
@Slf4j
public class ValidationController {
private final Output output;
private final ValidationService validationService;
@PostMapping(value = "/test")
public ResponseEntity<?> test(@ModelAttribute @Valid ValidationParamDto validationParamDto) {
return output.send(validationParamDto);
}
}
■ API 호출
■ 결과
[2] DTO 필드에 constraints 어노테이션 적용
Controller가 수행되기 전, Interceptor 수행 단계에서 validation을 체크한다.
ValidationParamDto.java
package com.sample.api.dto;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
@Getter
@Setter
public class ValidationParamDto {
private String userId;
@NotBlank
private String userName;
@NotBlank
private String addr;
@NotBlank
private String memo;
}
1) @NotBlank
Null, 빈문자열을 모두 체크한다.
ValidationController.java
이 방법에서도 @Valid 어노테이션을 추가해야한다.
package com.sample.api.controllers;
import com.sample.api.commons.Output;
import com.sample.api.dto.ValidationParamDto;
import com.sample.api.service.ValidationService;
import io.swagger.annotations.Api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@Api(tags = {"ValidationController"})
@RestController
@RequestMapping("/validation")
@RequiredArgsConstructor
@Slf4j
public class ValidationController {
private final Output output;
private final ValidationService validationService;
@PostMapping(value = "/test")
public ResponseEntity<?> test(@ModelAttribute @Valid ValidationParamDto validationParamDto) {
return output.send(validationParamDto);
}
}
1) @Valid 어노테이션
public ResponseEntity<?> test(@ModelAttribute @Valid ValidationParamDto validationParamDto) {
컨트롤러로 요청이 들어올때, ArgumentResolver가 컨트롤러 메서드의 객체를 만들어준다.
@Valid 어노테이션을 선언하면 ArgumentResolver에 의해 처리된다.
@ModelAttribute를 사용했으므로, 위 코드는 ArgumentResolver의 구현체인 ModelAttributeMethodProcessor에 의해 @Valid가 처리되는 방식이다.
■ API 호출
■ 결과
[3] org.springframework.validation.Validator 인터페이스 구현 방법
TestValidator.java
package com.sample.api.dto.validator;
import com.sample.api.dto.ValidationParamDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Component
@Slf4j
public class TestValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return ValidationParamDto.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ValidationParamDto validationParamDto = (ValidationParamDto) target;
log.info(validationParamDto.getUserId());
if("test".equals(validationParamDto.getUserId())) {
errors.rejectValue("userId", "userId", "userId이 test와 동일하다.");
}
}
}
검증하고자 하는 clazz 객체가검증 대상 타입이 맞는지 확인한다.
2) validate()
실제 검증 로직을 수행한다.
ValidationController.java
package com.sample.api.controllers;
import com.sample.api.commons.Output;
import com.sample.api.dto.ValidationParamDto;
import com.sample.api.dto.validator.TestValidator;
import com.sample.api.service.ValidationService;
import io.swagger.annotations.Api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
@Api(tags = {"ValidationController"})
@RestController
@RequestMapping("/validation")
@RequiredArgsConstructor
@Slf4j
public class ValidationController {
private final Output output;
private final ValidationService validationService;
private final TestValidator testValidator;
@InitBinder("validationParamDto")
public void initBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(testValidator);
}
@PostMapping(value = "/test")
public ResponseEntity<?> test(@ModelAttribute ValidationParamDto validationParamDto, BindingResult bindingResult) {
testValidator.validate(validationParamDto, bindingResult);
if (bindingResult.hasErrors()) {
log.info("validation error...");
}
return output.send(validationParamDto);
}
}
1) 매개변수에 BindingResult를 받아야한다.
@PostMapping(value = "/test")
public ResponseEntity<?> test(@ModelAttribute ValidationParamDto validationParamDto, BindingResult bindingResult) {
2) @InitBinder
- Controller로 들어오는 요청에 대한 추가적인 설정을 할 수 있다.
- 특정 객체에만 적용이 가능하며, 예제 코드에서는 validationParamDto 객체에만 적용되도록 했다.
- addValidator() 메서드를 사용하여 testValidator 객체를 등록한다.
@InitBinder("validationParamDto")
public void initBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(testValidator);
}
3) validate() 메서드를 호출한다.
testValidator.validate(validationParamDto, bindingResult);
4) validation 체크가 된 후, bindingResult.hasErrors()를 디버깅해보자.
■ 위 field, defaultMessage 를 꺼내서 공통 Exception 처리가 가능하다.
public BadRequestException(EnumMessage enumMessage, List<ObjectError> errors) {
super(enumMessage.getMessage());
this.enumMessage = enumMessage.getMessage();
if (errors != null) {
items = new ArrayList<>();
for (ObjectError error : errors) {
FieldError fieldError = (FieldError) error;
items.add(new BadRequestException.ValidationItem(fieldError.getField(), fieldError.getDefaultMessage()));
}
}
this.items = items;
}
[4] 또다른 상황
클라이언트A, 클라이언트B에서 동일한 API를 호출한다. 하지만 각 클라이언트별로 validation check 항목이 다르다면 어떻게 해야할까? API를 나눠서 DTO 파일을 별도로 생성하여 설정하는 방법도 가능하겠지만, validation에서 제공해주는 groups 속성을 사용하여 처리해보자.
Groups 추가
1) ClientA.java
package com.sample.api.dto.validator.groups;
public interface ClientA {
}
2) ClientB.java
package com.sample.api.dto.validator.groups;
public interface ClientB {
}
3) Client.java (Default)
package com.sample.api.dto.validator.groups;
public interface Client {
}
ValidationController.java
package com.sample.api.controllers;
import com.sample.api.commons.Output;
import com.sample.api.dto.ValidationParamDto;
import com.sample.api.dto.validator.TestValidator;
import com.sample.api.service.ValidationService;
import io.swagger.annotations.Api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
@Api(tags = {"ValidationController"})
@RestController
@RequestMapping("/validation")
@RequiredArgsConstructor
@Slf4j
public class ValidationController {
private final Output output;
private final ValidationService validationService;
private final TestValidator testValidator;
@PostMapping(value = "/test")
public ResponseEntity<?> test(@ModelAttribute ValidationParamDto validationParamDto) {
validationService.testClientA(validationParamDto);
validationService.testClientB(validationParamDto);
return output.send(validationParamDto);
}
}
1) service 호출 로직 추가
파라미터 validation은 Service에서 설정한다.
validationParamDto에 따라 testClientA(), testClientB() 메서드를 분기처리하여 호출해보자.
ValidationService.java
package com.sample.api.service;
import com.sample.api.dto.ValidationParamDto;
import com.sample.api.dto.validator.groups.Client;
import com.sample.api.dto.validator.groups.ClientA;
import com.sample.api.dto.validator.groups.ClientB;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.validation.Valid;
@RequiredArgsConstructor
@Service
@Slf4j
@Validated
public class ValidationService {
@Validated({ClientA.class, Client.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
@Validated({ClientB.class, Client.class})
public void testClientB(@Valid ValidationParamDto validationParamDto) {
log.info("testClientB");
}
}
1) 클래스 상위에 @Validated 어노테이션을 추가한다.
@Validated
public class ValidationService {
@Validated
Controller 외의 다른 계층에서 사용해야할 경우, @Validated를 선언해서 검증을 진행해야한다.
@Validated는 Spring이 지원하는 기능이며, AOP 기반으로 메서드의 요청을 가로채서 유효성을 검증한다.
@Validated를 클래스에 선언해주고, @Valid를 메서드에 선언해줘야한다.
2) 서비스 메서드 위에 validation을 설정할 groups를 명시한다.
▶ @Validated({ClientA.class, Client.class})
@Validated({ClientA.class, Client.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
▶ @Validated({ClientB.class, Client.class})
@Validated({ClientB.class, Client.class})
public void testClientB(@Valid ValidationParamDto validationParamDto) {
log.info("testClientB");
}
ValidationParamDto.java
각 필드별 groups를 설정한다.
package com.sample.api.dto;
import com.sample.api.dto.validator.groups.Client;
import com.sample.api.dto.validator.groups.ClientA;
import com.sample.api.dto.validator.groups.ClientB;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotBlank;
@Getter
@Setter
public class ValidationParamDto {
@NotBlank(groups = {Client.class})
private String userId;
@NotBlank(groups = {ClientA.class})
private String userName;
@NotBlank(groups = {ClientA.class})
private String addr;
@Length(min = 10, max = 10, groups = ClientA.class)
@Length(min = 5, max = 5, groups = ClientB.class)
private String memo;
}
1) service 메서드 명 위에 설정했던 groups를 따른다.
▶ userId
@NotBlank(groups = {Client.class})
private String userId;
testClientA()가 실행될때 userId의 @NotBlack 어노테이션 체크가 수행된다.
@Validated({ClientA.class, Client.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
▶ userName
@NotBlank(groups = {ClientA.class})
private String userName;
testClientA()가 실행될때 userName의 @NotBlank 어노테이션 체크가 수행된다.
@Validated({ClientA.class, Client.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
▶ memo
@Length(min = 10, max = 10, groups = ClientA.class)
@Length(min = 5, max = 5, groups = ClientB.class)
private String memo;
testClientA()가 수행될때 @Length(min = 10, max = 10, groups = ClientA.class) 체크가 수행된다.
testClientB()가 수행될때 @Length(min = 5, max = 5, groups = ClientB.class) 체크가 수행된다.
[4-1] 추가 상황. Default 까지도 Service 메서드 위에 중복으로 설정해줘야할까?
ValidationService 파일을 다시보자.
ValidationService.java
@Validated({ClientA.class, Client.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
@Validated({ClientB.class, Client.class})
public void testClientB(@Valid ValidationParamDto validationParamDto) {
log.info("testClientB");
}
■ Client.class를 없애보자.
@Validated({ClientA.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
@Validated({ClientB.class})
public void testClientB(@Valid ValidationParamDto validationParamDto) {
log.info("testClientB");
}
■ 아까 생성했던 인터페이스 ClientA, ClientB에서 Client.java를 상속하도록 하자.
1) ClientA.java
package com.sample.api.dto.validator.groups;
public interface ClientA extends Client {
}
2) ClientB.java
package com.sample.api.dto.validator.groups;
public interface ClientB extends Client {
}
3) Client.java (Default)
package com.sample.api.dto.validator.groups;
public interface Client {
}
■ 이렇게되면, ClientA가 groups로 설정되었을때 Client의 그룹으로 설정된 어노테이션 체크도 함께 수행한다.
@Validated({ClientA.class})
public void testClientA(@Valid ValidationParamDto validationParamDto) {
log.info("testClientA");
}
@Validated({ClientB.class})
public void testClientB(@Valid ValidationParamDto validationParamDto) {
log.info("testClientB");
}
Client.class를 groups로 가진 userId 필드의 어노테이션 체크를 testClientA(), testClientB() 실행시 모두 수행된다.
@NotBlank(groups = {Client.class})
private String userId;
■ 호출
■ 에러로그
javax.validation.ConstraintViolationException: testClientA.validationParamDto.userName: 공백일 수 없습니다, testClientA.validationParamDto.addr: 공백일 수 없습니다, testClientA.validationParamDto.memo: 길이가 10에서 10 사이여야 합니다
at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:120)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:749)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:691)
...
■ testClientA()가 수행됬을 경우
"testClientA.validationParamDto.userName: 공백일 수 없습니다,
testClientA.validationParamDto.addr: 공백일 수 없습니다,
testClientA.validationParamDto.memo: 길이가 10에서 10 사이여야 합니다"
■ testClientB()가 수행됬을 경우
"testClientB.validationParamDto.memo: 길이가 5에서 5 사이여야 합니다",
■ userId에 빈값으로 요청해보자.
"testClientB.validationParamDto.memo: 길이가 5에서 5 사이여야 합니다,
testClientB.validationParamDto.userId: 공백일 수 없습니다"
'Coding > Spring' 카테고리의 다른 글
Spring + Mybatis 활용 원리 (1) | 2022.12.01 |
---|---|
Maven + MockMvc 환경에서 Spring Rest Docs 설정하기 (2) | 2022.09.14 |
SpringBoot + SpringSecurity 프로젝트에 Swagger 3.0 적용하기 (2) | 2022.02.02 |
SpringBoot 프로젝트 + Postgresql 연동 및 Mac Postgresql 설치 과정 (0) | 2022.01.16 |
스프링 부트 @SpringBootApplication (0) | 2021.07.08 |