좌표를 변환했더니 서울이 사라졌다 — proj4와 ST_Transform의 함정

코드 한 줄에 서울이 사라졌다
5179로 받은 행정구역 데이터를4326으로 바꾸는 건, 라이브러리 함수 한 줄이면 되는 일입니다. 그런데 실행하고 지도를 열어보니, 서울이 없습니다. 데이터는 분명 들어갔는데 화면엔 텅 빈 바다뿐. 좌표계 변환은 GIS에서 가장 자주 쓰는 동작이면서, 동시에 가장 조용하게 데이터를 망가뜨리는 동작이기도 합니다.
지난 EPSG 코드 편은 이런 질문을 남기고 끝났습니다. "그러면 5179로 받은 데이터를 4326으로 어떻게 바꾸지?" 이번 글이 그 답이자, Phase 2("좌표와 측지")를 닫는 마지막 글입니다.
돌아보면 우리는 좌표라는 미끄러운 물건을 네 편에 걸쳐 붙잡아 왔습니다.
- 위도와 경도 — 순서를 바꾸면 서울이 바다로.
- 지구의 진짜 모양 — 측지계가 다르면 같은 좌표가 수백 미터 어긋남.
- 지도 투영법 — 어떻게 펼쳤느냐에 따라 평면 좌표가 완전히 달라짐.
- EPSG 코드 — 그 셋을 숫자 하나로 묶은 좌표계의 주민등록번호.
이제 지식은 다 모였으니, 실제로 좌표를 바꿔볼 차례입니다. 그리고 오늘 만날 함정들은 전부 앞 네 편의 개념을 코드에서 헷갈릴 때 튀어나옵니다.
변환이 실제로 하는 일 — 두 종류가 있다
좌표 변환이라고 다 같은 변환이 아닙니다. 속을 열어보면 성격이 전혀 다른 두 가지가 섞여 있어요.
① 같은 측지계 안에서의 변환 (위경도 ↔ 평면, 혹은 평면 ↔ 평면). 이건 순수한 수학 공식의 계산입니다. 투영 편에서 본 그 투영 수식을 그대로 적용하거나 거꾸로 푸는 것뿐이라, 정확하고 되돌려도 값이 그대로 돌아옵니다. 종이를 접었다 정확히 도로 펴는 것과 같아요. 예를 들어 4326(WGS84 위경도) → 3857(웹 메르카토르)는 측지계가 같으니 이 부류입니다.
② 측지계가 다른 변환 (예: 동경측지계 Bessel ↔ 세계측지계 GRS80). 이건 이야기가 다릅니다. 기준으로 삼은 지구의 모양과 중심 자체가 다르기 때문에, 단순 계산이 아니라 지구를 통째로 살짝 회전·이동·확대해서 맞춰야 합니다. 이걸 데이텀 변환(datum shift) 이라 부르고, 근본적으로 근사라서 미세한 오차가 따라붙습니다.
💡 대부분의 사고는 이 둘을 구분하지 못하는 데서 시작됩니다. "그냥 좌표 바꾸는 거잖아"라고 뭉뚱그리는 순간, ①처럼 정확해야 할 변환을 대충 하거나, ②처럼 지구를 옮겨야 하는 변환에서 측지계 정보를 빠뜨리게 되거든요.
도구 삼총사 — 어디서 바꾸나
좌표를 바꾸는 자리는 크게 셋입니다. 재미있는 건 이 셋이 사실상 같은 뿌리를 공유한다는 점이에요. 밑바닥엔 대부분 PROJ라는 오래된 C 라이브러리가 깔려 있습니다.
💡 PROJ가 뭐냐면 — 좌표 변환의 수학을 전담하는 오픈소스 엔진이에요. 1980년대 미국 USGS에서 시작해 지금은 GIS 좌표계의 사실상 표준 엔진이 됐죠. PostGIS·GDAL·QGIS가 전부 속으로 이 PROJ를 불러 씁니다. 이름이 좀 헷갈리는데, 옛날 버전이 오래
4.x라 "proj4" 라고도 불렸고, 그걸 자바스크립트로 이식한 게 함정 ②에서 만날 proj4js예요. 한 줄로 — PROJ = 모두가 공유하는 변환 엔진, proj4js = 그 엔진의 웹(JS)판.
| 도구 | 어디서 | 대표 용도 |
|---|---|---|
| proj4js | 브라우저·Node.js | 지도 라이브러리에서 실시간 변환 |
| ST_Transform | PostGIS(DB) | 저장된 공간 데이터 대량 변환 |
| GDAL / ogr2ogr | 커맨드라인 | 파일 통째로 포맷·좌표계 변환 |
셋 다 하는 일은 같습니다. "이 좌표를, 이 EPSG에서, 저 EPSG로." 그래서 함정도 셋에서 똑같은 모양으로 반복됩니다. 지금부터 그 함정 넷을 하나씩 보죠.
함정 ① ST_SetSRID는 변환이 아니다
PostGIS 초심자가 백이면 백 밟는 지뢰입니다. 이름이 비슷한 두 함수가 있어요.
-- ❌ 좌표계 '이름표'만 바꾼다. 숫자는 그대로.
ST_SetSRID(geom, 4326)
-- ✅ 좌표 '숫자'를 실제로 다시 계산한다.
ST_Transform(geom, 4326)
5179 데이터의 한 점이 (953000, 1950000) — 미터 단위 평면 좌표라고 해봅시다. 여기에 ST_SetSRID(geom, 4326)을 쓰면, PostGIS는 숫자를 건드리지 않고 꼬리표만 "이건 위경도야"로 갈아 끼웁니다. 그 순간 953000은 경도 95만 도가 되어버려요. 지구를 수천 바퀴 돈 자리, 그러니까 화면 어디에도 없는 곳입니다. 서울이 사라진 첫 번째 진범이 바로 이거죠.
조금 더 감이 오는 비유가 있어요. ST_SetSRID는 환율은 그대로 두고 통화 이름표만 '원'에서 '달러'로 바꿔 붙인 격입니다. 5000이라는 숫자는 안 변했는데, 5,000원이던 게 갑자기 5,000달러라고 우기는 셈이죠. 값을 실제로 환산하려면 — 즉 좌표를 진짜로 바꾸려면 — ST_Transform을 써야 합니다.
💡 정리하면 —
SetSRID는 "이 좌표가 원래 무슨 좌표계인지"를 알려줄 때만(즉 라벨이 비어 있거나 잘못 붙었을 때) 쓰고, 좌표계를 실제로 바꾸고 싶으면 언제나Transform입니다.
함정 ② proj4js는 한국 좌표계를 모른다
브라우저로 넘어오면 다른 함정이 기다립니다. proj4js는 4326, 3857 같은 전 세계 공용 좌표계는 기본으로 알지만, 5179·5186 같은 한국 좌표계는 모릅니다. 정의를 알려주지 않고 변환을 시도하면 그냥 에러가 나거나, 더 나쁘게는 엉뚱한 값이 조용히 나오죠.
해결은 간단합니다. 쓰기 전에 좌표계 정의를 한 번 등록해 주면 됩니다.
import proj4 from 'proj4';
// 5179(UTM-K)가 무엇인지 proj4에게 먼저 알려준다 (epsg.io에서 복사한 그대로)
proj4.defs(
'EPSG:5179',
'+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs',
);
// 이제 변환 가능: [경도, 위도]로 나온다
const [lon, lat] = proj4('EPSG:5179', 'EPSG:4326', [953428, 1950827]);
저 긴 문자열이 바로 EPSG 편에서 "코드 하나에 압축돼 있다"던 그 내용물입니다. 사실 우리가 앞에서 배운 개념들을 그대로 풀어 쓴 것이라, 하나씩 뜯어보면 낯설지 않아요.
| 파라미터 | 뜻 | 어디서 봤나 |
|---|---|---|
+proj=tmerc | 투영 = 횡축 메르카토르(TM) | 투영 |
+lon_0=127.5 | 중앙자오선 경도 127.5° | 투영 (TM 띠의 중심) |
+k=0.9996 | 축척 계수(0.04% 축소) | 투영 |
+x_0 / +y_0 | 원점 보정(false easting·northing) | EPSG (음수 방지) |
+ellps=GRS80 | 타원체 = GRS80(세계측지계) | 지구 모양 |
+towgs84=… | WGS84로의 데이텀 변환값 | ↓ 아래 함정 ④ |
+units=m | 단위 = 미터 | — |
+no_defs +type=crs | "이 문자열만 CRS로 써라"는 플래그 | — |
외울 필요는 전혀 없어요. epsg.io에서 코드를 검색하면 이 문자열을 복사해 쓸 수 있게 통째로 줍니다. 다만 줄 일부만 잘라 쓰지 말고 통째로 붙여넣으세요. 특히 +towgs84 항목은 뒤에 나올 함정 ④와 직결되는데, 이걸 빠뜨리면 좌표가 조용히 어긋나거든요.
💡
+towgs84=0,0,0,0,0,0,0이 전부 0인 건 우연이 아니에요. "GRS80(5179)와 WGS84(4326)는 사실상 같은 타원체라 옮길 필요가 없다"는 뜻입니다. 반대로 옛5174(Bessel)를 epsg.io에서 뽑으면 이 자리에-146,507,681…같은 0이 아닌 값이 들어 있어요. 지구를 그만큼 밀어야 WGS84에 맞는다는 거죠. 이 숫자의 정체는 바로 다음 함정에서 밝혀집니다.
💡 실전 팁 — 프로젝트에서 자주 쓰는 한국 좌표계(
5179,5186,5181)는 앱이 시작될 때proj4.defs로 한 번에 등록해두세요. 변환할 때마다 "얘를 등록했던가?" 헷갈리는 일이 사라집니다.
함정 ③ [경도, 위도]냐 [위도, 경도]냐
위도와 경도 편에서 순서만 바꿔도 서울이 바다로 간다고 했죠. 그 함정이 코드에서 부활하는 지점이 여기입니다. 게다가 라이브러리마다 기대하는 순서가 달라서 더 지독해요.
proj4js는 변환 결과를[경도, 위도]순서, 즉[x, y]로 돌려줍니다.- 그런데 Leaflet의
L.latLng(...)나 카카오맵은[위도, 경도]순서를 기대합니다.
proj4가 뱉은 [127.0, 37.5]를 그대로 Leaflet에 [127.0, 37.5]로 넘기면, Leaflet은 이걸 "위도 127도, 경도 37.5도" 로 읽습니다. 그런데 위도의 최대치는 90도예요. 위도 127도는 지구상에 존재하지 않는 값입니다. 그래서 점은 북극 너머 어딘가, 지도가 그릴 수 없는 곳으로 튕겨나가고 — 화면엔 또 아무것도 안 보입니다. 서울이 사라진 두 번째 진범이죠.
💡 그래서 좌표를 라이브러리 사이에서 넘길 땐, 늘 "이 배열은 지금 [x, y]인가, [lat, lng]인가" 를 한 번 되묻는 습관이 안전합니다. GeoJSON·proj4·PostGIS는
[경도, 위도], Leaflet·카카오는[위도, 경도]— 이 경계선만 기억해도 대부분 피합니다.
함정 ④ 측지계를 무시한 변환
마지막은 가장 미묘합니다. 함정 ①~③은 데이터가 아예 사라지니 금방 알아채지만, 이건 데이터가 멀쩡히 보이는데 살짝 어긋나 있어서 며칠 뒤에야 발견되곤 하거든요.
EPSG 편에서 옛 지적도가 아직도 동경측지계(Bessel) 기반 5174를 쓴다고 했죠. 이 데이터를 4326으로 바꿀 때, 변환기가 측지계 차이를 모른 채 투영 수식만 되돌리면 결과가 약 300미터 밀립니다. 건물이 옆 도로 위에 얹히는 딱 그 거리예요. 앞의 "변환의 두 종류"에서 봤던 ②번 데이텀 변환을 ①번처럼 대충 처리했을 때 생기는 일입니다.
정확히 하려면 변환기가 두 측지계 사이의 데이텀 변환 정보를 알아야 합니다. 앞서 proj4 문자열에서 봤던 +towgs84=…의 일곱 숫자가 바로 이것 — 지구를 얼마나 밀고(이동 3) · 돌리고(회전 3) · 늘려서(스케일 1) WGS84에 맞출지를 담은 값(7-파라미터 헬머트)이고, 더 정밀하게는 지역별 그리드 시프트 파일을 씁니다. 5179는 이 값이 전부 0이라 그냥 넘어갔지만, 5174는 0이 아닌 값이 채워져 있어야 300m 오차가 잡힙니다. 다행히 PostGIS의 ST_Transform이나 최신 PROJ는 EPSG 코드만 제대로 주면 이 과정을 알아서 챙깁니다. 관건은 원본 데이터의 EPSG를 정확히 알려주는 것 — 즉 함정 ①에서 본 "라벨을 올바로 붙이는 일"로 되돌아갑니다.
💡 한국은 정밀 변환을 위한 별도 파라미터·그리드를 국토지리정보원이 제공합니다. cm 단위 정밀도가 필요한 지적·측량 작업이라면, 일반 변환으로 만족하지 말고 이 공식 변환 정보를 쓰는지 확인해야 합니다.
정리 — 한 문장으로
좌표 변환은 숫자를 다시 계산하는 일이다. 라벨만 바꾸거나, 순서를 뒤집거나, 측지계를 빠뜨리는 순간 좌표는 조용히 엉뚱한 곳으로 간다.
| 함정 | 증상 | 핵심 |
|---|---|---|
| 변환의 두 종류 | — | 같은 측지계=수식(정확) / 다른 측지계=데이텀 변환(근사) |
SetSRID vs Transform | 데이터가 우주로 | 라벨갈이 vs 실제 재계산 — 바꾸려면 항상 Transform |
| 좌표 순서 | 점이 지도 밖으로 | proj4·GeoJSON=[경도,위도], Leaflet·카카오=[위도,경도] |
| 측지계 무시 | 300m 어긋남 | 원본 EPSG를 정확히 줘서 datum shift를 태울 것 |
이렇게 좌표를 다루는 법까지 익히면서, "GIS가 뭐야?"에서 시작한 개념과 좌표의 세계(Phase 1~2)를 한 바퀴 돌았습니다. 다만 Phase 2는 용어가 유난히 많았죠. 본격적으로 데이터 포맷으로 넘어가기 전에, 위경도부터 좌표 변환까지 여기까지의 용어를 한 장에 압축한 치트시트 특별편 을 먼저 놓아뒀습니다. 실전에서 헷갈릴 때 꺼내 보세요.
그리고 지금까지 우리가 "데이터"라고 부른 건 도대체 어떤 모습으로 저장돼 있을까요? 점·선·면으로 그린 지도와, 격자 사진처럼 찍은 지도는 근본이 다릅니다. 그다음 본편부터는 Phase 3 — GIS 데이터가 실제로 담기는 두 가지 얼굴, 벡터와 래스터로 넘어갑니다.