서비스 관점에서 Spatial Data 다루기

이 글은 MySQL 기반에서 spatial data 를 다루는 방법을 서비스 관점에서 최적화 하는 글 입니다.

따라서 다른 Database (like postgresql) 들에서는 적합하지 않을 수 있습니다.

하지만 기본 골자는 비슷하므로 참고하시면 되겠습니다.

들어가며

서비스 관점에서 spatial data 를 다루는 것은 꽤나 까다롭다.

spatial data 라고 하면 longitude, latitude 를 갖느 2차원 지도 좌표 데이터 값을 다루는 것을 보편적으로 의미한다.

왜 까다롭냐? 는 질문에는 다음과 같이 답할 수 있을 것이다.

  1. 좌표데이터는 꽤나 소수점이 많다. 그 말은 즉슨 정확한 데이터를 파싱하기 위해 여러 전후처리 작업이 든다는 의미.
  2. 따라서 좌표데이터를 여러개 다루려면, 그만큼 많은 계산이 필요하다. 다루는 데이터의 row 수가 증가할 수록 계산량은 계속 배가된다.

그렇다면, 조금 더 널널하지만 서비스 관점에서 우아하게 다룰 수 있는 방법은 없을까?

라는 질문이 머릿속을 가득 채웠다!

데이터 관점에서 좌표 데이터 다루기

일단 서비스 관점이 아닌, 데이터를 다루는 관점에서 좌표 데이터를 다루게 된다면 다음과 같을 것 이다.

  1. 쿼링의 속도는 크게 고려하지 않는다
  2. 정확한 데이터를 추출해 내는 것이 일순위이다

MySQL 의 좌표 값을 다루는 연산식이 몇가지 있다.

Spatial Datatype

POINT, LINESTRING, POLYGON, MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, 그리고 GEOMETRYCOLLECTION 등의 MySQL 에서 좌표를 다루기 위해 제공하는 기본 포인트 타입들이 있다.

포인트 데이터 타입은 Prisma ORM에서 제공하지 않는다. 다만 native mysql 데이터 타입이므로 쿼링을 할 시에 그에 맞는 네이티브 연산을 걸 수 있다.

CREATE TABLE LOCATION (
    location_point POINT
);
SELECT
	ST_Y(location_point) as latitude,
	ST_X(location_point) as longitude
FROM LOCATIONS

등과 같이 데이터를 POINT 타입으로 선언 후에 전처리 과정을 거쳐서 숫자값으로 좌표값을 각각 쿼링할 수 있다.

혹은 ST_Contains 연산자를 사용하여 해당 POINT 데이터가 바운더리에 속해 있는지 쿼링할 수 있다.

SELECT
    NOT ST_Contains(
        (SELECT shape FROM spatial_data WHERE id = 1),  -- The polygon
        (SELECT shape FROM spatial_data WHERE id = 2)   -- The point
    ) AS is_outside;

다만, 이런식으로 가져오게 되면 연산 비용이 꽤나 든다.

실제 정확한 POINT 타입을 비교해야 하기 때문이다. 또한 비교해야 하는 Polygon 이 복잡해질수록 연산량이 증가된다. 그리고 위치 데이터에 대한 오버헤드도 고려해야 한다.

하지만, spatial indexing 을 포인트 타입에 걸게 되면, ST_Contains 와 같은 연산자가 효율적으로 작동할 수 있다고 한다. 또한 더 정확한 데이터 파싱이 가능하므로, 데이터 사이언스 관점에서 매우 정확한 위치를 다루어야 한다면 이 방법이 더 적합하다.

내 관점에서 봤을 때에는 raw query 에 대한 복잡도가 증가하여 유지보수 측면에서 DX 가 떨어진다.

서비스 관점에서 Spatial Data 다루기 (feat. geohash)

대부분의 서비스 관점에서 위치 데이터를 다룰 때에는 다음을 고려하는게 보편적이다.

  1. 매우 정확한 (precision) 위치 좌표 데이터까지는 고려하지 않아도 된다
  2. 근사치의 위치에 속한 좌표 데이터만 필터링 하면 된다. (ex. 특정 시, 군, 구에 속해있는 좌표데이터를 가져오기 등)

따라서 내 판단에서는 굳이 MySQL 의 포인트타입 연산자를 고려하면서 까지 연산비용을 늘릴 필요는 없었다.

그래서 GeoHash에 대한 리서치를 진행하였다.

GeoHash에 대한 개념적인 부분은 구글링을 통해 진행하는 것이 더 좋다.

또한 https://techlog.coldsurf.io/article/geohash에 간략한 글을 첨부하였으니 읽어보시기 바란다.

ChatGPT 에 물어보니 다음과 같이 성능 측면에서 비교를 해주었다.

Which Is Faster?

  • Geohash with Prefix: Generally faster for filtering large datasets if you’re only interested in approximate location matching and have a well-indexed geohash column. It’s efficient for broad spatial queries where precision is less critical.
  • ST_Contains: Potentially slower if the spatial index is not present or if the polygons are complex. However, it is more precise and can handle complex spatial relationships if performance is optimized with proper indexing.

Geohash 개념을 도입하게 되면 다음과 같이 러프하게 아키텍쳐를 짤 수 있다.

CREATE TABLE locations (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255),
    latitude DOUBLE,
    longitude DOUBLE,
    geohash CHAR(12),
    INDEX (geohash)
);

INSERT INTO locations (name, latitude, longitude, geohash)
VALUES ('Location A', 37.7749, -122.4194, 'wydm'),
       ('Location B', 37.7750, -122.4180, 'wydm4'),
       ('Location C', 37.7800, -122.4200, 'wydms');
SELECT * FROM locations
WHERE geohash LIKE 'wydm%';

조금 더 설명을 붙여보자면 다음과 같다.

  • wydm 의 geohash 바운더리에 해당하는 데이터를 가져오고 싶다.
  • 그때에는 복잡한 연산식보다는 LIKE 쿼리를 돌려서, 해당하는 바운더리의 geohash 문자열을 포함하는지만 비교하면 된다

서비스 관점에서 query 에 대한 코드가 간단해진다.

또한 Prisma ORM 에서 지원되는 타입으로 전환이 가능해지기 때문에 (DOUBLE) DX 의 향상도 도모할 수 있다.

서비스 기획적으로도 기획자와 개발자가 이해하기 좋은 하나의 관점이 생긴다.

예를들자면 다음과 같다.

  • 지도 SDK에서 제공하는 줌레벨 (zoom level)에 맞게 클러스터링과 일반 핀을 나누고 싶다.
  • 클러스터링은 특정 줌레벨 이하일때에만 보여진다.
  • 그렇다면, 어떻게 구역을 나누어서 클러스터링 핀을 꽂을것인가?

이럴 때에 geohash 와 precision 관점으로 기획을 하게 되면 모두가 이해할 수 있는 기획이 된다.

preicision 값은 결국 geohash 값의 문자열 길이를 의미한다.

만약 유저가 wydm 에 대한 바운더리를 가지고 지도를 줌 인 했다면, wydm 바운더리 안에 있는 한단계만 더 높은 precision안에 속해 있는 데이터들만 가져오면 된다.

wydm의 격자안에 있는 b, c, f, g 등등의 상위 geohash 값들을 비교하여 가져오면 되기 때문이다.

출처: https://geohash.softeng.co/wydm

쿼리문으로 생각하면 다음과 같다

SELECT * FROM locations
WHERE geohash LIKE 'wydmb%' OR LIKE 'wydmc%' ...;

Prisma ORM 과 typescript로 클러스터링 데이터 가져오기

schema.prisma

model Location {
  id        Int     @id @default(autoincrement())
  name      String
  latitude  Float
  longitude Float
  geohash   String  @db.VarChar(12) // Use a VARCHAR column for geohashes
  @@index([geohash]) // Index to optimize geohash queries
}

yarn add ngeohash

를 하여 node 환경에서 geohash 를 다룰 수 있는 유틸 라이브러리를 설치한다

geohashUtil.ts

import * as ngeohash from 'ngeohash';

export function encodeGeohash(latitude: number, longitude: number, precision: number = 12): string {
  return ngeohash.encode(latitude, longitude, precision);
}

Insert Data

import { PrismaClient } from '@prisma/client';
import { encodeGeohash } from './geohashUtils';

const prisma = new PrismaClient();

async function insertLocation(name: string, latitude: number, longitude: number) {
  const geohash = encodeGeohash(latitude, longitude);
  await prisma.location.create({
    data: {
      name,
      latitude,
      longitude,
      geohash
    }
  });
}

Query Data

export async function findLocationsByGeohashPrefix(prefix: string) {
  const locations = await prisma.location.findMany({
    where: {
      geohash: {
        startsWith: prefix,
      },
    },
  });
  return locations;
}

Example Usage

findLocationsByGeohashPrefix('wydm').then(locations => {
  console.log('Locations within geohash prefix "wydm":', locations);
});

Example Grouping by geohash prefix

import { findLocationsByGeohashPrefix } from './prismaClient';

async function groupLocationsByGeohashPrefix() {
  const prefixes = ['wydm', 'wydn', 'wydp']; // Example prefixes
  const groupedLocations: { [key: string]: any[] } = {};

  for (const prefix of prefixes) {
    const locations = await findLocationsByGeohashPrefix(prefix);
    groupedLocations[prefix] = locations;
  }

  return groupedLocations;
}

// Example usage
groupLocationsByGeohashPrefix().then(groups => {
  console.log('Grouped Locations by Geohash Prefix:', groups);
});

위와 같이 geohash prefix 배열에 대한 각각의 location 그룹 데이터들을 구할 수 있다!

만약 각각의 geohash prefix에 대한 클러스터링 중앙 좌표값을 구해야 한다면 다음과 같다.

type Location = { latitude: number; longitude: number };
type Cluster = { [key: number]: Location[] }; // Keyed by cluster ID

// Compute centroids manually given the locations and their cluster assignments
function computeCentroids(clusters: Cluster): { centroidLat: number; centroidLng: number }[] {
  const centroids: { centroidLat: number; centroidLng: number }[] = [];

  for (const [clusterId, locations] of Object.entries(clusters)) {
    const totalLat = locations.reduce((sum, loc) => sum + loc.latitude, 0);
    const totalLng = locations.reduce((sum, loc) => sum + loc.longitude, 0);
    const count = locations.length;

    if (count > 0) {
      centroids[+clusterId] = {
        centroidLat: totalLat / count,
        centroidLng: totalLng / count
      };
    }
  }

  return centroids;
}

// Example usage
async function exampleUsage() {
  const locations = await findLocationsByGeohashPrefix('wydm');
  
  // Example of clustering results (this should come from your clustering algorithm)
  const clusterAssignments = {
    0: [{ latitude: 37.7749, longitude: -122.4194 }, { latitude: 37.7750, longitude: -122.4180 }],
    1: [{ latitude: 34.0522, longitude: -118.2437 }],
    2: [{ latitude: 40.7128, longitude: -74.0060 }]
  };
  
  const centroids = computeCentroids(clusterAssignments);
  console.log('Cluster Centroids:', centroids);
}

// Call the example function
exampleUsage();

결국 geohash prefix로 부터 다음과 같은 데이터 형식을 얻어낼 수 있다면, 클러스터링 데이터를 어디서든 적용할 수 있다!

type Cluster = {
	size: number
	centerLat: number
	centerLng: number
}

type Clusters = Cluster[]

마치며

SQL 문들이 조금 듬성듬성하고 실제로 잘 돌아가지 않을 수 있는데 관련해서 나도 GPT 를 참고하여 쓴 글이라 조금 더 프롬프트 엔지니어링을 발휘 하면 정확한 값을 얻어낼 수 있을 것이다.

핵심 골자는 대부분 써 놓았다고 생각하여 글을 마무리 하겠습니다.

← Go home