Map Server 구축해보기

2020년 5월 16일

작년에 GIS 공부 관련해서 **“Map Server 환경 설정 및 사용”**이라는 목표를 잡았었지만 달성하지 못했고 올해도 이어서 목표로 잡아두었습니다. 지도 관련 업무를 하면서 늘 클라이언트에서 타일 서버에서 호출은 하지만 늘 뒤단은 장막에 가려진 것처럼 보이지 않는 영역이었습니다. 그래서 이를 목표로 잡아 한번 DB에서 서버로, 서버에서 클라이언트로의 한 사이클을 직접 만들어보고 싶었습니다. 얼마 전 동료분께서 Vector Tile Server using PostGIS라는 글을 공유해 주셔서 이를 바탕으로 환경을 구성해보고 정리하는 글을 작성하게 되었습니다. 구성한 환경은 gis-study에서 확인하실 수 있습니다.

Data 구축하기

DB 설정하기

데이터를 저장할 DB로는 많이 알려진 PostgreSQLPostGIS를 사용하였습니다. 초기에는 OSM, PostGIS and Docker: an approach for automatic processing라는 글을 참조해서 Ubuntu에 직접 PostgreSQL를 설정하고 PostGIS를 설치하고 바로 데이터를 주입하는 방향으로 구성하였으나, 데이터 저장과 주입을 분리하고자 하여 DB 부분은 kartoza/postgis라는 이미지를 사용하였습니다.

version: '3.4'

services:
  db:
    container_name: postgis
    image: kartoza/postgis
    ports:
      - '5432:5432'
    restart: always
    environment:
      - PGDATA=/data/pgdata
      - POSTGRES_USER=postgres
      - POSTGRES_PASS=postgres
      - POSTGRES_DBNAME=osmdata
      - ALLOW_IP_RANGE=0.0.0.0/0
    volumes:
      - ./db/data:/data

추가적으로 DB에 주입한 데이터를 확인하려고 pgAdmin도 설정은 하였지만, 실제로는 IntelliJ에서 DB에 접근해서 본 경우가 많아서 사용하지는 않았습니다.

Data 주입하기

데이터를 저장할 DB는 준비되었으니 DB에 주입할 데이터를 찾아야 했습니다. 우선 관련된 일을 하면서 많이 봤던 OpenStreetMap 데이터를 주입하는 것을 목표로 잡았습니다. 데이터를 최종적으로 노출할 Client에서 Mapbox를 사용할 생각이었기 때문에 OSM PBF가 필요했고 이를 받아오도록 구성했습니다.

환경을 구성해보는 것이 목표였기 때문에 전 세계 데이터를 받지 않고 국가별 데이터 중 한국 데이터를 받아서 사용했습니다. 초기에는 OSM, PostGIS and Docker: an approach for automatic processing를 참고해서 데이터를 받는 부분도 도커에서 수행했으나 데이터를 받는 부분이 생각보다 속도가 나지 않아서 호스트에서 받은 파일을 가져다가 사용하도록 구성했습니다.

#!/bin/bash

set -e

start=`date +%s`

array=( \
  "south-korea-latest.osm.pbf,https://download.geofabrik.de/asia/south-korea-latest.osm.pbf" \
)

for i in "${array[@]}"; do
	echo "downloading $i"
	IFS=',' read file url <<< "${i}"
	axel -n 4 -a -v --output=../import/data/${file} ${url}

done

end=`date +%s`

runtime=$((end-start))
echo "$runtime"

import라는 서비스에서는 앞에서 받은 PBF 파일을 주입할 수 있도록 PostgreSQL 및 imposm3 등을 받도록 설정하였습니다. 최종적으로 /import/scripts/entry.sh를 실행해서 /import/scripts/import 아래의 있는 스크립트들을 실행하게 됩니다.

version: '3.4'

services:
  import:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - POSTGRES_HOST=localhost
      - POSTGRES_USER=postgres
      - POSTGRES_PASS=postgres
      - POSTGRES_DBNAME=osmdata
    network_mode: host
    volumes:
      - ./pbf:/home/osmdata/pbf

/import/scripts/import 아래에는 두 개의 스크립트가 있습니다. 우선 /import/scripts/import/import_osm.sh이라는 스크립트는 앞서 받았던 PBF를 DB에 주입하게 됩니다. 환경 구성만을 목적으로 해서 변경된 파일을 업데이트하도록 구성하지는 않았습니다.

#!/bin/bash

set -e

start=`date +%s`

data=( \
  "/home/osmdata/data/south-korea-latest.osm.pbf,/home/osmdata/pbf/south-korea-latest" \
)

for i in "${data[@]}"; do
	IFS=',' read pbf out <<< "${i}"
  echo "Filtering tags from $pbf"

  /home/osmdata/imposm import \
            -mapping "/home/osmdata/mapping.yml" \
            -dbschema-production "public" \
            -dbschema-import "import" \
            -dbschema-backup "backup" \
            -cachedir "/home/osmdata/pbf/impcache" \
            -overwritecache \
            -deployproduction \
            -diffdir "/home/osmdata/pbf/impdiff" \
            -diff \
            -srid 3857 \
            -read ${pbf} \
            -write \
            -connection "postgis://$user:$password@$host:$port/$dbname"
done

end=`date +%s`

runtime=$((end-start))
echo "$runtime"

또 다른 하나의 스크립트는 /import/scripts/import/import_sql.sh입니다. 이 스크립트는 /import/scripts/sql 아래 있는 SQL들을 실행하도록 하였습니다. 이를 통해 서버에서 사용할 Function들(postgis-vt-util)을 생성하도록 하고 있습니다.

#!/bin/bash

set -e

start=`date +%s`

if find "/home/osmdata/sql" -mindepth 1 -print -quit 2>/dev/null | grep -q .; then
    for f in /home/osmdata/sql/*; do
    case "$f" in
        *.sql)    echo "$0: running $f"; psql -h $host -p $port -d osmdata -U $user -f ${f} || true ;;
        *.sql.gz) echo "$0: running $f"; gunzip < "$f" | psql -h $host -p $port -d osmdata -U $user || true ;;
        *)        echo "$0: ignoring $f" ;;
    esac
    echo
    done
fi

end=`date +%s`

runtime=$((end-start))
echo "$runtime"

이때 추가되는 SQL 중에는 아래와 같은 Client에서 사용할 Vector Tile을 반환할 함수도 포함하고 있습니다.

create or replace function Buildings (x integer, y integer, zoom integer)
    returns bytea
    language sql immutable as
$func$
select ST_AsMVT(q, 'buildings', 4096, 'geom')
from (
         select
             id, name, type,
             ST_AsMVTGeom(
                     osm_buildings.geometry,
                     TileBBox(zoom, x, y),
                     4096,
                     256,
                     false
                 ) geom
         from osm_buildings
         where osm_buildings.geometry && TileBBox(zoom, x, y)
           and ST_Intersects(geometry, TileBBox(zoom, x, y))
     ) q;
$func$;

Server 구축하기

Server에서는 Client로부터 z, x, y 값을 받아서 DB에서 앞서 등록한 Function을 호출해서 Vector Tile을 Client에게 반환하도록 하였습니다.

const express = require('express');
const morgan = require('morgan');
const pg = require('pg');

const PORT = 8080;
const POSTGRES_USER = process.env.POSTGRES_USER || 'postgres';
const POSTGRES_PASS = process.env.POSTGRES_PASS || 'postgres';
const POSTGRES_HOST = process.env.POSTGRES_HOST || 'localhost';
const POSTGRES_DBNAME = process.env.POSTGRES_DBNAME || 'osmdata';

const app = express();

app.use(morgan('tiny'));

const router = express.Router();

router.get(
  '/tile/:z/:x/:y.:ext',
  asyncWrapper(async (request, response, next) => {
    try {
      const { z, x, y, ext } = request.params;
      const client = new pg.Client({
        user: POSTGRES_USER,
        host: POSTGRES_HOST,
        password: POSTGRES_PASS,
        database: POSTGRES_DBNAME,
        port: 5432,
      });
      client.connect();

      const result = await client.query(`SELECT buildings(${x},${y},${z});`);
      await client.end();
      const buffer = Buffer.from(result.rows[0].buildings, 'binary');

      response.writeHead(200, {
        'Content-Type': 'application/protobuf',
        'Access-Control-Allow-Origin': '*',
      });
      response.write(buffer, 'binary');
      response.end(null, 'binary');
    } catch (e) {
      console.error(e);
      response.status(500);
      response.send(e);
    }
  }),
);

app.use(router);

function asyncWrapper(asyncFn) {
  return async (req, res, next) => {
    try {
      return await asyncFn(req, res, next);
    } catch (error) {
      return next(error);
    }
  };
}

app.listen(PORT, () => console.log(`Example app listening at http://localhost:${PORT}`));
version: '3.4'

services:
  server:
    container_name: express
    build:
      context: ./server
      dockerfile: Dockerfile
    ports:
      - '8080:8080'
    restart: always
    depends_on:
      - db
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASS=postgres
      - POSTGRES_HOST=db
      - POSTGRES_DBNAME=osmdata

Vector Tile이 정상적으로 내려오는지 확인하고자 하시면 아래 코드로 확인할 수 있습니다.

var vt = require('@mapbox/vector-tile');
var request = require('request');
var Protobuf = require('pbf');
var zlib = require('zlib');

var a = request(
  {
    url: 'http://localhost:8080/tile/16/55899/25315.pbf',
    gzip: true,
    encoding: null,
  },
  (err, response, buffer) => {
    if (buffer[0] === 0x78 && buffer[1] === 0x9c) {
      buffer = zlib.inflateSync(buffer);
    } else if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
      buffer = zlib.gunzipSync(buffer);
    }

    var tile = new vt.VectorTile(new Protobuf(buffer));
    console.log(tile);
  },
);

상단의 코드에서 실행하면 아래와 같은 결과를 볼 수 있습니다.

VectorTile {
  layers: {
    buildings: VectorTileLayer {
      version: 2,
      name: 'buildings',
      extent: 4096,
      length: 4,
      _pbf: [Object],
      _keys: [Array],
      _values: [Array],
      _features: [Array]
    }
  }
}

Client 구축하기

Client에서는 앞서 만들어둔 타일 API를 호출해서 Mapbox에 그리도록만 하였고 아래와 같이 빌딩들이 노출되는 것을 볼 수 있습니다.

building layer

import React, { useState } from 'react';
import ReactMapGL, { Source, Layer } from 'react-map-gl';
import './main.css';

const TILE_SERVER_HOST = process.env.TILE_SERVER_HOST || 'localhost';

export default () => {
  const [viewport, setViewport] = useState({
    width: '100%',
    height: '100%',
    longitude: 127.0276241,
    latitude: 37.49795268,
    zoom: 16,
  });

  return (
    <div id="map">
      <ReactMapGL
        {...viewport}
        mapOptions={{
          attributionControl: false,
          localIdeographFontFamily: false,
        }}
        mapStyle={'mapbox://styles/mapbox/streets-v9'}
        mapboxApiAccessToken={'Your Token'}
        onViewportChange={nextViewport => setViewport(nextViewport)}
      >
        <Source id="osm-building" type="vector" tiles={['http://localhost:8080/tile/{z}/{x}/{y}.pbf']}>
          <Layer
            id="osm-building"
            source-layer="buildings"
            type="fill"
            paint={{
              'fill-color': '#007cbf',
            }}
          />
        </Source>
      </ReactMapGL>
    </div>
  );
};
version: '3.4'

services:
  client:
    container_name: next
    build:
      context: ./client
      dockerfile: Dockerfile
    environment:
      - TILE_SERVER_HOST=server
    ports:
      - '3000:3000'
    restart: always
    depends_on:
      - db
      - server

Other Data

앞선 작업들을 통해서 OSM 데이터를 저장하고 호출하여 노출하는 일련의 작업이 동작하는 것을 확인하였지만 OSM 데이터가 아닌 경우도 노출해보고자 하였습니다. 손쉽게 받을 수 있을 것 같은 전기차 충전소 데이터를 올려보고자 했고 경기데이터드림의 전기차 충전소 현황에서 CSV를 받아서 사용하였습니다.

받아둔 데이터에 대해 디코딩하고 매핑해서 DB에 주입하는 부분은 /import/tools/ 아래에 관련 데이터 작업들이 포함되어 있습니다. 이 과정 중에서 Vector Tile로 받기 위해 필요한 작업은 geometry에 공간 참조 ID를 만드는 부분이었습니다.

geometry:
  name: geometry
  type: geometry
  custom: true
  value: ST_Transform(ST_SetSRID(ST_Point({{경도}}, {{위도}}), 4326), 3857)

주입한 데이터의 Vector Tile을 가져올 수 있도록 아래와 같은 Function을 등록하였습니다.

create or replace function ElectricVehicleChargingStation (x integer, y integer, zoom integer)
    returns bytea
    language sql immutable as
$func$
select ST_AsMVT(q, 'electric_vehicle_charging_station', 4096, 'geom')
from (
         select
             id, name,
             ST_AsMVTGeom(
                     electric_vehicle_charging_station.geometry,
                     TileBBox(zoom, x, y),
                     4096,
                     256,
                     false
                 ) geom
         from electric_vehicle_charging_station
         where electric_vehicle_charging_station.geometry && TileBBox(zoom, x, y)
           and ST_Intersects(electric_vehicle_charging_station.geometry, TileBBox(zoom, x, y))
     ) q;
$func$;

Server에서는 추가된 타일에 대한 정보도 추가로 내려줄 수 있도록 이렇게 수정하였습니다. 여러 buffer들을 Buffer.concat(buffers)로 한 번에 묶어서 내려주었습니다.

router.get(
  '/tile/:z/:x/:y.:ext',
  asyncWrapper(async (request, response, next) => {
    try {
      const { z, x, y, service, ext } = request.params;
      const client = new pg.Client({
        user: POSTGRES_USER,
        host: POSTGRES_HOST,
        password: POSTGRES_PASS,
        database: POSTGRES_DBNAME,
        port: 5432,
      });
      client.connect();

      let result;      const buffers = [];      const tables = ['buildings', 'electricvehiclechargingstation'];      for (let i = 0; i < tables.length; i += 1) {        const table = tables[i];        result = await client.query(`SELECT ${table}(${x},${y},${z});`);        buffers.push(Buffer.from(result.rows[0][table], 'binary'));      }      await client.end();

      response.writeHead(200, {
        'Content-Type': 'application/protobuf',
        'Access-Control-Allow-Origin': '*',
      });
      response.write(Buffer.concat(buffers), 'binary');      response.end(null, 'binary');
    } catch (e) {
      console.error(e);
      response.status(500);
      response.send(e);
    }
  }),
);

Client에서도 아래와 추가된 레이어를 노출할 수 있도록 추가하였습니다.

export default () => {
  ...

  return (
    <div id="map">
      <ReactMapGL
        {...viewport}
        mapOptions={{
          attributionControl: false,
          localIdeographFontFamily: false,
        }}
        mapStyle={'mapbox://styles/mapbox/streets-v9'}
        mapboxApiAccessToken={'Your Token'}
        onViewportChange={nextViewport => setViewport(nextViewport)}
      >
        <Source
          id="gis-study-source"
          type="vector"
          tiles={['http://localhost:8080/tile/{z}/{x}/{y}.pbf']}>
          <Layer
            id="osm-building"
            source-layer="buildings"
            type="fill"
            paint={{
              'fill-color': '#007cbf'
            }}/>
          <Layer            id="electric-vehicle"            source-layer="electric_vehicle_charging_station"            type="circle"            paint={{              'circle-color': '#ec1652'            }}/>        </Source>
      </ReactMapGL>
    </div>
  );
}

앞선 작업들을 하면 아래와 같이 경기도의 전기차 충전소가 추가로 노출됨을 확인할 수 있었습니다.

custom layer

마치며

우선 1년 넘게 미루고 있던 일을 마무리 지을 수 있어서 기뻤습니다. 이 기쁨 외에도 조금이나마 어두웠던 장막을 들쳐본 느낌이 들었습니다. DB에 데이터가 어떻게 들어가고 들어간 데이터를 어떻게 가지고 올 수 있는지에 대한 궁금증을 해결할 수 있었습니다. 이런저런 작업들을 꽤 진행하고 나서야 docker-osm라는 저장소에 잘 구성되어 있음을 알았습니다. 더 많은 정보들이 필요하시면 해당 저장소를 찾아보면 좋을 것 같습니다.

참고

Recently posts
© 2016-2023 smilecat.dev