March 24, 20238 minutes
안녕하세요?
오늘은 내 홈페이지에 지도를 넣어볼까 하는데요.
웹에서 지도 서비스를 해주는 데는 많이 있습니다.
구글, 카카오, 네이버, 빙 맵 등 여러 가지가 있는데요.
전부 유료입니다.
만약 제 웹이 대박을 터트린다고 했을 때 사용료를 어마어마하게 내야 한다면 좀 부담이 되겠죠.
그래서 무료 지도 서비스를 찾다가 정부에서 운영하는 공간정보 오픈 플랫폼인 VWorld에 대해 알게 되었습니다.
VWorld는 오픈 API도 운영하는데요.
무료입니다.
오픈 플랫폼이라 그런지 사용된 기술도 OpenLayers를 기반으로 맵 타일만 멋지게 만들어놨는데요.
일단 홈페이지에 가서 오픈 API Key를 받아 보도록 하겠습니다.
https://www.vworld.kr/dev/v4api.do
위 코드로 들어가서 일단 가입 및 로그인해야 하는데요.
여기서 인증키 발급으로 들어갑니다.
위와 같이 빈칸에 적당히 넣으면 자동 발급되는데요.
활용 API에는 꼭 WMTS/TMS API를 선택해야 합니다.
이게 우리가 사용할 서비스이기 때문이죠.
인증키 관리메뉴에 가면 아래와 같이 내 Key가 보입니다.
준비가 끝났네요.
그럼 본격적인 개발에 들어가 볼까요?
먼저, VWorld가 제공하는 WMTS API가 있습니다.
위와 같이 나옵니다.
이제 테스트를 위한 Next.js 템플릿을 만들까요?
npx create-next-app@latest --typescript --eslint ./map-test
✔ Would you like to use `src/` directory with this project? … No / Yes
✔ Would you like to use experimental `app/` directory with this project? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/cpro95/Codings/Javascript/blog/map-test.
Using npm.
Initializing project with template: default
Installing dependencies:
- react
- react-dom
- next
- typescript
- @types/react
- @types/node
- @types/react-dom
- eslint
- eslint-config-next
added 271 packages, and audited 272 packages in 15s
102 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success! Created map-test at /Users/cpro95/Codings/Javascript/blog/map-test
이제 Next.js 빈 템플릿에 모든 걸 다 지우고 _app.tsx, _document.tsx, index.tsx 파일만 남겨둡시다.
// index.tsx 파일
import Head from 'next/head'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function Home() {
return (
<>
<Head>
<title>VWorld Map Test</title>
<meta name='description' content='VWorld Map Test' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h2>VWorld Map Test</h2>
<div id='map' style={{ width: 500, height: 500 }}></div>
</main>
</>
)
}
VWorld나 다른 모든 지도 데이터는 OpenLayers를 이용하고 있는데요.
다음과 같이 OpenLayers 패키지를 설치합시다.
npm i ol
ClientSide에서 작동하려면 React에서는 useEffect 훅 밖에 없는데요.
지도 생성과 가공 코드를 useEffect 훅 안에서 하라는 뜻입니다.
import Head from 'next/head'
import { Inter } from 'next/font/google'
import { useEffect, useState } from 'react'
import 'ol/ol.css'
import { Map, View } from 'ol'
import { OSM } from 'ol/source'
import { defaults } from 'ol/control'
import { fromLonLat } from 'ol/proj'
import { Tile } from 'ol/layer'
const inter = Inter({ subsets: ['latin'] })
export default function Home() {
useEffect(() => {
// create Map instance
const map = new Map({
controls: defaults({ zoom: true, rotate: false }).extend([]),
layers: [
new Tile({
source: new OSM(),
}),
],
target: 'map',
view: new View({
center: fromLonLat([127.189972804, 37.723058796]),
zoom: 15,
}),
})
})
return (
<>
<Head>
<title>VWorld Map Test</title>
<meta name='description' content='VWorld Map Test' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h2>VWorld Map Test</h2>
<div id='map' style={{ width: 500, height: 500 }}></div>
</main>
</>
)
}
실행결과를 볼까요?
위와 같이 지도가 아주 잘 나옵니다.
지도가 두 번 나오는 건 리액트의 dev 모드라서 렌더링이 두 번 되는 현상 때문입니다.
production 모드에서는 발생하지 않으니까 걱정 안 하셔도 됩니다.
이제 코드 설명을 해보겠습니다.
Map이라는 객체를 하나 크게 만들면 됩니다.
그리고 그 Map이라는 객체에, HTML에 꽂을 곳만 지정하면 되는데요.
바로 target : ‘map’ 요 부분입니다.
<div id='map' style={{ width: 500, height: 500 }}></div>
위 코드처럼 div 태그의 id를 map이라고 했고 Map 객체에 target을 ‘map’이라고 적은 겁니다.
지도가 여기 빈 div 태그 위치에 그려진다는 얘기죠.
단 여기서 주의해야 될 점은 div 태그가 extrinsic width, height를 가져야 한다는 겁니다.
즉, 명목적으로 높이 너비 값이 있어야 지도가 표시된다는 겁니다.
만약 width, height 값이 없으면 div 태그는 비어 있기 때문에 실제 브라우저에서도 아무것도 안 나오는 형국이고 결국 지도도 안 나옵니다.
그래서 위처럼 적당한 width, height 값을 넣어야 합니다.
Map 객체에 지정해야 할게 4가지가 있는데요.
controls, layers, target, view입니다.
controls는 전체 맵의 컨트롤 부분입니다.
controls: defaults({ zoom: true, rotate: false }).extend([]),
위 코드처럼 디폴트 컨트롤을 지정해 줄 수 있고 또는 extend 메서드로 확장도 할 수 있습니다.
만약 Fullscreen 기능을 추가하고 싶다면 아래와 같이 하면 됩니다.
controls: defaults({ zoom: true, rotate: false }).extend([new FullScreen()]),
두 번째는 바로 layers라고 합니다.
레이어는 Map이 생성되고 거기에 어떤 데이터를 넣느냐는 겁니다.
위에서는 아래와 같이 했는데요.
layers: [
// OSM is OpenStreetMap
new Tile({
source: new OSM(),
}),
],
지도 데이터는 OpenLayers의 경도, 위도 규칙에 Tile 이라는 그림파일을 지정해 줘서 실제 우리가 보는 지도처럼 보이게 하는 건데요.
위 코드처럼 우리는 new Tile로 Tile 객체를 만들었는데 바로 OSM 입니다.
OSM은 전 세계 지도 데이터이고 Opensource 인데요.
OpenStreetMap 이라고 합니다. 공짜죠.
그리고 3번째 target는 아까 설명해 드렸고,
마지막 View 부분입니다.
View 부분은 최초로 지도의 어느 부분을 보여주는지 정해주는 코드입니다.
view: new View({
center: fromLonLat(center),
zoom: 15,
}),
center 값에는 현재 어느 위치를 보여줄까 하는 건데요.
저는 [127.189972804, 37.723058796] 값을 골랐는데요.
꼭 배열로 집어넣어야 합니다.
이제 Map 객체를 만들었고 이 Map 객체를 브라우저에 나타내 보는 것도 했는데요.
이제 지도 위에 마커를 넣는 걸 해보겠습니다.
import Head from 'next/head'
import { Inter } from 'next/font/google'
import { useEffect, useState } from 'react'
import 'ol/ol.css'
import { Map, View } from 'ol'
import { OSM } from 'ol/source'
import { Tile, Vector } from 'ol/layer'
import { defaults } from 'ol/control'
import { fromLonLat } from 'ol/proj'
import Point from 'ol/geom/Point.js'
import Feature from 'ol/Feature.js'
import { Style, Text, Icon } from 'ol/style'
import VectorSource from 'ol/source/Vector.js'
const inter = Inter({ subsets: ['latin'] })
export default function Home() {
useEffect(() => {
// create Map instance
const map = new Map({
controls: defaults({ zoom: true, rotate: false }).extend([]),
layers: [
new Tile({
source: new OSM(),
}),
],
target: 'map',
view: new View({
center: fromLonLat([127.189972804, 37.723058796]),
zoom: 15,
}),
})
let svgIcon: string
if (process.env.NODE_ENV === 'development')
svgIcon = 'http://localhost:3001/marker-default.png'
else svgIcon = 'https://map-test.pages.dev/marker-default.png'
let marker = new Feature({
geometry: new Point(fromLonLat([127.189972804, 37.723058796])),
})
let myStyle = new Style({
text: new Text({
text: 'Hello Map',
font: 'bold 14px sans-serif',
offsetY: 10,
}),
image: new Icon({
anchor: [0.5, 1],
src: svgIcon,
scale: 1.0,
}),
})
marker.setStyle(myStyle)
// create a vector layer and add the marker feature to it
const markerLayer = new Vector({
source: new VectorSource({
features: [marker],
}),
})
// add myLayer
map.addLayer(markerLayer)
})
return (
<>
<Head>
<title>VWorld Map Test</title>
<meta name='description' content='VWorld Map Test' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h2>VWorld Map Test</h2>
<div id='map' style={{ width: 500, height: 500 }}></div>
</main>
</>
)
}
실행 결과를 보시면 마커가 아주 잘 보이네요.
마커를 만드는 방법은 Vector를 만들어야 합니다.
Tile, Vector 모두 ol/layer에 있으니 헷갈리시면 안 됩니다.
특히 Vector는 ol/source에도 있기 때문입니다.
코드를 보시면 좀 이해하실 수 있을 겁니다.
최종적으로 map.addLayer(markerLayer) 명령어로 아까 만들었던 Map에 레이어를 추가하고 있죠.
지금까지 지도는 별로 보기가 안 좋았는데요.
바로 OpenStreetMap 지도이기 때문입니다.
그럼 브이월드 지도로는 어떻게 바꿀까요?
import { XYZ } from 'ol/source'
먼저 XYZ를 import 하시고,
Map 생성 코드를 아래와 같이 바꾸시면 됩니다.
const map = new Map({
controls: defaults({ zoom: true, rotate: false }).extend([]),
layers: [
// new Tile({
// source: new OSM(),
// }),
// VWorld Map
new Tile({
visible: true,
source: new XYZ({
url: `http://api.vworld.kr/req/wmts/1.0.0/API-KEy/Base/{z}/{y}/{x}.png`,
}),
}),
],
target: 'map',
view: new View({
center: fromLonLat([127.189972804, 37.723058796]),
zoom: 15,
}),
})
브이월드 맵을 쓸 수 있는 WMTS 주소가 바로 위 코드에 나와 있는 주소인데요.
API-Key 부분에는 맨 처음 브이월드에 가입하고 인증키 받을 걸 넣으면 됩니다.
이제 실행 결과를 볼까요?
위 그림을 보시면 지도가 훨씬 깔끔해졌습니다.
좀 더 멋진 걸 보여드릴게요.
먼저, WMTS API의 요청파라미터를 볼까요?
tileMatrix에 Base, gray, midnight 등 여러 가지를 넣을 수 있네요.
midnight 한번 넣어 보겠습니다.
new Tile({
visible: true,
source: new XYZ({
url: `http://api.vworld.kr/req/wmts/1.0.0/API-KEy/midnight/{z}/{y}/{x}.png`,
}),
}),
위 그림을 보시면 midnight이 무슨 뜻인지 쉽게 이해되실 겁니다.
그리고 중요한 점은 브이월드 WMTS 요청 주소 형식은 위 코드에 나와 있는 XYZ 형식으로 쓰셔야 합니다.
브이월드 API 설명서에 나와있는 형식으로 쓰면 안 되더라고요.
이번에는 아래 그림처럼 마커를 클릭했을 때 간단히 설명을 보여줄 수 있는 팝업창을 만들어 보도록 하겠습니다.
일단 우리가 만든 useEffect 훅 안의 코드에서 마지막에 몇개 추가하도록 하겠습니다.
먼저, 맵 위에 클릭했을 때의 이벤트리스너를 만들어야 하는데요.
useEffect 훅 끝부분에 아래와 같이 넣으면 됩니다.
...
...
...
// add myLayer
map.addLayer(markerLayer)
// 여기까지가 기존 코드 부분입니다.
// 아래 코드는 마커를 클릭했을 때 이벤트 처리 로직입니다.
map.on('click', function (event) {
map.forEachFeatureAtPixel(event.pixel, function (feature: any) {
const coordinate = feature.getGeometry().getCoordinates()
const attributes = feature.getProperties()
const name = feature.get('name')
const tel = feature.get('tel')
const address = feature.get('address')
// create popup with name and tel attributes
const popup = new Overlay({
position: attributes.geometry.flatCoordinates,
element: document.createElement('div'),
})
const content = document.createElement('div')
content.innerHTML = `
<div class="card w-full bg-base-100 shadow-xl">
<div class="card-body">
<button class="btn btn-square btn-xs md:btn-sm bg-base-100 text-gray-700 font-bold hover:text-gray-200 popup-close">
X
</button>
<div class="grid text-xs md:text-sm">
<div>${name}</div>
<div>${tel}</div>
<div>${address}</div>
</div>
</div>
</div>
`
popup.getElement()?.appendChild(content)
map.addOverlay(popup)
const closeButton = content.querySelector('.popup-close')
closeButton?.addEventListener('click', () => {
map.removeOverlay(popup)
})
})
})
위 코드는 클릭 시 Feature에서 데이터를 얻어 오고 있습니다. name, tel, address 정보인데요.
그럼, 우리가 지도상의 위치를 Point로해서 Feature 객체에 넘겼는데요.
그 부분에서 특별히 원하는 값을 설정할 수 있습니다.
아랫부분을 보시면 Feature 객체에 geometry 객체만 설정하는 것이 아니라,
우리가 원하는 코드도 설정할 수 있습니다.
let marker = new Feature({
geometry: new Point(fromLonLat([127.189972804, 37.723058796])),
name: "나의 로케이션",
tel: "02-333-2222",
address: "주소는 서울시 종로 1번지"
})
아! 그리고 Overlay 객체를 import 해야 합니다.
import { Map, Overlay, View } from "ol";
참고로 content.innerHTML 부분의 코드 중에 UI부분은 DaisyUI를 썼습니다.
그리고 X 버튼을 누르면 팝업창이 사라지는 코드는 map.removeOverlay(popup)
함수를 사용해서 처리했습니다.
3초후에 자동으로 사라지게 할 수 도 있는데요.
// hide popup overlay after 3 seconds
setTimeout(() => {
map.removeOverlay(popup);
}, 3000);
그럼.