[Spring] 위치 기반 서비스 구현하기

Dev_ch·2023년 3월 6일
4
post-thumbnail
해당 이미지는 카카오맵을 사용하였습니다.

모두가 이런 앱을 한번쯤은 봤을 것 이다. 지도에 무언가를 검색하였을때 그 화면내에서 존재하는 데이터들이 조회되고 눌렀을때 그에 대한 상세정보가 뜨는, 위에 이미지와 같은 해당 위치안의 바이크 라던지, 미용실 이라던지 이러한 기능들은 많은 앱에서 사용되고있다.

과연 이러한 것들은 클라이언트가 어떤 요청을 하고 백엔드는 어떠한 API를 구현해 해당 시스템을 개발하는지 살펴보고 구현해보도록 하자.


💡 우리가 원하는 기능

  • 사용자가 지도를 볼 수 있고, 지도에서 검색 및 해당 위치에서 재검색을 함
  • 검색됐을경우 해당 위치를 중심으로 2km 반경의 데이터들을 조회 후 Response

해당 부분은 개발하면서 프론트엔드와의 협업도 상당히 중요함을 느꼈다. 어떠한 방식으로 설계하는지에 따라 요청에 대한 기준이 달라질수도 있기에 이는 서로 소통을 통해 설계하도록 하자.


📑 Dependency 및 설정 파일

implementation group: 'org.hibernate', name: 'hibernate-spatial', version: '5.6.9.Final'

build.gradle에 JPA의 Spatial type을 사용하기 위한 의존성을 주입해준다.

# yml
spring:
	jpa:
		database-platform: org.hibernate.spatial.dialect.mysql.MySQL56InnoDBSpatialDialect

# properties
spring.jpa.database-platform=org.hibernate.spatial.dialect.mysql.MySQL56InnoDBSpatialDialect

추가적으로 yml이나 properties 설정파일에 위와 같이 값을 추가해준다.


🤔 구현 방식

사용자가 지도 위치 중심으로 데이터를 불러오게 하기 위해선 클라이언트가 보고있는 지도의 네 곳의 모서리의 좌표를 보내주거나, 또는 사용자가 지도에서 중심이 되는 위치 좌표를 보내주면 된다. 필자는 지도에서 중심이 되는 위치의 좌표를 클라이언트에게 요청받아 서비스를 구현하였지만, 네 곳의 모서리의 좌표를 통해 구현하는 방식도 있으니 참고하도록 하자!


💻 구현하기

Entity.java

@Entity
...
public class Content extends BaseEntity {

	...

    private Double latitude;

    private Double longitude;

	// import org.locationtech.jts.geom.Point;
    private Point point;
    

⚠️ org.locationtech.jts.geom.Point 사용하여 자료형을 선언해주어야 한다.

Entity 클래스에 Point 자료형으로 선언한 컬럼을 하나 만들어줄 것 이다. latitude와 longitude는 클라이언트에게 요청받을때 DTO 에선 필수값이지만 굳이 Entity에 만들어 DB에 저장해줄 필요는 없다.

다만, Point에 대한 컬럼을 DB에 어떻게 save 해야 하는지 의문이 들 수 있는데, 이와 같이 변수에 데이터를 담아주면 된다.

        Point point =
                latitude != null && longitude != null ?
                        (Point) new WKTReader().read(String.format("POINT(%s %s)", latitude, longitude))
                        : null;

필자의 경우 삼항연산자를 이용하여 위도와 경도가 null이 아닐때만 Porin 값을 저장하게끔 만들었고 해당 구문을 통해 Point 자료형을 변수에 담고 DB에 저장 할 수 있었다.

Location.java

@Getter
public class Location {
  
    private Double latitude;
    private Double longitude;
  
    public Location(Double latitude, Double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }
}

GeometryUtil.java

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;
    }
}

해당 클래스들은 사용자의 중심 위치값을 받았을때 이를 토대로 북동쪽과 남서쪽의 위치를 구하는 클래스이다. 이는 하버사인 공식(Haversine Formula)을 이용히여 두 지점 사이의 최단거리를 구한다고 한다.

Controller.java

    @GetMapping("...")
    public CustomResponseEntity<List<ContentDto.mapListContent>> myMapList(
            @RequestParam final Double x,
            @RequestParam final Double y,
			...
    ) {
        return CustomResponseEntity.success(contentService.listMyMap(x, y, ...));
    }

Controller에서의 핵심은 클라이언트가 보고있는 지도의 중심 좌표를 x y로 받는다. 요청받은 x, y 값으로 위 클래스들을 이용해 북동쪽과 남서쪽의 값을 구하여 해당 값 안에있는 data들을 불러올 것 이다.

Service.java

 @Transactional
    public List<ContentDto.mapListContent> listMyMap(UserDetails userDetails, Double x, Double y) {
    // Location 자료형으로 변수를 선언하여 해당 요청받은 x,y 값으로 북동쪽과 남서쪽의 위치를 계산
        Location northEast = GeometryUtil.calculate(x, y, 2.0, Direction.NORTHEAST.getBearing());
        Location southWest = GeometryUtil.calculate(x, y, 2.0, Direction.SOUTHWEST.getBearing());

// 이를 바탕으로 NativeQuery로 북동쪽, 남서쪽 거리를 String으로 작성
        String pointFormat = String.format(
                "'LINESTRING(%f %f, %f %f)'",
                northEast.getLatitude(), northEast.getLongitude(), southWest.getLatitude(), southWest.getLongitude()
        );

// NativeQuery로 작성한 pointFormat을 적용
        Query query = em.createNativeQuery(
                "" +
                        "SELECT * \n" +
                        "FROM content AS c \n" +
                        "WHERE c.group_id IN (" + join + ") " +
                        "AND " +
                        "MBRContains(ST_LINESTRINGFROMTEXT(" + pointFormat + "), c.point)"
                , Content.class
        ).setMaxResults(10);

        List<Content> contents = query.getResultList();

        return contents.stream().map((Content content) ->
                ContentDto.mapListContent.response(
                        ...
                )
        ).toList();
    }

해당 서비스 클래스를 살펴보자면,

  1. 위에 작성한 클래스를 바탕으로 요청받은 x, y값으로 북동쪽과 남서쪽의 거리를 계산 (2km 반경으로)
  2. 계산된 값을 NativeQuery에 적용하기위해 String으로 해당 구문을 작성
  3. NativeQuery로 계산해준 값을 토대로 함수를 사용해 포함된 데이터들을 전부 Read
    • WHERE 구문에서는 해당 프로젝트에서 추가로 필요한 조건이 있어 AND 연산자를 사용하였지만, 추가 조건이 필요하지 않다면
    "WHERE MBRContains(ST_LINESTRINGFROMTEXT(" + pointFormat + "), c.point)"
    만 구문에 작성해주면 된다.
    • Query안에 클래스 타입을 설정해주어야한다.
    • 해당 데이터는 10개까지만 조회하도록 설정해두었다.
  4. 필자의 경우 해당 List값을 Dto로 전환하여 Response했기에 map을 사용하여 return하였다.

🤔 다른 방법은 뭐가 있을까요 ?

@Query(value = "select * from content\n" +
            "where x between ?1 and ?2 and y between ?3 and ?4;",
            nativeQuery = true)
    List<Content> findWithinMap(Double startX, Double endX, Double startY, Double endY);

해당 쿼리를 통해 양 끝 쪽 모서리 값을 통해 쿼리로 계산할 수 있다. 사실 어떠한 방법을 사용해도 상관없지만, 위치라는 데이터를 명확하게 자료형으로 선언하여 DB에 저장 및 계산을 한다는 점을 강조하기 위해 해당 방법이 아닌 다른 방법으로 구현하였다.


결론

해당 방법을 통해 클라이언트가 요청한 좌표값을 토대로 범위 내에 있는 데이터를 조회해보았다. 이제 이를 응용하여서 지도내에서 처리할 수 있는 서비스들을 여러가지로 구현해 볼 수 있다. 결국은 요청받은 값을 Query를 통하여 계산해 목록을 조회하는 것 이므로 JPA나 NativeQuery 등 이용하여 다양한 값을 보낼 수 있게끔 응용하여 구현해보자 🤝

해당 기능을 구현함에 있어 열심히 검색 했고 아래는 정말 많은 도움이 된 블로그이니 꼭 참고도 해보도록하자.

도움이 된 블로그

https://momentjin.tistory.com/136
https://wooody92.github.io/project/JPA와-MySQL로-위치-데이터-다루기/

profile
내가 몰입하는 과정을 담은 곳

0개의 댓글