스프링부트

spring boot 필드값 조건별 validation 하기 - custom annotation

알쓸개잡 2023. 7. 29.

REST API 를 개발하다 보면 특정 필드의 값에 따라서 다른 필드에 값이 반드시 존재해야 하는 케이스가 생긴다.

Spring 에서는 어노테이션 기반으로 필드의 유효성을 체크할 수 있도록 편리함을 제공하지만 위와 같이 특정 필드 값에 따라서 다른 필드에 값이 반드시 존재해야 하는 경우에 대한 체크는 제공하지 않는다.

이번 포스팅에서는 특정 필드 값에 따른 조건별 유효성 체크 방법에 대해서 기록한다.

 

@Valid 와 @Validated

@Valid 와 @Validated 의 가장 큰 차이는 아래와 같다고 생각한다.

Spring 에서는 메서드 수준 유효성 검사에 JSR-303 의 @Valid 어노테이션을 기본적으로 사용하지만 그룹 유효성 검사를 지원하지 않는다. 그룹 유효성 검사는 유효성 검사 마커를 정의하여 해당 마커가 지정된 필드에 대해서는 유효성 검사를 수행하는 것을 의미한다. 

@Valid 어노테이션이 지정된 경우 그룹이 지정된 필드의 유효성 검사는 수행하지 않는다.

@Validated 어노테이션에 그룹이 지정된 경우 그룹이 지정되지 않은 필드의 유효성 검사는 수행하지 않는다.

 

 

Dependency

spring boot 에서 validation 을 사용하기 위해서는 아래 디펜던시가 필요하다.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.2</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
    
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
<dependencies>

버전은 <parent> 에 지정된 spring-boot-starter-parent 에 속한 validation 버전이 적용된다.

spring-boot-starter-validation 디펜던시를 추가하면 아래 그림과 같은 하위 종속성들이 포함된다.

spring-boot-starter-validation 종속성

종속된 라이브러리를 보면 org.hibernate.validator:hibernate-validator:8.0.1.Final 이 포함되어 있고 그 아래에 jakarta.validation:jakarta.validation-api:3.0.2 가 포함되어 있다. spring boot 3.0 이후 부터는 jarkarta validation 3.0 이후 버전이 사용된다. 

jakarta.validation-api 에서는 constraint annotation 이 정의되어 있고, ConstraintValidator 구현체는 hibernate-validator 에 있다.

ConstraintValidator 를 구현한 여러 구현체

 

코드 구현

예시를 설명하자면 야구선수 등록 API 가 있고 야구선수 등록 데이터에는 uid, 이름, 선수타입, 타율, 홈런수, 방어율, 다승수 데이터가 있다.

선수타입에는 타자, 투수가 정의되어 있고 선수타입이 타자인 경우에는 타율, 홈런수 정보가 반드시 필요하고 투수인 경우에는 방어율, 다승수 정보가 반드시 필요하다.

 

custom constraint validation annotation

package com.example.conditional.validation.example.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Repeatable(Conditional.List.class)
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = {
	ConditionalValidation.class
})
public @interface Conditional {

	String message() default "this field is required";
	Class<?>[] groups() default {};

	//조건부 대상이 되는 필드명
	String selected();

	//조건부 대상이 요구되는 값
	String[] values();

	//값이 존재해야 하는 필드명
	String[] required();

	Class<? extends Payload>[] payload() default {};


	@Target({ TYPE })
	@Retention(RUNTIME)
	@Documented
	@interface List {
		Conditional[] value();
	}
}

유효성 검사를 위한 어노테이션을 정의하기 위해서 필요한 것은 @Constraint 어노테이션이 지정되어야 하고 유효성 검사 구현체가 있어야 한다. 여기서는 ConditionalValidation.class 가 유효성 검사 구현체가 된다.

각 필드의 설명은 아래와 같다.

selected 필드

- 조건 대상이 되는 필드명을 지정한다. 선수 타입에 따라서 값이 존재해야 하는 필드가 달라지기 때문에 선수타입 필드명이 셋팅되겠다.

values 필드

- selected 에 지정된 필드의 값을 정의한다. selected 필드에 지정된 필드의 값이 values 에 포함된 값이라면 required 에 지정된 필드의 값은 반드시 존재해야 한다.

required 필드

- 반드시 값이 존재해야 하는 필드를 정의한다. 

 

custom constraint validatior

유효성 검사를 진행할 구현체이다.

package com.example.conditional.validation.example.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.util.ObjectUtils;
import java.util.Arrays;

public class ConditionalValidation implements ConstraintValidator<Conditional, Object> {
	private String selected;
	private String[] required;
	private String[] values;
	private String message;

	//지정된 Conditional 어노테이션 객체가 전달된다
	@Override
	public void initialize(Conditional constraintAnnotation) {
		ConstraintValidator.super.initialize(constraintAnnotation);
		this.selected = constraintAnnotation.selected();
		this.values = constraintAnnotation.values();
		this.required = constraintAnnotation.required();
		this.message = constraintAnnotation.message();
	}

	/**
	 *
	 * @param object validate 대상 객체
	 * @param context 제약조건이 평가되는 context
	 * @return validation 성공 여부
	 */
	@Override
	public boolean isValid(Object object, ConstraintValidatorContext context) {
		boolean valid = true;
		Object actualValue = PropertyAccessorFactory
			.forDirectFieldAccess(object)
			.getPropertyValue(selected);

		if (actualValue != null) {
			Object selectedValue;
			if (actualValue instanceof Enum) {
				selectedValue = ((Enum<?>) actualValue).name();
			} else {
				selectedValue = actualValue;
			}

			if (Arrays.asList(values).contains((String) selectedValue)) {
				for (String fieldName : required) {
					Object requiredValue = PropertyAccessorFactory
						.forDirectFieldAccess(object)
						.getPropertyValue(fieldName);

					//요구되는 필드의 값이 빈 값이거나 null 인 경우 validation 은 실패
					boolean empty = ObjectUtils.isEmpty(requiredValue);
					if (empty) {
						context.disableDefaultConstraintViolation();
						context
							.buildConstraintViolationWithTemplate(message)
							.addPropertyNode(fieldName)
							.addConstraintViolation();
						valid = false;
						break;
					}
				}
			}
		}

		return valid;
	}
}

ConstraintValidator 를 implement 한 구현체이다. ConstraintValidator 클래스는 아래와 같다.

ConstraintValidator interface

구현 메소드는 initialize 와, isValid 메소드이며 initialize 의 파라미터에는 지정된 어노테이션 타입의 객체가 전달된다. ConditionalValidation 클래스에는 Conditional 어노테이션이 지정되었기 때문에 Conditional 어노테이션 객체가 전달된다.

isValid 메소드의 value 에는 유효성 검사를 위한 객체가 전달된다. ConditionalValidation 에는 Object 로 지정되었기 때문에 Object 타입의 객체가 전달된다. 아래 DTO 정의처럼 PlayerDTO 로 타입을 지정할 수도 있겠지만 이렇게 되면 PlayerDTO 로 사용범위가 한정되기 때문에 범용성을 위해서 Object 타입으로 정의하였다.

 

DTO 정의

package com.example.conditional.validation.example.dto;

import com.example.conditional.validation.example.validation.Conditional;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.*;
import lombok.*;
import java.math.BigDecimal;

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@Conditional.List(
	{
		//type 의 값이 HITTER(타자) 인 경우 battingAverage, homeruns 값은 존재해야 함
		@Conditional(
			selected = "type", 
			values = "HITTER", 
			required = {"battingAverage", "homeruns"}),
		//type 의 값이 PITCHER(타자) 인 경우 era, wins 값은 존재해야 함
		@Conditional(
			selected = "type", 
			values = "PITCHER", 
			required = {"era", "wins"})
	}
)
public class PlayerDTO {

	@NotBlank
	private String uid;

	@NotBlank
	private String name;

	@NotNull
	private PlayerType type;

	//타율 (타자)
	@DecimalMin("0.000") @DecimalMax("1.000")
	private BigDecimal battingAverage;

	//홈런 (타자)
	@Min(0)	@Max(100)
	private Integer homeruns;

	//방어율 (투수)
	@DecimalMin("0.000") @DecimalMax("10.000")
	private BigDecimal era;

	//다승 (투수)
	@Min(0)	@Max(100)
	private Integer wins;
}

 

Controller 정의

package com.example.conditional.validation.example.controller.annotation;

import com.example.conditional.validation.example.dto.PlayerDTO;
import com.example.conditional.validation.example.service.PlayerService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(path = "/annotation")
@RequiredArgsConstructor
public class PlayerController {
	private final PlayerService playerService;

	@PostMapping(path = "/player", consumes = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<PlayerDTO> createPlayer(
		@Validated
		@RequestBody PlayerDTO playerDTO) {
		PlayerDTO playerDTO1 = playerService.create(playerDTO);
		return ResponseEntity.ok(playerDTO1);
	}
}

 

Service 정의

package com.example.conditional.validation.example.service;

import com.example.conditional.validation.example.dto.PlayerDTO;
import org.springframework.stereotype.Service;

@Service
public class PlayerService {

	public PlayerDTO create(PlayerDTO playerDTO) {
		return playerDTO;
	}
}

 

예외 처리를 위한 ControllerAdvisor 정의

package com.example.conditional.validation.example.controller;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
@Slf4j
public class ControllerAdvice {

	@ExceptionHandler(MethodArgumentNotValidException.class)
	public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException e, HttpServletRequest request) {
		Map<String, String> errorDefaultMessages = new HashMap<>();
		Map<String, Object> errors = new HashMap<>();
        //validation 을 수행한 객체명 정보 추출
		String objectName = e.getObjectName();
        //validation 체크를 실패한 정보를 추출한다. 필드명과 defaultmessage
		e.getFieldErrors()
			.forEach(error -> errorDefaultMessages.put(error.getField(), error.getDefaultMessage()));

		log.error("failure check request validation, uri: {}, objectName: {}, {}", request.getRequestURI(), objectName, errors);
		return ResponseEntity.badRequest().body(errorDefaultMessages);
	}
}

유효성 검사에 실패한 경우에는 MethodArgumentNotValidException 을 발생시킨다. 이에 대한 예외 처리를 RestControllerAdvice 역할을 하는 클래스에서 처리하도록 한다. MethodArgumentNotValidException 로부터 유효성 검사가 실패한 필드정보와 디폴트 메세지 정보를 얻을 수 있는데 디폴트 메시지는 기본적으로 제공되는 유효성검사 어노테이션에 지정되어 있다. @Min 어노테이션을 예로 들면 아래와 같이 정의되어 있다.

jakarta.validation.constraints.Min.message 는 org.hibernate.validator 의 ValidationMessage.properties 에 정의되어 있으며 정의 값은 아래와 같다.

유효성 검사 실패 디폴트 메시지 정의

 

 

샘플 코드 구현은 여기까지이다. 확인을 위한 테스트 코드를 작성해보자.

 

테스트코드

package com.example.conditional.validation.example.controller.annotation;

import com.example.conditional.validation.example.dto.PlayerDTO;
import com.example.conditional.validation.example.dto.PlayerType;
import com.example.conditional.validation.example.service.PlayerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.util.UUID;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = PlayerController.class)
@Import(PlayerService.class)
class PlayerControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@DisplayName("타자 등록 유효성 체크 성공 테스트")
	@Test
	void create_hitter_validation_success_test() throws Exception {
		PlayerDTO playerDTO = PlayerDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("hitter1")
			.type(PlayerType.HITTER)
			.battingAverage(new BigDecimal("0.400"))
			.homeruns(50)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/annotation/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isOk())
			.andDo(print());
	}

	@DisplayName("투수 등록 유효성 체크 성공 테스트")
	@Test
	void create_pitcher_validation_success_test() throws Exception {
		PlayerDTO playerDTO = PlayerDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("pitcher")
			.type(PlayerType.PITCHER)
			.era(new BigDecimal("0.235"))
			.wins(10)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/annotation/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isOk())
			.andDo(print());
	}

	@DisplayName("타자 등록 유효성 체크 실패 테스트 - 홈런정보 누락")
	@Test
	void create_hitter_validation_fail_test() throws Exception {
		PlayerDTO playerDTO = PlayerDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("hitter1")
			.type(PlayerType.HITTER)
			.battingAverage(new BigDecimal("0.400"))
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/annotation/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("투수 등록 유효성 체크 실패 테스트 - 다승정보 누락")
	@Test
	void create_pitcher_validation_fail_test() throws Exception {
		PlayerDTO playerDTO = PlayerDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("pitcher1")
			.type(PlayerType.PITCHER)
			.era(new BigDecimal("0.235"))
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/annotation/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("타자 등록 유효성 체크 실패 테스트 - 홈런 개수 limit")
	@Test
	void create_hitter_validation_limit_fail_test() throws Exception {
		PlayerDTO playerDTO = PlayerDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("hitter1")
			.type(PlayerType.HITTER)
			.battingAverage(new BigDecimal("0.400"))
			.homeruns(1000)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/annotation/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("투수 등록 유효성 체크 실패 테스트 - 다승 개수 limit")
	@Test
	void create_pitcher_validation_limit_fail_test() throws Exception {
		PlayerDTO playerDTO = PlayerDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("pitcher1")
			.type(PlayerType.PITCHER)
			.era(new BigDecimal("0.235"))
			.wins(1000)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/annotation/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}
}

선수타입이 타자인 경우 요청 payload에 홈런 수 정보를 누락한 경우 결과는 아래와 같다.

선수타입이 투수인 경우 요청 payload 에 다승 수 정보를 누락한 경우 결과는 아래와 같다.

 

 

필드의 값 조건에 따라서 유효성 검사를 수행하는 custom 한 constrain validator 를 생성하는 방법을 간단하게 알아보았다. 

@Constraint 어노테이션이 지정된 custom 어노테이션을 정의하고 ConstraintValidator 를 구현한 구현체를 정의함으로써 여러 상황에 대처할 수 있는 유효성 검사기를 만들어 사용할 수 있다. 

 

다음 포스팅에서는 @JsonTypeInfo, @JsonSubTypes 어노테이션을 이용하여 validation 대상 필드를 분리하는 방법에 대해서 알아보겠다.

 

2023.07.29 - [스프링부트] - spring boot 필드값 조건별 validation 하기 - json subtype

 

 

git 샘플코드

https://gitlab.com/blog4031530/spring-conditional-validation-example

댓글

💲 추천 글