스프링 부트 Hibernate-Spatial + QueryDSL 반경 검색 (MySQL)

Jang990·2023년 8월 11일
1

이전 글에서는 JPA와 QueryDSL을 사용하여 주어진 거리 반경내에 있는 고객을 검색하는 기능을 만들어봤습니다.

이번 글에서는 Hibernate-SpatialQuerydsl-Spatial을 함께 이용해서
반경검색 기능을 QueryDSL을 활용하여 동적쿼리로 만들어보겠습니다.

시작

Hibernate-Spatial

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버전대로 진행합니다.

build.gradle 설정

// 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'

yml 설정

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는 제거해도 된다고 생각했습니다.


Point로 변경하기

이전 글에서는 위도와 경도를 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/

QueryDSL 변경하기

이전 글에서는 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 - 현재 위치를 활용해서 거리순으로 정렬한 후 고객 이름이 포함된 고객들을 조회합니다.

JPQL 사용하기

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 Template 사용하기

JDBC를 사용해서 쿼리문들 만들고 StringBuilder와 같은 것을 활용해서 동적으로 쿼리를 작성해야 합니다.

이 방법이 사실 가장 단순하고 좋은 방법인 것 같습니다.

내가 한 방식 - QueryDSL 유지

여러 오류를 마주치고 몇시간 동안 시달렸으니
결과적으로 JDBC Template을 사용하는게 훨씬 좋은 방법이였던 것 같습니다.

그래도 일단 QueryDSL을 유지하도록 했습니다.

다시 공간과 관련된 Dialect인MySQL8SpatialDialectST_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 ?
profile
공부한 내용을 적지 말고 이해한 내용을 설명하자

0개의 댓글