공간데이터와 공간인덱스로 지도 개선하기
들어가며
요즘카페 서비스에 지도기능을 추가하기로 하였다.
사용자 설문조사 결과 지도와 관련된 피드백이 많았기 때문에 카페를 지도에 나타내기로 하였다.
초기에는 다음과 같이 좌표를 기준으로 원을 그리고 이를 탐색하기로 하였다. 원 안에 그려진 카페들을 핀을 통해서 보여주는 방식이다.
우리 서비스는 DB로 MySql을 사용하고 있는데 지도를 사용하기 위해 공간 정보를 담을 수 있는 공간데이터, 공간데이터를 효율적으로 사용할 수 있는 공간 함수, 검색 성능을 올릴 수 있는 공간 인덱스가 존재한다.
이를 활용하여 요즘카페 서비스의 지도 기능을 성능 좋게 만들어 보겠다.
MySQL 공간 데이터 다루기
MySql에는 여러가지 공간 데이터가 존재한다.
- GEOMETRY
- POINT
- 좌표 공간의 한 지점
- LINESTRING
- 다수의 Point를 연결해주는 선문
- POLYGON
- 다수의 선분들이 연결되어 닫혀있는 상태
값 컬렉션으로는
- MULTIPOINT
- 다수의 포인트 집합
- MULTILINESTRING
- 다수의 LineString 집합
- MULTIPOLYGON
- 다수의 PolyGon 집합
- GEOMETRYCOLLECTION
- 모든 공간 데이터들의 집합
현재 개발하려는 기능은 각 카페마다 x, y 좌표를 가지고 있어야 하기 때문에, POINT 데이터 타입을 사용하기로 하였다.
CREATE TABLE cafe_coordinates
(
id BIGINT NOT NULL AUTO_INCREMENT,
coordinate POINT NOT NULL,
cafe_id BIGINT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (cafe_id) REFERENCES cafe (id)
);
CREATE SPATIAL INDEX idx_coordinates ON cafe_coordinates (coordinate);
카페 좌표를 저장할 테이블을 만들고, coordinate column에 인덱스를 걸어주었다.
공간 데이터에 인덱스를 걸 때는 공간 인덱스를 사용하였다. 이는 2차원의 데이터를 인덱싱하고, 검색하는 목적의 인덱스이다.
공간 인덱스(R-Tree)
우리의 MySql이 BTree로 관리 되듯, 공간 인덱스는 RTree를 통해 관리된다.
R-Tree는 점, 선, 면 과 같은 다차원 정보를 효율적으로 저장하기 위한 트리 자료구조이다.
R-Tree의 핵심은 MBR이다.
MBR
MBR이란 (Minimum Bounding Rectangle) 최소 경계 사각형 이다. 모든 다각형을 포함할 수 있는 사각형이라고 할 수 있다.이런 MBR을 만들고 노드로 관리하는 트리이다.
이런 성수역 골목이 있다고 해보자
지도를 없애고 공간데이터의 MBR을 판단해보면
이런식으로 공간데이터의 MBR이 산출될 수 있다. 그림을 대충 그렸다 .. 아래 이미지와 다를 수 있다.
대충 이런 이미지에서 다음과 같은 트리 구조가 나올 수 있다.
J라는 공간 데이터를 조회한다면 루트에서 노드를 따라 찾아가면서 전체 탐색을 하는 것보다 효율적으로 탐색을 진행할 수 있다.
쿼리 실행 계획 확인하기
좌표 테이블도 만들고 공간인덱스도 만들어놨으니 인덱스가 잘 작동하는지 확인해 본다.
MySql에서 제공하는 공간함수를 이용해서 쿼리를 작성할 수 있다.
SELECT c.id, c.name, co.coordinate
FROM cafe_coordinate AS co
INNER JOIN cafe AS c
ON co.cafe_id = c.id
WHERE ST_CONTAINS(ST_Buffer(ST_PointFromText('POINT(37.54464 127.05589)', 4326), 200), co.coordinate);
다음과 같은 방식으로 조회해보면
다음과 같이 조회될 수 있다.
쿼리
위의 결과처럼 coordinate에 공간데이터가 저장되는데, 이는 공간함수가 값을 생성하는 것이다.
ST_PointFromText('Point(37.54464 127.05589)', 4326) 를 먼저 설명하자면,
쿼리에서처럼 Point(위도 경도) 와 같은 형식이 WKT인데, 이는 지리적인 개체의 위치와 형태를 설명하는 것이다.
이후 파라메터는 SRID 값인데, 이는 좌표 시스템이다. 4326으로 설정한 이유는 이는 전 세계적으로 GPS 시스템에서 사용되는 좌표계이며 지구의 형태를 곡면으로 모델링하는 데 사용되는 값이기에 사용하게 되었다.
ST_CONTAINS(ST_Buffer(ST_PointFromText('POINT(37.54464 127.05589)', 4326), 200), co.coordinate);
ST_Buffer
함수를 사용하여 원의 반경을 만든 다음, ST_Contains
함수를 사용하여 해당 반경 내에 있는 카페 좌표를 찾는다. 이를 통해 지정된 지점을 중심으로 반경 내에 있는 모든 카페를 검색할 수 있다.
기존에는
ST_DISTANCE_SPHERE(ST_PointFromText('Point(37.54464 127.05589)', 4326), co.coordinate) <= 200;< />trong>
위의 함수는 두 지점 사이의 거리를 구하는 데 사용한다. 구의 표면을 이용하여 두 지점 사이의 최단 거리를 계산하고 이를 미터로 환산해준다. 그래서 위의 값에서 받은 값과 좌표값들을 모두 비교해서 그 거리가 200m 이하일 경우에 조회를 시켜주는 방식이다.
인덱스를 잘 타고있다.
현재 100여개의 데이터에서는 유의미한 속도 차이가 존재하지는 않는다. 하지만 data가 많아진다면
인덱스 있을때
인덱스 없을때
와 같이 성능이 개선된 것을 확실하게 알 수 있다.
인덱스를 안타는 경우
위에서 언급한 SRID라는 값이 존재한다. DB의 Row별로 이 값이 다를경우 문제가 발생할 수 있다.
Table
CREATE TABLE cafe_coordinates
(
id BIGINT NOT NULL AUTO_INCREMENT,
coordinate POINT NOT NULL,
cafe_id BIGINT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (cafe_id) REFERENCES cafe (id)
);
테이블을 생성할 때 공간 데이터 column에 SRID 값을 적용시킬 수 있다. Default는 0으로 삽입된다.
ALTER TABLE cafe_coordinates MODIFY COLUMN coordinate POINT NOT NULL SRID 4326;
다음과 같이 수정하였다.
ROW
데이터 삽입도 SRID 값을 이용해주어야한다.
INSERT INTO cafe_coordinates (coordinate, cafe_id) VALUES (ST_PointFromText('POINT(10 20)', 4326), 1);
이 데이터 값은 MySql에서 alter 할 수 없어 데이터를 새롭게 추가해줘야한다.
https://dev.mysql.com/doc/refman/8.0/en/spatial-index-optimization.html
이 문서를 확인하면 모든 SRID 값이 일치해야 인덱스를 탈 수 있다.
코드 적용
전체 코드를 보여주기 보단, 몇가지 주의해야 될 점들에 대해 작성해보려한다.
build.gradle
implementation group: 'org.hibernate', name: 'hibernate-spatial', version: '6.2.5.Final'
hibernate5 부터 공식적으로 공간데이터를 지원하기 때문에 gradle에 의존성을 추가하였다.
new Coordinate()
public static Point generateWithCoordinate(final double latitude, final double longitude) {
final Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude));
point.setSRID(SRID);
return point;
}
Point를 생성할 때 사용하는 메소드이다. Coordinate를 생성하는데 이 값이
다음과 같이 x,y 로 되어있는데 이는 경도, 위도 순이니 주의 해야한다 ..
당연하게 경도는 y축 위도는 x 축이라 고 생각했었는데 다르다.
핵심
해당하는 좌표를 받는다 → 지도 범위에 따른 StringPolygon을 생성한다 → 이 값을 이용해 DB에서 조회한다 → 반환한다.
비즈니스 요구사항 변경
기존에는 다음과 같이 원 안에 포함되어있는 카페를 반환하였다. 하지만 사용자들이 뷰를 보았을 때 지도 내에 있는 카페 전부가 뜨는 것을 기대하지 저 동그란 영역에서만 나올 것이라고 기대하지는 않을 것 같아서 구현을 변경하게 되었다.
해결
클라이언트로부터 지도 각 모서리의 좌표를 받는다. 좌표를 통해 위도 경도를 만들고 이를 이용해 폴리곤을 만든다.
final Coordinate[] vertexes = new Coordinate[]{
new Coordinate(maxLongitude, minLatitude),
new Coordinate(maxLongitude, maxLatitude),
new Coordinate(minLongitude, maxLatitude),
new Coordinate(minLongitude, minLatitude),
new Coordinate(maxLongitude, minLatitude)
};
다음과 같이 vertex를 만들 수 있다. 4개의 좌표지만 5개가있는 이유는 닫힌 형태의 다각형을 만들기 위함이다!
@Query("""
SELECT co.cafe.id AS id, co.cafe.name AS name, co.cafe.address AS address, ST_X(co.coordinate) AS latitude, ST_Y(co.coordinate) AS longitude
FROM CafeCoordinate co
WHERE ST_CONTAINS(:area, co.coordinate)
""")
List<CafePinDto> findCafePinsFromCoordinate(@Param("area") final Polygon area);
다음과 같은 방식으로 JPQL로 공간함수를 이용할 수 있다.
MYSQL 5.6이상의 버전을 사용한다면 많은 공간함수를 사용할 수 있을 것이다. 이를 통해 entity로 받아올 수 있었다.
나가며
MySql에서 지원해주는 공간 기능이 이렇게 다양한지 몰랐다. 이를 통해 지도를 사용하는 방법에 대해 알 수 있었다.
폴로햄 감사합니다.