JPA + MariaDB로 위도/경도 기반 위치 검색 구현하기

zwundzwzig·2023년 11월 3일
0

클라이언트에서 위도, 경도 위치 값과 범위를 요청으로 보내면, DB에서 해당 반경 내 존재하는 모든 값을 응답하는 기능 구현기

이전 환경

array_of_pos : 사용자가 뛴 경로 좌표값을 확보한 컬럼

  • 처음에 아무 생각없이 마리아DB 내에 text 컬럼으로 생성했다..
  • 그리고 자바에선 별도의 Dto를 List에 담아 저장했으며 컨버터 하나 만들어서 구현해놨다.
  • 그냥 막연하게 나중에 거리 구현할 때 가져와서 Double로 파싱하고 하면 되겠지.. 싶었다. (확실히 경험하지 못한 기능의 스키마를 짤 땐 나중에 고생하게 됨을 다시금 느꼈다..)
    @Column(columnDefinition = "TEXT")
    @Convert(converter = CoordinateConverter.class)
    private List<CoordinateDto> arrayOfPos;

이렇게 DB를 구축하니까 도저히 위치 검색 기능을 어떻게 해야할 지 감이 안 왔다.

처음 계획했던대로 라면 객체 형태로 돼 있는 해당 값을 가져와서 파싱하고 해야 한다고 생각했다.

결과적으로 이러나 저러나 지리 데이터를 제공하는 자료구조가 필요하다고 느꼈다.

변경

우선, 설정부터

// build.gradle
implementation 'org.hibernate.orm:hibernate-spatial:{hibernate vesion}'

// application.yml
spring:
  jpa:
    properties:
      hibernate:e
        dialect: org.hibernate.dialect.MariaDBDialect
        spatial:
          enabled: true

JPA로 공간 데이터를 다루기 위해 Hibernate ORM의 정식 라이브러리인 hibernate-spatial을 사용했다.

방언Dialect의 경우, MySql과 다르게 웬만한 패키지가 다 deprecated 돼서 MariaDBDialect만 사용하면 되고, spatial.enabled 값만 바꿔서 공간 데이터 읽을 수 있도록 바꿨다.

이제 해당 컬럼 타입을 변경했다.

import org.locationtech.jts.geom.LineString;

private LineString arrayOfPos;

여기서 사용한 LineString 타입은 jdk에서 제공하지 않는다.

대신 jts 라이브러리가 해당 타입을 제공하며 LineString 이외에 여러 공간 데이터가 있다. 사진을 보자.

내가 프로젝트에서 사용한 타입은 좌표를 위한 Point, 그리고 경로를 위한 LineString 두 개이다. 추후 특정 범위를 정해야 할 때 Polygon 타입도 사용할 수도 있을 거 같다.

좌표 찍기

사용자가 보낸 위도 경도 값을 활용해 좌표 데이터를 구현했다.

public Point createPoint(Double latitude, Double longitude) {
    GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
    Coordinate coordinate = new Coordinate(longitude, latitude);
    return geometryFactory.createPoint(coordinate);
}

GeometryFactory를 사용한 방법 이외에도 WKT를 사용해 좌표 데이터를 만들 수 있다. WKTReader를 사용하면 WKT를 읽어서도 공간 데이터를 만들 수 있다.

public Point createPoint(Double latitude, Double longitude) throws ParseException {
    WKTReader wktReader = new WKTReader();
    Geometry read = wktReader.read("POINT(" + longitude + " " + latitude + ")");
    return (Point) read;  
}

WKTReader의 read() 메서드가 추상클래스인 Geometry타입을 반환하기 때문에 제네릭을 활용해 구하면 된다. 하지만 ParseException를 처리해야 해서 GeometryFactory를 사용했다.

여기서 주목할 점은 위도 경도가 아니라 경도, 위도 순으로 인자를 넣어 인스턴스를 만들었다는 점이다. 지리 정보 시스템(GIS) 및 지리학적 계산에서 일관성을 유지하기 위함이란다.

추가적으로 4326으로 SRID Spatial Reference Identifier 값을 설정했다. SRID는 공간 데이터 좌표를 구분하는 식별 코드라고 한다.

디폴트 값은 0이며, 0일 때는 좌표 데이터에 어떤 공간적 의미도 부여되지 않은 상태이고, 4326은 지구의 전역 좌표 시스템인 WGS84 좌표계에 바인딩된다.. 정도로 이해하고 넘어갔다.

MariaDB 공간 함수 활용하기

이제 좌표를 찍었으니 이 좌표가 다른 공간 데이터와 얼마나 차이가 있는 지 확인해봐야 한다.

다음은 내가 사용한 마리아DB에서 제공하는 공간 함수이다.

함수명설명
ST_GeomFromText(WKT[, SRID])WKT와 SRID를 통해 geometry를 생성
ST_Buffer((Multi) Point, Radius)Point(또는 MultiPoint)로 부터 Radius를 반지름으로 갖는 원을 생성
ST_Contains(geom(a), geom(b))b가 a에 포함되어 있으면 1을 반환, 아니면 0을 반환
ST_AsText(geom)스트링으로 반환 ex) LineString (123 38, 130 33)
ST_Length(LineString)선의 길이 반환
ST_StartPoint(LineString)선의 시작 Point(x, y)를 반환
ST_Distance_Sphere(geom(a), geom(b))두 지점 간의 구면거리 (sphere distance)를 계산
ST_X, ST_Y각각 경도, 위도 값을 반환

우선 DB 콘솔을 통해 테스트를 해봤다.

SET @my_home = Point(127.0569, 37.2876);

SELECT 
	location
    , ST_AsText(ST_StartPoint(array_of_pos)) as start_point
    , ST_Distance_Sphere(ST_STARTPOINT(array_of_pos), @my_home) AS distance_from_my_position 
FROM routes 
WHERE ST_CONTAINS(ST_BUFFER(ST_STARTPOINT(arrayOfPos), 5000), @my_home);

+-----------------+---------------------------------+---------------------------+
| location        | start_point                     | distance_from_my_position |
+-----------------+---------------------------------+---------------------------+
| 서울시 송파구 잠실동 | POINT(127.7777 33.12345)        |        467634.57888031966 |
| 서울시 마포구 서교동 | POINT(122.03073774 37.33128013) |         444494.5345670442 |
| 서울시 종로구 창신동 | POINT(122.03073774 37.33128013) |         444494.5345670442 |
| 서울시 관악구 신림동 | POINT(130.03073774 32.33128013) |         614267.6688142484 |
+------------------+-------------------------------+---------------------------------+---------------------------+

다음은 Repository 내 JPQL 메서드이다.

@Query(value = 
	"SELECT r.id AS seq, r.location AS location" +
          ", ST_X(ST_StartPoint(r.arrayOfPos)) AS longitude" +
          ", ST_Y(ST_StartPoint(r.arrayOfPos)) AS latitude" +
          ", ST_AsText(r.arrayOfPos) AS course" +
          ", ST_Length(r.arrayOfPos) AS distance" +
          ", ST_Distance_Sphere(ST_STARTPOINT(r.arrayOfPos), :point) AS distanceFromMyPosition " +
    "FROM r " +
    "WHERE ST_CONTAINS(ST_BUFFER(ST_STARTPOINT(r.arrayOfPos), :radius), :point) ")
  List<Map<String, Object>> findRoutesWithinRadius(@Param("point") Point point, @Param("radius") int radius);

  default List<SearchNearbyRouteResponseDto> findNearbyRouteList(Point point, int radius) {
    List<Map<String, Object>> result = findRoutesWithinRadius(point, radius);
    return result.stream().map(row -> SearchNearbyRouteResponseDto.builder()
            .seq((UUID) row.get("seq"))
            .location(row.get("location").toString())
            .startPoint(new CoordinateDto((Double) row.get("latitude"), (Double) row.get("longitude")))
            .course(parseLineString(row.get("course").toString()))
            .distance((Double) row.get("distance"))
            .distanceFromMyPosition((Double) row.get("distanceFromMyPosition"))
            .build()).collect(Collectors.toList());
  }
  
------------------------------------------------------------------------------------------------
  public static List<CoordinateDto> parseLineString(String lineString) {
    List<CoordinateDto> coordinates = new ArrayList<>();
    String[] points = lineString.replaceAll("LINESTRING\\(|\\)", "").split(",");

    for (String point : points) {
      String[] latLon = point.trim().split(" ");
      Double longitude = Double.parseDouble(latLon[0]);
      Double latitude = Double.parseDouble(latLon[1]);
      coordinates.add(new CoordinateDto(latitude, longitude));
    }

    return coordinates;
  }

아직 QueryDsl에 자신이 없어서 우선 JPQL로 구현했다.

디비에서 뽑아온 데이터를 우선 Map에 담고 동일한 위치 내에서 dto에 담는 메서드를 구현해 Object에 타입을 다운캐스트하면서 담았다.

LineString의 경우, DB에서 추출한 데이터가 import org.locationtech.jts.geom. 이 값이 아닌 org.geolatte 이 타입에서 가져오기 우선 하드 코딩으로 구현했다.

참고

profile
개발이란?

0개의 댓글