Spatial Data Engineering

일반 데이터에서 27은 그냥 숫자지만, 공간 데이터에서 좌표는 CRS 없이는 아무 의미도 없다


핵심 개념

공간 데이터는 관계형 파이프라인의 특수 사례가 아니라 자기만의 실패 모드를 가진 별개의 영역이다. Point 기하와 복잡한 polygon/multipolygon을 텍스트 행에 욱여넣는 것은 그만한 가치가 없는 복잡도를 더한다.

기하를 이해하는 DB(PostGIS 등)는 근본적으로 다른 질의 로직을 가능하게 한다.

-- 이 점이 이 폴리곤 안에 있나? (이 상점이 이 동네에 있나?)
SELECT ST_Contains(neighborhood.geom, store.geom);
 
-- 두 폴리곤이 겹치면 정확한 교집합 면적은?
SELECT ST_Area(ST_Intersection(polygon_a.geom, polygon_b.geom));

그렇다고 평면 데이터가 무용해지는 게 아니다. 평면 인구통계와 기하 도형을 결합하는 것이 물리적 공간에 대한 질문에 답하게 해준다.

함정 1 — CRS(좌표 참조 체계) 불일치

가장 흔하고 가장 조용한 실패다. 좌표는 CRS 없이는 의미가 없고, 원본과 다른 계로 해석하면 데이터가 깨진다.

계단위흔한 출처
EPSG:4326 (WGS 84)도 (위도/경도)OpenStreetMap, 대부분의 GPS
EPSG:3857 (Web Mercator)미터ArcGIS API, 웹 타일
국가별 그리드 (예: EPSG:2100)미터정부 통계 기관

3857 미터를 4326 도로 잘못 받아들이면 좌표가 붕괴한다 — 200만 도를 찍으려다 GIS 소프트웨어가 빈 화면이 된다.

두 가지 수정 지점이 있다.

  • 파이프라인에서 재투영 — GeoPandas의 to_crs로 PostGIS 진입 전 단일 포맷으로 통일
  • 소스에서 강제 — ArcGIS API 요청 페이로드에 'outSR': '4326'을 직접 넣어 서버가 애초에 올바른 좌표를 반환하게 한다

두 번째가 가능하면 항상 낫다. 공간 데이터 작업에는 이런 다차원적 접근이 필요하다는 것이 요점이다.

함정 2 — Spatial sliver

서로 다른 기관이 그린 지도를 융합할 때 발생한다. 두 데이터셋이 같은 존을 담고 있지 않다 — 다른 ID, 다른 경계.

코뮌 단위 평균 같은 지표는 지역화된 정확한 데이터를 파괴하므로 ST_Intersection으로 양쪽 속성을 모두 보존하는 micro-fragment를 만들게 된다. 그 결과 미세한 slivers가 생긴다.

증상은 극적이다: km²당 인구밀도가 수억으로 나온다. 원인은 150m² sliver가 20km² 부모와 정확히 같은 인구를 상속한 것이다.

두 선택지

  1. 작은 조각에 인구를 보간한다 — 실제 데이터 대신 추측을 하는 것이라 프로젝트 무결성을 망친다
  2. Two-Tier 데이터 모델 — 권장
    • Tier 1 (fragment 레벨): 실제 지역화된 물리 지표만 (m²당 가격, 기하, 편의시설 수)
    • Tier 2 (집계 단위 레벨): 공식 경계 기준 materialized view로 인구 계산을 정확히 유지

집계 가능한 것(count, 면적)과 집계 불가능한 것(상속된 인구)을 레이어로 분리하는 것이 원칙이다.

함정 3 — 공간 인덱스, 그것도 올바른 종류

ST_DWithin 같은 공간 조인은 수학적으로 무겁다. 인덱스가 없으면 초 단위여야 할 쿼리가 한 시간 넘게 얼어붙는다.

게다가 터미널을 닫아도 쿼리가 죽지 않는다. 서버 사이드에서 orphan 커넥션으로 계속 돌므로 pg_stat_activity에서 찾아 pg_terminate_backend()로 죽여야 한다.

일반 GIST 인덱스로 충분하지 않을 수 있다. 쿼리가 실세계 곡률 거리를 미터로 계산하려고 기하를 ::geography로 캐스트한다면, 그 표현식 위의 functional index가 필요하다.

-- 기하 컬럼에 대한 평범한 GIST 인덱스로는 부족
CREATE INDEX idx_amenities_geometry ON amenities USING GIST (geometry);
 
-- Postgres는 정확히 그 geography 캐스트 표현식 위의 인덱스가 필요
CREATE INDEX idx_amenities_geog ON amenities USING GIST ((geometry::geography));

공간 인덱싱은 선택적 최적화가 아니라 초 단위 완료와 영원히 도는 것의 차이다.

함정 4 — 밀도의 분모

접근성·밀도 지표는 분모를 잘못 잡기 쉽다. 500m 도보 반경 안의 편의시설 수를 세면서 fragment 자신의 면적으로 나누면 안 된다.

fragment 대부분이 500m 원보다 작으면(실제 사례에서 13,862개 중 91% 이상) 밀도가 km²당 수백만이 된다 — 100m²로 50개 상점을 나누는 격이다.

-- 틀림: 자기 footprint로 나눔
COUNT(i.*) / (ST_Area(f.geometry::geography) / 1000000.0)
 
-- 맞음: 실제 walkable catchment로 나눔
COUNT(i.*) / (ST_Area(ST_Buffer(f.geometry::geography, 500)) / 1000000.0)

ST_Buffer로 catchment를 생성하고 ST_Area로 그 면적을 계산해야 한다. 조인 조건(ST_DWithin 500m)과 분모가 같은 기하를 가리켜야 한다는 것이 일반화된 규칙이다.

MVP를 벗어나는 시점

전형적 시작: 하드코딩된 크레덴셜이 흩어진 단일 파일 Python 스크립트, pgAdmin 세션 히스토리의 임기응변 SQL로만 존재하는 핵심 DB 객체. 컨테이너가 사라지면 프로젝트도 사라진다.

빠른 MVP로 아이디어를 실제로 보고 약점을 일찍 드러내는 단계는 값지다. 하지만 오래 머물 단계가 아니다.

  • 모듈러 설계 — 소스별 파일, 쿼리 디렉토리 분리
  • ETL 표준화 — 모든 스크립트를 extract() / transform() / load()로
  • 중앙 오케스트레이션 — 의존 순서를 제어하는 단일 스크립트에 --only / --skip 플래그. 공간 데이터는 소스마다 갱신 주기가 극단적으로 다르다(센서스는 몇 년, OSM은 상시) → 스텝 독립 실행이 필수
  • 의존성 격리 — 지오 라이브러리는 네이티브 컴파일 의존성이 많다. 문제가 되는 파서는 별도 환경에 격리하고 subprocess로 트리거

정리된 공간 웨어하우스의 힘

제대로 구조화하고 나면 상관 분석에 Python으로 내보낼 필요가 없다 — PostGIS 쿼리에서 직접 회귀와 상관을 계산할 수 있다. 라이브 공간 선형 회귀로 “km²당 편의시설 1개 증가마다 부동산 가치 €8.51/m² 증가” 같은 결과를 바로 얻는 식이다.

연관 개념


Source: Designing a Spatial ETL Pipeline in PostGIS