QueryDSL로 spatial 쿼리를 작성해보자!

eora21·2024년 3월 16일
0

너나드리 개발기

목록 보기
3/8

Geometry 조회 코드를 작성하던 중, QueryDSL에서 이를 지원해준다는 것을 알게 되었습니다.
그러나 코드 구성을 소개하는 글은 찾지 못 했고, 직접 공식문서를 찾아가며 알게 된 점들을 공유하고자 글을 작성합니다.

데이터 타입부터 확인

QueryDSL로 partial 조회를 구성한다는 것은, JPA/Hibernate에서 Geometry 필드를 사용하고 있다는 뜻일 겁니다.
Geometry는 총 3가지 패키지로 구분이 되어 있으며, 이를 위해 QueryDSL도 3가지의 패키지로 구분되어 있습니다. 본인이 어떤 것을 사용 중인지부터 파악하는 게 좋습니다.

3가지 패키지는 다음과 같습니다.

  • org.geolatte.geom
  • com.vividsolutions.jts.geom
  • org.locationtech.jts.geom

vividsolutions는 locationtech의 과거 버전이라고 보시면 될 것 같습니다.

각각을 Expression으로 변환해 주는 클래스는 다음과 같습니다.

  • com.querydsl.spatial.GeometryExpressions
  • com.querydsl.spatial.jts.JTSGeometryExpressions
  • com.querydsl.spatial.locationtech.jts.JTSGeometryExpressions

해당하는 부분을 확인하시고, 본인에게 맞는 Expressions를 사용하시면 됩니다. 참고로 저는 org.locationtech.jts.geom을 사용했습니다.

JPA, QueryDSL 종속성 작성

클래스 패키지에서 보셨듯, spatial 타입을 사용하시려면 따로 종속성을 작성해주셔야 합니다.
JPA, QueryDSL 둘 다 spatial을 사용하기 위해 build.gradle에 추가한 내용을 작성하겠습니다.

implementation 'org.hibernate:hibernate-spatial:6.4.1.Final'
implementation "com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
implementation "com.querydsl:querydsl-sql-spatial:${dependencyManagement.importedProperties['querydsl.version']}"

annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor 'org.projectlombok:lombok'

해당 부분에서 필요한 것만 고르신 후 작성하시면 되겠습니다(querydsl 버전 명시 부분은 사용중이신 버전으로 직접 작성해도 무방합니다).

조건 작성

spatial 조건은 대부분 where 또는 on 내에 사용되는 필터링으로 작성될 것이므로, 이 부분을 중점적으로 작성하도록 하겠습니다.

제 프로젝트를 기준으로 작성하고자 하는 쿼리는 다음과 같습니다.

...WHERE ST_Within(location.point, ST_Buffer(ST_GeomFromText('POINT(0 0)', 4326), 1000));

각 좌표계(4326)에서 location.point가 (0, 0)을 기준으로 1km 이내에 있는지 확인하고자 하는 쿼리입니다.

여러 구현 방법이 있겠지만, point는 객체로 생성하여 사용하고, ST_Buffer는 쿼리를 사용하도록 하겠습니다.

QueryDSL 코드 설명

작성된 코드는 다음과 같습니다.

.where(Expressions.booleanOperation(SpatialOps.WITHIN, location.point,
                JTSGeometryExpressions.asJTSGeometry(
                                locationSupplier.newPoint(latLng.lat, latLng.lng))
                        .buffer(geometryDistance.distance())),
    ...
)
.fetch();

이대로 복사하셔도 정상 동작되진 않을 것입니다. 코드 구성을 조금 말씀드리면서, 어떠한 구조로 이루어 진 것인지 확인하도록 하겠습니다.

Point 생성

point는 객체로 생성하여 넘기는 것이 좀 더 간편할 것 같다는 생각이 들었습니다. 따라서 직접 작성한 locationSupplier를 사용하였습니다. 만약 쿼리에서만 point를 사용하신다면 JTSGeometryExpressions.fromText를 사용해 보세요.

다만 이미 프로젝트 여러 군데에서 point를 사용하신다면 생성한 객체 그대로 쓰시는 게 좀 더 안전할 것 같습니다.

GeometryFactory

우선 point를 생성하기 위해 사용할 GeometryFactory부터 살펴봅시다.

private static final int WGS84_SRID = 4326;

@Bean
public GeometryFactory geometryFactory() {
    return new GeometryFactory(new PrecisionModel(), WGS84_SRID);
}

각좌표계를 위해 4326을 앞서 등록하는 GeometryFactory입니다. 해당 팩토리를 이용하여 만들어진 Geometry 객체들은 모두 SRID가 4326이 됩니다.

PrecisionModel()은 정확도와 관련이 있는데, 저는 기본 설정을 적용하기 위해 단순히 객체를 생성해서 넣어 주었습니다.

locationSupplier.newPoint

public Point newPoint(double lat, double lng) {
    return geometryFactory.createPoint(new Coordinate(lng, lat));
}

간단하게 팩토리에서 point를 만들어 반환하는 코드입니다.
각좌표계에서는 lat-lng 순서가 아닌 lng-lat 순서로 생성되어야 합니다. 따라서 Coordinate 생성 시 파라미터 순서를 변경하여 작성하였습니다.

JTSGeometryExpressions.asJTSGeometry()

JTSGeometryExpressions.asJTSGeometry(point)

앞서 point가 객체로 생성된 경우, 이를 표현식으로 바꿔줘야 합니다. 따라서 JTSGeometryExpressions.asJTSGeometry를 통해 표현식으로 래핑해 주었습니다.

buffer

JTSGeometryExpressions.asJTSGeometry(point).buffer(distance)

표현식으로 변경한 point를 buffer 메서드와 파라미터를 통해 ST_Buffer를 구현하도록 했습니다.
이 또한 객체 자체를 사용하여 전달할 수 있습니다.
org.locationtech.jts.precision.EnhancedPrecisionOp.buffer()를 사용하면 geometry를 buffer로 확장하여 반환받을 수 있습니다.
point.buffer()를 사용해도 동일하게 동작합니다.

다만, 그렇게 만들어진 geometry는 point의 경우 32개의 꼭지점을 가지는 거대한 polygon 형태입니다. 이를 직접 내보내는 것보다 db에서 ST_Buffer 연산을 하는 것이 더 효율적이지 않을까 합니다.

SpatialOps.WITHIN

Expressions.booleanOperation(SpatialOps.WITHIN, location.point, buffer)

SpatialOps.WITHIN은 ST_Within을 나타냅니다.
이를 통해 location.point가 buffer에 속하는지 확인하게 됩니다.
또한 where 내에는 booleanOperation을 비롯한 predicate가 와야 하므로, 해당 코드를 통해 where 내에서 동작하게끔 작성할 수 있습니다.
WITHIN 말고도 SpatialOps에 정의된 enum 객체들을 통해 원하는 연산을 골라 작성할 수 있습니다.

쿼리 확인

ST_Within, ST_Buffer 쿼리가 정상적으로 생성되는 것을 확인할 수 있습니다.

체이닝

순서를 살짝 바꿔서, 좀 더 쉽게 작성해 봅시다.
현재 연산에 대해 단계적으로 생각해보자면,

  • 제공받은 파라미터를 통해 Point를 만든다.
  • 해당 Point에 Buffer 연산을 한다.
  • Buffer로 만든 영역에 location.point가 속해 있는지 확인한다.

와 같은 순서입니다. 이를 순서대로 체이닝을 통해 구현할 수 있습니다.

.where(JTSGeometryExpressions.asJTSGeometry(locationSupplier.newPoint(latLng.lat, latLng.lng))
                .buffer(geometryDistance.distance())
                .contains(location.point)

파라미터의 순서가 바뀌었기 때문에 .within() 대신 .contains()를 사용하였고, 더 깔끔한 코드를 만들 수 있었습니다.

제 글이 원하시는 쿼리 작성에 도움이 된다면 좋겠습니다.

Reference

http://querydsl.com/static/querydsl/latest/reference/html/ch02s04.html
https://javadoc.io/doc/com.querydsl/querydsl-spatial/latest/com/querydsl/spatial/package-summary.html
https://github.com/querydsl/querydsl/tree/master/querydsl-spatial/src/main/java/com/querydsl/spatial
https://velog.io/@sdsd0908/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-Hibernate-Spatial-QueryDSL-%EB%B0%98%EA%B2%BD-%EA%B2%80%EC%83%89-%EC%8B%A4%ED%8C%A8MySQL

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글