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

반응형
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: 공백일 수 없습니다"

 

 

 

반응형

Designed by JB FACTORY