이전 글에서는 JPA와 QueryDSL을 사용하여 주어진 거리 반경내에 있는 고객을 검색하는 기능을 만들어봤습니다.
이번 글에서는 Hibernate-Spatial
과 Querydsl-Spatial
을 함께 이용해서
반경검색 기능을 QueryDSL을 활용하여 동적쿼리로 만들어보겠습니다.
Hibernate Spatial은 지리 데이터를 계산하기 위해 만들어 졌고, Hibernate 5.0 버전 부터 Hibernate 라이브러리에 공식적으로 마이그레이션이 됐다.
현재 지원하는 데이터베이스는 Oracle, PostgreSQL, MySQL, MSSQL, H2이고, 각 데이터베이스에 구현 되어있는 지리 데이터처리 구현체를 추상화한 인터페이스가 Hibernate Spatial이다.
Hibernate Spatial은 JTS와 geolatte-geom이라는 기하학 모델을 제공한다고 한다.
이러한 GIS(Geometry Information System)를 Native Query로 날리지 않고 Hibernate에 추상화된 함수를 통해 JPQL로 쉽게 짤 수 있다.
출처 - https://seongsu.me/skill/hibernate-spatial/
해당 글은 다음 환경에서 진행합니다.
SpringBoot 2.7.11
Java 11
JPA
MySQL
SpringBoot 3버전대에서 진행하려 했는데 querydsl plugin 부분의 문제로 distance
, distanceSphere
가 나오지 않아서 2버전대로 진행합니다.
// QueryDSL 설정
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// querydsl 설정
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"
// === 하이버네이트 공간 검색 특화 - 추가됨 ===
implementation 'org.hibernate:hibernate-spatial'
implementation 'com.querydsl:querydsl-spatial'
// ========================================
}
// querydsl 설정 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
// querydsl 설정 끝
이전 글의 설정에서 spatial과 관련된 hibernate와 querydsl의 의존성이 추가되었습니다.
implementation 'org.hibernate:hibernate-spatial'
implementation 'com.querydsl:querydsl-spatial'
spring:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
show_sql: true
# dialect: com.map.gaja.global.config.MySQLCustomDialect # 주석처리
dialect: org.hibernate.spatial.dialect.mysql.MySQL8SpatialDialect
이전에는 JPA와 QueryDSL을 사용했기 때문에 MySQLCustomDialect
이라는 클래스를 만들어서 방언을 등록해주었습니다.
하지만 이제 이런 부분은 hibernate-spatial
에 만들어져 있는 클래스를 사용하고,
이에따라 이전 글에서 만든 MySQLCustomDialect
는 제거해도 된다고 생각했습니다.
이전 글에서는 위도와 경도를 Double형으로 저장했습니다.
이제는 org.locationtech.jts.geom.Point
형으로 저장하면 됩니다.
import lombok.*;
import org.locationtech.jts.geom.*;
import javax.persistence.Column;
import javax.persistence.Embeddable;
@Embeddable
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ClientLocation {
@Column(columnDefinition = "POINT SRID 4326")
private Point location;
}
org.locationtech.jts.geom.Point
는 다음과 같은 방식으로 만들 수 있습니다.
public Point createPoint(double latitude, double longitude) {
GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
return geometryFactory.createPoint(new Coordinate(longitude, latitude));
}
GeometryFactory
는 thread-safe하다고 나와있기 때문에 메소드 호출시마다 생성하지 않고 static 필드로 빼서 사용해도 괜찮습니다.(?)
@Column(columnDefinition = "POINT SRID 4326")
해당 코드를 통해 MySQL에서 해당 테이블에 SRID 4326의 데이터만 저장되도록 만들 수 있습니다.(PostGis는 다르게 설정해야 합니다.)
SRID란 공간 참조 식별자(Spatial Reference Identifier)의 줄임말입니다.
GPS 의 기준이 되는 WGS84 시스템은 SRID 4326, 단순 직교 좌표계는 SRID 0 입니다.
https://chang12.github.io/mysql-geospatial-index-2/
이전 글에서는 NativeSqlCreator
라는 것을 만들어서
QueryDSL에서 MySQL의 함수인 ST_Distance_Sphere
를 처리하는 코드를 만들었습니다.
지금은 querydsl-spatial
를 추가해줬기 때문에 NativeSqlCreator
또한 사용하지 않아도 된다고 생각했습니다.
이해를 위해 전체코드를 써놓지만 getCalcDistanceNativeSQL
에서 getCalcDistance
로 메소드 내의 코드의 변화만 봐도 충분합니다.
public List<ClientResponse> findClientByConditions(NearbyClientSearchRequest locationSearchCond, String wordCond, Pageable pageable) {
List<ClientResponse> result = query.select(
Projections.constructor(ClientResponse.class,
...
)
)
.from(client)
.where(
nameContains(wordCond), // 동적 쿼리 - 위치 계산과는 상관없음
isClientInRadius(locationSearchCond) // 반경 내에 Client 필터링
)
.orderBy(distanceAsc(locationSearchCond), client.createdDate.desc())
...
.fetch();
...
}
// 반경 내에 Client가 있는지
private BooleanExpression isClientInRadius(NearbyClientSearchRequest locationSearchCond) {
if(isLocationSearchCondEmpty(locationSearchCond)) {
return null;
}
LocationDto currentLocation = locationSearchCond.getLocation();
return getCalcDistanceNativeSQL(currentLocation)
.loe(locationSearchCond.getRadius());
}
// 핵심! NativeSQL 생성 - 변경될 부분
private NumberExpression<Double> getCalcDistanceNativeSQL(LocationDto currentLocation) {
return mysqlNativeSQLCreator.createCalcDistanceSQL(
currentLocation.getLongitude(), currentLocation.getLatitude(),
client.location.longitude, client.location.latitude
);
}
public List<ClientResponse> findClientByConditions(List<Long> groupIdList, NearbyClientSearchRequest locationSearchCond, @Nullable String wordCond) {
...
List<ClientResponse> result = query.select(
Projections.constructor(ClientResponse.class,
...
getCalcDistanceWithNativeSQL(locationSearchCond.getLocation()) // 현재 위치에서 몇 미터 떨어져있는지 Response에 담아줘야 함
)
)
.from(client)
.join(client.group, group)
.leftJoin(client.clientImage, clientImage)
.where(nameContains(wordCond), isClientInRadius(locationSearchCond), groupIdEq(groupIdList))
.orderBy(distanceAsc(locationSearchCond), client.createdAt.desc())
...
.fetch();
return result;
}
private OrderSpecifier<?> distanceAsc(NearbyClientSearchRequest locationCond) {
return getCalcDistanceWithNativeSQL(locationCond.getLocation()).asc();
}
private BooleanExpression isClientInRadius(NearbyClientSearchRequest locationSearchCond) {
if (locationSearchCond.getRadius() == null) {
return null;
}
LocationDto currentLocation = locationSearchCond.getLocation();
return getCalcDistanceWithNativeSQL(currentLocation)
.loe(locationSearchCond.getRadius());
}
// 핵심! 변경된 부분 - 오류가 발생합니다.
private NumberExpression<Double> getCalcDistance(LocationDto currentLocation) {
ClientLocation clientLocation = new ClientLocation(currentLocation.getLatitude(), currentLocation.getLongitude());
return client.location.location.distanceSphere(clientLocation.getLocation());
}
이렇게 ST_Distance
를 통해 거리를 구하는 코드는 오류가 발생하지 않습니다.
return client.location.location.distance(clientLocation.getLocation());
하지만 QueryDSL을 통해 MySQL의 ST_Distance_Sphere
를 사용하려고 하면 알아먹지 못하고 오류가 발생합니다.
관련글: stackoverflow - Querydsl and MySQL - distancesphere does not exist
return client.location.location.distanceSphere(clientLocation.getLocation());
구 안에서 두 좌표 사이의 거리:
ST_Distance_Sphere
평면안에서 두 좌표 사이의 거리:ST_Distance
ST_Distance
를 사용하면 오차가 발생할 수 있으니 ST_Distance_Sphere
를 사용해야합니다.
그렇게 된다면 다시 이전 글에서 만든 NativeSqlCreator
를 사용하는 상황이 발생합니다.
이렇게 된다면 굳이 spatial과 관련된 querydsl의 의존성을 추가할 이유가 없습니다.
지금 상황은 "동적 쿼리" + "반경 검색 기능"을 충족하는 코드를 작성해야 하는 상황입니다.
hibernate-spatial
을 추가했기 때문에 JPQL로 반경 검색 기능을 만들 수 있으나 QueryDSL을 활용하여 동적 쿼리를 만들기는 힘든 상황입니다.
현재는 동적쿼리를 만드는 4가지 상황이 있습니다.
현재위치정보 - 고객이름정보
1. 현재위치정보 X, 고객이름정보 X - 생성된 순으로 모든 고객을 조회합니다.
2. 현재위치정보 O, 고객이름정보 X - 현재 위치를 활용해서 거리순으로 모든 고객을 조회합니다.
3. 현재위치정보 X, 고객이름정보 O - 생성된 순으로 고객 이름이 포함된 고객들만 조회합니다.
4. 현재위치정보 O, 고객이름정보 O - 현재 위치를 활용해서 거리순으로 정렬한 후 고객 이름이 포함된 고객들을 조회합니다.
Hibernate-Spatial을 추가했기 때문에 JPQL에 사용하고 Point 자료형을 사용하여 공간 관련 함수에 대한 값을 넣을 수 있습니다.
다음과 같이 4가지 상황의 메소드를 만들어서 if문으로 분기처리하면 될 것입니다.
public interface ClientRepository extends JpaRepository<Client, Long> {
...
@Query(value = "SELECT NEW com.a....(정보들) " +
"FROM Client c .... " +
"WHERE 1번상황 ... " +
"ORDER BY ...")
List<ClientResponse> findCase1();
@Query(value = "SELECT NEW com.a....(정보들+거리정보) " +
"FROM Client c .... " +
"WHERE 2번 조건 ... ST_Distance_Sphere(...) " +
"ORDER BY ...")
List<ClientResponse> findCase2(Point location);
@Query(value = "SELECT NEW com.a....(정보들) " +
"FROM Client c .... " +
"WHERE 3번 조건 ... " +
"ORDER BY ...")
List<ClientResponse> findCase3(String name);
@Query(value = "SELECT NEW com.a....(정보들+거리정보) " +
"FROM Client c .... " +
"WHERE 4번 조건 ... ST_Distance_Sphere(...) " +
"ORDER BY ...")
List<ClientResponse> findCase4(Point location, String name);
}
JDBC를 사용해서 쿼리문들 만들고 StringBuilder
와 같은 것을 활용해서 동적으로 쿼리를 작성해야 합니다.
이 방법이 사실 가장 단순하고 좋은 방법인 것 같습니다.
여러 오류를 마주치고 몇시간 동안 시달렸으니
결과적으로 JDBC Template을 사용하는게 훨씬 좋은 방법이였던 것 같습니다.
그래도 일단 QueryDSL을 유지하도록 했습니다.
다시 공간과 관련된 Dialect인MySQL8SpatialDialect
에 ST_Distance_Shphere
를 추가했습니다. yml 설정에서도 dialect를 MySQLCustomDialect
로 변경해주었습니다.
public class MySQLCustomDialect extends MySQL8SpatialDialect {
public MySQLCustomDialect() {
super();
this.registerFunction("ST_Distance_Sphere",new StandardSQLFunction("ST_Distance_Sphere", StandardBasicTypes.DOUBLE));
}
}
Hibernate-Spatial 형식에 맞도록 Native쿼리 생성 방법을 약간 수정했습니다.
이전 방법처럼 Expressions.stringTemplate
을 활용해서 현재 위치를 넣으려 했으나 오류가 발생해서 StringFormat
을 사용해서 쿼리를 만드는 방법을 선택했습니다.
@Component
public class MysqlNativeSqlCreator implements NativeSqlCreator {
/*
// 이전 방식
public NumberExpression<Double> createCalcDistanceSQL(Double latitudeCond, Double longitudeCond,
NumberPath<Double> dbLatitude, NumberPath<Double> dbLongitude) {
return Expressions.numberTemplate(Double.class,"ST_Distance_Sphere({0}, {1})",
Expressions.stringTemplate("POINT({0}, {1})",
latitudeCond,
longitudeCond
),
Expressions.stringTemplate("POINT({0}, {1})",
dbLatitude,
dbLongitude
)
);
}
*/
static final String radiusSearchQueryTemplate = "ST_Distance_Sphere(ST_GeomFromText('%s', 4326), {0})";
static final String currentLocationPointFormat = "Point(%f %f)";
@Override
public NumberExpression<Double> createCalcDistanceSQL(Point currentLocation, JTSPointPath dbLocation) {
String radiusSearchQuery = createRadiusSearchQuery(currentLocation);
return Expressions.numberTemplate(Double.class, radiusSearchQuery, dbLocation);
}
private String createRadiusSearchQuery(Point currentLocation) {
String currentLocationPoint = String.format(currentLocationPointFormat, currentLocation.getY(), currentLocation.getX());
return String.format(radiusSearchQueryTemplate, currentLocationPoint);
}
}
왠만하면 이런 공간함수를 사용할 때는 JDBC Template을 사용하는 것이 좋을 것 같습니다.
select
client0_.client_id as col_0_0_,
...
ST_Distance_Sphere(ST_GeomFromText('Point(35.006000 125.006000)', 4326), client0_.location) as col_10_0_,
client0_.created_at as col_11_0_
from
client client0_
--조인문 생략.--
where
(
client0_.name like ? escape '!'
)
and ST_Distance_Sphere(ST_GeomFromText('Point(35.006000 125.006000)',4326), client0_.location)<=?
and client0_.group_id=?
order by
ST_Distance_Sphere(ST_GeomFromText('Point(35.006000 125.006000)', 4326), client0_.location) asc,
client0_.created_at desc limit ?