<내잔고를부탁해> 의 여러 서비스는 위치 정보를 기반으로 동작하게 되어있다. 예를 들어 매칭 기능 같은 경우 내가 원하는 위치를 지정하여 창고를 등록해두면 해당 위치를 기준으로 특정 반경 내에서 수요가 맞는 아이템이 있는지를 검색한다. 기획 단계에서 이런 위치 정보 기반의 서비스를 구상할 때는 실질적으로 어떻게 구현할 것인지에 대한 기술적 고민을 거의 하지 않았었기 때문에, 막상 구현하려고 하니 어떻게 할지 머리속이 깜깜했었다. 프로젝트 초반부였어서 사실 간단한 부분인데도 많은 시도와 실패 끝에 겨우 기능 구현에 성공했는데, 이미 몇 달 지난 일이지만 기억나는 대로 정리해서 올려본다.
1. 초기 아이디어
비지니스 로직을 처음 작성하기 시작 했을 때, 가장 먼저 Storage(창고) 도메인의 기능 구현을 하려고 했었다. <내잔고를 부탁해> 는 여러가지 서비스를 제공하지만 핵심적인 특징은 위치 기반 서비스라는 생각이 있었기 때문이었다. 아이템, 그룹채팅 등 위치 정보에 따라 조회되고 생성되는 도메인 들은 사실 연관된 창고의 위치에 의존해서 작동하기 때문에 실질적으로 “위치” 필드를 가지고 있는 도메인은 창고 뿐이었다.
먼저 가장 간단한 검색 기능을 구현하고자 했다. 위경도 값으로 표현된 특정 좌표와 반경을 입력하면, 해당 범위 내의 모든 창고를 조회하는 기능이었다. 가장 직관적으로 떠오르는 접근법은 흔히 Brute Force 라고 불리는 알고리즘이었는데 DB 에서 모든 좌표 데이터를 가지고 와서 좌표 사이의 거리를 계산하여 필터링 하는 방법이었다. 사실 이렇게 하면 원하는 대로 동작은 하겠지만, 창고의 개수가 늘어남에 따라 DB 에서 요청하는 정보가 아주 커질 수 있고 서버와 DB 에 상당한 부하가 있을 것 같았다. 아무리 연습용 프로젝트라고 하지만 이렇게 까지 타협하고 싶지 않았다.
이를 해결할 방법이 없을까 해서 고민했던것이 대한민국의 행정구역별 지리정보를 DB 에 저장해두고 창고를 미리 지역별로 분류해 두는 것이었다. 이걸 클러스터링이라고 부른다고 한다. 이후 특정 지역에서 창고 조회를 요청하게 되면 요청된 지역과 인접한 지역의 창고들만 먼저 로드하는 방법이었다. 이론적으로는 아주 간단하고 명료하였으나, 행정구역 데이터, 인접 구역 정보 등 그 많은 지리데이터를 핸들링할 자신이 없었다. 지리 데이터라는게 좌표 체계도 다양하고 이론적 배경이 생각보다 방대했다.
==>
공부해보니,,, DB 의 핵심적인 기능중에 "인덱스" 라는 것이 있었다. 인덱스는 책에서 목차를 만들어두듯, 트리 형태의 분류체계를 미리 만들어 놓아 검색 쿼리를 더 효과적으로 수행하는 방법이다. 특히 지리데이터 같은 경우는 R tree 라는 방식의 인덱싱을 쓰는데, 내가 생각했던 방법이 거의 고대로 구현이 되어 있었다.
사실 이때는 프로젝트의 극초반부였고 DB 나 쿼리에 대한 이해도 굉장히 낮았었다.(ORM 이라는 단어도 몰랐었다.) 이런저런 방법을 강구하고 나서야 처음에 떠올렸던 접근법(DB 에서 지리데이터를 가지고 와서 서버 내에서 가공을 하겠다는) 에 심각한 오류가 있다는 것을 깨달았다. 처음부터 DB 에서 좌표간 거리를 구하는 연산을 시행하고, 반경 조건을 적용하여 원하는 데이터만을 추출해서 가지고 와야했다. (클러스터링 같은건 여전히 유효한 최적화 알고리즘이지만)
2. Hibernate Spatial
위경도 좌표 간 실제 거리는 하버사인 공식을 통해 계산할 수 있다. 해당 공식은 그렇게 큰 부하가 걸리는 연산을 포함하진 않지만, 여러 삼각함수를 활용해야 하며 계산식이 간단하지는 않다. 더구나 sql 쿼리에 거리 계산식을 담아내는 것은 불가능해보였다. 이와 관련하여 리서치를 하다가 DB 에는 자체적으로 가지고 있는 Geometry Fuction 들이 있으며, 스프링에서 이를 활용하기 위한 Hibernate Spatial 이라는 라이브러리가 있다는 것을 알게 되었다.
Hibernate Spatial 은 지리 데이터를 ORM 으로 다루기 위한 라이브러리로, 이를 이용하면 JTS 나 Geolatte-geom 같은 라이브러리에서 정의된 다양한 지리데이터 타입을 DB 에 저장하고 다시 조회하여 객체에 맵핑할 수 있다. 다시말해 Double 타입으로 위경도를 저장하고 쿼리에 좌표 간 거리를 구하는 공식을 sql 로 작성해서 대입시키는 것 대신, 지리 정보를 특별한 데이터 타입으로 직접 저장하고 특별하게 이와 함께 동작하는 지리 함수들을 사용하여 바로 객체에 맵핑할 수 있다는 것이었다.
참고 : Hibenate document https://docs.jboss.org/hibernate/orm/5.2/userguide/html_single/Hibernate_User_Guide.html#spatial
Hibernate Spatial 의 ORM 기술과 이와 연계된 DB 의 내장 함수(Geometry Fuction) 를 사용하기 위해서는 의존성 추가와 Dialect 설정이 필요하다.
1) Hibernate Spatial 적용을 위한 설정
- 의존성 추가
implementation 'org.hibernate:hibernate-spatial:5.6.15.Final'
implementation 'org.locationtech.jts:jts-core:1.19.0'
- dialect 적용
hibernate:
dialect: org.hibernate.spatial.dialect.postgis.PostgisPG95Dialect
hibernate-spatial 을 추가하고 (현재 스프링 부트 버전에 맞는 버전을 사용) 지리 데이터타입을 쓰기위해 jts 의존성도 추가 했다. 위 두가지만 설정하면 아래와 같이 JTS 라이브러리의 지리 타입 객체를 DB의 테이블에 맵핑할 수 있게 된다. 참고로 Point 타입을 그냥 네이티브 쿼리를 써서 불러와서 바로 맵핑하려고하면 직렬화/역직렬화 부분에서 문제가 생긴다. (그런데 이제와서 생각해보면 직렬화 역직렬화 도구만 적절하게 활용하면 그냥 쌩쿼리로도 기능 구현이 가능할 것 같다.)
- Storage 클래스 내부 Point 타입 필드
@Column(columnDefinition = "Geometry(Point, 4326)")
private Point location;
포인트 타입의 필드를 선언한 부분이다. 어노테이션으로 속성을 지정한 부분은 WGS 84 좌표 체계의 점 타입의 컬럼이라고 지정한 부분이다. 사실 저걸 제외해도 작동할지 모르겠는데, 좌표 체계는 정말 다양하기 때문에 확실하게 지정을 해준 것이다. WGS 84 좌표체계는 가장 보편적으로 쓰이는 좌표 체계이다.
2) Geometry Fuction 사용
지리 함수를 쓰는데서 한참을 애먹었었다. JPQL 이 미숙해서 그 부분에서 실수를 한 줄 알았었다. 그런데 그게 아니고 Hibernate Spatial 이 DB 에 따라서 지원하는 지리 함수가 다르기 때문에 발생한 일이었다. 내가 사용하고자 했던 함수가 당시 사용하고 있던 MySql 에서는 쓸 수가 없었다. 이 지점에서 MySql 에서 Postgresql 로 마이그레이션을 진행했다. 관련 내용은 깃헙에 Issue 로 정리했다.
관련 Issue [https://github.com/Team-Naejango/back-end/issues/7]
요약하자면 Postgresql 가 Hibernate 와 잘 연동되어 더 다양한 지리 함수를 사용할 수 있고 속도도 빠르다는 내용인데, 이는 PostGis 라는 익스텐션 덕분이다.
여튼 DB 를 바꾸고 나서야 함수가 잘 동작했다. 나는 ST_DWithin() 과 ST_Distance() 두개의 함수를 이용했다. 그냥 이름 읽어보고 JPQL 보면 알 수 있을 것 같으니 설명은 생략한다. ST_DWithin 의 마지막 인자는 계산의 정확도와 관련있는데 false 로 설정하면, 정확도가 조금 떨어지는 대신 속도가 빨라진다.
@Query(value = "select s from Storage s where ST_DWithin(:center, s.location, :radius, false) = true")
List<Storage> findNearbyStorage(@Param("center") Point point, @Param("radius") int radius);
@Query(value = "select new practice.Geomquery.dto.StorageNearbyDto(s.name, round(cast(ST_DistanceSphere(:point, s.location) as double)) as distance) from Storage s order by distance asc")
List<StorageNearbyDto> findStorageNearby(@Param("point") Point point);
3. Querydsl 에 적용
Querydsl 은 사실 설정이 귀찮아서 도입하지 않으려고 했었는데 창고 검색이나 매칭에 여러가지 조건이 주어지기 때문에 동적으로 쿼리를 생성해야 했다. Querydsl 없이 동적 쿼리를 생성한다는 것은 솔직히 고문 수준이다. 챗지피티를 활용하면 좀 나을지도 모르겠다. 아이템 검색 기능을 위해 아래처럼 별도의 레포지토리를 만들어서 구현했었는데, 솔직히 중간에 Querydsl 도입을 염두에 두어가지고 테스트 몇개를 실패했는데 그냥 포기했다.
public class ItemJPQLRepositoryImpl implements ItemJPQLRepository {
@PersistenceContext
EntityManager em;
@Override
public List<SearchItemsDto> findItemsByConditions(Point center, int radius, int page, int size, Category cat, SearchingConditionDto conditionDto) {
var query = em.createQuery(searchingQueryBuilder(conditionDto, cat), SearchItemsDto.class);
// 파라미터를 지정합니다.
query.setParameter("center", center);
query.setParameter("radius", radius);
if (cat != null) query.setParameter("cat", cat);
if (conditionDto.getItemType() != null) query.setParameter("itemType", conditionDto.getItemType());
for (int i = 1; i <= conditionDto.getKeyword().length; i++) {
query.setParameter("keyword" + i, conditionDto.getKeyword()[i - 1]);
}
if (conditionDto.getStatus() != null) query.setParameter("status", conditionDto.getStatus());
return query.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
}
@Override
public List<MatchItemDto> findMatchByCondition(Point center, int radius, int size, MatchingConditionDto condition) {
int tagCount = condition.getHashTags().length;
var query = em.createQuery(matchingQueryBuilder(tagCount), MatchItemDto.class);
query.setParameter("center", center);
query.setParameter("radius", radius);
query.setParameter("cat", condition.getCategory());
query.setParameter("itemType", condition.getItemTypes());
if (tagCount == 1) {
query.setParameter("tag1", condition.getHashTags()[0]);
} else if(tagCount == 2) {
query.setParameter("tag1", condition.getHashTags()[0]);
query.setParameter("tag2", condition.getHashTags()[1]);
} else {
query.setParameter("tag1", condition.getHashTags()[0]);
query.setParameter("tag2", condition.getHashTags()[1]);
query.setParameter("tag3", condition.getHashTags()[2]);
}
return query
.setMaxResults(size)
.getResultList();
}
private static String matchingQueryBuilder(int tagCount) {
String SELECT = "SELECT NEW com.example.naejango.domain.item.dto.MatchItemDto";
String PROJECTION = "(it, c, ROUND(CAST(ST_DistanceSphere(:center, st.location) AS double)) AS distance) ";
String FROM = "FROM Storage st JOIN st.items it JOIN it.category c ";
String WHERE_DISTANCE_CONDITION = "WHERE ST_DWithin(:center, st.location, :radius, FALSE) = TRUE ";
String AND_CAT_CONDITION = "AND c = :cat ";
String AND_TYPE = "AND it.itemType IN :itemType ";
String AND_STATUS_TRUE = "AND it.status = true ";
String ORDER_DISTANCE = "ORDER BY distance ASC";
StringBuilder sb = new StringBuilder();
sb.append(SELECT);
sb.append(PROJECTION);
sb.append(FROM);
sb.append(WHERE_DISTANCE_CONDITION);
sb.append(AND_CAT_CONDITION);
if (tagCount == 1) {
sb.append("AND it.name = :tag1 ");
} else if(tagCount == 2) {
sb.append("AND (it.name = :tag1 OR it.name = :tag2) ");
} else {
sb.append("AND (it.name = :tag1 OR it.name = :tag2 OR it.name = :tag3) ");
}
sb.append(AND_TYPE);
sb.append(AND_STATUS_TRUE);
sb.append(ORDER_DISTANCE);
return sb.toString();
}
private static String searchingQueryBuilder(SearchingConditionDto conditions, Category cat) {
String SELECT = "SELECT NEW com.example.naejango.domain.storage.dto.SearchItemsDto";
String PROJECTION = "(it, st, c, ROUND(CAST(ST_DistanceSphere(:center, st.location) AS double)) AS distance) ";
String FROM_STORAGE_JOIN_ITEM_CAT = "FROM Storage st JOIN st.items it JOIN it.category c ";
String WHERE_DISTANCE_CONDITION = "WHERE ST_DWithin(:center, st.location, :radius, FALSE) = TRUE ";
String AND_CAT = "AND c = :cat ";
String AND_KEYWORD = "AND it.name LIKE :keyword";
String AND_TYPE = "AND it.itemType IN :itemType ";
String AND_STATUS = "AND it.status = :status ";
String ORDER_DISTANCE = "ORDER BY distance ASC";
ItemType[] itemType = conditions.getItemType();
String[] keywords = conditions.getKeyword();
Boolean status = conditions.getStatus();
StringBuilder sb = new StringBuilder();
// SELECT
sb.append(SELECT);
sb.append(PROJECTION);
// FROM
sb.append(FROM_STORAGE_JOIN_ITEM_CAT);
// WHERE
sb.append(WHERE_DISTANCE_CONDITION);
if (cat != null) sb.append(AND_CAT);
if (itemType != null) sb.append(AND_TYPE);
for (int i = 1; i <= keywords.length; i++) {
sb.append(AND_KEYWORD);
sb.append(i);
sb.append(" ");
}
if (status != null) sb.append(AND_STATUS);
// ORDER BY
sb.append(ORDER_DISTANCE);
return sb.toString();
}
}
Querydsl 을 쓰면 아래처럼 깔끔하게 정리된다.
@Repository
public class ItemRepositoryImpl implements ItemRepositoryCustom {
private final JPAQueryFactory queryFactory;
public ItemRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<SearchItemsDto> findItemsByConditions(Point center, int radius, int page, int size, Category cat,
String[] keywords, ItemType itemType, Boolean status) {
return queryFactory.select(Projections.constructor(SearchItemsDto.class,
item,
category,
storage,
Expressions.numberTemplate(Integer.class, "ROUND(CAST(ST_DistanceSphere({0}, {1}) AS double))", center, storage.location).as("distance"))
)
.from(storage)
.join(storage.items, item)
.join(item.category, category)
.where(catEq(cat),
itemTypeEq(itemType),
nameLikeAnd(keywords))
.offset((long) page * size)
.limit(size)
.orderBy(Expressions.stringPath("distance").asc())
.fetch();
}
@Override
public List<MatchItemDto> findMatchByCondition(Point center, int radius, int size, MatchingConditionDto condition) {
return queryFactory.select(Projections.constructor(MatchItemDto.class,
item,
category,
Expressions.numberTemplate(Integer.class, "ROUND(CAST(ST_DistanceSphere({0}, {1}) AS double))", center, storage.location).as("distance"))
)
.from(storage)
.join(storage.items, item)
.join(item.category, category)
.where(catEq(condition.getCategory()),
itemTypeIn(condition.getItemTypes()),
nameLikeOr(condition.getHashTags()))
.limit(size)
.orderBy(Expressions.stringPath("distance").asc())
.fetch();
}
private BooleanBuilder nameLikeOr(String[] words) {
BooleanBuilder condition = new BooleanBuilder();
for (String tag : words) {
condition.or(item.name.like(tag));
}
return condition;
}
private BooleanBuilder nameLikeAnd(String[] words) {
BooleanBuilder condition = new BooleanBuilder();
for (String tag : words) {
condition.and(item.name.like(tag));
}
return condition;
}
private static BooleanExpression itemTypeIn(ItemType[] itemTypes) {
return itemTypes != null? item.itemType.in(itemTypes) : null;
}
private Predicate itemTypeEq(ItemType itemType) {
return itemType != null? item.itemType.eq(itemType) : null;
}
private BooleanExpression catEq(Category category) {
return category != null? QCategory.category.eq(category) : null;
}
}
별로 안깔끔하다고 느낄지 모르겠지만 이후 지리 데이터 관련한 모든 쿼리는 Querydsl 로 작성하였고, JPQL 로 작성하면 N+1 오류가 나던 것도 Querydsl 로 해결하기도 했다. (관련해서는 다른 글에서 짧게 다루려고 함)
4. 결론
사실 간단한 라이브러리일 수 있으나 프로젝트 초반에 이런저런 시행착오를 겪으면서, 의존성을 다루는 법이나 ORM 이 어떤 기능을 하는지, 직렬화와 역직렬화, yml 파일 작성법 등 많은 공부를 하게 됐다. 사실 이 글은 뭐랄까 Hibernate Spatial 을 소개하는 글이라기 보다 이걸 도입하는 과정에서 생각했던 것들과 배운 것들을 회상하기 위한 글일지도 모르겠다.