PostGIS 를 사용해보자

suhwani·2024년 6월 3일
0
post-thumbnail

현재

현재는 Postgres 의 BigDecimal 을 사용해서 위도와 경도를 저장하고 있다.
저장하는 정도에서 끝나면, 딱히 개선할 여지가 없었겠지만, 나는 이 데이터를 가지고 거리 계산, 반경 등을 해야하는데, 내가 직접 함수를 작성하기 보다는 이미 작성된 함수를 내가 잘 다루는 편이 좋을 것 같아서 찾아보았다. Postgres 에서 Mysql 보다 좋은 점이 여기 있었다. 물론 이것 때문은 아니지만…ㅎㅎ 장점 중 하나

💁‍♂️ 유클리드 좌표계(좌표평면)가 아닌 WGS84 좌표계(위도, 경도)를 사용해야 했는데, MySQL의 공간 연산 함수는 WGS84 좌표계에 대한 지원이 미흡했다. 예를 들어, MySQL의 공간 연산 함수 중 하나인 ST_Intersection(두 선분의 교차점 계산)은 WGS84 좌표계를 쓰는 것과 관계없이 좌표평면을 기준으로 계산하기 때문에 예상한 값과 다른 값을 반환하게 된다. 여러 레퍼런스를 찾아보면 공간데이터를 다룰 때 MySQL 보다는 PostgreSQL의 PostGIS가 사용성과 성능 측면에서 월등하다고 한다. 실제로 PostGIS는 공식 문서에서 WGS84 좌표계 역시 지원한다고 나와있다.

하여튼 이런 상황에서 PostGis 를 적용해보자!!!

설치부터 시작하자

PostGis 설치


Mac homebrew 사용해서 PostGis 를 설치해보았다.

- brew install postgresql # postgres 설치
- brew install postgis # postgis 설치
- brew services start postgresql # postgres 시작
- psql postgres # postgres 시작 
- CREATE EXTENSION postgis; # console 에서 postgis 설치

Properties


spring.jpa.properties.hibernate.dialect=org.hibernate.spatial.dialect.postgis.PostgisPG95Dialect

# 기존 postgres 만 사용했을 때 
# spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

# 아래와 같이 작성해도 괜찮다고 하는데, 나는 안되더라구..
# spring.jpa.properties.hibernate.dialect=org.hibernate.spatial.dialect.postgis.PostgisDialect

build.gradle


implementation 'org.hibernate:hibernate-spatial:6.4.4.Final'

DBeaver


DB 툴을 “DBeaver” 사용 중인데, 각자 툴에 맞게 아래 과정을 진행하시면 됩니다.

아래 Local Postgres 에 진입하고, “Available Extensions” 에서 “postgis” 가 있는지 확인합니다.

  • 만약 없다면 설치 먼저 하고 오셔야 합니다~

이후 사용 중인 Database 로 “Extension“ 을 가져와야합니다.

  • “Extension” 에서 “postgis” 를 가져옵니다.


Entity


아래처럼 Postgres PostGis 를 사용하여 Entity 를 작성합니다.

@Column(columnDefinition = "geometry(Point, 4326)")
private Point coordinates;

이 때 Point 라이브러리가 여러 개가 나올텐데, 얘를 쓰는 게 많은 함수를 사용할 수 있어서 좋습니다.

import org.locationtech.jts.geom.*;

Data 생성 완료


  • 이후 SpringBoot 를 실행하면 아래처럼 속성이 생깁니다~~

테스트용 API 작성하고 실행하자

  • 테스트용이기에 빠른 실행을 목표로 코드를 작성하였습니다

Controller


@PostMapping
public ResponseEntity<?> createCCTV(@RequestBody CCTVDto cctvDto) {
    CCTVEntity createdCCTV = cctvRepository.save(cctvDto.toCCTVEntity());
    return ResponseEntity.ok().body(new SuccessResponse(cctvDto.fromEntity(createdCCTV)));
}

Dto


  • Point 객체는 아래처럼 import org.locationtech.jts.geom.*; 을 사용합니다.
public static CCTVDto fromEntity(CCTVEntity entity) {
		CCTVDto dto = new CCTVDto();
		dto.setId(entity.getId());
		dto.setLocationAddress(entity.getLocationAddress());
		dto.setLatitude(entity.getPoint().getY());
		dto.setLongitude(entity.getPoint().getX());
		return dto;
}

public CCTVEntity toCCTVEntity() {
    GeometryFactory geometryFactory = new GeometryFactory();
    Point point = geometryFactory.createPoint(new Coordinate(this.longitude, this.latitude));

    return CCTVEntity.builder()
        .point(point)
        .locationAddress(locationAddress)
        .build();
}

테스트용 API 결과


  • 이런 식으로 보이게 됩니다.

로직을 생성해보자

Repository


PostGis 를 데이터베이스에 적용했기 때문에, PostGis 함수를 사용할 수 있습니다.
PostGis 에서는 좌표를 계산할 때, 평면에서의 계산과 구에서의 계산을 둘 다 지원합니다.

제가 사용할 함수는 ST_DWithin 입니다.
파라미터를 보면 알 수 있듯이, 특정 Location 기준으로 Distance 내에 있는 cctv 목록을 반환합니다.

  • ST_DWithin - Tests if two geometries are within a given distance.
@Repository
public interface CctvRepository extends JpaRepository<CctvEntity, Long> {
		// distance 는 미터(m) 기준입니다. 
    @Query(value = "SELECT * FROM cctv WHERE ST_DWithin(gps, ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326), :distance, true)", nativeQuery = true)
    List<CctvEntity> findCctvsByDistance(@Param("longitude") double longitude, @Param("latitude") double latitude, @Param("distance") double distance);

}

위 함수는 데이터베이스 Postgres 의 PostGis 함수를 사용하는 것이므로, JPA 가 아닌 SQL 문을 작성해야합니다.

여기서 distance 는 미터(m) 기준입니다. 그리고, 경도와 위도를 받게 되어있는데 Point 객체를 직접 받아도 사용 가능합니다! 저는 Client 에서 경도와 위도를 주기 때문에 그대로 받아서 SQL 문으로 Point 객체를 만든 것입니다.

따라서 Native Query 를 사용하였습니다!!

💁‍♀️ 네이티브 쿼리란,

SQL을 개발자가 직접 정의해서 사용할 수 있도록 해주는 수동모드

JPQL은 데이터베이스들이 따로 지원하는 것들에 있어 모든 것을 SQL로 자동으로 바꿔주지 않음 (인라인 뷰, UNION, INTERSECT 등등)

쉽게 말해, 어떠한 다양한 이유로 JPQL을 사용할 수 없는 경우나 SQL쿼리를 최적화 해서 데이터 베이스의 성능을 향상시킬 때 JPA는 Native SQL을 통해 SQL을 직접 사용할 수 있는 기능을 제공

로직을 테스트해보자

Controller

이걸 사용하겠다! 는 아니고, 내가 만든 로직이 잘 돌아가는지 확인하기 위해서 제작되었습니다.


위 Repository 함수를 사용하려면 어떤 정보를 받아야되는지, 어떻게 API 요청을 설계했는지 보겠습니다

    @GetMapping
    public ResponseEntity<?> getAllCctv(
        @RequestParam(required = true, value = "longitude") double longitude,
        @RequestParam(required = true, value = "latitude") double latitude,
        @RequestParam(required = false, defaultValue = "1000", value = "distance") double distance) {
        
        List<CctvDto> CctvList = cctvService.findCctvsNearbyLocationWithinDistance(longitude, latitude, distance);
        return ResponseEntity.ok().body(CctvList);
    }

x축인 longitude 경도, y축인 latitude 위도, 범위인 distance 거리 를 받습니다.

대신 distance 는 기본값을 사용할 수 있도록 하였고, 범위는 1 km 로 하였습니다.
longitude 와 latitude 는 필수로 받습니다.

경도와 위도가 조금만 달라져도 실제 거리는 많이 차이납니다.
테스트해본 결과, (10.0000, 10.0000) 과 (10.0000, 10.0001) 은 위도 소수점 4번째 자리가 단 ‘1’ 달랐을 뿐이지만, 실제 거리로는 10m 이상 차이가 발생합니다.

Service

여기 코드는 실제로 다른 Controller 나 Service 에서 사용될 예정입니다. 아마도 Kafka 에서 해당 로직을 사용하겠죠..? 저는 그래요


Service 를 작성해봅시다.
이런 식으로 받습니다.

실제로 DB 에 저장이 되고, 빼올 때는 Point 객체를 사용하지만 이걸 Client 에게 줄 때는 Dto 를 사용해서 경도와 위도로 바꾸어 반환합니다. (이렇게 하는 게 좋은지는 모르겠지만…?)

    public List<CctvDto> findCctvsNearbyLocationWithinDistance(double longitude, double latitude, double distance) {

        List<CctvEntity> cctvEntities = cctvRepository.findCctvsByDistance(longitude, latitude, distance); // distance 기준: m(미터) 

        return cctvEntities.stream()
                .map(CctvDto::fromEntity) // 엔티티를 DTO로 변환
                .collect(Collectors.toList());
    }

결과


아래처럼 cctv 정보를 미리 DB 에 저장하고, 테스트 해보았습니다.
아주 잘 나오네요!! 하아 힘들었다!

총평


⚠️ ***흔히 Mysql 과 Postgres 의 차이가 뭔가요??***

이 질문에 이제까지는 대답하지 못했어요. 사실 뭐가 다른지 잘 몰라요.
둘 다 관계형 DB 인데, 성능이 뭐가 좋고, 읽기에는 이게 좋고 쓰기에는 뭐가 좋다.
이런 말 솔직히 이해 못해요. 내가 성능테스트 해본 거 아니잖아요.

그치만 이제는 여러 데이터베이스 중에 뭘 선택해야되지?? 라는 고민이 들 때
확장이나 지원하는 데이터 타입, 지원하는 함수 등등을 살펴봐라!!
내가 PostGis 를 썼었는데, Mysql 은 이런 거 안되더라!! 라고 말은 할 수 있을 거 같아요 !!

profile
Backend-Developer

0개의 댓글