주변 사용자 조회 로직 변경
앞의 포스트는 사용자의 위치 변경에 대한 내용이라면 이번 포스트는 주변 사용자 조회 로직 변경에 대한 내용이다.
주변 사용자 조회 요청에 대한 응답 데이터는 주변 사용자의 위치 정보와 최근 계획의 할 일 카테고리, 반경 내 모든 사용자의 카테고리 랭킹 정보로 구성된다. 각 데이터의 생성은 반경 내 주변 사용자가 많은 경우 서버에 문제가 발생할 수 있어 기존 로직을 수정하여 해결한다.
주변 사용자의 위치 정보 및 최근 계획의 할 일 카테고리 데이터 응답에 대한 로직 변경
기존 로직은 반경 내 모든 사용자의 위치 정보를 가져와 할 일 테이블에서 최근 종료된 계획에 대한 할 일을 모두 가져와 해당 데이터를 생성한다. 반경 내 모든 사용자를 가져온다면 사용자가 많을 시 클라이언트 단에서 사용자들을 모두 표시하기 어렵다는 문제가 존재한다. 서버 단에서는 모든 사용자에 대한 최근 계획의 모든 할 일을 조회해야 하므로 성능 상 문제가 일으킨다.
반경 내 주변 사용자가 많을 시 조사한 결과 두 가지정도의 처리 방식이 존재한다. 근접한 사용자를 클러스터링하여 처리하는 방식과 일부 사용자만 보여주는 방식이 있다.
기능의 특성 상 주변 사용자가 한 할 일이 중요하다고 생각하는데 클러스터링 방식으로 처리하면 클라이언트 단에서 카테고리 별로 필터링하기도 어렵고 해당 사용자의 할 일을 확인하기 위해서는 개별 사용자 데이터가 될 때까지 줌인(확대)해야 한다. 일부 사용자만 보여주는 방식은 보여줄 최대 인원수를 정하여 사용자의 수가 기준 값을 넘어간다면 기준 값의 수만큼만 보여주고 줌인(확대) 했을 때 반경을 작게하여 해당 반경에 해당하는 사용자를 다시 조회하는 방식이다. (줌인했을 때에도 해당 줄어진 반경에 대하서 사용자의 수가 기준 값보다 크다면 기준 값만큼만 보여준다) 이 방식은 줌인(확대)하지 않고도 개별 사용자의 할 일을 확인할 수 있기 때문에 기능에 더 어울리는 방식이다.
그러면, 주변 사용자의 수가 기준 값을 넘었을 때 보여줄 사용자를 선택하는 방법에 대해 생각해볼 필요가 있다.
주변 사용자를 선택할 기준이 되는 데이터를 고를 합리적인 정책이 있지 않아 사용자의 updated_at 데이터를 활용할 생각이다. updated_at 컬럼은 Member 테이벌의 데이터 변경 발생했을 때 기록하는 시간 값으로 사용자의 위치 정보를 수정했을 때 해당 값은 업데이트 된다. 사용자의 위치 정보가 현재 기능과 밀접하게 연관되어 있기 때문에 updated_at 을 사용해도 큰 무리가 없을 것 같다. 물론, 사용자의 프로필을 수정을 하면 updated_at 데이터가 변경되기는 하지만 프로필 수정과 위치 수정의 비율이 압도적으로 위치 수정이 많을 것 같아서 크게 문제가 될 것 같지는 않다.
응답으로 제공할 주변 사용자를 선택했으면 그 이후의 로직은 기존과 동일하다.
반경 내 모든 사용자의 카테고리 랭킹 정보 조회
기존의 방식과 크게 달라지는 부분은 이 로직이다. 기존 방식은 요청이 왔을 때 반경 내 모든 사용자들의 최근 종료된 계획의 할 일 데이터로부터 카테고링 랭킹 정보를 만들어낸다. 이 로직은 주변 사용자가 많은 경우 서버 성능에 큰 문제를 만들어낸다.
기존의 문제가 요청이 왔을 때 카테고리 랭킹 데이터를 생성하는 것이 문제였으니 미리 데이터가 만들어져 있다면 요청 시 해당 데이터를 조회만 하면 된다는 생각이 들어 집계 테이블로 생성하여 해결하는 쪽으로 방향을 잡았다. 하지만, 모든 사용자의 위치가 계속 변경되기 때문에 집계 테이블에서 한 번의 쿼리로 원하는 데이터를 만들어 낼 수 없다. 사용자에 대한 집계 데이터를 만드는 것은 불가능하다. 사용자가 아닌 위치에 대하여 집계 데이터를 생성하여 요청한 사용자를 기준으로 원하는 데이터를 만들어낸다.
1. 집계 테이블
집계 테이블의 스키마는 다음과 같다.
CREATE TABLE `cell_category_count` (
`count` int NOT NULL,
`category_id` bigint NOT NULL,
`cell_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
...
PRIMARY KEY (`category_id`,`cell_id`),
CONSTRAINT `FK8s2nvmutjh8h1xaityc034591` FOREIGN KEY (`category_id`) REFERENCES `category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
셀 id(geohash 7단계) 와 category_id 복합키를 기본키로 한다.
셀 id geohash를 7단계 값을 사용하는 이유는 6단계(1.22km * 0.61km) 는 셀의 크기가 커서 원의 반경의 형태와 크게 달라질 것이라고 생각을 하였고 8단계(38.2km * 19.1km) 은 사용자의 위치 변경으로 너무 많은 집계 테이블의 업데이트가 발생할 것 같아 중간이 7단계를 선택하였다.
2. Bounding Box
사용자의 위치 데이터와 반경을 기준으로 Bounding Box를 구한다. Bounding Box는 반경 원을 내접하고 있는 사각형이라고 생각하면 된다. 경위도 좌표와 반경으로 Bounding Box 의 네 꼭짓점의 좌표를 구할 수 있다.
Bouding Box 의 네 꼭짓점 좌표를 구하는 방법은 다음과 같다.
위도 변화량 구하기 = ${\Delta \lambda_{\text{rad}} = \frac{d}{R \times \cos(\phi_{\text{rad}})}}$
경도 변화량 구하기 = ${\Delta \lambda = \arcsin\left(\frac{\sin\left(\frac{d}{R}\right)}{\cos(\phi)}\right)}$
BoundingBox 의 NE 좌표 (lat + 위도 변화량, lon + 경도 변화량)
BoundingBox 의 NW 좌표 (lat + 위도 변화량, lon - 경도 변화량)
BoundingBox 의 SE 좌표 (lat - 위도 변화량, lon + 경도 변화량)
BoundingBox 의 SW 좌표 (lat - 위도 변화량, lon - 경도 변화량)
3. BoundinBox 에 속하는 geohash(7단계) 구하기
BoundingBox의 네 꼭지점 좌표를 geohash(7단계)로 변환
네 geohash 를 기준으로 내부에 포함된 geohash 들 구하기
geohash 에서 부여되는 문자열은 규칙이 존재한다.
홀수번째 해시(경도 정보 인코딩)의 경우는 왼쪽 이미지의 값처럼 부여되고 짝수번째 해시(위도 정보 인코딩)의 경우 오른쪽 이미지의 값처럼 부여된다.


따라서, geohash(7단계)에서 인접한 geohash값을 파악할 수 있고 BoundingBox 내부의 geohash 값들을 구할 수 있다. 하지만, BoundingBox는 원(반경)이 아닌 원을 포함하고 있는 사각형이다. 따라서, 실제 거리 정보를 이용해 원(반경) 안에 포함되지 않은 geohash는 제거한다.
public static List<GeoHash> deriveGeoHashInBoundingBox(GeoHash nwGeoHash, GeoHash neGeoHash, GeoHash swGeoHash, double latitude, double longitude, double radius) {
GeoHash curRowHash = GeoHash.fromGeohashString(nwGeoHash.toString());
GeoHash endRowHash = GeoHash.fromGeohashString(neGeoHash.toString());
List<GeoHash> boundingBoxGeoHashes = new ArrayList<>();
while(true){
GeoHash curColHash = GeoHash.fromGeohashString(curRowHash.toString());
GeoHash endColHash = GeoHash.fromGeohashString(endRowHash.toString());
while(true){
if (withinCircle(curColHash, latitude, longitude, radius)) {
boundingBoxGeoHashes.add(curColHash);
}
if(curColHash.equals(endColHash)) break;
curColHash = curColHash.getEasternNeighbour();
}
if(curRowHash.equals(swGeoHash)){
break;
}
curRowHash = curRowHash.getSouthernNeighbour();
endRowHash = endRowHash.getSouthernNeighbour();
}
return boundingBoxGeoHashes;
}
4. 카테고리별 랭킹 조회
3에서 구한 geohash 값을 통해 집계 테이블에서 geohash들에 해당하는 데이터를 조회한다. 조회된 데이터를 category id 별로 합해 카테고리별 랭킹 데이터를 생성한다.