스프링에서 외부 API 호출을 위해 RestTemplate, WebClient을 제공하는데 스프링 부트 3.2 버전부터 정식으로 RestClient가 추가되었다.

  • RestClient - synchronous client with a fluent API.
  • WebClient - non-blocking, reactive client with fluent API.
  • RestTemplate - synchronous client with template method API.
  • HTTP Interface - annotated interface with generated, dynamic proxy implementation.

WebClient, HttpInterface를 사용하면 직관적이고 깔끔하게 구현할 수 있지만 webflux 의존성을 추가해야 되는 단점이 있다. RestClient는 새로운 동기식 HTTP 클라이언트로 WebClient와 유사한 API로 사용할 수 있다. 이전에는 HttpInterface를 사용하려면  WebClient를 사용했어야 했는데 스프링 6.1부터 HttpExchangeAdapter의 구현체로 RestClientAdapter와 RestTemplateAdapter 둘 다 사용할 수 있게 되었다.

이번에 카카오맵 API를 사용하면서 RestClient, HttpInterface를 사용했는데 RestClient는 create() 혹은 builder를 통해 생성할 수 있고 builder를 사용하면 추가로 baseUrl, header, statusHandler 등 여러 설정을 할 수 있다.

 

 

build() 내부를 보면 설정이 없는 경우 여러 초기화 작업을 해준다. RestTemplate, RestClient는 내부적으로 HTTP 통신을 위해 Apache HttpComponents를 사용한다.

 

 

RestClient 설정시 ClientRequestFactory를 추가할 수 있는데 ClientHttpRequestFactory는 HttpClient, ConnectionManager 등의 설정을 할 수 있다.

RestClient.Builder에서 HttpComponents5 라이브러리를 직접 추가하지 않으면 SimpleClientHttpRequestFactory가 default로 들어간다. SimpleClientHttpRequestFactory를 그대로 사용하면 운영 환경에서 많은 문제가 생길 수 있어서 HttpComponentsClientHttpRequestFactory를 사용하는 것이 좋다.

(원래 HttpComponentsClientHttpRequestFactory가 default인데 라이브러리를 따로 추가하지 않은 경우 발생하는 문제이다.)

 

HttpComponentsClientHttpRequestFactory를 생성할 수가 없어서 주석을 보니 스프링 부트3 버전대에서 HttpComponents 5.1 이상 라이브러리를 추가로 필요로 한다고 나와있다.

 

HttpComponents 5.2.x 버전을 추가하고 다시 보면 default가 HttpComponentsClientHttpRequestFactory로 바뀌고 HttpComponentsClientHttpRequestFactory 또한 직접 생성해서 설정할 수도 있다.

 

하지만..! 스프링 부트 3.2 기준으로 HttpComponents 5.2.x 버전을 추가하면 HttpInterface Proxy 생성이 안 되는 문제가 있어서 최신 버전인 5.3으로 추가해야 한다

 

HttpComponentsClientHttpRequestFactory 내부에는 httpClient의 default ConnectionManager로 PoolingHttpClientConnectionManager가 주입이 되어 있다. PoolingHttpClientConnectionManager는 커넥션 풀을 사용하여 커넥션 관련 설정을 custom 할 수 있다.

 

 

HttpComponentsClientHttpRequestFactory는 상황에 따라 타임 아웃, 커넥션 풀 등의 설정을 통해 변경할 수 있다.

 

 

HttpInterface는 아래와 같이 인터페이스, 애노테이션 기반으로 정의를 해서 직관적으로 사용할 수 있다.

 

 

현재는 직접 해당 인터페이스의 프록시 구현체를 만들어서 Bean으로 등록해주어야 한다.

 

 

이렇게 설정한 외부 API 호출을 테스트하려면 @RestClientTest를 활용할 수 있다. 주의점은 RestTemplate, RestClient를 주입받는 Bean에서 파라미터로 RestTemplateBuilder or RestClient.Builder를 사용해야 한다. 그래서 위에 HttpInterface를 Bean으로 등록할 때 RestClient.Builder를 파라미터로 사용했다.

 

RestClientTest는 최소한의 Context로 테스트를 실행하기 때문에 테스트할 HttpInterface를 value에 추가해 주었다. MockRestServiceServer는 외부 API를 테스트하기 위한 Mock 서버이다

 

 

테스트는 간단하게 예상 Response 등을 설정해 주고 mockServer에 설정을 해준 뒤에 예상 경로로 요청이 가는지, 바인딩이 제대로 되는지 등의 테스트를 검증한다.

 

 

RestClient는 custom 메시지 컨버터를 추가할 수 있지만 default로 등록된 컨버터 중 MappingJackson2HttpMessageConverter가 json 응답을 객체에 바인딩해 준다.

 

 

[참고]

 

Spring Boot에서 외부 API 테스트하기

안녕하세요? 이번 시간엔 Spring Boot의 @RestClientTest 예제를 진행해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. 1. 문제 상황 예를 들어 외부 API를

jojoldu.tistory.com

 

RestTemplate 사용시 주의사항 - Incheol's TECH BLOG

ResponseEntity response = restTemplate.exchange(" ", HttpMethod.GET, requestEntity, String.class);

incheol-jung.gitbook.io

 

Apache HttpComponent 제대로 사용하기

Java 어플리케이션에서 일반적으로 http호출을 할때 Apache HttpClient를 사용하며 http client가 버전업을 하여 Apache HttpComponent로 변경된것은 아마 다들 알고있는 사항일것입니다. (아주 옛날에 바꼈으니

inyl.github.io

 

 

IntelliJ 디버깅을 할 때 JPA의 지연 로딩 옵션이 실행 되면서 쿼리가 나가는 경우가 있다. 아래 예시에서 장소(Spot)는 여러개의 방명록(Post)을 가지고 있고 @OneToMany 매핑이 되어 있다. 먼저 findAll()로 조회를 한 상태에서는 select 쿼리만 나간 상태이다.

 

 

이제 Spot 리스트에서 Spot의 내부를 열어보는순간 JPA의 BatchSize로 설정해놓은 size만큼 in 쿼리가 실행이 된다.

 

 

IntelliJ 디버깅에서 지연 로딩 쿼리가 발생하는 것으로 인해서 혼동이 올 수 있다. IntelliJ에서는 이를 옵션으로 설정할 수 있다.

Debugger 창의 variables 창 부분을 우클릭하면 Mute Renderers, Customize Data View 옵션이 있다. Mute Renderers에서 전체 체크를 하거나 Customize Data View에서 개별적으로 설정을 할 수 있다.

 

 

Mute Renderers를 체크하게 되면 디버거에서 모든 값들을 불러와서 보여주지 않고 제한적으로 보여준다. 이제 posts의 size()를 클릭하면 size()를 불러오면서 지연 로딩 쿼리가 나가게 된다.

 

 

위에 posts를 보면 인스턴스가 PersistentBag으로 되어 있는데 이는 JPA에서 Collection을 감싼 프록시 클래스다.

 

PersistentBag의 size()를 호출하면 내부적으로 실제 컬렉션이 초기화가 된다.

 

readSize() 내부를 보면 initialize()에서 initializeCollection()을 호출하고 EventListner로 초기화를 한다.

 

생각해보면 @OneToMany 관계인 Colleciton이 지연 로딩 상태에서 size() 또는 get()으로 List의 내부 요소에 대해 알려면 쿼리가 나가는 것이 당연하다. 

 

[참고]

https://intellij-support.jetbrains.com/hc/en-us/community/posts/4411482324114-How-could-intellij-changed-Hibernate-lazyload-rules-with-breakpoint-

https://kimcoder.tistory.com/503?category=964983

 

https://www.baeldung.com/java-debug-interface

 

지도 기반의 프로젝트를 진행하면서 지구상 좌표의 위도(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

 

SRS(Spatial REference System)은 익숙한 용어로 좌표계(Coordinate System)라고 생각하면 된다.

SRS는 GCS(Geographic Coordinate System)PCS(Projected Coordinate System)로 구분이 된다.

GCS는 지구 구체상의 특정 위치나 공간을 표현하는 좌표계로 위도(Latitude)와 경도(Longitude)와 같은 각도 단위로 표시된다. PCS는 지구를 평면으로 투영시킨 좌표계를 말한다.

 

목적이나 용도별로 사용하는 지리 좌표가 매우 다양해서 동일 지점이라고 해도 어느 공간 좌표계(SRS)를 사용하느냐에 따라 표시 방법이 달라진다.

SRID(Spatial Reference ID, SRS_ID)는 특정 SRS를 지칭하는 고유 번호로 흔히 사용하는 WGS 84 좌표계의 SRID는 4326이다.

 

MySQL 8.0부터 SRID 지원이 추가되면서 SRID를 별도로 지정하지 않으면 SRID가 0인 평면 좌표계로 인식이 된다. SRID가 0으로 정의된 컬럼에 WGS 84 좌표계를 참조하는 공간 데이터를 저장하려고 하면 에러가 발생한다. 만약 SRID를 명시적으로 정의하지 않으면 해당 컬럼은 모든 SRID를 저장할 수 있지만 SRID가 제각각이면 인덱스 검색이 불가능하기 때문에 명시하는 것이 좋다. 그리고 MySQL 8.0에서 지원되는 공간 함수들이 모든 SRID를 지원하는 것은 아니기 때문에 잘 보고 선택을 해야 한다.

 

MySQL의 공간 인덱스(Spatial Index)는 R-Tree 인덱스 알고리즘을 이용해 2차원의 데이터를 인덱싱하고 검색하는 인덱스이다.

내부 메커니즘은 B-Tree와 흡사한데 B-Tree는 인덱스를 구성하는 컬럼의 값이 1차원 scalar 값인 반면, R-Tree는 2차원의 공간 개념 값이다.

 

MySQL의 공간 확장(Spatial Extension)을 이용하면 지도, 위치 기반의 서비스를 구현하는데 큰 도움이 된다.

 

Spatial Extension 기능

- 공간 데이터를 저장할 수 있는 데이터 타입

- 공간 데이터의 검색을 위한 공간 인덱스(R-Tree)

- 공간 데이터의 연산 함수(거리 또는 포함 관계의 처리)

 

공간 데이터 타입

- 공간 정보의 저장 및 검색을 위해 여러 가지 기하학적 도형 정보를 관리할 수 있는 데이터 타입으로 Geometry는 Point, Line, Polygon의 부모 타입이다

 

MBR(Minimum Bounding Rectangle)이란 공간 데이터 타입의 도형을 감싸는 최소 크기의 사각형을 의미한다. 그리고 MBR의 포함 관계를 B-Tree 형태로 구현한 인덱스가 R-Tree 인덱스다.

 

 

아래 그림을 보면 공간 데이터 도형을 감싸는 MBR이 있고 최소 MBR 사각형들을 그룹 단위로 감싸는 MBR이 있다. 그리고 이러한 MBR을 감싸는 MBR이 있다. 이렇게 공간 데이터의 영역을 포함 관계로 인덱스를 만들면 일정 반경 내의 검색을 인덱스를 활용하여 효율적으로 할 수 있게 된다.

 

R-Tree 인덱스를 이용하기 위해서는 ST_Contains(), ST_Within() 등과 같은 포함 관계를 비교하는 함수로 검색을 해야 한다. 예제를 찾아보면 ST_Distance(), ST_Distance_Sphere()를 이용한 경우도 보이는데 이 함수는 공간 인덱스를 사용하지 못한다. 

 

열심히 썼는데 글이 날아가서 자세한 부분은 문서를 보고 사용하는 것이 좋을 것 같다 ㅠㅠ

 

MySQL :: MySQL 8.0 Reference Manual :: 12.16.1 Spatial Function Reference

MySQL 8.0 Reference Manual  /  ...  /  Functions and Operators  /  Spatial Analysis Functions  /  Spatial Function Reference 12.16.1 Spatial Function Reference The following table lists each spatial function and provides a short description of eac

dev.mysql.com

 

ST_Buffer()를 사용하면 특정 위치로부터 반경(m)에 해당하는 점들을 반환하는데 ST_Buffer_Strategy()를 추가할 수 있다. 기본 point 전략이 ST_Buffer_Strategy(point_circle, 32)로 조회를 해보면 33개의 좌표로 이루어진 Polygon 타입을 반환한다.

참고로 WGS 84 좌표계는 POINT(위도, 경도)로 입력을 해야 되는데 SRID 값에 따라 POINT(경도, 위도)인 좌표계도 있어서 스펙을 찾아보고 해야 한다.

 

ST_Buffer()를 통해 범위를 결정하고 ST_Contains() 같은 MBR을 이용해서 포함 관계를 비교하는 함수를 통해 일정 반경 내의 장소들을 검색할 수 있다. 인덱스를 설정하고 더미 데이터를 10000건 정도 넣어서 확인을 해봤는데 range 접근 방식으로 데이터를 읽는 것을 확인할 수 있다.

 

ST_Distance_sphere() 함수를 사용했을 때는 풀 테이블 스캔을 하기 때문에 주의해야 한다.

 

 

[참고]

Real MySQL 8.0

'데이터베이스' 카테고리의 다른 글

[Real MySQL] 실행 계획  (0) 2024.01.15
[Real MySQL] 인덱스  (0) 2023.04.16
[Real MySQL] MySQL의 격리 수준  (0) 2022.12.03
[Real MySQL] MySQL 아키텍처  (0) 2022.12.02
[MyBatis] 마이바티스 스프링 연동 모듈  (0) 2022.07.13

+ Recent posts