feat: 구마모토 전용 Docker 지도 서비스 구축

- Google Maps API에서 Leaflet + OpenStreetMap으로 전환
- 구마모토 지역 특화 타일 서버 Docker 컨테이너 추가
- PostgreSQL + PostGIS + Mapnik 스택으로 지도 타일 생성
- API 키 불필요한 오픈소스 지도 솔루션 구현
- 개발/프로덕션 환경 Docker Compose 설정 완료
- 빠른 로딩과 오프라인 지원 가능한 지도 서비스

주요 변경사항:
- src/components/Map.tsx: Leaflet 기반으로 완전 재작성
- docker/map-server/: 구마모토 지역 타일 서버 구축
- docker-compose.yml, docker-compose.dev.yml: 지도 서버 연동
- package.json: leaflet, react-leaflet 의존성 추가
This commit is contained in:
hyungi
2025-11-09 09:26:12 +09:00
parent c07e1bc95e
commit a724b1bf32
11 changed files with 641 additions and 132 deletions

View File

@@ -0,0 +1,44 @@
# 구마모토 지역 전용 타일 서버
FROM ubuntu:22.04
# 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
apache2 \
python3 \
python3-pip \
wget \
unzip \
osmosis \
postgresql \
postgresql-contrib \
postgis \
postgresql-postgis-scripts \
osm2pgsql \
mapnik-utils \
python3-mapnik \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
# 작업 디렉토리 설정
WORKDIR /opt/map-server
# 구마모토 지역 OSM 데이터 다운로드 스크립트
COPY scripts/download-kumamoto-data.sh /opt/map-server/
COPY scripts/setup-tiles.py /opt/map-server/
COPY config/mapnik-style.xml /opt/map-server/
# 권한 설정
RUN chmod +x /opt/map-server/download-kumamoto-data.sh
# Apache 설정
COPY config/apache-tiles.conf /etc/apache2/sites-available/tiles.conf
RUN a2ensite tiles && a2dissite 000-default
# 포트 노출
EXPOSE 80
# 시작 스크립트
COPY scripts/start-server.sh /opt/map-server/
RUN chmod +x /opt/map-server/start-server.sh
CMD ["/opt/map-server/start-server.sh"]

View File

@@ -0,0 +1,31 @@
<VirtualHost *:80>
ServerName kumamoto-tiles
DocumentRoot /var/www/html
# 타일 서빙을 위한 설정
<Directory "/var/www/html/tiles">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
# CORS 헤더 추가 (프론트엔드에서 접근 가능하도록)
Header always set Access-Control-Allow-Origin "*"
Header always set Access-Control-Allow-Methods "GET, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type"
</Directory>
# 타일 캐싱 설정
<LocationMatch "\.(png|jpg|jpeg)$">
ExpiresActive On
ExpiresDefault "access plus 7 days"
Header append Cache-Control "public"
</LocationMatch>
# 타일 URL 리라이팅
RewriteEngine On
RewriteRule ^/tiles/([0-9]+)/([0-9]+)/([0-9]+)\.png$ /tiles/$1/$2/$3.png [L]
# 로그 설정
ErrorLog ${APACHE_LOG_DIR}/tiles_error.log
CustomLog ${APACHE_LOG_DIR}/tiles_access.log combined
</VirtualHost>

View File

@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<Map srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over" background-color="#f8f8f8">
<!-- 데이터베이스 연결 설정 -->
<Parameters>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
</Parameters>
<!-- 도로 스타일 -->
<Style name="roads">
<Rule>
<Filter>[highway] = 'motorway'</Filter>
<LineSymbolizer stroke="#ff6b6b" stroke-width="4" stroke-linejoin="round" stroke-linecap="round"/>
</Rule>
<Rule>
<Filter>[highway] = 'trunk'</Filter>
<LineSymbolizer stroke="#ff8e53" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
</Rule>
<Rule>
<Filter>[highway] = 'primary'</Filter>
<LineSymbolizer stroke="#ffa726" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
</Rule>
<Rule>
<Filter>[highway] = 'secondary'</Filter>
<LineSymbolizer stroke="#ffcc02" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
</Rule>
<Rule>
<Filter>[highway] = 'tertiary'</Filter>
<LineSymbolizer stroke="#fff176" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
</Rule>
<Rule>
<Filter>[highway] = 'residential' or [highway] = 'unclassified'</Filter>
<LineSymbolizer stroke="#ffffff" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
</Rule>
</Style>
<!-- 물 스타일 -->
<Style name="water">
<Rule>
<Filter>[natural] = 'water' or [waterway] = 'river' or [waterway] = 'stream'</Filter>
<PolygonSymbolizer fill="#4fc3f7" fill-opacity="0.8"/>
<LineSymbolizer stroke="#29b6f6" stroke-width="1"/>
</Rule>
</Style>
<!-- 건물 스타일 -->
<Style name="buildings">
<Rule>
<Filter>[building] != ''</Filter>
<PolygonSymbolizer fill="#e0e0e0" fill-opacity="0.8"/>
<LineSymbolizer stroke="#bdbdbd" stroke-width="0.5"/>
</Rule>
</Style>
<!-- 녹지 스타일 -->
<Style name="landuse">
<Rule>
<Filter>[landuse] = 'forest' or [natural] = 'wood'</Filter>
<PolygonSymbolizer fill="#81c784" fill-opacity="0.6"/>
</Rule>
<Rule>
<Filter>[landuse] = 'grass' or [leisure] = 'park'</Filter>
<PolygonSymbolizer fill="#aed581" fill-opacity="0.6"/>
</Rule>
<Rule>
<Filter>[landuse] = 'residential'</Filter>
<PolygonSymbolizer fill="#f5f5f5" fill-opacity="0.4"/>
</Rule>
</Style>
<!-- 관광지 포인트 스타일 -->
<Style name="tourism">
<Rule>
<Filter>[tourism] = 'attraction' or [tourism] = 'museum'</Filter>
<MarkersSymbolizer file="/opt/map-server/symbols/attraction.png" width="16" height="16" allow-overlap="true"/>
</Rule>
</Style>
<!-- 지명 라벨 스타일 -->
<Style name="place_labels">
<Rule>
<Filter>[place] = 'city'</Filter>
<TextSymbolizer face-name="Noto Sans CJK JP Regular" size="14" fill="#333333" halo-fill="#ffffff" halo-radius="2">[name]</TextSymbolizer>
</Rule>
<Rule>
<Filter>[place] = 'town'</Filter>
<TextSymbolizer face-name="Noto Sans CJK JP Regular" size="12" fill="#555555" halo-fill="#ffffff" halo-radius="1">[name]</TextSymbolizer>
</Rule>
</Style>
<!-- 레이어 정의 -->
<Layer name="landuse" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
<StyleName>landuse</StyleName>
<Datasource>
<Parameter name="type">postgis</Parameter>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
<Parameter name="table">(SELECT way, landuse, natural, leisure FROM planet_osm_polygon WHERE landuse IS NOT NULL OR natural IS NOT NULL OR leisure IS NOT NULL) as landuse</Parameter>
<Parameter name="geometry_field">way</Parameter>
<Parameter name="srid">3857</Parameter>
</Datasource>
</Layer>
<Layer name="water" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
<StyleName>water</StyleName>
<Datasource>
<Parameter name="type">postgis</Parameter>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
<Parameter name="table">(SELECT way, natural, waterway FROM planet_osm_polygon WHERE natural = 'water' UNION SELECT way, natural, waterway FROM planet_osm_line WHERE waterway IN ('river', 'stream')) as water</Parameter>
<Parameter name="geometry_field">way</Parameter>
<Parameter name="srid">3857</Parameter>
</Datasource>
</Layer>
<Layer name="buildings" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
<StyleName>buildings</StyleName>
<Datasource>
<Parameter name="type">postgis</Parameter>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
<Parameter name="table">(SELECT way, building FROM planet_osm_polygon WHERE building IS NOT NULL) as buildings</Parameter>
<Parameter name="geometry_field">way</Parameter>
<Parameter name="srid">3857</Parameter>
</Datasource>
</Layer>
<Layer name="roads" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
<StyleName>roads</StyleName>
<Datasource>
<Parameter name="type">postgis</Parameter>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
<Parameter name="table">(SELECT way, highway FROM planet_osm_line WHERE highway IS NOT NULL) as roads</Parameter>
<Parameter name="geometry_field">way</Parameter>
<Parameter name="srid">3857</Parameter>
</Datasource>
</Layer>
<Layer name="tourism" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
<StyleName>tourism</StyleName>
<Datasource>
<Parameter name="type">postgis</Parameter>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
<Parameter name="table">(SELECT way, tourism FROM planet_osm_point WHERE tourism IS NOT NULL) as tourism</Parameter>
<Parameter name="geometry_field">way</Parameter>
<Parameter name="srid">3857</Parameter>
</Datasource>
</Layer>
<Layer name="place_labels" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
<StyleName>place_labels</StyleName>
<Datasource>
<Parameter name="type">postgis</Parameter>
<Parameter name="host">localhost</Parameter>
<Parameter name="port">5432</Parameter>
<Parameter name="user">postgres</Parameter>
<Parameter name="dbname">kumamoto_map</Parameter>
<Parameter name="table">(SELECT way, name, place FROM planet_osm_point WHERE place IN ('city', 'town') AND name IS NOT NULL) as places</Parameter>
<Parameter name="geometry_field">way</Parameter>
<Parameter name="srid">3857</Parameter>
</Datasource>
</Layer>
</Map>

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# 구마모토 지역 OSM 데이터 다운로드 및 설정
echo "구마모토 지역 지도 데이터 다운로드 중..."
# 구마모토 지역 경계 (대략적인 좌표)
# 북: 33.2, 남: 32.4, 동: 131.2, 서: 130.2
NORTH=33.2
SOUTH=32.4
EAST=131.2
WEST=130.2
# 구마모토 지역 OSM 데이터 다운로드
wget -O /tmp/kumamoto.osm.pbf \
"https://overpass-api.de/api/interpreter?data=[out:pbf][timeout:300];(relation[admin_level=4][name~'熊本'][name~'Kumamoto'];>;);out meta;"
# 대안: 일본 전체에서 구마모토 지역 추출
if [ ! -f /tmp/kumamoto.osm.pbf ] || [ ! -s /tmp/kumamoto.osm.pbf ]; then
echo "Overpass API에서 다운로드 실패, 대안 방법 사용..."
# 일본 큐슈 지역 데이터 다운로드 (더 안정적)
wget -O /tmp/kyushu.osm.pbf \
"http://download.geofabrik.de/asia/japan/kyushu-latest.osm.pbf"
# osmosis를 사용해 구마모토 지역만 추출
osmosis --read-pbf /tmp/kyushu.osm.pbf \
--bounding-box top=$NORTH left=$WEST bottom=$SOUTH right=$EAST \
--write-pbf /tmp/kumamoto.osm.pbf
fi
echo "구마모토 지역 데이터 다운로드 완료!"
# PostgreSQL 데이터베이스 설정
echo "데이터베이스 설정 중..."
# PostgreSQL 시작
service postgresql start
# 데이터베이스 생성
sudo -u postgres createdb kumamoto_map
sudo -u postgres psql -d kumamoto_map -c "CREATE EXTENSION postgis;"
sudo -u postgres psql -d kumamoto_map -c "CREATE EXTENSION hstore;"
# OSM 데이터를 PostgreSQL에 임포트
echo "OSM 데이터 임포트 중..."
sudo -u postgres osm2pgsql -d kumamoto_map -c /tmp/kumamoto.osm.pbf \
--hstore --style /usr/share/osm2pgsql/default.style --multi-geometry
echo "구마모토 지도 데이터 설정 완료!"

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
구마모토 지역 타일 생성 스크립트
"""
import os
import math
import mapnik
import psycopg2
from PIL import Image
# 구마모토 지역 경계
KUMAMOTO_BOUNDS = {
'north': 33.2,
'south': 32.4,
'east': 131.2,
'west': 130.2
}
# 타일 크기
TILE_SIZE = 256
def deg2num(lat_deg, lon_deg, zoom):
"""위경도를 타일 번호로 변환"""
lat_rad = math.radians(lat_deg)
n = 2.0 ** zoom
xtile = int((lon_deg + 180.0) / 360.0 * n)
ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return (xtile, ytile)
def num2deg(xtile, ytile, zoom):
"""타일 번호를 위경도로 변환"""
n = 2.0 ** zoom
lon_deg = xtile / n * 360.0 - 180.0
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
lat_deg = math.degrees(lat_rad)
return (lat_deg, lon_deg)
def create_mapnik_map():
"""Mapnik 지도 객체 생성"""
m = mapnik.Map(TILE_SIZE, TILE_SIZE)
# 한국어/일본어 폰트 설정
mapnik.FontEngine.register_fonts('/usr/share/fonts/truetype/noto/')
# 스타일 로드
mapnik.load_map(m, '/opt/map-server/mapnik-style.xml')
return m
def generate_tile(m, z, x, y):
"""개별 타일 생성"""
# 타일 경계 계산
lat_north, lon_west = num2deg(x, y, z)
lat_south, lon_east = num2deg(x + 1, y + 1, z)
# 지도 범위 설정
bbox = mapnik.Box2d(lon_west, lat_south, lon_east, lat_north)
m.zoom_to_box(bbox)
# 타일 렌더링
im = mapnik.Image(TILE_SIZE, TILE_SIZE)
mapnik.render(m, im)
return im.tostring('png')
def generate_kumamoto_tiles():
"""구마모토 지역 타일 생성"""
print("구마모토 지역 타일 생성 시작...")
# Mapnik 지도 생성
m = create_mapnik_map()
# 줌 레벨별 타일 생성 (8-16레벨)
for zoom in range(8, 17):
print(f"줌 레벨 {zoom} 타일 생성 중...")
# 구마모토 지역의 타일 범위 계산
x_min, y_max = deg2num(KUMAMOTO_BOUNDS['north'], KUMAMOTO_BOUNDS['west'], zoom)
x_max, y_min = deg2num(KUMAMOTO_BOUNDS['south'], KUMAMOTO_BOUNDS['east'], zoom)
tile_count = 0
for x in range(x_min, x_max + 1):
for y in range(y_min, y_max + 1):
# 타일 디렉토리 생성
tile_dir = f"/var/www/html/tiles/{zoom}/{x}"
os.makedirs(tile_dir, exist_ok=True)
# 타일 파일 경로
tile_path = f"{tile_dir}/{y}.png"
# 이미 존재하는 타일은 건너뛰기
if os.path.exists(tile_path):
continue
try:
# 타일 생성
tile_data = generate_tile(m, zoom, x, y)
# 타일 저장
with open(tile_path, 'wb') as f:
f.write(tile_data)
tile_count += 1
except Exception as e:
print(f"타일 생성 실패 ({zoom}/{x}/{y}): {e}")
print(f"줌 레벨 {zoom}: {tile_count}개 타일 생성 완료")
print("구마모토 지역 타일 생성 완료!")
if __name__ == "__main__":
generate_kumamoto_tiles()

View File

@@ -0,0 +1,26 @@
#!/bin/bash
echo "구마모토 지도 서버 시작 중..."
# PostgreSQL 시작
service postgresql start
# 데이터베이스가 없으면 초기 설정 실행
if ! sudo -u postgres psql -lqt | cut -d \| -f 1 | grep -qw kumamoto_map; then
echo "초기 데이터베이스 설정 실행 중..."
/opt/map-server/download-kumamoto-data.sh
echo "타일 생성 중..."
python3 /opt/map-server/setup-tiles.py
else
echo "기존 데이터베이스 사용"
fi
# Apache 모듈 활성화
a2enmod rewrite
a2enmod headers
a2enmod expires
# Apache 시작
echo "Apache 웹서버 시작..."
apache2ctl -D FOREGROUND