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

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

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를 여러분의 실데이터로 바꾸기만 하면, 그대로 운영용 행정구역 분석 대시보드가 됩니다.