식당 검색 성능 개선 (1) -MySQL 공간 데이터

Jongmyung Choi·2023년 11월 29일

개요

위치, 키워드 기반의 식당 검색을 구현하면서 어떤 과정으로 성능 개선을 하였는지 기록하기 위해 글을 작성하였다.

  1. MySQL의 공간 데이터를 활용한 식당검색
  2. 공간 인덱스를 통한 성능 개선
  3. Elasticsearch 의 위치 기반 검색

다음과 같은 순서로 성능을 개선하였고 마지막엔 성능테스트로 이를 비교해보는 것까지 진행할 예정이다.

우리 서비스에서는 현재 위치를 기반으로 몇 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 를 통해 확인한다.

해당 쿼리를 실행하면

데이터를 잘 불러오는 것을 확인할 수 있다.

스프링에서 적용

이제 이 쿼리를 스프링에 적용하여 구현해보자.

설정

  1. org.hibernate.Version.getVersionString() 을 통해 hibernate 버전을 확인한다.

  2. implementation 'org.hibernate:hibernate-spatial:5.4.20.Final’ build.gradle 에 해당 버전을 의존성 추가 해준다.

  3. yml 파일에 spring.jpa.database-platform: org.hibernate.spatial.dialect.mysql.MySQL56InnoDBSpatialDialect 를 추가 해준다.

util 클래스

학부시절 두점 사이의 거리를 구하기 위해서 하버사인 공식을 사용했었는데 그걸 이용해서 거리를 구할 것이다.

다음은 하버사인공식 사용을 위한 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 클래스

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

Store 클래스

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

StoreService 클래스

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이 이루어지는것 같다.
다음 글에서 공간인덱스를 적용하여 성능을 개선하는 방법을 알아보자

참고

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

profile
총명한 개발자

0개의 댓글