위치, 키워드 기반의 식당 검색을 구현하면서 어떤 과정으로 성능 개선을 하였는지 기록하기 위해 글을 작성하였다.
다음과 같은 순서로 성능을 개선하였고 마지막엔 성능테스트로 이를 비교해보는 것까지 진행할 예정이다.
우리 서비스에서는 현재 위치를 기반으로 몇 km 이내의 식당만 검색하는 기능을 구현할 것이다.
mysql에서는 x,y 좌표(위경도)로 구성된 지리 정보를 다룰 수 있기 위해서 다양한 기능을 제공한다.
지리 공간데이터를 다루기 위해 제공 하는 기능은 공간데이터 타입, 공간함수, 공간 인덱스, 공간 연산 등 이 있다.
mysql 에서 지리정보를 저장하기 위해 사용되는 특수한 데이터 타입이다.
| 타입 | 정의 | 예시 |
|---|---|---|
| Point(점) | 위도와 경도로 표현된 특정 위치 | POINT(37.5,127.04) |
| LineString(선) | 여러 점을 연결하여 형성되는 선 | LINESTRING(37.5 127.04,37.4 127.1, 37.45 127.08) |
| Polygon(다각형) | 폐곡선으로 이루어진 지리적인 영역 | POLYGON((37.5 127.04,37.4 127.1, 37.45 127.08, 37.42 127.03 …)) |
| Multi-(여러개) | 여러개의 타입 모음 | MULTIPOINT , MULTILINESTRING.. |
| GeomCollection(집합) | 다양한 유형의 공간 데이터 요소들의 그룹 | GEOMETRYCOLLECTION ( POINT (37.5 127.04), LINESTRING (37.36 127.42, 37.321 127.65), POINT (37.322 127.423) ) |
공간 데이터를 생성, 분석, 조작하는 데에 사용하는 함수이다.
이외에도 여러개가 있지만 다음은 대표적인 함수 및 우리가 사용할 함수이다.
| 함수 | 정의 |
|---|---|
| ST_GeomFromText | 문자열을 공간 데이터로 변환 |
| ST_Contains | 하나의 공간 요소가 다른 공간 요소를 포함하는지 확인 |
| ST_Intersection | 교집합인 공간 객체를 반환 |
| ST_Union | 합집합인 공간 객체를 반환 |
| ST_Difference | 차집합인 공간 객체를 반환 |
위에서의 공간데이터 타입과 공간함수를 이용해서 우리가 원하는 식당 정보를 검색하는 쿼리를 작성해보자.
우리 서비스의 Store 테이블은 다음과 같은 컬럼으로 구성되어있다.

남서쪽의 좌표와 북동쪽의 좌표가 주어졌을때 그 사각형 안에 속하는 식당들을 구하는 쿼리이다.
SET @point = ST_GeomFromText(CONCAT('LINESTRING(', 37.507762783404516, ' ', 127.04775097175624, ',', 37.49504447531931, ' ', 127.03020739357909, ')'));
SELECT * FROM store as s
where MBRContains(@point, POINT(s.lat,s.lon));
ST_GeomFromText 를 통해 text를 공간데이터로 변환시키고,
MBRContains 를 이용하여 특정 좌표가 해당 사각형에 속하는지 확인한다.
MBR 이란 최소경계사각형을 의미한다.
여기서는 남서쪽 좌표와 북동쪽 좌표를 포함하는 최소경계사각형(=해당 점을 양 끝점으로 하는 직사각형)에 특정 좌표가 포함되는지 MBRContains 를 통해 확인한다.
해당 쿼리를 실행하면

데이터를 잘 불러오는 것을 확인할 수 있다.
이제 이 쿼리를 스프링에 적용하여 구현해보자.
org.hibernate.Version.getVersionString() 을 통해 hibernate 버전을 확인한다.
implementation 'org.hibernate:hibernate-spatial:5.4.20.Final’ build.gradle 에 해당 버전을 의존성 추가 해준다.
yml 파일에 spring.jpa.database-platform: org.hibernate.spatial.dialect.mysql.MySQL56InnoDBSpatialDialect 를 추가 해준다.
학부시절 두점 사이의 거리를 구하기 위해서 하버사인 공식을 사용했었는데 그걸 이용해서 거리를 구할 것이다.
다음은 하버사인공식 사용을 위한 Util 클래스 이다.
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;
}
}
MBR 을 구하기 위해 북동쪽 남서쪽 좌표를 알아야한다.
따라서 다음과 같은 enum 클래스를 만들어 두었다.
@Getter
public enum Direction {
NORTH(0.0),
WEST(270.0),
SOUTH(180.0),
EAST(90.0),
NORTHWEST(315.0),
SOUTHWEST(225.0),
SOUTHEAST(135.0),
NORTHEAST(45.0);
private final Double bearing;
Direction(Double bearing) {
this.bearing = bearing;
}
}
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Store {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String address;
@Embedded
private Location location;
@Embeddable
public class Location {
@Column(nullable = false)
double lat;
@Column(nullable = false)
double lon;
}
private final EntityManager em;
@Transactional(readOnly = true)
public List<Store> searchNearStores(double lat, double lng,double distance) {
Location northEast = GeometryUtil
.calculate(lat, lng, distance, Direction.NORTHEAST.getBearing());
Location southWest = GeometryUtil
.calculate(lat, lng, distance, Direction.SOUTHWEST.getBearing());
double x1 = northEast.getLat();
double y1 = northEast.getLon();
double x2 = southWest.getLat();
double y2 = southWest.getLon();
String selectQuery = "SELECT * FROM store AS s WHERE MBRContains(ST_GeomFromText(CONCAT('LINESTRING(', :x1, ' ', :y1, ',', :x2, ' ', :y2, ')')), POINT(s.lat, s.lon))";
List<Store> result = em.createNativeQuery(selectQuery, Store.class)
.setParameter("x1", x1)
.setParameter("y1", y1)
.setParameter("x2", x2)
.setParameter("y2", y2)
.setMaxResults(10)
.getResultList();
System.out.println(Arrays.toString(result.toArray()));
return result;
}
이제 해당 서버에 요청을 보내보면

해당하는 식당을 잘 가지고 온다.
근데 굉장히 느리다..

jmeter로 부하테스트를 진행하는데 도저히 진행이 안될정도로 느리고 에러도 많이 발생한다.
위치에 인덱스도 적용이 안되어있어서 거의 full scan이 이루어지는것 같다.
다음 글에서 공간인덱스를 적용하여 성능을 개선하는 방법을 알아보자