Spring

SpringBoot + Validation 프로젝트 설정 (커스텀 Annotation 적용 방법, DTO 필드에 constraints 어노테이션 적용, Validator 인터페이스 구현, constraints 어노테이션에 groups 속성 설정)

LearnerKSH 2022. 8. 25. 00:27
728x90
반응형

들어가며

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

 

스프링부트 공통 Exception 처리하기

사용된 어노테이션 SpringBoot 프레임워크에서 Exception 처리를 공통처리를 해보자. 그전에, 알아야할 어노테이션을 정리해보자. 어노테이션 설명 @RestController @Controller + @ResponseBody 이다. Json, Xml..

devfunny.tistory.com

/**
 * 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와 동일하다.");
        }
    }
}

 

1) supports()

검증하고자 하는 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: 공백일 수 없습니다"

 

 

 

반응형