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;
    


기준 좌표에서 일정 거리 떨어진 좌표 데이터 구하기

이론

코드

  • 단순 계산을 위한 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());
            }
        }
    }
    

    geometry1

  • 북동쪽 좌표 (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)
    
  • 실제 구글맵에서는 아래와 같이 표시된다.

    geometry2


결론

  • 위 방식을 사용하면 디스크 접근 빈도수가 감소하여 성능 향상에 도움이 된다고 한다.
  • MBR과 같이 공간 좌표계를 이용하기 위한 이론적 부분은 아래 Reference에서 확인하도록 하자.


References

댓글남기기