SpringBoot + JPA 과제 중 구현 로직에 대한 고민 정리.
이번 맛집 추천 서비스 과제를 진행하면서 맛집 목록 조회에 대한 로직을 구현하던 중
생긴 고민과 해결 방법에 대해 정리해 봤습니다.
이번 과제는 공공데이터를 활용하여, 지역 음식점 목록을 자동으로 업데이트하고 이를 활용한다. 사용자 위치에 맞게 맛집 및 메뉴를 추천하여 더 나은 다양한 음식 경험을 제공하고, 음식을 좋아하는 사람들 간의 소통과 공유를 촉진하려 하는 과제입니다.
(내 위치 또는 지정한 위치 기반으로 식당 및 해당 맛집의 메뉴를 추천한다.)
맛집 목록 조회 요구사항
맛집 목록 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만큼 거리들을 조회하면서 거리 차이를 계산하는 쿼리를 작성하니
불필요한 코드도 삭제되고, 두 번째 고민에서의 페이징과, 정렬 기능의 문제들을 해결하게 되었다.
이런 방법이 있음에도 불구하고 왜 나는 멍청하게 직접 하드코딩해서 구현을 하는 방법 하나만 생각해서 진행했는지..
생각이 너무 없었던 것 같다 ㅠㅠ
다음부터는 너무 좁게 생각하지 말고 생각의 폭을 넓혀 여러 가지 구현 방법을 생각하고 로직을 구성해야 되겠다..