
SharkGeo는 산림·지리공간 분야 업무용으로 개발 중인 WPF(.NET 8) 기반 GIS 데스크탑 앱입니다. 기본 기능들이 어느 정도 갖춰진 상태에서 "좌표계 설정" 기능을 붙였는데, 이게 생각보다 훨씬 많은 문제를 연쇄적으로 터뜨렸습니다. 이 글은 그 과정에서 겪은 버그와 해결 방법을 기록한 글입니다.
GIS에서 좌표계(CRS, Coordinate Reference System)는 "이 숫자가 지구 위 어디를 가리키는지"를 정의하는 규약입니다.
문제는 실무 데이터가 다양한 좌표계로 뒤섞여 있다는 점입니다. 국토정보플랫폼에서 내려받은 SHP는 EPSG:5186, 공개 위성지도(Vworld, OSM)는 EPSG:4326 기반 Web Mercator 타일 방식, 현장 측량 데이터는 또 다른 좌표계일 수 있습니다. 이것들을 하나의 화면에 올리려면 모든 좌표를 프로젝트 기준 좌표계 하나로 통일해야 합니다.
첫번째로 한 작업은 위성지도를 먼저 올릴 수 있어야 합니다. 위성지도를 무료로 제공하고 있는 곳을 찾아서 연결할 수 있는 메뉴를 구성했고, 기본적으로 URL로 TileMap을 가져와서 MapControl에 붙여야 하는데, MapControl에 올라가는 순간 굉장이 느립니다. 타일맵이라서 Panning을 하거나 확대/축소하게되면 특정 부분들이 느리게 올라오면서 TileMap에 구멍이 생깁니다.
(첫 작업은 Manual로 오픈되 URL에서 지도를 올리는 작업이고, 이후는 API Key를 이용해서 작업을 꼭 해야합니다. 언제 Manual로 제공하는 지도(구서버에서 가져오는 지도)를 중지할지 알 수 없습니다.)

지도가 아래와 같이 올라오는 것을 볼 수가 있습니다. 마우스의 패닝과 확대/축소시에 굉장히 느린 부분을 해결하는데 꽤 복잡한 과정을 거쳤는데, 목표는 QGIS에서 위성지도를 올려서 작업하는 것보다는 빠르게 하자!!! 이게 제 목표입니다. 타일맵이기에 캐쉬를 써가며 1차 목표는 성공적으로 처리했습니다. QGIS보다는 월등하게 빠르게 패닝과 Zoom이 동작하네요^^;
[타일맵이 확대/축소시에 타일에 구멍이 생겼을때, 해결 및 속도개선 사항]
1. 패닝을 한다는 것은 기존 메모리에 올라와 있는 지도를 움직인다는 것이라서 즉시 렌더취소를 해야한다.
2. 다운로드 중이던 타일에 Exception발생할 수 있는 부분들을 캐쉬처리해서 다시 받을 수 있도록 처리한다.
3. 내부적으로 네트워크 오류가 발생해서 구멍 발생하는 부분을 재시도 하도록 한다.
4. 동시에 다운로드 8개 정도로 제한해서 속도 향상한다.

위성지도를 올렸으니, 그 다음작업으로는 프로젝트 좌표계를 설정할 수 있어야하고, 후속으로 올라오는 Layer들은 어떻게 간판하게 좌표계를 설정하냐가 관건입니다. 저는 아래의 라이브러리를 사용했습니다.
[WPF (.NET 8.0)]
ProjNet 2.0.0 — 좌표 변환 엔진
NetTopologySuite — 공간 지오메트리 처리
Microsoft.Data.Sqlite — GeoPackage(GPKG) 읽기
SharpZipLib — SHP 압축 처리
좌표계를 그냥 처음부터 개발한다? 생각도 하기 싫은 분야입니다. 누군가가 잘 만들어서 검증된 누겟과 라이브러리를 잘 검색하면 답이 나오게 됩니다.

문제1. Vector를 그냥 하나 올릴때는 문제가 없지만, 프로젝트 좌표계를 설정하고 Vector를 올리면 깨짐증상
프로젝트 좌표계를 EPSG:5186으로 설정하고, EPSG:5186으로 저장된 SHP 파일을 불러오면 선들이 완전히 다른 위치에 그려지거나 화면 밖으로 날아가 버렸습니다.
GetTransform은 내부적으로 ProjNet의 CoordinateTransformationFactory를 사용합니다. 그런데 소스와 타겟이 동일한 좌표계여도 수치 오차가 발생합니다. WKT 문자열로 생성한 두 CRS 객체는 내부 파라미터가 미묘하게 다를 수 있고, 타원체 기준면 변환 과정에서 수백 미터 오차가 생길 수 있습니다.
※ 해결방법 : 소스 CRS의 EPSG 코드와 타겟 CRS의 EPSG 코드가 같으면 변환기를 만들지 않습니다. (결론적으로 FindByWkt로 EPSG코드를 먼저 찾고, EPSG 코드 기반으로 변환기를 만드는 것이 WKT 원문 비교보다 훨씬 안정적입니다.)
if (prjWkt != null)
{
var sourceCrs = CrsService.FindByWkt(prjWkt);
if (sourceCrs != null && sourceCrs.EpsgCode == targetCrs.EpsgCode)
{
// 동일 좌표계일때는 변환 불필요
transform = null;
}
else if (sourceCrs != null)
{
// 알려진 CRS 간 변환: WKT 원문보다 EPSG 코드 기반이 정확
transform = CrsService.GetTransform(sourceCrs.EpsgCode, targetCrs.EpsgCode);
}
else
{
// 미인식 CRS: WKT 원문으로 시도
transform = CrsService.GetTransform(prjWkt, targetCrs.Wkt);
}
}
문제2. .Prj 파일 형식이 제각각이다.
.prj 파일을 열어보면 좌표계를 뽑아가지고 올 수 있습니다. 이 작업얘기를 하기 전에 전 개인적으로 어떤 작업을 하건 모든 것은 자동으로 하고 싶습니다. 그냥 좌표계를 알고 있으면 설정을 하면 끝이지만, 저는 이렇게 번거로운 작업을 하기가 싫었습니다. 벡터레이어를 올리면 그냥 자동으로 좌표계를 프로젝트 좌표계를 세팅해서, 내부적으로 좌표계를 재투영해서 위성지도와 레이어들을 자동으로 맞추고 싶었습니다.
문제3. 매칭
GPKG파일은 불러와서 맵에 올리면 신기하게 좌표계를 무시하고 WGS84(경위도)로 가정하고 변환했는데요. 이 원인은 GPKG는 SQLite 기반 포맷을 가지고 있어서, 내부에 공간 참조 정보를 담는 테이블이 따로 있습니다. 그래서 이 테이블을 읽지 않고 무조건 4326을 타겟 CRS 변환을 적용했습니다.
-- GPKG 내부 테이블 구조
gpkg_geometry_columns -- 각 레이어의 srs_id 기록
gpkg_spatial_ref_sys -- srs_id별 WKT 정의
문제4. .prj 없는 SHP의 좌표를 어떻게 알까?
.prj가 없는 SHP는 무조건 WGS84(4326)로 가정했는데, 실제로는 EPSG:5186인 파일이 많아 좌표가 완전히 틀렸습니다. 해결법은 그냥 이런 경우에는 팝업을 띄워서 사용자에게 묻기를 진행했습니다. .prj가 없으면 CRS 선택 다이얼로그를 띄워 사용자가 직접 지정하도록 하고, 그 정보를 LayerItem에 저장해 뒀습니다.
문제5. 프로젝트 CRS를 바꾸면 이미 맵에 올라가 있는 벡터 레이어가 모두 깨짐
레이어 두 개를 올린 상태에서 프로젝트 CRS를 변경하면, 새로 올리는 레이어는 새 CRS 기준으로 좌표가 맞지만 이미 올라간 레이어는 이전 CRS 기준 좌표 그대로라 두 레이어가 엉뚱한 위치에 그려졌습니다. 해결은 의외로 간단하게 그냥 CRS 변경 시 자동 재투영을 진행하면 됩니다. CurrentCrs setter에서 변경 감지 후, 파일 경로가 있는 모든 벡터 레이어를 새 CRS로 재로드합니다.
문제6. Geographic CRS(4326)에서 배경지도 타일을 못 가져오는 문제
프로젝트 CRS를 WGS84(EPSG:4326)로 설정하면 배경지도가 전혀 나오지 않았습니다. 원인은 배경지도 타일 서비비스는 projectToWgs84 변환기가 없으면 일찍 종료하는 것이었습니다. 프로젝트가 CRS가 이미 WGS84이면 변환기 필요없음으로 일단락했습니다.
// 투영 CRS일 때만 변환기 필요
if (!isGeographic && (projectToWgs84 == null || wgs84ToProject == null)) return result;
// 뷰포트 → WGS84 변환
if (isGeographic)
{
// 프로젝트 CRS = WGS84: 월드 좌표 자체가 lon/lat
lonMin = viewportWorldBounds.Left;
lonMax = viewportWorldBounds.Right;
latMin = viewportWorldBounds.Top;
latMax = viewportWorldBounds.Bottom;
}
else
{
(lonMin, latMin) = CrsService.Transform(projectToWgs84!, ...);
(lonMax, latMax) = CrsService.Transform(projectToWgs84!, ...);
}
문제 7. 배경지도 타일에 구멍이 뚫리는 심각한 버그
이 문제는 블로그 첫 부분에 언급해두었네요. 너무 심각했던 문제라서....
ProjNet은 Java의 GeoTools나 Python의 pyproj에 비해 .NET 생태계에서 선택지가 거의 없는 라이브러리입니다. 문서도 적고 예제도 부족해서 대부분 직접 소스를 파봐야 했습니다. 이번 작업에서 얻은 삽질을 정리하면:
SharkGeo는 작업시간이 오래 소요되는 산림 분야 재해위험성 검토, 지형 분석, 공간정보 처리를 위한 WPF GIS 앱으로 개발 중입니다.