JPA와 MySQL로 위치 데이터 다루기
업데이트:
JPA와 MySQL로 위치 데이터 다루기
CSV 파일로 MySQL Point 타입 좌표 데이터 입력해보자.
JPA를 활용해서 MySQL 위치 데이터를 다루어보자.
MySQL과 위치 좌표 데이터
개요
- 입력 된 좌표를 기준으로 일정 반경거리 내에 위치한 음식점 정보를 가져오려 한다.
- MySQL 5.7부터 공간 데이터 타입을 지원한다. 이를 활용하여 위치 데이터를 인덱싱 할 수 있다.
기본 설정
개발 환경
- Spring Boot 2.3.3
- Hibernate 5.4.20
- MySQL 5.7
- Gradle
설정
-
JPA에서 Spatial Type을 사용하기 위한
hibernate-spatial
의존성을 추가한다. 이때 hibernate 버전이 동일하도록 추가한다.// dependancy compile group: 'org.hibernate', name: 'hibernate-spatial', version: '5.4.20.Final' // hibernate version org.hibernate.Version.getVersionString()
-
application.yml 설정은 아래와 같다. spring.jpa.database-platform을 추가했다.
spring: datasource: url: jdbc:mysql://localhost:3306/osikdang username: henry password: henry jpa: hibernate: ddl-auto: update generate-ddl: true database: mysql database-platform: org.hibernate.spatial.dialect.mysql.MySQL56InnoDBSpatialDialect
-
스키마 생성을 위한 Entity를 만든다. 주의 할 점은
Point
타입 사용 시org.locationtech.jts.geom.Point
패키지를 사용하도록 한다.@Entity @Getter @ToString @NoArgsConstructor public class Restaurant { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String category; private String categoryCode; private String categoryMain; private String categorySub; private String categoryIndustry; private String addressProvince; private String addressCity; private String addressDistrict; private String addressDistrictOld; private String addressOld; private String address; private Integer zipCode; private Point point; }
CSV 파일로 MySQL Point 타입 좌표 데이터 입력하기
CSV 데이터 MySQL 입력
-
아래와 같이 변수와 POINT()를 이용해서 좌표 데이터를 넣을 수 있다.
LOAD DATA LOCAL INFILE '/restaurant.csv' INTO TABLE restaurant FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' (@var1, @var2, @var3, @var4, @var5, @var6, @var7, @var8, @var9, @var10, @var11, @var12, @var13, @var14, @var15, @var16) SET id = @var1, address = @var2, address_city = @var3, address_district = @var4, address_district_old = @var5, address_old = @var6, address_province = @var7, category = @var8, category_code = @var9, category_industry = @var10, category_main = @var11, category_sub = @var12, point = POINT(@var13, @var14), name = @var15, zip_code = @var16;
기준 좌표에서 일정 거리 떨어진 좌표 데이터 구하기
이론
- 하버사인 공식(Haversine Formula)을 이용히여 구의 두 지점 사이의 최단거리를 구한다.
- https://pyxispub.uzuki.live/?p=1006
코드
-
단순 계산을 위한 Utility 클래스로 static으로 사용한다.
@Getter public class Location { private Double latitude; private Double longitude; public Location(Double latitude, Double longitude) { this.latitude = latitude; this.longitude = longitude; } }
/** * Haversine Formula * φ2 = asin( sin φ1 ⋅ cos δ + cos φ1 ⋅ sin δ ⋅ cos θ ) * λ2 = λ1 + atan2( sin θ ⋅ sin δ ⋅ cos φ1, cos δ − sin φ1 ⋅ sin φ2 ) */ public class GeometryUtil { public static Location calculate(Double baseLatitude, Double baseLongitude, Double distance, Double bearing) { Double radianLatitude = toRadian(baseLatitude); Double radianLongitude = toRadian(baseLongitude); Double radianAngle = toRadian(bearing); Double distanceRadius = distance / 6371.01; Double latitude = Math.asin(sin(radianLatitude) * cos(distanceRadius) + cos(radianLatitude) * sin(distanceRadius) * cos(radianAngle)); Double longitude = radianLongitude + Math.atan2(sin(radianAngle) * sin(distanceRadius) * cos(radianLatitude), cos(distanceRadius) - sin(radianLatitude) * sin(latitude)); longitude = normalizeLongitude(longitude); return new Location(toDegree(latitude), toDegree(longitude)); } private static Double toRadian(Double coordinate) { return coordinate * Math.PI / 180.0; } private static Double toDegree(Double coordinate) { return coordinate * 180.0 / Math.PI; } private static Double sin(Double coordinate) { return Math.sin(coordinate); } private static Double cos(Double coordinate) { return Math.cos(coordinate); } private static Double normalizeLongitude(Double longitude) { return (longitude + 540) % 360 - 180; } }
계산식을 서비스 계층에서 활용하기
이론
- 일정 거리 범위 내에있는 좌표들을 비교하기 위해서
MBR(Minimal Boundary Rectangle)
이라는 최소 경계 사각형 좌표가 필요하다. - MBR을 구하기 위해 북동쪽, 남서쪽 좌표를 구해야 한다.
- distance의 단위는 km이며 1은 반경 1km를 의미한다.
- https://momentjin.tistory.com/m/136
코드
-
native query를 사용하여 반경 내에 존재하는 음식점을 조회한다.
-
.setMaxResults(10)
으로 최대 10개만 가져오도록 페이징 처리했다.@Getter public enum Direction { NORTH(0.0), WEST(270.0), SOUTH(180.0), EAST(90.0), NORTHWEST(315.0), SOUTHWEST(225.0), SOUTHEAST(135.0), NORTHEAST(45.0); private final Double bearing; Direction(Double bearing) { this.bearing = bearing; } }
@Service @RequiredArgsConstructor public class RestaurantService { private final EntityManager em; @Transactional(readOnly = true) public List<Restaurant> getNearByRestaurants(Double latitude, Double longitude, Double distance) { Location northEast = GeometryUtil .calculate(latitude, longitude, distance, Direction.NORTHEAST.getBearing()); Location southWest = GeometryUtil .calculate(latitude, longitude, distance, Direction.SOUTHWEST.getBearing()); double x1 = northEast.getLatitude(); double y1 = northEast.getLongitude(); double x2 = southWest.getLatitude(); double y2 = southWest.getLongitude(); String pointFormat = String.format("'LINESTRING(%f %f, %f %f)')", x1, y1, x2, y2); Query query = em.createNativeQuery("SELECT r.id, r.address, r.address_city, " + "r.address_district, r.address_district_old, r.address_old, r.address_province, " + "r.category, r.category_code, r.category_industry, r.category_main, r.category_sub, " + "r.point, r.name, r.zip_code " + "FROM restaurant AS r " + "WHERE MBRContains(ST_LINESTRINGFROMTEXT(" + pointFormat + ", r.point)", Restaurant.class) .setMaxResults(10); List<Restaurant> restaurants = query.getResultList(); return restaurants; } }
눈으로 확인해보기
-
실제 예시를 통해 실제 좌표가 어디에 찍히는지 확인해 보자.
-
아래는 예제를 위한 코드이다.
-
기준 좌표는 맥도날드 서초뱅뱅점(37.4901548250937, 127.030767490957)으로 반경 300m를 조회하여 출력 해보도록 한다.
@Component public class SampleRunner implements ApplicationRunner { @Autowired RestaurantService restaurantService; @Transactional(readOnly = true) @Override public void run(ApplicationArguments args) { final List<Restaurant> nearRestaurants = restaurantService .getNearByRestaurants(37.4901548250937, 127.030767490957, 0.3); for (Restaurant restaurant : nearRestaurants) { System.out.println( restaurant.getName() + " / " + restaurant.getCategorySub() + " / " + restaurant .getAddressOld() + " / " + restaurant.getPoint()); } } }
-
북동쪽 좌표 (x1 : 37.4920625469542, y1 : 127.0331718968735)
-
남서쪽 좌표 (x2: 37.4882470545089, y2: 127.0283632078535)
-
페이징 처리한 결과는 아래와 같이 출력된다.
고래똥 / 한식/백반/한정식 / 서울특별시 서초구 서초동 1339-4번지 / POINT (37.4895303786052 127.030436319555) 참치바리 / 참치전문점 / 서울특별시 서초구 서초동 1339-7 / POINT (37.4895773750866 127.030673180212) 왕대박 / 해장국/감자탕 / 서울특별시 서초구 서초동 1339-7 / POINT (37.4895773750866 127.030673180212) snowfox / 라면김밥분식 / 서울특별시 서초구 서초동 1338-20번지 / POINT (37.4901548250937 127.030767490957) 맥도날드서초뱅뱅점 / 패스트푸드 / 서울특별시 서초구 서초동 1338-20번지 / POINT (37.4901548250937 127.030767490957) 봉천동진순자계란말이김밥서초점 / 라면김밥분식 / 서울특별시 서초구 서초동 1338-20번지 / POINT (37.4901548250937 127.030767490957) 마블 / 한식/백반/한정식 / 서울특별시 서초구 서초동 1338-20번지 / POINT (37.4901548250937 127.030767490957) 일일향 / 중국음식/중국집 / 서울특별시 서초구 서초동 1338-21번지 / POINT (37.4906123669241 127.030524107463) 시몽복지식당 / 한식/백반/한정식 / 서울특별시 서초구 서초동 1338-20번지 / POINT (37.4901548250937 127.030767490957)
-
실제 구글맵에서는 아래와 같이 표시된다.
결론
- 위 방식을 사용하면 디스크 접근 빈도수가 감소하여 성능 향상에 도움이 된다고 한다.
- MBR과 같이 공간 좌표계를 이용하기 위한 이론적 부분은 아래 Reference에서 확인하도록 하자.
댓글남기기