지도 기반의 프로젝트를 진행하면서 지구상 좌표의 위도(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로 주입을 받아서 테스트하는 것이 좋을 것 같다.
[참고]
'Spring' 카테고리의 다른 글
[SpringBoot 3.2] RestClient, HttpInterface 사용법 (RestClientTest 및 HttpComponents 라이브러리 주의) (0) | 2023.12.18 |
---|---|
[Spring] IntelliJ 디버깅시 JPA 지연 로딩(Lazy Loading) 주의점 (0) | 2023.12.03 |
[Spring] 트랜잭션과 @Transacional 사용하는 이유 (0) | 2023.05.02 |
[Spring] 댓글 더보기 기능 구현 (LIMIT) (0) | 2023.04.28 |
Servlet과 Servlet Container (5) | 2023.03.25 |