Spring

SpringBoot + JPA 과제 중 구현 로직에 대한 고민 정리.

개발자 박현준 2023. 11. 8. 18:48
728x90

이번 맛집 추천 서비스 과제를 진행하면서 맛집 목록 조회에 대한 로직을 구현하던 중

생긴 고민과 해결 방법에 대해 정리해 봤습니다.

 

이번 과제는 공공데이터를 활용하여, 지역 음식점 목록을 자동으로 업데이트하고 이를 활용한다. 사용자 위치에 맞게 맛집 및 메뉴를 추천하여 더 나은 다양한 음식 경험을 제공하고, 음식을 좋아하는 사람들 간의 소통과 공유를 촉진하려 하는 과제입니다.

(내 위치 또는 지정한 위치 기반으로 식당 및 해당 맛집의 메뉴를 추천한다.)

 

맛집 목록 조회 요구사항

맛집 목록 API

  • 아래 쿼리 파라미터를 사용 가능합니다.   
query 속성 default(미입력 시 값) 설명
lat string 필수값 지구 y축 원점 기준 거리
lon string 필수값 주기 x축 원점 기준 거리
range double 필수값 km 를 소숫점으로 나타냅니다. 0.5 = 500m / 1.0 = 1000km
sort string 거리순 정렬기능 파라메터 구조는 자유롭게 구현하되, 위에서 계산된 요청 좌표와 식당 사이의 거리인 거리순 과 평점순을 지원해야합니다.
기타      
  • lat, lon : 각각 위, 경도를 나타내며 필수값입니다.(없을 시 400)
    • 내 주변보기 또는 특정 지역 보기 기능을 하지만 이는 클라이언트에서 구현합니다.
      • 내 주변보기: 클라이언트에서 유저 lat, lon을 파라미터로 넣어줌.
      • 시군구에서 선택한 항목의 위경도로 요청, 범위는 사용자 필수 입력.
  • range = Km를 의미하며, 사용자 요청 주소(lat, lon 과의 거리를 의미합니다.)
    • 1.0 지정 시 요청 lat, lon에서 1km 이내의 가게만 노출됩니다.
  • 기타 page, search , filter 등은 선택사항입니다.
  • API에서는 API 요청된 lat, lon, range를 토대로 조회된 내용만 반환하면 됩니다.

 

첫 번째 고민

첫 번째 고민은 클라이언트에서 넘어온 위치에서 range 만큼 반경에 대한 맛집 목록을 조회해야 하는데,

맛집 목록에는 클라이언트에서 넘어온 위치와의 거리 차이가 계산이 안되어있기 때문에

전체 맛집 목록을 조회해서 하나하나 거리 계산 후 range에 맞는 반경에 대한 맛집 목록을 가져와야 하나?라는 생각을 했었다.

 

만약 위 생각대로 진행한다면 너무 비효율적이지 않을까?라는 생각이 들어 

맛집 목록을 전체 다 조회 후 계산하지 말고 효율적으로 조회하는 방법이 없을까? 생각하던 중

팀원 분들 중 한 분이 아래 방법으로 해보는 건 어떤지 의견을 주셨다.

 

대략 1km에 위도 경도 차이를 구해서 range만큼 *해서 그 사이에 있는 값들만 가져오면 range 반경에 맞는 맛집 목록들을 불러올 수 있지 않을까요??

(나는 이 방법에 대한 생각을 전혀 하지 못한 상태였는데 의견을 주셔서 정말 감사했다.)

 

의견을 듣고 나서 위 방법대로 구현을 해보기 위해 아래 로직을 생각해 봤다.

 

1km에 위도 경도 차이(대략)는 위도 = 0.009009, 경도 = 0.011236 차이가 난다고 한다.

그렇다면 맛집 목록을 조회할 때

lat - (0.009009 * range) ~ lat + (0.009009 * range)

lon - (0.011236 * range) ~ lon + (0.011236 * range)

위 결과로 맛집 목록을 조회할 때 저 사이에 있는 값들을 가져온다면. 전체를 조회해서 계산 후 가져오는 것이 아닌,

range 반경에 있는 맛집 목록만 가져올 수 있게 되어서 조회에 대한 효율성을 높일 수 있게 되었다.

        /**
         * 1km에 위도 경도 차이(대략) : 위도 = 0.009009, 경도 = 0.011236
         * lat - (0.009009 * range) ~ lat + (0.009009 * range)
         * lon - (0.011236 * range) ~ lon + (0.011236 * range)
         * 파라미터로 넘어온 lat, lon으로 위 계산으로 범위를 구하면 대략 range 반경에 있는 맛집을 조회할 수 있다.
         */
        double leftLat = Double.parseDouble(lat) - (0.009009 * range);
        double rightLat = Double.parseDouble(lat) + (0.009009 * range);
        double leftLon = Double.parseDouble(lon) - (0.011236 * range);
        double rightLon = Double.parseDouble(lon) + (0.011236 * range);


        /** 1. range 반경에 있는 맛집 목록을 StoreResponse에 맞게 불러온다. */
        List<StoreResponse> list = storeRepository.findByAllList(leftLat, leftLon, rightLat, rightLon);
    @Query("select new com.wanted.yamyam.api.store.dto.StoreResponse(a.lat, a.lon, a.name, a.address, a.category, a.rating) from Store a where a.lat >= :lat1 and a.lat <= :lat2 and a.lon >= :lon1 and a.lon <= :lon2")
    List<StoreResponse> findByAllList(@Param("lat1") double lat1, @Param("lon1") double lon1, @Param("lat2") double lat2, @Param("lon2") double lon2);

 

 

두 번째 고민

첫 번째 고민이 해결된 후 또 다른 고민이 생겼다.

맛집 목록을 조회한 후 페이징 처리와, 평점, 거리순으로 정렬을 해야 하는 기능이 있었는데, 현재 range 반경에 있는 맛집 목록들을 조회하는 데 성공했지만,

거리 차이가 얼마나 나는지에 대한 계산이 안되어있어,

거리순으로 정렬을 하지 못하는 문제와(평점은 맛집 테이블에 이미 계산된 결괏값이 저장되어 있어서 문제가 없었다.)

정렬 후에 결괏값으로 페이징처리 해야 해서 페이징 처리도 안 되는 문제가 발생했다.

 

첫 번째 방법으로는 조회한 맛집 목록들을 Loop를 돌면서 거리 차이를 계산해서 맛집 Response에 저장을 해서 정렬을 하고 페이징 처리 로직을 직접 만들어서 해결하는 방법을 생각했다.

        /** 2. 조회한 맛집 목록을 Loop 돌면서 파라미터로 넘어온 위치에서 range 반경에 있는 맛집들과의 거리 차이를 구해서 StoreResponse.distance에 저장한다. */
        for (int i = 0; i < list.size(); i++) {
            StoreResponse store = list.get(i);
            double distance = findDistance(Double.parseDouble(lat), Double.parseDouble(lon), store.getLat(), store.getLon());
            store.setDistance(distance);
        }
        
        
        /**
         * 3. StoreResponse에 저장된 맛집 목록을 거리순 or 평점순으로 정렬한다.(default = 거리순)
         * (StoreResponse에 거리차이를 저장 해야 거리순 정렬이 가능해서 직접 정렬했음.)
         */
        if (sort.equals("rating")) {
            list = list.stream().sorted(Comparator.comparing(StoreResponse::getRating).reversed()).collect(Collectors.toList());
        } else {
            list = list.stream().sorted(Comparator.comparing(StoreResponse::getDistance)).collect(Collectors.toList());
        }
        
        
        
        /**
         * 4. StoreResponse를 파라미터로 들어온 page, pageCount에 맞게 return 하기 위한 페이징처리
         * (3번 작업(정렬) 후 페이지를 나눠야하기 때문에 직접 페이징 처리 했음.)
         */
        int[] paging = storeResponsePaging(page, pageCount, list.size());

        return new StoreListResponse(list.subList(paging[0], paging[1]), paging[2]);
        
        
        
    private double findDistance(Double lat1, Double lon1, Double lat2, Double lon2) {
        double R = 6371; // km
        double dLat = Math.toRadians(lat2 - lat1);
        double dLon = Math.toRadians(lon2 - lon1);

        lat1 = Math.toRadians(lat1);
        lat2 = Math.toRadians(lat2);

        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

        return R * c;
    }
    
    
    
    /** StoreResponse를 직접 페이징 처리하기 위한 메서드 */
    private int[] storeResponsePaging(int page, int pageCount, int total) {
        int totalPage;
        if (total % pageCount == 0) {
            totalPage = total / pageCount;
        } else {
            totalPage = total / pageCount + 1;
        }

        int fromIndex = page * pageCount;
        if (fromIndex < 0) {
            fromIndex = 0;
        } else if (fromIndex > total) {
            fromIndex = total;
        }

        int toIndex = fromIndex + pageCount;
        if (toIndex > total) {
            toIndex = total;
        }

        return new int[]{fromIndex, toIndex, totalPage};
    }

 

 

위 로직을 작성하였지만, 페이징 처리와, 정렬 기능을 직접 구현하다 보니 코드가 복잡해지고 너무 지저분 해지는 문제가 있었다.

하지만 너무 좁게 생각을 해서 그런지, 이 방법 말고는 어떻게 처리를 해야 할지 생각이 나지 않았다.

 

그래서 멘토님한테 현재의 문제점을 설명하고 이 외에 해결 방법이 있는지에 대한 의견을 물어봤다.

멘토님 안녕하세요! 맛집 목록 조회 구현 로직에 대해 궁금한게 있어서 연락드립니다!

현재 구현 로직
1번: range 반경을 아래 방법으로 위도 경도 차이를 구해서 range 반경에 있는 맛집 목록을  불러오고 있습니다.
 1km에 위도 경도 차이(대략) : 위도 = 0.009009, 경도 = 0.011236
 lat - (0.009009 * range) ~ lat + (0.009009 * range)
 lon - (0.011236 * range) ~ lon + (0.011236 * range)
 파라미터로 넘어온 lat, lon으로 위 계산으로 범위를 구하면 대략 range 반경에 있는 맛집을 조회할 수 있다.

List<StoreResponse> list = storeRepository.findByAllList(leftLat, leftLon, rightLat, rightLon);

위에 Repository에서 조회하는 과정에서 바로 페이징, 정렬을 하면 좋을텐데, 거리순 정렬을 하기위한 거리차이 계산이 안되어있는 문제가 있어 불가능 했습니다.

그래서 2번:  1번 항목에서 조회한 맛집 목록을 Loop 돌면서 파라미터로 넘어온 위치에서 range 반경에 있는 맛집들과의 거리 차이를 구하고StoreResponse.distance에 저장하는 아래 로직을 만들었습니다.
for (int i = 0; i < list.size(); i++) {
    StoreResponse store = list.get(i);
    double distance = findDistance(Double.parseDouble(lat), Double.parseDouble(lon), store.getLat(), store.getLon());
    log.info("distance = {}", distance); // 잠시 거리 차이 확인용 추후 삭제
    store.setDistance(distance);
}


그리고 나서 3번: 직접 아래 로직으로 거리순 or 평점순으로 정렬을 진행하고
if (sort.equals("rating")) {
    list = list.stream().sorted(Comparator.comparing(StoreResponse::getRating).reversed()).collect(Collectors.toList());
} else {
    list = list.stream().sorted(Comparator.comparing(StoreResponse::getDistance)).collect(Collectors.toList());
}
 
4번: 직접 아래 페이징 로직 메서드를 만들어서 진행하였습니다.
private int[] storeResponsePaging(int page, int pageCount, int total) {
    int totalPage;
    if (total % pageCount == 0) {
        totalPage = total / pageCount;
    } else {
        totalPage = total / pageCount + 1;
    }

    int fromIndex = page * pageCount;
    if (fromIndex < 0) {
        fromIndex = 0;
    } else if (fromIndex > total) {
        fromIndex = total;
    }

    int toIndex = fromIndex + pageCount;
    if (toIndex > total) {
        toIndex = total;
    }

    return new int[]{fromIndex, toIndex, totalPage};
}


5번: 4번 메서드 불러와서 list.subList로 자른 후 return
int[] paging = storeResponsePaging(page, pageCount, list.size());
return new StoreListResponse(list.subList(paging[0], paging[1]), paging[2]);


문제점: 맛집 목록을 조회하기 위해 이 로직을 사용하게 된다면 정렬, 페이징을 직접 처리하기 때문에 코드의 양도 많아지고
효율성도 좋지 않을거라고 생각합니다. 그런데 저는 거리순 정렬을 하기 위해서는 이 방법을 택해야 했다고 생각을 하는데, 
혹시 이 로직에서의 문제점이나, 변경해야 할 로직, 멘토님이 생각하시는 거리순을 구해서 정렬하는 부분에 더 좋은 로직이 있을지 궁금합니다!!

 

 

멘토님이 너무 친절하게 답변을 해주셨다.

안녕하세요 **님!

현재 처럼 구현하는거에 불편함을 가지시는게 맞습니다 ㅎㅎ

실제로라면 

1. gis 관련 라이브러리 사용.

2. SQL 쿼리를 직접 작성.
https://gautamsuraj.medium.com/haversine-formula-for-spring-data-jpa-db6a53516dc9
https://stackoverflow.com/questions/29576674/spring-query-haversine-formula-with-pageable (JPA 페이지 적용)

2가 적합해 보입니다.

첨부 링크처럼, SQL 쿼리를 작성해서 DB 에서 distance 를 구하고, 정렬하여 일부를 가져오도록 구현하는게 실무 방식일것 같아요.

제가 잠깐 두 링크 봤는데, 저렇게 되면 모든 레스토랑 거리를 구하고 정렬하는거라 비효율 적이고,

위 **님이 말씀하신 "1번 " 방식도 쿼리에 우선 반영 되어야 겠어요!

 

나는 왜 이제까지 조회할 때 쿼리에서 거리차이를 계산하는 방법을 생각 못했는지 모르겠다..

위 방식대로 하면 페이징, 정렬 기능도 Pageable로 다 할 수 있었을 텐데 말이다.

 

첫 번째, 두 번째 고민 해결 후 코드

    /**
     * 맛집 목록 조회
     * @param lat : 원하는 위치의 위도
     * @param lon : 원하는 위치의 경도
     * @param range : 원하는 반경 조회 km
     * @return
     */
    @Transactional(readOnly = true)
    public StoreListResponse storeList(Pageable pageable, String lat, String lon, double range) {
        if (lat.isEmpty() || lon.isEmpty()) {
            throw new ErrorException(LAT_LON_NO_VALUE);
        }

        /**
         * 1km에 위도 경도 차이(대략) : 위도 = 0.009009, 경도 = 0.011236
         * lat - (0.009009 * range) ~ lat + (0.009009 * range)
         * lon - (0.011236 * range) ~ lon + (0.011236 * range)
         * 파라미터로 넘어온 lat, lon으로 위 계산으로 범위를 구하면 대략 range 반경에 있는 맛집을 조회할 수 있다.
         */
        double leftLat = Double.parseDouble(lat) - (0.009009 * range);
        double rightLat = Double.parseDouble(lat) + (0.009009 * range);
        double leftLon = Double.parseDouble(lon) - (0.011236 * range);
        double rightLon = Double.parseDouble(lon) + (0.011236 * range);


        /** 1. range 반경에 있는 맛집 목록을 StoreResponse에 맞게 불러온다. */
        Page<StoreResponse> list = storeRepository.findByStoreList(pageable, Double.parseDouble(lat), Double.parseDouble(lon), leftLat, leftLon, rightLat, rightLon);

        return new StoreListResponse(list.get().collect(Collectors.toList()), list.getTotalPages());
    }


    public interface StoreRepository extends JpaRepository<Store, StoreId> ,StoreRepositoryCustom{


        @Query("select new com.wanted.yamyam.api.store.dto.StoreResponse(" +
                "a.lat, a.lon, a.name, a.address, a.category, a.rating, " +
                "round((6371 * acos(cos(radians(:lat)) * cos(radians(a.lat)) * " +
                "cos(radians(a.lon) - radians(:lon)) + sin(radians(:lat)) * sin(radians(a.lat)))), 3) as distance) " +
                "from Store a " +
                "where a.lat >= :lat1 and a.lat <= :lat2 and a.lon >= :lon1 and a.lon <= :lon2")
        Page<StoreResponse> findByStoreList(
                Pageable pageable, @Param("lat") double lat, @Param("lon") double lon,
                @Param("lat1") double lat1, @Param("lon1") double lon1,
                @Param("lat2") double lat2, @Param("lon2") double lon2);

    }

 

첫 번째 고민에서의 방법과, 두 번째 고민에서의 방법을 합쳐 위 코드로 변경했다.

 

이렇게 첫 번째 고민에서 range만큼 거리들을 조회하면서 거리 차이를 계산하는 쿼리를 작성하니

불필요한 코드도 삭제되고, 두 번째 고민에서의 페이징과, 정렬 기능의 문제들을 해결하게 되었다.

 

이런 방법이 있음에도 불구하고 왜 나는 멍청하게 직접 하드코딩해서 구현을 하는 방법 하나만 생각해서 진행했는지..

생각이 너무 없었던 것 같다 ㅠㅠ

다음부터는 너무 좁게 생각하지 말고 생각의 폭을 넓혀 여러 가지 구현 방법을 생각하고 로직을 구성해야 되겠다..

728x90