NestJS와 Socket.io를 통한 실시간 위치 처리 개발기 1
NOWIL
by NOWIL
9 min read

Categories

Tags

Series

  1. NestJS와 Socket.io를 통한 실시간 위치 처리 개발기 1
  2. NestJS와 Socket.io를 통한 실시간 위치 처리 개발기 2

썸네일 이미지 출처 : Working with geographic features and spatial data in MySQL 8

위급 상황 대응 시스템을 개발하기 위해 필요한 주요 기능 중 하나로 신고가 등록되면 신고자의 현재 위치를 실시간으로 화며에서 보여주는 기능이 있었습니다. 해당 기능을 구현하기 위해 NestJS에서 socket.io를 이용한 geometry 공간 데이터를 처리하는 과정을 기록하였습니다.

위치 데이터

공간 데이터에 대해 이해하려면 공간이 무엇인지 알아야 합니다. 물론 수학, 물리학등에서 이야기하는 공간에 대한 정의 등을 하는 건 당연히 아닙니다. 여기서 다루는 공간은 위치에 대한 이야기입니다. 어떤 데이터가 위치(지리)에 대한 정보를 담고 있다는 뜻은 주체(들)의 좌표(Point), 이동경로/도로(Line), 시군구 법정동과 같은 영역(Polygon) 등을 나타내고 있다는 의미입니다. 그럼 이러한 위치를 표현하는 방법은 무엇이 있을까요? 대표적으로 지리 좌표계가 있습니다.

지리 좌표계

Untitled

지리 좌표계(Geographic Coordinate System, GCS)는 지구상에 위치를 좌표로 표현하기 위해 3차원의 구면을 이용하는 좌표계를 의미합니다. 한 지점은 경도(longitude)와 위도(latitude)로 표현되며 이 단위는 도(degree)로 표시됩니다.

Untitled

위 그림과 같이 사실 지구는 원이 아니라 타원체인데, 3차원의 지구를 좌표로 표현하기 위해서 지오이드(geoid)라는 방법론도 제안되었으나, 복잡성을 배제하기 위해 결과적으로는 평평한 타원체로 정의가 되있고 이를 지구 타원체(Earth Ellipsoid)라고 합니다.

이 타원체의 중심점(datumn)을 기반으로 측량을 하고 좌표를 통해 표현하게 되는데, 세계적으로 통일성을 유지하기 위해 국제 표준 타원체인 GRS80과 WGS84가 제시되었습니다.

(하지만 위 두 좌표 모두 지구의 중심을 타윈체의 원점으로 정의하므로 큰 차이가 없이 사용됩니다.)
위 좌표계가 바로 우리가 흔히 알고 있는 기본 위경도 좌표로, 경도(X)와 위도(y)를 갖고 있는 데이터라면 WGS84 좌표라고 생각하면 됩니다.
출처 : GIS 좌표계 종류

EPSG 코드

EPSG 코드는 전세계 좌표계 정의에 대한 고유한 명칭 입니다. 지구를 표현할 때,

  • 어떤 타원체(WGS84, GRS80) ?
  • 어디를 중심으로(지리좌표계) ?
  • 어떻게 변환(투영법, TM, UTM) ?

할거냐에 따라서 다양한 좌표계가 생겨나기 때문에, 이를 EPSG 코드라는 표준 코드를 만들어둔 것이죠.

예를 들면, 아래와 같이 각기 다른 좌표계 생성 방식에 따라 구분이 필요합니다.

  • 지리좌표계 : WGS84 → EPSG4326
  • 투영좌표계 : GRS80 타원체를 UTM-k로 투영한 좌표계 → EPSG5179

특히 위경도 좌표의 경우 같은 좌표계지만 WGS84GRS80EPSG4326와 같이 표현되면서 혼동할 수가 있으므로 고유 코드인 EPSG 코드를 알아두면 헷갈리지 않고 좌표계를 활용할 수 있습니다.

출처: https://yganalyst.github.io/spatial_analysis/spatial_analysis_3/

UntitledEPSG Code 종류 (wikipedia)

MySQL에서의 지리 좌표계

Spatial 데이터 타입

MySQL GIS Datatypes

Untitled

회색은 추상화 클래스 입니다.

Untitled

출처 : https://youngwoon.tistory.com/3

WKT & WKB

geometry 객체를 표현하는데 OGC(Open Geospatial Consortium)에 의해 제정한 표준 공간 데이터 형식은 두 가지가 있습니다.

  1. Well-Known Text (WKT) format
    WKT는 지도, 공간 객체의 공간 참조 시스템(Spatial Reference System) 그리고 공간 참조 시스템 간의 변환을 표현하기 위한 텍스트 마크업 언어입니다. 이와 유사한 WKB(Well-Known Binary)라고 알려진 바이너리 형식이 PostGIS와 같은 데이터베이스에 동일한 정보를 저장하고 변환되는데 사용됩니다.

  2. Well-Known Binary (WKB) format
    WKB는 OpenGIS 스펙에 의해 정의된 지오메트리 값을 표현하기 위해 사용됩니다. KWB는 지오메트리 WKB 정보를 담고 있는 BLOB 타입의 값으로 표현되는 바이너리 스트림으로써 지오메트리 데이터를 상호간에 교환하기 위해 사용됩니다. WKB는 1바이트의 부호없는 정수와 4바이트의 부호없는 정수 그리고 8바이트의 배정밀 소수(IEEE 754)를 사용합니다. 여기서 1바이트는 8비트입니다.

참고 : http://www.gisdeveloper.co.kr/?p=994

내부적으로, MySQL은 지오메트리 값을 WKT 또는 WKB 형식과 동일하지 않은 형식으로 저장합니다. (내부 형식은 WKB와 비슷하지만 SRID를 나타내는 초기 4바이트가 있습니다.)

예를 들어, WKT인 POINT(1 1)을 동일한 WKB로 변환하면 다음과 같은 총 21개로 구성된 연속된 바이트입니다.

0101000000000000000000F03F000000000000F0BF

위 바이트는 아래 테이블과 같은 구조로 이루어져 있습니다.

Untitled

출처: https://dev.mysql.com/doc/refman/8.0/en/gis-data-formats.html

TypeORM

WKT/WKB/EWKT/EWKB/TWKB/GeoJSON 포멧에 대해 파싱, 직렬화를 위한 wkx 패키지를 설치합니다.

npm i wkx

Entity 정의

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity('LOCATION')
export class Location {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 })
  start_point: string;

  @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 })
  current_point: string;

  @Column({
    type: 'geometry',
    spatialFeatureType: 'LineString',
    srid: 4326,
    nullable: true,
  })
  route: string;
}

Point 생성

시작 위치와 현재 위치를 저장하기 위해 start_point, current_point 컬럼을 설정하고 postman을 통해 위도 경도 값을 생성해보겠습니다.

// ./dto/create-location.dto
export class CreateLocationDto {
  payload: {
    latitude: number;
    longitude: number;
  };
}
// location.service.ts
async create(body: CreateLocationDto) {
    try {
      const { latitude, longitude } = body.payload;
      const createdLocation = new Location();
      createdLocation.start_point = `POINT(${latitude} ${longitude})`;
      createdLocation.current_point = `POINT(${latitude} ${longitude})`;
      await this.locationRepository.save(createdLocation);
      return createdLocation;
    } catch (e) {
      console.log(e);
      ...
    }

❗ 위도 경도 순서는 POINT(${latitude} ${longitude}) 이어야 합니다. 그 이유는 제가 엔티티를 설정할때 컬럼의 srid를 4326로 설정했기 때문입니다. 즉, WGS84좌표계를 사용했기 때문이죠.

참고) MySQL 내부 Spatial Data Type

mysql> select *
from information_schema.st_spatial_reference_systems
where srs_id=3857\G
*************************** 1. row ***************************
               SRS_NAME: WGS 84 / Pseudo-Mercator
                 SRS_ID: 3857
           ORGANIZATION: EPSG
ORGANIZATION_COORDSYS_ID: 3857
             DEFINITION: PROJCS["WGS 84 / Pseudo-Mercator",
             GEOGCS["WGS 84",DATUM["World Geodetic System 1984",
             SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG",>"7030"]],
             AUTHORITY["EPSG","6326"]],
             PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],
             UNIT["degree",0.017453292519943278,AUTHORITY["EPSG",>"9122"]],
             AXIS["Lat",NORTH],AXIS["Lon",EAST],AUTHORITY["EPSG",>"4326"]],
             PROJECTION["Popular Visualisation Pseudo Mercator",AUTHORITY>["EPSG","1024"]],
             PARAMETER["Latitude of natural origin",0,AUTHORITY["EPSG",>"8801"]],
             PARAMETER["Longitude of natural origin",0,AUTHORITY["EPSG",>"8802"]],
             PARAMETER["False easting",0,AUTHORITY["EPSG","8806"]],
             PARAMETER["False northing",0,AUTHORITY["EPSG","8807"]],
             UNIT["metre",1,AUTHORITY["EPSG","9001"]],
             AXIS["X",EAST],AXIS["Y",NORTH],AUTHORITY["EPSG","3857"]]
            DESCRIPTION: NULL

WGS84좌표계의 DEFINITION 컬럼에서 중요한 내용은 AXIS 이며, 예제의 DEFINITION 컬럼에는 >AXIS 가 두번 표시됩니다. 두 AXIS 는 위도와 경도 순서로 나열되어 있습니다. 그래서 >WGS84좌표계를 사용하는 위치 정보에서 특정 위치를 표시할 때에는 “POINT(위도 경도)” 와 >같이 표현 해야 합니다. 나열된 순서대로 첫번째는 X축, 두번째는 Y축에 해당 하며, ST_X()함수는 >위도 값을 반환하며,ST_Y()함수는 경도 값을 반환합니다.
→ AXIS[ “Lat” , NORTH ], AXIS[ “Lon” , EAST ]
Lat: 위도(latitude), Lon: 경도(longitude)

출처 : MySQL - 확장 검색 - 공간 검색 - Spatial Data Types - Real MySQL 8.0

Untitled

API를 요청하면 QueryFailedError: ER_SP_DOES_NOT_EXIST: FUNCTION example_db.GeomFromText does not exist 에러가 발생합니다. 그 이유는 MySQL 5.7.6 이상 버전부터는 GeomFromText 함수(deprecated됨)를 인식하지 못하기 때문에 STGeomFromText와 같은 ST 접두사를 사용한 공간 함수를 사용해야 합니다.

참고: Mysql function CONTAINS and GEOMFROMTEXT does not working

이러한 문제를 해결하기 위해서는 typerom options을 수정하는 방법과 createQueryBuilder를 통해 생성 쿼리를 직접 설정하는 방법이 있습니다.

typeorm options 수정

typerom options의 옵션에 legacySpatialSupportfalse 로 설정합니다.

const options: DataSourceOptions = {
  type: "mysql",
  host: configService.get("DATABASE_HOST"),
  port: 3306,
  timezone: "+09:00",
  charset: "utf8mb4_general_ci",
  logging: true,
  username: configService.get("DATABASE_USER"),
  password: configService.get("DATABASE_PASSWORD"),
  database: `example_db`,
  entities: [__dirname + "/../**/*.entity{.ts,.js}"],
  synchronize: true,
  legacySpatialSupport: false, // legacySpatialSupport 수정(default: true)
};

그리고 API를 다시 요청해보겠습니다.

Untitled

Untitled

정상적으로 저장됩니다.

createQueryBuilder로 생성하기

또 다른 방법으로는 createQueryBuilder에서 geometry 생성 쿼리를 직접 설정하는 것입니다.

const currentPoint = `${longitude} ${latitude}`;
const createdLocation = await this.locationRepository
  .createQueryBuilder()
  .insert()
  .values({
    start_point: () => `ST_GeomFromText('POINT(${currentPoint})', 4326)`,
    current_point: () => `ST_GeomFromText('POINT(${currentPoint})', 4326)`,
  })
  .execute();

❗ 위의 예시와 같이 createQueryBuilder 를 통해 MySQL에서 제공하는 ST_GeomFromText 을 직접사용할 때는 srid값 또한 같이 직접 입력해줘야 합니다. 안그럼 srid를 0으로 처리하여 out of range 에러가 발생합니다.

Untitled

geometry 포맷 수정

start_point, current_pointstring 타입으로 응답됩니다. 저희가 entity를 설정할 때 각 컬럼의 데이터 타입을 string으로 설정했기 때문입니다. 간단한 위치 데이터를 처리할 때는 이러한 데이터 타입도 큰 문제는 없습니다. 하지만 string 타입은 위치 데이터를 다양하게 다루기 어려움이 많습니다. 예를 들어, 위치 데이터가 경로와 같은 Line 타입일 때, 새로운 Point 를 추가하려면 string 타입이 적합한 데이터 타입은 아닙니다.

이러한 문제를 해결해주는 포맷이 있습니다. 바로 GeoJSON입니다.

GeoJSON

GeoJSON은 JSON으로 위치 데이터와 속성 데이터를 저장하는 형식입니다. 다른 GIS 포맷과는 다르게 IETF(Internet Engineering Task Force)에서 제안되어 유지되고 있습니다. 좌표는 경위도 좌표체계로 저장되는데, 흔히 구글맵이나 OSM에서는 (위도, 경도)의 순서로 저장하는데 반해, GeoJSON은 (경도, 위도)의 순서로 저장됩니다.

GeoJSON의 장점으로는 아래와 같습니다.

  • XML과 비교하여 스카마나 태그 규칙에 대해 훨씬 자유롭다.
  • 데이터 용량이 다른 포맷에 비해 상대적으로 작다.
  • JSON 형식이므로 프로그래밍 언어에서 쉽게 객체화 시킬 수 있다. 특히 Javascript에서는 단 1줄로 객체화 시킬 수 있다.
  • 다양한 응용 프로그램에 적재되기에 용이하며, 실제로 다양한 응용 프로그램에서 활용된다.

출처: GeoJSON Format(형식)

그럼 LOCATION의 gemetry 타입의 컬럼들을 GeoJSON으로 처리하는 방법을 알아보겠습니다.

GeoJSON 포맷은 데이터 타입이 아닌, GIS를 나타내는 포맷이기에 LOCATION 엔티티의 데이터 타입을 수정할 필요는 없습니다. 단지 해당 데이터 타입을 가져오는 코드를 수정하면 됩니다.

// location.service.ts
async get(body: any) {
    try {
      const getLocation = await this.locationRepository.findOne({
        where: { id: body.id },
      });
      console.log(typeof getLocation.current_point); // string
      return getLocation;
    } catch (e) {
      console.log(e);
      ...
    }
  }

데이터베이스에 저장된 위치 데이터의 현재 위치 컬럼의 데이터 타입을 출력하면 string으로 나타납니다. 그럼 이 string을 GeoJSON으로 파싱해주면 됩니다. 이러한 작업을 쉽게 해주는게 초반에 설치한 wkx 패키지입니다.

// location.service.ts
...
import { Geometry } from 'wkx';
...
const getLocation = await this.locationRepository.findOne({
	where: { id: body.id },
});
console.log(typeof getLocation.current_point); // string
const currentPointGeojson = Geometry.parse(getLocation.current_point).toGeoJSON();
return { currentPointGeojson };
...

코드는 너무나 간단합니다. 데이터베이스에서 가져온 location 데이터의 파싱할 컬럼을 wkx 패키지의 Geometry 객체로 파싱을 한 후 toGeoJSON() 메서드를 실행해주면 끝입니다.

Untitled

http://geojson.io 에서 위 GeoJSON 값을 입력하면 정상적으로 위치가 나타날까요?? 아쉽지만 아닙니다.

origin은 (위도, 경도)의 순서로 저장하는데 반해, GeoJSON은 (경도, 위도)의 순서로 저장하기 때문입니다. 이 문제를 해결하기 위해 coordinates 의 array 메서드인 reverse() 메서드를 사용하였습니다.

const currentPointGeojson = Geometry.parse(`SRID=4326;${getLocation.current_point}`).toGeoJSON();
currentPointGeojson["coordinates"] = currentPointGeojson["coordinates"].reverse();
return { origin: getLocation.current_point, currentPointGeojson };

Untitled

Untitled

http://geojson.io에 변경된 GeoJSON 값을 입력하면 정상적으로 위치가 출력됩니다.

경로 다루기

이전 목차에서 위치 데이터를 객체처럼 다루기 위해 GeoJSON을 사용하였습니다. 그럼 이제 실제로 사용자의 위치 데이터를 받아 경로 컬럼를 업데이트 해보겠습니다.

//location.service.ts
async update(body: any) {
    try {
      const { latitude, longitude } = body.payload;
      const currentPoint = `POINT(${latitude} ${longitude})`;
      const updatedLocation = await this.locationRepository.findOne({
        where: { id: body.locationId },
      });
      const startPointGeojson = Geometry.parse(`SRID=4326;${updatedLocation.start_point}`).toGeoJSON();
      const start_point = startPointGeojson['coordinates'];
      let routeData: Point[] = [];
      if (updatedLocation.route) {
        const routeJSON = Geometry.parse(`SRID=4326;${updatedLocation.route}`).toGeoJSON();
        const preRoute = routeJSON['coordinates'];
        console.log('preRoute', preRoute);
        preRoute.push([latitude, longitude]);
        routeData = preRoute;
      } else {
        routeData = [start_point, [latitude, longitude]];
      }
      console.log('routeData', routeData);
      const route: string = routeData.map((p) => `${p[0]} ${p[1]}`).join(',');
      console.log('route', route);
      updatedLocation.current_point = currentPoint;
      updatedLocation.route = `LINESTRING(${route})`;
      await this.locationRepository.save(updatedLocation);
      return updatedLocation;
    } catch (e) {
      console.log(e);
      ...
    }
  }

route 컬럼은 기본적으로 null 로 되어 있습니다. 만약 LINESTRING 타입을 사용하려 한다면 리스트 내 Point 가 반드시 2개 이상 이어야 합니다. 그 이유는

LineString is a Curve with linear interpolation between points.
(LineString 은 점들 사이의 선형 보간이 있는 Curve 입니다.)
출처 : MySQL 8.0 Reference Manual

라는 LineString 의 정의 때문입니다. 여기서 선형 보간이란

선형 보간법(linear interpolation)은 끝점의 값이 주어졌을 때 그 사이에 위치한 값을 추정하기 위하여 직선 거리에 따라 선형적으로 계산하는 방법이다.
출처 : https://ko.wikipedia.org/wiki/선형_보간법

즉, 그 사이에 위치한 값을 추정해야 하므로 (x0,y0),(x1,y1)과 같은 두 개 이상의 점이 반드시 필요한 겁니다. 만약 두 점이 같은 좌표라면 어떨까요? Untitled 같은 (경도,위도) 값을 LineString 에 입력해도 정상적으로 저장됩니다.

하지만 저희 프로젝트에선 시작점을 반드시 알고 있기 때문에 만약 기존에 저장되어 있는 route가 없다면 LineString 의 시작점을 start_point 컬럼 값으로 설정하고 사용자가 전송한 현재 위치값을 그 다음 저장하였습니다.

route가 있다면 기존의 route를 GeoJSON으로 파싱한 후, coordinates의 리스트에 사용자가 전송한 현재 위치값을 push() 메소드를 통해 저장하였습니다. 그리고 새로 업데이트된 route 리스트인 routeData에 각각의 elements의 위도 경도값을 저장하기 위해 문자열로 변환해주는 작업이 필요 합니다. 이를 위해 map() 메서드를 통해 각각의 elements를 순회하며 {위도} {경도} 형태의 문자열로 바꿔주었습니다. 그리고 map() 으로 새로 생성된 리스트를 하나의 문자열로 합쳐주기 위해 join() 메서드를 사용하였습니다.

routeData;
/*
[
  [ 37.566296, 126.977943 ],
  [ 37.566296, 126.977943 ],
  [ 37.565858, 126.97883 ]
]
*/
routeData.map((p) => `${p[0]} ${p[1]}`);
/*
[
'37.566296 126.977943',
'37.566296 126.977943',
'37.565858 126.97883'
]
*/
routeData.map((p) => `${p[0]} ${p[1]}`).join(",");
// 37.566296 126.977943,37.56673 126.97841,37.566632 126.979071,37.565858 126.97883

그리고 join() 메서드를 통해 생성한 stringLINESTRING(${route}) 코드를 통해 route 컬럼에 저장합니다.

Untitled

❗ route 컬럼을 업데이트하는 과정에서 GeoJSON의 (경도, 위도)의 순서로 호출해야 되어, reverse() 메서드를 통해 순서를 바꿔주는 과정을 생략하였습니다.
그 이유는 route 컬럼을 업데이트하는 과정에서 클라이언트에 GeoJSON 포맷으로 보여주기 위해서 아니라 LineString에 Point를 더 원활하게 추가하기 위해 사용하였기 때문입니다.
만약 클라이언트에게 GeoJSON 포맷으로 응답해야 한다면 reverse() 메서드를 사용하여 위도 경도의 index를 수정해야 합니다.

데이터베이스 GUI tool에서 route컬럼에 저장된 LINESTRING을 지도에서 시각화하면 정상적으로 경로가 나타납니다! Untitled

지금까지 사용자에게 위치 정보를 받아 데이터베이스에 geometry data type으로 저장하는 과정을 완료하였습니다. 이제 실시간 위치 처리를 위해서 사용자의 위치 정보를 실시간으로 받아오는 과정이 필요합니다. 이러한 과정은 웹소켓 라이브러리인 Socket.io를 통해 구현해야 합니다.

이 과정은 다음 장에서 살펴보겠습니다.

참고자료

MySQL - 확장 검색 - 공간 검색 - Spatial Data Types - Real MySQL 8.0

Saving and Retrieving Well-known Text (WKT) in MySQL or Postgres with TypeORM

Latitude and Longitude Finder on Map Get Coordinates