지도 기반의 프로젝트를 진행하면서 지구상 좌표의 위도(latitude), 경도(longitude)를 필요로 하는 API가 여러개가 되다보니 Validation을 공통으로 관리하면서 좌표 범위에 대한 Validation을 나타내고 싶어서 Custom Validator를 만들었다.

좌표계는 여러 종류가 있는데 모바일 환경에서는 일반적으로 WGS 84 좌표계를 사용한다.(GPS 좌표계) 현재 진행중인 지도 기반의 프로젝트가 WGS 84 좌표계를 사용하고 좌표의 범위를 한국으로만 제한하고 있어서 한국 좌표 범위에 대한 validation을 추가하였다.

 

WGS 84 좌표계에서 한국의 범위는 위도 약 33 ~ 39도, 경도 약 124 ~ 132도에 포함이 된다.

 

Custom Validator를 사용하기 위해서는 애노테이션과 실제 검증 로직이 담긴 ConstraintValidator 인터페이스를 구현한 클래스가 필요하다. 먼저 KoreaCoordinate 애노테이션을 만들고 @Constraint에 ConstraintValidator 구현체를 추가한다.

 

@Constraint(validatedBy = CoordinateValidator.class)

@Target({ElementType.FIELD, ElementType.TYPE})

@Retention(RetentionPolicy.RUNTIME)

public @interface KoreaCoordinate {

String message() default "잘못된 좌표입니다.";

Class<?\>\[\] groups() default {};

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

}

 

ConstraintValidator 인터페이스를 구현하는 것은 isValid() 메서드를 구현하면 되는데 초기화 작업이 필요하면 default 메서드인 initialize()도 구현하면 된다. 단순 검증 여부가 아닌 상세한 에러 메시지를 반환해주기 위해 값이 유효하지 않은 경우 addConstraintViolationMessage()를 통해 ConstaintViolation을 추가해준다.

 

public class CoordinateValidator implements ConstraintValidator<KoreaCoordinate, CoordinateDto> {
    public static final String COORD_NOT_EMPTY = "좌표에 빈 값이 들어갈 수 없습니다.";
    public static final String COORD_INVALID_RANGE = "좌표의 범위가 유효하지 않습니다.";
    private static final double MIN_LATITUDE = 33.0;
    private static final double MAX_LATITUDE = 39.0;
    private static final double MIN_LONGITUDE = 124.0;
    private static final double MAX_LONGITUDE = 132.0;

    @Override
    public boolean isValid(CoordinateDto value, ConstraintValidatorContext context) {
        boolean isValid = true;
        final Double lon = value.lon();
        final Double lat = value.lat();
        if (lon == null) {
            addConstraintViolationMessage(context, "lon", COORD_NOT_EMPTY);
            isValid = false;
        } else if (lon < MIN_LONGITUDE || lon > MAX_LONGITUDE) {
            addConstraintViolationMessage(context, "lon", COORD_INVALID_RANGE);
            isValid = false;
        }
        if (lat == null) {
            addConstraintViolationMessage(context, "lat", COORD_NOT_EMPTY);
            isValid = false;
        } else if (lat < MIN_LATITUDE || lat > MAX_LATITUDE) {
            addConstraintViolationMessage(context, "lat", COORD_INVALID_RANGE);
            isValid = false;
        }

        return isValid;
    }

    private void addConstraintViolationMessage(ConstraintValidatorContext context, String field, String message) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(message)
            .addPropertyNode(field)
            .addConstraintViolation();
    }
}

 

이제 GET 요청을 보면 전달 받은 위도(lat), 경도(lon)를 CoordinateDto로 받고 있고 validation 처리가 되는 것은 ModelAttribute의 ArgumentResolver인 ModelAttributeMethodProcessor를 보면 된다.

 

@GetMapping("/spots")
public ResponseEntity<ApiResponse<NearbySpotListResponse>> getNearbySpotList(
    @ModelAttribute @Valid CoordinateDto coord,
    @RequestParam(name = "radius") @PositiveOrZero(message = "반경(m)은 0보다 커야 됩니다.") Integer radius
) {
    var response = spotService.getNearbySpotList(new NearbySpotRequest(coord.toCoord(), radius));
    return ResponseEntity.ok(ApiResponse.success(response));
}

 

참고로 ModelAttributeMethodProcessor의 supportsParameter()를 보면 ModelAttribute 애노테이션이 없어도 simple property 타입이 아니면 지원을 하기 때문에 생략이 가능하지만 확실하게 명시해주는 것이 좋다.

 

@Override
public boolean supportsParameter(MethodParameter parameter) {
    return (parameter.hasParameterAnnotation(ModelAttribute.class) ||
            (this.annotationNotRequired && !BeanUtils.isSimpleProperty(parameter.getParameterType())));
}

 

ModelAttributeMethodProcessor의 resolveArgument() 내부에서 validateIfApplicable()를 통해 Valid 애노테이션을 체크하고 validate()를 해서 bindingResult()의 error가 있을 경우 MethodArgumentNotValidException을 throw한다.

 

 

 

MethodArgumentNotValidException의 bindingResult를 보면 errors에 에러가 담긴 것을 볼 수 있다.

 

 

Cusom Validator를 테스트하기위해 validator를 초기화하고 검증을 했다.

class CoordinateValidatorTest {
	private Validator validator;

	@BeforeEach
	public void setUp() {
		validator = Validation.buildDefaultValidatorFactory().getValidator();
	}

	@DisplayName("유효한 좌표 범위가 들어오면 Validator를 통과한다.")
	@Test
	void coordinateValidSuccessTest() {
		//given
		CoordinateDto coordinateDto = new CoordinateDto(127.0, 37.0);
		//when
		Set<ConstraintViolation<CoordinateDto>> violations = validator.validate(coordinateDto);

		//then
		assertThat(violations).isEmpty();
	}

	@DisplayName("빈 값 또는 유효하지 않은 좌표가 들어오면 ConstraintViolation가 추가된다.")
	@MethodSource(value = "getCoordinateTestDatas")
	@ParameterizedTest(name = "{0}, validErrors {1}")
	void coordinateValidFailTest(CoordinateDto coordinateDto, Tuple... validErrors) {
		String fieldPath = "propertyPath.currentLeafNode.name";
		String errorMessagePath = "messageTemplate";
		// When
		Set<ConstraintViolation<CoordinateDto>> violations = validator.validate(coordinateDto);

		// Then
		assertThat(violations)
			.extracting(fieldPath, errorMessagePath)
			.contains(validErrors);

	}

	private static Stream<Arguments> getCoordinateTestDatas() {
		return Stream.of(
			Arguments.of(new CoordinateDto(null, 3711.0), new Tuple[] {
				new Tuple("lon", CoordinateValidator.COORD_NOT_EMPTY),
				new Tuple("lat", CoordinateValidator.COORD_INVALID_RANGE)
			}),
			Arguments.of(new CoordinateDto(null, null), new Tuple[] {
				new Tuple("lon", CoordinateValidator.COORD_NOT_EMPTY),
				new Tuple("lat", CoordinateValidator.COORD_NOT_EMPTY)
			}),
			Arguments.of(new CoordinateDto(127.0, 3711.0), new Tuple[] {
				new Tuple("lat", CoordinateValidator.COORD_INVALID_RANGE)
			}),
			Arguments.of(new CoordinateDto(-127.0, -37.0), new Tuple[] {
				new Tuple("lon", CoordinateValidator.COORD_INVALID_RANGE),
				new Tuple("lat", CoordinateValidator.COORD_INVALID_RANGE)
			})
		);
	}
}

 

 

스프링부트에서는 Validator를 사용하기 위해 ValidationAutoConfiguration에서 LocalValidatorFactoryBean을 자동으로 설정하기 때문에 테스트 환경에서 Validation.buildDefaultValidatorFactory().getValidator() 방식으로 초기화를 하기보다는 스프링부트 테스트 환경에서 Autowired로 주입을 받아서 테스트하는 것이 좋을 것 같다.

 

[참고]

 

 

ConstraintValidator을 이용해서 효과적인 검증 - Yun Blog | 기술 블로그

ConstraintValidator을 이용해서 효과적인 검증 - Yun Blog | 기술 블로그

cheese10yun.github.io

 

 

Spring Boot Bean Validation 제대로 알고 쓰자

지난번에 작성한 Java Bean Validation 제대로 알고 쓰자에 이어서 Spring Boot 환경에서 Validation을 어떻게 사용할 수 있는지 확인해보겠습니다. Spring에서도 Hibernate Validator를 사용합니다. Java Bean Validation

kapentaz.github.io

 

 

Spring에서 Custom validation 테스트하기 - Increment

스프링의 Bean validation을 활용하는 custom annotation을 테스트하는 과정에서 겪은 문제를 남겨둡니다. 개요 본문의 코드는 GitHub에서 확인하실 수 있습니다. 스프링에서는 기본으로 제공되는 validation

www.latera.kr

 

+ Recent posts