행정구역 지리 분석하기: 좌표 변환 및 시각화
💠

행정구역 지리 분석하기: 좌표 변환 및 시각화

ClickHouse 분류
Case Study
Type
Lab
작성자

Ken

좌표 한 쌍을 던지면 "이건 세종특별자치시" 라고 답하는 시스템을, PostGIS 없이 ClickHouse만으로. 그리고 그 결과를 Superset 지도 위에 보여주기
image
image

지오 분석 요구는 한국 엔터프라이즈에서 생각보다 자주 나옵니다. "사용자가 시군구별로 어떻게 분포하나", "매장/센서/관측소를 행정구역 단위로 집계해 지도에 칠해 달라", "특정 폴리곤 안에 들어온 이벤트만 뽑아 달라". Elasticsearch의 hexagonal aggregation 같은 기능을 레퍼런스로 들고 오는 경우도 많습니다.

이걸 ClickHouse로 풀 수 있을까요? 결론부터 말씀드리면 분류·집계는 ClickHouse가 아주 잘합니다. 다만 한국 데이터를 다룰 때 반드시 밟고 넘어가야 하는 함정이 하나 있습니다 — 바로 좌표계입니다. 이 글은 그 함정을 포함해, 시군구 251개 경계를 적재하고, Polygon Dictionary로 리버스 지오코딩을 구현하고, Superset deck.gl로 choropleth를 그리는 전 과정을 핸즈온으로 정리합니다.

작성 환경: ClickHouse OSS 25.5.11.15, Superset 4.1.1 + clickhouse-connect, 데이터는 southkorea/southkorea-maps의 시군구 GeoJSON(WGS84).

1. ClickHouse의 지리 분석 기능

먼저 기대치를 맞춰 보겠습니다. ClickHouse는 PostGIS 같은 풀 GIS가 아닙니다. 하지만 "포인트를 폴리곤에 매핑하고 대규모로 집계"하는 작업, 즉 실무 지오 분석의 8할은 네이티브로 처리합니다.

되는 것:

  • pointInPolygon((lon, lat), polygon) — 점이 폴리곤 안에 있는가. hole(구멍) 있는 폴리곤도 지원.
  • Polygon Dictionary — point-in-polygon 검사를 딕셔너리로 오프로드합니다. 좌표 → 행정구역 리버스 지오코딩에 최적화되어 있어, 폴리곤 수가 많아도 빠릅니다.
  • geoToH3 / h3ToGeoBoundary / h3kRing … — Uber H3 헥사곤 인덱싱입니다. UInt64 정수라 컬럼 스토리지·집계와 정합성이 좋습니다.
  • geohashEncode, S2 함수군 등.

안 되는 것 (이게 핵심):

  • 좌표계 재투영(reprojection)이 없습니다. ST_Transform 같은 함수가 없습니다. 즉 EPSG:5179 → 4326 변환을 ClickHouse가 해 주지 않습니다.
  • union/buffer/simplify 같은 무거운 지오메트리 가공도 없습니다 — 이건 전처리(QGIS/GeoPandas)의 몫입니다.

그래서 설계 원칙은 분명합니다. 무거운 지오메트리·좌표 변환은 적재 전 외부에서 끝내고, ClickHouse는 분류와 집계에 집중시킵니다.

2. 가장 큰 함정 — 좌표계 (그리고 "조용히 틀리는" 실패)

한국 공식 행정경계 데이터(행정안전부·국토교통부 SHP 등)는 대부분 EPSG:5179(UTM-K, Korea 2000 Unified CS) 또는 TM 좌표계로 배포됩니다. 단위는 미터입니다. 반면 ClickHouse의 모든 geo 함수는 WGS84 경위도(degree, EPSG:4326) 를 전제합니다.

문제는 둘을 섞으면 에러가 나지 않는다는 점입니다. 그냥 조용히 틀린 답을 냅니다. 세종특별자치시의 동일 지점을 두 좌표계로 넣어 비교해 보면 명확하게 드러납니다.

SELECT
  geoToH3(127.289, 36.480, 7)                                                   AS sejong_h3,
  -- 세종 WGS84 좌표를 한반도 bbox에 넣으면
  pointInPolygon((127.289, 36.480),     [(124.5,33.0),(132.0,33.0),(132.0,38.7),(124.5,38.7)]) AS wgs84_ok,
  -- 같은 지점의 EPSG:5179(미터) 좌표를 넣으면
  pointInPolygon((612656.0, 1791892.0), [(124.5,33.0),(132.0,33.0),(132.0,38.7),(124.5,38.7)]) AS utmk_bad
sejong_h3 = 608482148894113791
wgs84_ok  = 1     ← degree 좌표: 박스 안 (정상)
utmk_bad  = 0     ← meter 좌표: 박스 밖 (조용히 틀림)

같은 세종인데 4326은 "한국 안", 5179는 "한국 밖"으로 판정됩니다. 만약 이걸 모르고 5179 데이터를 그대로 적재해 리버스 지오코딩을 돌리면, 모든 좌표가 매칭에 실패하거나 엉뚱한 구역으로 빠집니다 — 그것도 에러 한 줄 없이 말이죠. 그래서 데이터는 무조건 4326 상태로 적재해야 합니다.

변환이 필요한 소스를 받았다면 적재 전에 처리합니다:

ogr2ogr -t_srs EPSG:4326 sig_4326.geojson sig_5179.shp
# 또는
python -c "import geopandas as gpd; gpd.read_file('sig_5179.shp').to_crs(4326).to_file('sig_4326.geojson', driver='GeoJSON')"

이 PoC는 처음부터 WGS84인 southkorea/southkorea-maps를 써서 변환 단계 자체를 없앴다. 그래도 안전장치로, 적재 스크립트가 첫 좌표값의 크기를 보고 좌표계를 자동 판별하도록 했습니다. 경도가 124~132, 위도가 33~39 범위면 4326, 백만 단위 큰 숫자면 5179로 보고 적재를 차단합니다.

3. 아키텍처 — 데이터가 흐르는 경로

전체 파이프라인은 다음과 같습니다. GeoJSON을 받아 좌표계를 검증하고, ClickHouse에 두 가지 표현으로 적재한 뒤, 하나는 딕셔너리(분류)에, 다른 하나는 시각화(Superset)에 사용합니다.

왜 표현을 둘로 나눌까요? 도구마다 요구하는 폴리곤 포맷이 다르기 때문입니다.

테이블
구조
용도
geospatial.sig_polygons
Array(Array(Array(Tuple(Float64,Float64)))) = [polygon][ring][point]
Polygon Dictionary 소스 (정확한 분류)
geospatial.sig_map
외곽 ring 1개 = 1행, coordinates[[lon,lat],…] JSON 문자열
deck.gl Polygon 시각화

딕셔너리는 hole과 MultiPolygon을 온전히 표현해야 정확히 분류할 수 있고, deck.gl Polygon은 ring 하나를 JSON 배열로 받는 게 편합니다. 같은 GeoJSON에서 두 형태를 동시에 만듭니다.

4. 구현

4.1 Docker 스택

ClickHouse와 Superset를 같은 compose 네트워크에 띄웁니다. 그래야 Superset이 clickhouse:8123으로 접근할 수 있습니다.

docker compose up -d --build
curl 'http://localhost:8123/?query=SELECT%20version()'   # 25.5.x
curl -I http://localhost:8088/health                      # HTTP 200

Superset 이미지에는 ClickHouse 드라이버가 없으므로 clickhouse-connect 를 반드시 설치합니다(requirements-local.txt에 추가 후 빌드). 이게 빠지면 뒤에서 연결이 되지 않습니다.

4.2 스키마와 Polygon Dictionary

딕셔너리가 핵심입니다. LAYOUT(POLYGON)이 point-in-polygon을 위한 공간 인덱스를 만들어 줍니다.

CREATE DICTIONARY geospatial.sig_dict
(
    key  Array(Array(Array(Tuple(Float64, Float64)))),
    code String,
    name String
)
PRIMARY KEY key
SOURCE(CLICKHOUSE(HOST 'localhost' PORT 9000 DB 'geospatial' TABLE 'sig_polygons' USER 'default'))
LAYOUT(POLYGON(STORE_POLYGON_KEY_COLUMN 1))
LIFETIME(0);
⚠️ LIFETIME(0)은 자동 reload를 하지 않습니다. 데이터 적재 반드시 SYSTEM RELOAD DICTIONARY geospatial.sig_dict를 한 번 호출해야 딕셔너리가 채워집니다. (ClickHouse Cloud에서는 딕셔너리를 DDL 방식 + default 유저로 생성해야 한다는 점도 같이 기억해 두시면 좋습니다.)

4.3 적재 — GeoJSON을 두 형태로

load_geo.py는 GeoJSON을 읽어 feature마다 코드·이름을 뽑고, geometry를 두 가지로 변환합니다.

  • 딕셔너리용: GeoJSON Polygon[coords]로 한 번 감싸 [polygon][ring][point] 구조를 맞추고, MultiPolygon은 coords를 그대로 씁니다. 각 점 [lon, lat]은 튜플로 변환합니다.
  • 시각화용: 각 polygon part의 외곽 ring만 뽑아 [[lon,lat],…] JSON 문자열로 구성합니다. MultiPolygon(섬이 있는 시군구 등)이면 part마다 1행을 만들고 code/name/metric을 공유합니다.

clickhouse-connect로 중첩 리스트/튜플을 그대로 insert하면 드라이버가 nested 타입을 처리해 줍니다. 적재 결과는 시군구 251개, 시각화용 ring 325행입니다(MultiPolygon 때문에 시군구 수보다 많습니다).

5. 검증 — 정말 맞나

리버스 지오코딩이 핵심 기능이니, 알기 쉬운 기준점으로 확인해 보겠습니다.

SELECT dictGet('geospatial.sig_dict', 'name', (127.289, 36.480)) AS sejong;  -- 세종
SELECT dictGet('geospatial.sig_dict', 'name', (126.9780, 37.5665)) AS seoul; -- 서울 중심
SELECT dictGet('geospatial.sig_dict', 'name', (129.0756, 35.1796)) AS busan; -- 부산
입력 좌표 (lon, lat)
결과
기대
(127.289, 36.480)
세종시
세종(행정수도) ✓
(126.9780, 37.5665)
종로구
서울 중심 ✓
(129.0756, 35.1796)
연제구
부산 인근 ✓

세 점 모두 올바른 행정구역을 반환합니다. 좌표계 변환과 적재가 정상이라는 뜻입니다. 그리고 2절에서 본 좌표계 함정(wgs84_ok=1, utmk_bad=0)도 같은 데이터에서 재현됩니다 — 검증 쿼리에 함정을 박아 두면, 누군가 5179 데이터를 잘못 넣었을 때 결과로 바로 드러납니다.

6. 풀 파이프라인 — 포인트를 행정구역으로 집계하기

리버스 지오코딩이 되면 진짜 가치가 나옵니다. 바로 임의의 포인트 떼를 시군구로 분류해 집계하는 작업입니다.

먼저 한반도 bbox 안에 랜덤 포인트 20만 개를 찍어 딕셔너리로 분류해 봤습니다. 약 19.2만 개가 육지 시군구로 분류되었고(나머지는 바다), 균등 랜덤이라 면적이 큰 군 지역이 자연스럽게 상위에 올라옵니다.

여기서 한 발 더 나가 "관측소 대시보드"를 만들었습니다. 전국에 관측소 3,000개를 찍되 딕셔너리로 시군구에 분류되는(=육지) 점만 채택하고, 각 점에 합성 기온/고도와 분류된 시군구 코드를 부여했습니다. 그다음 시군구별로 집계합니다.

-- 시군구별 관측소 집계
CREATE TABLE geospatial.sig_station_agg ENGINE = MergeTree ORDER BY code AS
SELECT sigungu_code AS code,
       any(sigungu_name) AS name,
       count()           AS station_count,
       avg(temp_c)       AS avg_temp,
       avg(elevation_m)  AS avg_elev
FROM geospatial.weather_stations
GROUP BY sigungu_code;

실측 결과: 관측소 3,000개가 217개 시군구에 분포했고, 기온은 북부 강원 ~3°C에서 남부 ~9°C까지 -0.7 ~ 17.9°C 그라데이션을 보였습니다. 상위 시군구는 인제군 53 · 홍천군 49 · 안동시 48 · 의성군 44 · 평창군 41 순으로, 면적이 큰 군 지역이 자연스럽게 잡혔습니다.

⚠️ 한 가지 ClickHouse 특이점: mutation(ALTER … UPDATE)은 상관 서브쿼리(correlated subquery)를 지원하지 않습니다. 집계값을 sig_map.metric에 되먹이려다 UNKNOWN_IDENTIFIER로 막혔습니다. 해결은 집계 결과를 flat 딕셔너리로 만들고 dictGetOrDefault로 조회하는 우회입니다. 이런 식으로 "조인 대신 딕셔너리"가 ClickHouse에선 자주 정답이 된다.

마지막으로 sig_map(폴리곤 ring)에 이 집계를 LEFT JOIN해서 — 관측소 0개인 시군구도 빠지지 않게 — choropleth용 테이블을 만듭니다. 여기 lon/lat을 실제 사용자 행동 로그로만 바꾸면 그대로 "시군구별 사용자 분포 맵"이 됩니다.

7. Superset 시각화 — deck.gl Polygon

Superset에 ClickHouse를 연결합니다. 서비스명을 host로 쓰는 게 포인트입니다.

clickhousedb://default:@clickhouse:8123/geospatial

차트는 deck.gl Polygon으로 만듭니다:

  • Polygon Column: coordinates
  • line_type / Polygon Encoding: json (좌표가 [[lon,lat],…] 배열이므로)
  • Metric: MAX(metric) (색상 인코딩) + linear color scheme

deck.gl Polygon chart-data API가 325행(폴리곤 ring + 이름 + metric)을 정상 반환하면 끝입니다. 관측소 대시보드에서는 ① deck.gl Scatter로 관측소 포인트, ② deck.gl Polygon으로 시군구 choropleth, ③ 둘을 겹친 오버레이 지도, ④ TOP 시군구 테이블까지 네 개 차트를 한 대시보드에 묶었습니다.

눈 검증 한 줄: 시군구 경계가 한반도 위에 색칠되고 세종 폴리곤이 충청 중앙에 정확히 찍히면, 좌표계가 시각적으로도 맞다는 최종 증거가 됩니다.

참고: deck.gl 배경 지도 타일은 MAPBOX_API_KEY가 있어야 깔끔하게 나옵니다. 토큰이 없어도 폴리곤·포인트 자체는 렌더되지만 배경이 비어 보일 수 있습니다.

8. 마치며

PostGIS 없이, 클라우드 없이, 로컬 Docker 한 스택만으로 "좌표 → 시군구 분류 → 집계 → 지도 시각화"의 전 과정이 돌아갔습니다. 핵심 교훈을 추리면 다음과 같습니다:

  1. ClickHouse는 분류·집계에 강합니다. Polygon Dictionary는 한국 행정구역 리버스 지오코딩의 정석 도구입니다.
  2. 좌표계가 1번 함정입니다. ClickHouse는 재투영을 못 하니 4326으로 변환해 적재하고, 검증에 함정을 박아 "조용한 실패"를 드러내시기 바랍니다.
  3. 조인보다 딕셔너리입니다. mutation 제약을 만나면 flat 딕셔너리 + dictGetOrDefault가 자주 답이 됩니다.
  4. 표현을 도구에 맞춰 분리하세요. 딕셔너리용 MultiPolygon과 시각화용 ring을 같은 소스에서 동시에 만듭니다.

확장 방향도 명확합니다. 이번에는 시군구(251개)까지였지만, 읍면동(수천 개)은 vertex가 많아 mapshaper/topojson 단순화 단계를 더하면 됩니다. 진짜 H3 헥사곤 빈(Elastic hexbin 같은) 시각화가 hard requirement라면 Superset deck.gl Polygon 대신 kepler.gl이나 커스텀 deck.gl/MapLibre 프론트엔드로 가는 게 낫습니다 — ClickHouse는 geoToH3 집계 결과를 어느 쪽에도 똑같이 잘 먹입니다.

데이터 출처는 southkorea/southkorea-maps(WGS84 시군구 GeoJSON)이고, 전체 코드는 docker-compose.yml + DDL + 적재/셋업 스크립트로 재현 가능하게 묶었습니다. geospatial.points 또는 weather_stations를 여러분의 실데이터로 바꾸기만 하면, 그대로 운영용 행정구역 분석 대시보드가 됩니다.