Hibernate Spatial, 그리고 MySQL과 함께하는 QueryDSL을 활용한 위치 기반 쿼리

TAEYONG KIM·2024년 6월 21일
2

MySQL

목록 보기
5/6

상황

위치 기반의 서비스를 제공하는 애플리케이션에서 위도와 경도를 기반으로 주변 케이크 샵을 제공해야 하는 서비스를 구현해야 하는 상황이었습니다.

제가 생각한 방법은 2가지가 있습니다.
첫 번째, 조건에 맞는 케이크 샵들을 모두 영속성 컨텍스트로 불러와서 위도와 경도 값을 기반으로 반경 km 내에 있는 케이크 샵들을 필터링하는 로직
두 번째, 데이터가 저장된 디스크 즉, 데이터베이스에서 데이터들을 필터링하고 검색하여오는 로직

모두가 예상할 수 있지만, 데이터 크기가 커질수록 JPA를 활용하는 Spring Boot 애플리케이션 로직에서 필터링하는 작업은 매우 큰 부담입니다.
우리가 페이징하는 이유도 비슷한 이유라고 할 수 있을 것 같습니다.

이를 줄이기 위해 데이터베이스에서 제공하는 인덱스와 함수를 바탕으로 성능을 최적화시킬 수 있습니다.이에 적합한 방법이 MySQL에서 제공하는 위도와 경도를 기반으로 한 Data Type인 Geometry를 활용하는 것입니다.


Hibernate와 MySQL

Hibernate

우리가 사용하는 JPA는 ORM프레임워크를 단순하게 Java 진영에서 사용할 수 있도록 제공해주는 API일 뿐이지 프레임워크가 아닙니다. 구체적으로는 Hibernate라는 ORM 프레임워크를 통해 이용하는 것이고 더 나아가, JDBC를 통해 사용하는 쿼리들이 객체지향 Mapping이 되어 있는 즉, 추상화되어있는 것입니다.
이에 대해, 깊이 이야기하기에는 주제와 맞지 않기 때문에 넘어가겠습니다.

따라서, Hibernate Spatial은 geographic data를 핸들링하기 위해 개발되었음. 5.0 버전 이후로, Hibernate Spatial은 Hibernate ORM 프로젝트 일부가 되었고, 표준적인 방법에서 geographic data를 다룰 수 있게 해줍니다.

Spatial data type는 Java standard library의 일부도 아니고, JDBC 명세에도 없음.

Hibernate Spatial은 두 가지의 geometry models를 지원합니다.
JTS(https://www.tsusiatsoftware.net/jts/main.html)와
geolatte-geom(https://github.com/GeoLatte/geolatte-geom)

Geolatte-geom(A geometry model for Java)은 데이터베이스 네이티브 타입을 위해 encoder/decoder을 구현하였습니다. hibernate-spatial type를 사용하기 위해서는 의존성을 추가해야 합니다.

Dependencies에 추가

implementation 'org.hibernate.orm:hibernate-spatial:6.4.4.Final'
implementation 'com.querydsl:querydsl-spatial'

MySQL


MySQL에서는 다음과 같은 Geometry 타입을 지원합니다. 또한, MySQL에서 지원하는 Spatial Index(공간 인덱스) R-Tree 인덱스 알고리즘을 이용해 2차원의 데이터를 인덱싱하고 검색할 수 있습니다
내부 메커니즘은 B-Tree와 흡사합니다
R-Tree 알고리즘에서는 MBR(Minimum Bounding Rectangle) 즉, 해당 도형을 감싸는 최소 크기의 사각형을 기반으로 동작합니다
고도화함에 따라 다른 TYPE를 선택할 수 있겠지만 쉽고 빠르게 적용하기 위해 POINT를 선택한 이유가 될 수 있습니다.

즉, POINT를 중심으로 최소 크기의 사각형이 생긴다고 바라볼 수 있습니다
예를 들어, 해당 POINT를 중심으로 5km 이내에 POINT를 검색한다고 할 때, 반지름 5km, 지름 10km의 원을 그리게 됩니다.
이후, 원을 기반으로 사각형을 그리면 최소 크기의 사각형을 만들 수 있습니다
결과적으로, 이 사각형 내에 있는 POINT를 탐색할 수 있습니다.

대표적인 함수들
https://dev.mysql.com/doc/refman/8.0/en/spatial-function-reference.html

주의사항

MySQL Documentation을 참고하자면,
For comparisons to work properly, each column in a SPATIAL index must be SRID-restricted. That is, the column definition must include an explicit SRID attribute, and all column values must have the same SRID.

SRID 속성에 대해 Column Definition을 포함해야 한다고 정의되어 있습니다.
그래서 우리 팀은 Entity에서도 해당 Definition을 정의하고 있습니다.

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "cake_shop")
public class CakeShop extends AuditEntity {
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = "shop_id", nullable = false)
      private Long id;

      @Column(name = "thumbnail_url", length = 200)
      private String thumbnailUrl;

      @Column(name = "shop_name", length = 30, nullable = false)
      private String shopName;

      @Column(name = "shop_address", length = 50)
      private String shopAddress;

      @Column(name = "shop_bio", length = 40)
      private String shopBio;

      @Column(name = "shop_description", length = 500)
      private String shopDescription;

      @Column(name = "location", nullable = false, columnDefinition = "POINT SRID 4326")
      private Point location;
    ...
 }

여기서 4326은 4326(WGS84)를 의미하며, 우리가 흔히 아는 위도 경도 좌표 시스템을 의미합니다.
Naver, Kakao 등 Map API에서도 위도 경도를 제공할 때, 해당 표준을 따라서 제공하고 있습니다.

요구사항

사용자 위치를 중심으로 반경 5km내의 케이크 샵 가게들을 조회해야 하는 요구사항이 발생했을 때, 우리는 Hibernate에서 제공하는 dwithin(Returns true if the geometries are within the specified distance of one another)를 활용할 수 있습니다.

따라서 아주 쉽게 구현할 수 있다고 생각했지만 가장 큰 이슈는 MySQL 데이터베이스에 대해서는 지원하지 않는다는 점입니다. 여기서 자꾸 에러가 터져서 분석하느라 많은 시간을 소비했고, 문서를 읽으면서 알 수 있었습니다.

문서를 잘 읽고 개발하는 습관은 중요한 것 같습니다😅

따라서 MBR을 기반으로 Spatial Index를 활용할 수 있는 함수들은 MySQL 공식 문서에 의하면, ST_Contains(g1, g2)를 활용할 수 있겠습니다.

  • ST_Contains(g1, g2): 첫 번째 파라미터는 탐색하고 싶은 범위, 두 번째 파라미터는 존재하는지 확인하고 싶은 Geometry Type
  • ST_BUFFER(:point, 5000)은 첫번째 파라미터가 Point 변수. 즉, 사용자 위치를 통해 경도와 위도를 담고 있는 point를 중심으로 5000 meter 즉 반경 5KM의 사각형을 범위로 만드는 함수입니다

따라서, 서버에서는 ST_BUFFER 함수를 활용해 사용자 기반 위도와 경도 값을 받으면 Point변수를 만들 수 있습니다.

이를 기반으로 데이터베이스에 저장되어 있는 케이크 샵들을 탐색하는 쿼리를 아래에서 소개하겠습니다

JPQL을 활용한 방법


import java.util.List;

import org.locationtech.jts.geom.Point;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import com.cakk.domain.mysql.entity.shop.CakeShop;

public interface CakeShopJpaRepository extends JpaRepository<CakeShop, Long> {

	@Query(value = "select CS from CakeShop as CS where ST_CONTAINS(ST_BUFFER(:point, 5000), CS.location)")
	List<CakeShop> findByLocationBased(Point point);
}

JPQL을 활용하는 방법은 간단하지만,
우리 팀에서는 추가적으로 요구하는 조건들이 존재했습니다.

조건1. 페이지 크기를 입력 받으면 해당 크기만큼 페이징 해주기
조건2. 검색어가 존재하면, 해당 검색어를 통해 만족하는 케이크 샵 가져오기
조건3. 사용자가 위치 기반 반경 5km내 케이크 샵 가져오기

이런 조건들을 동적으로 만족시켜주기 위해서 많은 프로젝트에서 QueryDSL을 활용하고 있습니다.
QueryDSL에서는 해당 ST_CONTAINS를 활용하기 위해서는
Expressions.booleanTemplate을 활용할 수 있습니다.

QueryDSL을 활용한 방법

public List<CakeShop> findByKeywordWithLocation(
		Long cakeShopId,
		String keyword,
		Point location,
		Integer pageSize
	) {
		return queryFactory
			.selectFrom(cakeShop)
			.innerJoin(cakeShop.businessInformation).fetchJoin()
			.leftJoin(cakeShop.cakes).fetchJoin()
			.leftJoin(cakeShop.cakeShopOperations).fetchJoin()
			.where(
				includeDistance(location).and(containKeyword(keyword)), ltCakeShopId(cakeShopId)
			)
			.orderBy(cakeShopIdDesc())
			.limit(pageSize)
			.fetch();
	}
    
    private BooleanExpression includeDistance(Point location) {
		if (isNull(location)) {
			return null;
		}

		return Expressions.booleanTemplate("ST_Contains(ST_BUFFER({0}, 5000), {1})", location, cakeShop.location);
	}
    
    
    private BooleanBuilder containKeyword(String keyword) {
		BooleanBuilder builder = new BooleanBuilder();

		if (nonNull(keyword)) {
			builder.or(containsKeywordInShopBio(keyword));
			builder.or(containsKeywordInShopDesc(keyword));
		}

		return builder;
	}
    
    private BooleanExpression ltCakeShopId(Long cakeShopId) {
		if (isNull(cakeShopId)) {
			return null;
		}

		return cakeShop.id.lt(cakeShopId);
	}

다음 목표

  1. 쿼리 실행 계획과 Spatial Index를 통해 성능 개선해보기
  2. 서비스 운영과 함께 쿼리 로그 분석해보기

감사합니다.

자세한 코드를 보고 싶다면 Github을 방문해주세요!

Reference

profile
백엔드 개발자의 성장기

0개의 댓글