Mysql 좌표 거리계산, JPA, nativeQuery 를 이용한 거리계산

JungWooLee·2022년 8월 26일
1

SpringBoot ToyProject

목록 보기
9/14

이어서 하기

  • view 단에서 사용자 위치와 근접한 레스토랑 정보를 반환합니다
  • 현재 엔티티로 사용되고 있는 클래스의 경우 현재 사용자와의 거리 컬럼이 들어가있지 않는데 반환하는 DTO의 경우 사용자와의 거리값 근처의 레스토랑을 찾을 수 있도록 하여야합니다
    • 새로운 레스토랑 DTO 모델을 만들어 줍니다
  • Repository 에서 쿼리를 통한 사용자 요청 (x,y) 좌표를 파라미터로 받아 근처 1키로 거리의 음식점들을 반환하도록 합니다
  • 이와 관련된 테스트

1. RestaurantDTO 생성, 엔티티와 매핑, 거리별 테스트

RestaurantDTO

@Data
@AllArgsConstructor
public class RestaurantDTO implements RestaurantDTOInterface {

    public long id;

    public String address;

    public String category;

    public String image_Url;

    public String name;

    public Double diff_Distance;

    public String business_Hours;

    public Double visitor_Review_Score;

    public Long save_Count;

    public Double booking_Review_Score;

    public RestaurantType restaurant_Type;

    public Double x;

    public Double y;

}

기존의 DTO와는 구성이 많이 다릅니다.

  • @Data : getter, setter, RequiredArgsConstructor, ToString, EqualsAndHashCode, Value가 포함된 어노테이션입니다. 하나의 어노테이션으로 DTO에 대한 설정을 완료할 수 있다는 점에서 유용할 것같지만 ToString 같은 경우 민감정보가 들어가있을때에 정보 유출이 될 수 있기때문에 남용하지 않는것이 관용입니다. 하지만 현재 DTO의 경우 로깅도 해야하고 민감정보라고 할만한 것이 없기때문에 Data로 진행합니다
  • 인터페이스의 경우 밑에서 자세히 나오겠지만 JPA에서 native query 사용시 발생하는 mapping 문제로 인하여 추가되었습니다
  • 엔티티에서 추가된 필드 Double diff_Distance 입니다. 이곳에는 사용자 좌표와 음식점 사이의 거리를 나타낼 구간입니다
  • 필드의 변수명이 헝가리언 표기법이 아닌 언더바를 포함한 스네이크 표기법으로 대체되었습니다. 이는 JPA를 통한 모델 매핑시에 칼럼명과 일치시켜주어야 하기 때문입니다.

매핑 테스트

빠진 것이 없는지 간단한 테스트를 진행합니다
앞서 작성한 DTO에서 빌더를 생성한뒤 매핑시켜줍니다

@BeforeEach 를 통하여 모든 테스트 진행전에 데이터를 담을 수 있도록 합니다
getRestaurantData_v2 의 경우 모든 카테고리의 음식점들을 100개 스크래핑하여 DB에 저장하는 서비스 테스트 입니다

Test

	@Test
    @DisplayName("x,y 좌표값을 받아와 위치기반 가까운 거리의 매장정보를 반환")
    public void getRestaurantDTOfromDB () throws Exception {
        // given
        AddressRequest request = new AddressRequest(126.9738873,37.5502692);
        // when
        List<RestaurantDTO> dtos = new ArrayList<RestaurantDTO>(); // 초기화진행
        List<Restaurant> restaurantList = restaurantsRepository.findAll();
        for (Restaurant restaurant : restaurantList) {
            // repository 의 db값 -> response dto 로 변환 (거리 계산을 해야하기 때문에 엔티티 그대로 모델로 사용하지못함)
            dtos.add(RestaurantDTO.builder()
                    .address(restaurant.getAddress())
                    .diffDistance(utility.distance(
                            restaurant.getX(),
                            restaurant.getY(),
                            request.getX(),
                            request.getY(),
                            "meter"))
                    .businessHours(restaurant.getBusinessHours())
                    .restaurantType(restaurant.getRestaurantType())
                    .bookingReviewScore(restaurant.getBookingReviewScore())
                    .name(restaurant.getName())
                    .saveCount(restaurant.getSaveCount())
                    .saveCount(restaurant.getSaveCount())
                            .x(restaurant.getX())
                            .y(restaurant.getY())
                    .build());

        }
        // then

        for (RestaurantDTO dto : dtos) {
            log.info(dto.toString());
        }
    }

거리 계산을 위해 utility 에 좌표간 거리 계산을 추가하였습니다

public double distance(double lat1, double lon1, double lat2, double lon2, String unit) {

        double theta = lon1 - lon2;
        double dist = Math.sin(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(deg2rad(theta));

        dist = Math.acos(dist);
        dist = rad2deg(dist);
        dist = dist * 60 * 1.1515;

        if (unit == "kilometer") {
            dist = dist * 1.609344;
        } else if(unit == "meter"){
            dist = dist * 1609.344;
        }

        return (dist);
    }


    // This function converts decimal degrees to radians
    public double deg2rad(double deg) {
        return (deg * Math.PI / 180.0);
    }

    // This function converts radians to decimal degrees
    public double rad2deg(double rad) {
        return (rad * 180 / Math.PI);
    }

실험적이지만 거리 출력은 잘되고 있는것으로 확인하였습니다.

문제파악

본래 막연한게 findAll 을 사용하여 거리값을 계산하고 여기서 1km 반경내에 있는 음식점들을 dto에 담아주려고 했는데 이는 불필요한 프로세스를 증가시키는 꼴일 것 같아 처음에는 프로시저를 통한 Sql 내에서 좌표를 뽑아오는 형식을 생각해냈습니다

다만 JPA 에서 프로시저를 사용하였을때, 반환되는 값이 엔티티가 아닐 경우 매핑이 이루어 지지 않는다. 이에 관련하여 아래 stackoverflow를 참고
https://stackoverflow.com/questions/71177494/is-it-possible-to-use-jparepository-without-entity

즉, JPA는 JAVA 객체를 DB 테이블의 항목/행에 매핑, JAVA유형을 데이터베이스 테이블에 매핑하는 규격인데 프로시저를 통해 반환되는 값이 엔티티가 아닐 경우 JPA의 목적을 무너뜨리기 때문에 쓰이지 않음. 이를 해결할 방도로는 JDBC 추상화를 사용할 수 있음 (Spring Data JDBC, native queries, JPQL, HQL, or a bare JDBC API)

이중, JPQLNATIVE 쿼리를 사용해 시도해보았습니다

MYSQL에서 좌표간 거리 계산

SELECT R.ID,
            R.BUSINESS_HOURS, R.BOOKING_REVIEW_SCORE,
            R.NAME,R.IMAGE_URL,R.CATEGORY,
            R.RESTAURANT_TYPE,R.SAVE_COUNT,
            R.VISITOR_REVIEW_SCORE,
            ST_Distance_Sphere(Point(:x,:y),POINT(R.X, R.Y)) AS diff_Distance,
            R.X,
            R.Y,
            R.ADDRESS
            FROM RESTAURANT AS R HAVING diff_Distance <= 1000 order by diff_Distance

경도(longitude): 126.XXXXXX -> X
위도(latitude): 37.XXXXXX -> Y

POINT : Geometry 타입이며 좌표를 나타낼 때에 사용 POINT(경도, 위도)의 형태를 띔
ST_Distance_Sphere : 두 포인트 파라미터를 입력값으로 받아 두 좌표간 거리를 meter 로 반환하여 줌

JPQL 과 NATIVE 쿼리를 사용한 DTO 매핑

JPQL과 native 쿼리에는 각자의 장단점이 있습니다.
JPQL은 엔티티 객체를 조회하는 객체지향 쿼리다. 따라서 테이블을 대상으로 쿼리하는 것이 아니라 엔티티 객체를 대상으로 쿼리한다. 문법은 SQL과 유사하며 간결하다. JPQL은 결국 SQL로 변환된다

  1. 대소문자 구분
    엔티티와 속성은 대소문자를 구분한다. 예를 들어, Member, username은 대소문자를 구분해줘야 한다. 반면에 SELECT, FROM, WHERE 같은 JPQL 키워드는 대소문자를 구분하지 않아도 된다.
  2. 엔티티 이름
    JPQL에서 사용한 Member는 클래스 명이 아니라 엔티티 명이다. 엔티티명은 @Entity(name="abc")로 지정할 수 있다. 엔티티 명을 지정하지 않으면 클래스 명을 기본값으로 사용한다.
  3. 별칭은 필수
    FROM TABLE 에서 엘리어스 (AS) 를 생략할 수 없다. 객체의 .을 기준으로 필드를 받아오기 때문
  4. new 명령어
    select new package.RestaurantDTO(...) from Restaurant r

우선 dto 를 반환 받을 수 있다는 점에서 가장 먼저 접근한 방식입니다.
다만 실제 ST_Distance_Sphere 의 경우 jpql 이 내장 함수로 인식하지 못하는 문제 때문에 포기하게 되었습니다.

다음은 native 쿼리입니다
이 경우에는 선택의 폭이 다소 넓을 수 있지만 DTO로 바로 매핑 받으려고 하면
No converter found capable of converting from type 에러를 마주하게 됩니다
→ 이유는 2개이상의 Object 배열을 클래스에서 뽑아올때 Mapping에 대한 정보가 없기때문

그렇기에 서칭중 찾게된 해결법은 인터페이스 기반 projection 입니다.
참고 문서 : https://www.baeldung.com/jpa-queries-custom-result-with-aggregation-functions
이를 사용하려면 속성 이름과 일치하는 getter 메서드로 구성된 인터페이스를 정의하여야 합니다

그렇기에 위에 있던 DTO 가 인터페이스를 상속받는 것이죠!

public interface RestaurantDTOInterface {
     long getId();

    String getAddress();

    String getCategory();

    String getImage_Url();

    String getName();

    Double getDiff_Distance();

    String getBusiness_Hours();

    Double getVisitor_Review_Score();

    Long getSave_Count();

    Double getBooking_Review_Score();

    RestaurantType getRestaurant_Type();

    Double getX();

    Double getY();
}

DTO 에서는 @Data 어노테이션을 설정하였기 때문에 따로 getter 메서드를 만들 필요없습니다


2. 테스트, 결과확인

	@Test
    public void 좌표값을기준으로1km반경내음식점찾기() throws Exception{
        //given
        AddressRequest request = new AddressRequest(126.9738873,37.5502692);
        //when
        List<RestaurantDTOInterface> r = restaurantsRepository.getRestaurantByLocation(request.getX(), request.getY());
        //then
        for (RestaurantDTOInterface restaurantDTOInterface : r) {
            log.info(restaurantDTOInterface.getName());
            log.info(restaurantDTOInterface.getDiff_Distance()+"");
        }
    }

기존 @BeforeEach 로 되어있던 저장하기를 거친 이후 request 가 주어졌을때 위도, 경도를 통해 해당 위치에서 1키로 반경내의 음식점들을 갖고옵니다.

로그를 남겨 결과를 추적합니다 (인터페이스의 경우 tostring 을 지정해줄 수 없어 get으로 필드를 호출합니다)

[2022-08-26 11:00:21:24406] INFO  24228 --- [    Test worker] RestaurantServiceImplTest                : 마르페
[2022-08-26 11:00:21:24423] INFO  24228 --- [    Test worker] RestaurantServiceImplTest                : 88.9926457763199
[2022-08-26 11:00:21:24423] INFO  24228 --- [    Test worker] RestaurantServiceImplTest                : 모모키친
[2022-08-26 11:00:21:24423] INFO  24228 --- [    Test worker] RestaurantServiceImplTest                : 91.87147253642308
...

0개의 댓글