전체 내역

·study·2026.09.06·7 min read·조회수512

마커가 바다 한가운데 찍힐 때, 지도 개발자를 위한 좌표계 기초

마커가 엉뚱한 바다에 모이거나 몇 미터씩 어긋나는 버그는 대부분 좌표계 문제였습니다. 국내 GIS 프로젝트에서 자주 만나는 좌표계와, OpenLayers와 proj4로 변환하는 방법을 정리했습니다.

관제 화면을 처음 만들 때 가장 많이 본 버그는 "마커가 이상한 데 찍혀요"였습니다. 아프리카 앞바다, 정확히는 경위도 (0, 0) 근처에 마커가 몽땅 모여 있거나, 지도는 맞는 것 같은데 몇 미터씩 비껴 있거나요. 원인을 따라가 보면 거의 항상 좌표계를 섞어 쓴 게 문제였습니다.

이 글에서는 국내 GIS 프로젝트에서 자주 만나는 좌표계와 버그 유형, 그리고 OpenLayers와 proj4로 변환하는 방법을 정리해 보겠습니다. 저처럼 지도를 처음 맡게 된 분께 도움이 되면 좋겠습니다.

1. 좌표계는 크게 두 종류입니다

지리 좌표계는 단위가 도(°)입니다. 우리가 흔히 아는 경위도가 여기에 속하고, 대표가 EPSG:4326(WGS84)입니다. 투영 좌표계는 단위가 미터입니다. EPSG:3857(웹 메르카토르)이나 국내에서 쓰는 EPSG:5186, 5179가 여기에 속합니다.

지구는 둥근데 화면은 평평하니, 구면 좌표를 평면으로 펴는 방법이 필요합니다. 이걸 투영이라고 하고, 펴는 방식에 따라 좌표계가 갈립니다.

2. 자주 만나는 좌표계

2-1. EPSG:4326, WGS84 경위도

GPS나 대부분의 공개 API, GeoJSON이 쓰는 좌표입니다. [경도, 위도] 순서이고 단위는 도입니다. 강남역은 대략 [127.0276, 37.4979]입니다.

2-2. EPSG:3857, 웹 메르카토르

구글 지도나 OSM, VWorld 같은 웹 배경지도 타일이 쓰는 좌표계입니다. 단위는 미터이고, OpenLayers의 기본 뷰 좌표계이기도 합니다. 같은 강남역이 대략 [14140648, 4508737]처럼 큰 숫자가 됩니다.

2-3. EPSG:5186, 5179, 국내 측량 좌표계

국내 공공 데이터(도로, 지적, 시설물 대장)는 GRS80 타원체를 쓰는 국내 좌표계로 오는 경우가 많습니다. 지자체 시설물이나 측량 데이터는 EPSG:5186(Korea 2000 / Central Belt 2010)이 많았고, 도로명주소나 전국 단위 데이터는 EPSG:5179(Korea 2000 / Unified CS)인 경우가 많았습니다.

3. 증상을 보면 원인이 보입니다

여러 번 겪다 보니 증상만 보고도 원인을 꽤 맞힐 수 있게 됐습니다.

마커가 (0, 0) 근처 바다에 모여 있다면, 경위도(4326)를 변환 없이 3857 뷰에 넣은 겁니다. 127.02 같은 숫자를 미터로 읽으니 원점에서 127m 떨어진 곳에 찍히는 거죠.

반대로 마커가 지도 밖 아주 먼 곳에 찍힌다면 미터 좌표(5186)를 경위도로 착각한 경우가 많습니다. 위아래가 뒤집힌 듯 엉뚱한 곳이라면 [위도, 경도] 순서로 넣은 거고요.

제일 찾기 어려운 건 수 미터에서 수십 미터 정도 어긋나는 경우입니다. 타원체나 원점이 다른 좌표계를 같은 걸로 취급했을 때 생기는데, 얼핏 보면 맞아 보여서 늦게 발견됩니다.

4. OpenLayers에서 변환하기

4326과 3857 사이 변환은 OpenLayers에 들어 있습니다.

import { fromLonLat, toLonLat, transform } from "ol/proj";
 
const center = fromLonLat([127.0276, 37.4979]);        // 4326 → 3857
const lonLat = toLonLat(center);                          // 3857 → 4326
const same = transform([127.0276, 37.4979], "EPSG:4326", "EPSG:3857");

국내 좌표계는 proj4로 정의를 등록해야 쓸 수 있습니다.

import proj4 from "proj4";
import { register } from "ol/proj/proj4";
import { transform } from "ol/proj";
 
proj4.defs(
  "EPSG:5186",
  "+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs",
);
proj4.defs(
  "EPSG:5179",
  "+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +units=m +no_defs",
);
register(proj4); // 이제 OpenLayers가 5186, 5179를 알아본다
 
// 시설물 대장(5186) 좌표를 지도 뷰(3857)로
const viewCoord = transform([203500.12, 549800.55], "EPSG:5186", "EPSG:3857");

GeoJSON을 읽을 때는 데이터 좌표계와 지도 좌표계를 같이 알려 주면 됩니다.

import GeoJSON from "ol/format/GeoJSON";
 
const features = new GeoJSON().readFeatures(geojson, {
  dataProjection: "EPSG:5186",     // 파일에 들어 있는 좌표
  featureProjection: "EPSG:3857",  // 지도에 그릴 좌표
});

5. 팀 규칙으로 정한 것들

좌표계 버그를 몇 번 겪고 나서 팀 규칙으로 정한 게 있습니다.

첫째, 변환은 경계에서 한 번만 합니다. API 응답을 받는 곳(어댑터)에서 앱 공통 좌표계로 바꾸고, 컴포넌트 안에서는 변환하지 않습니다.

둘째, 변수 이름에 좌표계를 넣습니다. coord 대신 lonLat, xy5186, viewCoord처럼 씁니다. 리뷰할 때 섞어 쓴 곳이 바로 보입니다.

셋째, 가능하면 타입으로도 구분합니다. 똑같은 number[]라도 브랜드 타입으로 나눠 두면 컴파일러가 실수를 잡아 줍니다.

type LonLat = [number, number] & { readonly __crs: "EPSG:4326" };
type ViewCoord = [number, number] & { readonly __crs: "EPSG:3857" };
 
const toView = (c: LonLat) => fromLonLat(c) as ViewCoord;
 
declare const fromApi: LonLat;
toView(fromApi);          // OK
// toView(someViewCoord); // 타입 에러: 3857 좌표를 또 변환하려는 실수를 막는다

마지막으로 거리나 면적은 웹 메르카토르 좌표로 바로 계산하지 않습니다. 웹 메르카토르는 위도가 높을수록 크기가 부풀려지기 때문입니다. 거리는 ol/sphere의 getDistance(경위도 기준)를 쓰거나, getLength에 좌표계를 지정해서 씁니다.

import { getDistance } from "ol/sphere";
 
const meters = getDistance([127.0276, 37.4979], [127.0396, 37.5009]); // 경위도 두 점 사이 실제 거리

마치며

좌표계 버그는 증상만 봐도 원인을 대충 짐작할 수 있습니다. 그리고 대부분은 어디서 변환하는지가 정해져 있지 않아서 생깁니다.

지도를 처음 맡으셨다면, 코드를 짜기 전에 "우리 앱은 어떤 좌표계로 통일하고, 어디서 바꾼다"를 먼저 정해 두시길 권합니다. 그것만 정해 둬도 마커가 바다로 가는 일은 거의 없어집니다.

Comments (0)