같은 위치 데이터, 다른 질문: 문제의 모양이 스택을 정한다

같은 위치 데이터, 다른 질문: 문제의 모양이 스택을 정한다

Tech

안녕하세요, 숨고 Backend Engineer Gabi입니다.

숨고에서 고수를 찾을 때, 고객은 지도를 드래그합니다. 강남 한복판이면 한 화면에 고수가 수천 명입니다. 다 찍으면 지도는 핀으로 뒤덮여 아무것도 보이지 않게 됩니다.

반대편엔 다른 문제가 있었습니다. 강남구에 사는 어떤 고수는, 지하철 한 정거장 옆 서초구의 일감을 받지 못하고 있었습니다. 자기 활동 지역으로 등록을 안 했을 뿐인데요.

들어가며

둘 다 "위치"에서 출발한 문제지만 풀이과정은 정반대였습니다. 하나는 고객이 고수를 찾는 수요 쪽이고, 다른 하나는 고수가 일감의 범위를 넓히는 공급 쪽이기 때문이었습니다. 그리고 흥미롭게도, 두 문제는 같은 위치 데이터에서 출발했는데도 정반대의 스택으로 갈라졌습니다.

이 글은 그 갈림길에 대한 이야기입니다. '이 화면에 누가 있나'와 '옆 동네는 어디인가'라는 두 질문의 모양이 점이냐 면이냐, 실시간이냐 사전 계산이냐를 가르고, 그 모양이 다시 저장소와 라이브러리 선택까지 정해 버립니다. 그리고 그 모양을 제대로 읽고 나면, 대개 이미 가지고 있는 스택으로 충분합니다. 면 데이터를 다루는 쪽에서는 PostGIS 같은 GIS 전용 인프라를 기술 검토 단계에서 후보로 올렸지만, 결국 원래 쓰던 Elasticsearch와 MySQL로 두 기능을 모두 해결하였습니다. 지금부터 그 과정을 소개해 보도록 합니다.

  • 본문의 코드 예시는 핵심 의도를 강조하기 위해 일부 단순화되어 있습니다.

공통 출발점: 우리가 가진 건 텍스트 주소뿐이었다

두 기능 모두 출발점은 같았습니다. 고수의 프로필에 적혀 있는 주소는 '서울특별시 강남구 …' 같은 텍스트 한 줄. 지도에 찍을 좌표도, 옆 동네를 따질 행정구역의 면(Polygon) 데이터도 우리 DB엔 없었습니다.

그래서 둘 다 공공 데이터로 부족분을 보강하는 데서 출발했습니다. 주소기반산업지원서비스가 다행히 필요한 형태의 데이터를 공개하고 있었습니다.

기능필요한 데이터형식갱신 주기
지도찾기위치정보요약DB + 도로명주소 한글CSV의 개별 점 좌표월변동자료 (매월 10일경)
인접 지역 추천구역의 도형shapefile의 폴리곤전체자료 (연 1회 · 개편 시 변동됨)

같은 출발점이었지만, 다운로드받은 파일을 열어보니 한국 GIS의 관문이 기다리고 있었습니다.

한국 공공 GIS 데이터를 처음 만났을 때의 작은 함정들

  • 좌표계가 UTMK(EPSG:5179)입니다. 웹 지도에서 쓰는 WGS84(EPSG:4326)와 달라서 변환이 필요합니다
  • shapefile의 한글 인코딩이 euc-kr입니다. UTF-8로 열면 깨집니다
  • shapefile의 .prj 파일이 누락되거나 부정확한 경우가 있어, 좌표계를 코드에서 명시적으로 지정해야 합니다
  • EPSG:5179는 축 순서가 (Northing, Easting)입니다. pyproj 2.x+는 EPSG 정의대로 받기 때문에, 데이터의 (x, y) 표기를 그대로 넣으면 안 되고 한 번 swap해야 합니다

두 기능 모두 이 변환을 거쳐야 했지만, 입력 데이터의 형태가 달라 도구 선택이 갈렸습니다.

# 점(Point) 좌표: CSV의 개별 행 # 가벼운 pyproj로 충분. shapefile 파싱 같은 추가 기능이 필요 없음. from pyproj import Transformer transformer = Transformer.from_crs('EPSG:5179', 'EPSG:4326') latitude, longitude = transformer.transform(utmk_y, utmk_x) # 축 순서 swap # 면(Polygon) 좌표: shapefile의 지오메트리 # geopandas가 파싱과 좌표 변환을 한 줄로 처리. import geopandas as gpd gdf = gpd.read_file(shp_path, encoding='euc-kr') gdf = gdf.set_crs(epsg=5179).to_crs(epsg=4326)

입력이 점이냐 면이냐, 단 하나의 기준이 라이브러리 선택을 갈랐습니다. 같은 EPSG 변환인데도 말이죠.

image

원본 한 줄이 저장소에 담기기까지

말로만 '변환'이라고 하면 감이 잘 안 오니, 위치정보요약DB 한 줄이 우리 저장소에 어떻게 자리 잡는지 따라가 보겠습니다. (아래 값은 공개된 예시 주소이며, 실제 사용자 데이터가 아닙니다.)

위치정보요약DB는 본인인증 후 제공되는 데이터이므로, 실제 행 대신 행정안전부 · KLID가 공개한 「도로명주소DB 레이아웃(위치정보요약DB)」의 필드 구조로 설명합니다. 좌표는 이 데이터에만 있고(도로명주소 한글 공개분엔 없음), 우리는 두 데이터를 조합합니다.

전체분(entrc_sido.txt) 중 우리가 쓰는 필드 (| 구분 · cp949). 이동사유코드는 변동분(entrc_mod.txt)에만 있습니다.

순번컬럼비고
3법정동코드
4 / 5시도명 / 시군구명
7 / 8도로명코드 / 도로명
9지하여부0지상 · 1지하 · 2공중 · 3수상
10 / 11건물본번 / 건물부번
17 / 18X좌표 / Y좌표중추원점, GRS80 UTM-K (EPSG:5179)
변동분 19이동사유코드31 생성 · 34 변동 · 63 삭제
{ "id": "<도로명주소관리번호>", "street_address": { "area1": "서울특별시", "area2": "중구", "street": "세종대로", "house_number": "110", "full_address": "서울특별시 중구 세종대로 110" }, "coordinates": { "lat": 37.566295, "lon": 126.977945 } }

이 원본 좌표(UTM-K) X 953896.18 · Y 1952009.36를 WGS84로 변환하면 위도 37.566295 · 경도 126.977945, 곧 위 문서의 coordinates 값이 됩니다.

수요편 (고객): 지도찾기 (이 화면에 누가 있나)

지도찾기는 사용자(고객)가 지도를 드래그할 때마다 화면에 보이는 영역 안의 고수를 실시간으로 보여줘야 합니다. 좌표(점)를 실시간으로 조회하는 문제입니다.

image

우리는 이미 고수 검색 인프라로 Elasticsearch를 쓰고 있었고, ES는 geo_point 타입과 다양한 공간 쿼리를 기본 지원합니다. 새 저장소를 들이는 것보다, 기존 검색 인덱스에 좌표 필드를 하나 추가하는 편이 훨씬 가벼웠습니다.

뷰포트 안의 고수만 추리기 (geo_bounding_box)

지도 화면의 영역은 좌상단과 우하단 좌표로 정의됩니다. geo_bounding_box로 이 사각형 안의 고수만 필터링합니다.

'geo_bounding_box': { 'coordinates': { 'top_left': { 'lat': top_left_latitude, 'lon': top_left_longitude }, 'bottom_right': { 'lat': bottom_right_latitude, 'lon': bottom_right_longitude } } }

마커가 겹친다: geohash_grid로 격자 클러스터링

서울 강남에서 모든 고수를 찍으면 어떻게 될까요? 마커가 서로 겹쳐서 누가 누군지 알 수 없게 됩니다. 단순히 응답 개수를 제한하는 건 "어떤 고수를 대표로 보여줄지"라는 새 문제를 만들 뿐입니다.

이 문제를 ES의 Geohash grid aggregation으로 풀었습니다. 지도를 격자로 잘게 나누고, 각 셀 안에서 대표 1명만 뽑는 방식입니다. precision 값(1~12)이 높을수록 셀이 작아집니다.

'geo_buckets': { 'geohash_grid': { 'field': 'coordinates', 'precision': precision_by_scale[scale], # 줌 레벨에 따라 정밀도 조절 }, 'aggs': { 'top_filters_hits': { 'top_hits': { 'size': 1, # 셀당 대표 1명 'sort': [ {'review_count': {'order': 'desc'}}, {'last_session_day': {'order': 'desc'}}, ], }, }, }, }

축척별 precision 튜닝

적절한 precision은 책에서 찾을 수 없었습니다. 축척마다 직접 마커 분포를 보면서 정한 값입니다. 기존엔 최대 축척이 500m로 제한되어 서울 전체를 보려면 지도를 60번 이상 드래그해야 했는데, 이번에 50km까지 확대하면서 각 축척별로 적정값을 다시 설정하였습니다.

축척precision셀 크기 (약)비고
~300m12~3.7cm × 1.9cm사실상 클러스터링 없이 개별 노출
500m7~153m × 153m
1km ~ 5km6~1.2km × 0.6km
10km ~ 20km5~4.9km × 4.9km
30km ~ 50km4~39km × 20km

한 화면엔 최대 70명, 동일 셀에서는 리뷰 수 1위만 노출됩니다. 같은 셀의 다른 고수들은 줌인해 셀이 쪼개지면 차차 드러납니다. 새로 들어온 고수, 리뷰가 적은 고수도 사용자가 더 좁혀 들어가면 발견될 수 있는 구조입니다.

이미지에 대한 설명을 써주세요.

좌표는 어떻게 늘 최신화를 유지하나

고수 프로필의 주소가 바뀌면 ES 인덱스가 즉시 따라가야 합니다. Kafka change stream으로 주소 변경 이벤트를 받아, 좌표 인덱스에서 새 좌표를 조회해 ES를 업데이트합니다. 매칭되는 좌표가 없으면 기존 위경도 값을 폴백으로 설정합니다.

공공 데이터의 월변동자료는 별도 배치 잡으로 처리합니다. 각 레코드에는 신규 / 변경 / 삭제를 뜻하는 이동사유코드가 붙어 있는데, 좌표 색인에 필요한 건 신규와 변경이라 이 둘만 다음과 같이 분기합니다.

  • 신규: 주소와 좌표를 함께 색인
  • 변경: ES에 같은 키가 없을 때만 색인 (중복 방지)

핵심은 좌표 변환과 색인이 사용자 요청 경로에 들어가지 않는다는 것입니다. API는 미리 색인된 데이터에 geo_bounding_box만 던질 뿐입니다.

이미지에 대한 설명을 써주세요.
.

공급편 (고수): 인접 지역 추천 (옆 동네는 어디인가)

인접 지역 추천은 지도찾기와 결정적으로 한 가지가 달랐습니다. 실시간성이 필요 없다는 점입니다. "강남구 옆에는 어떤 구가 있는가?"는 매 순간 달라지는 질문이 아닙니다. 이런 경우는 한 번만 계산해 두면 됩니다.

이 조건을 받아들이고 나니, 선택이 달라졌습니다.

이미지에 대한 설명을 써주세요.

면 데이터를 앞에 두자 첫 질문이 자연스럽게 나왔습니다: "이거 PostGIS가 필요한 거 아닌가?" 폴리곤 교차 연산이라고 하면 가장 먼저 떠오르는 게 PostGIS니까요. 검토는 했습니다. 하지만 다음 세 가지가 결정적이었습니다.

  • 실시간 공간 검색이 필요 없다 : 결과를 사전 계산해 (기준 코드, 인접 코드) 쌍으로 저장하면 끝
  • 대상이 작다 : 수도권 시군구 79개, 시도까지 합해 경계 데이터가 82건입니다. 이 규모에서는 경계 컬럼에 SPATIAL 인덱스를 걸어 성능을 확보할 수 있었습니다. 다만 이 인덱스를 만들려면 컬럼이 NOT NULL이어야 해서 스키마를 그렇게 고정했습니다
  • 이미 MySQL을 쓰고 있다 : MySQL의 Spatial 기능이 이 정도 연산에 충분합니다

데이터량이 작고 실시간 공간 검색이 필요 없다는 두 가지가 확인된 시점에서, 전용 GIS 인프라를 새로 들일 이유가 사라졌습니다.

그래서 새 DB를 들이지 않고, 기존 MySQL Geometry 컬럼에 면 데이터를 저장하는 길을 선택했습니다.

shapefile에서 DB까지

다운로드한 shapefile에는 시도·시군구·행정동·법정동 4단계 면 데이터가 담겨 있는데, 인접 지역 추천에서는 그중 시군구 단위만 사용합니다. 현재는 수도권(서울/경기/인천)으로 적재 범위를 한정해 두었습니다.

geopandas로 파싱한 면 데이터를 GeoAlchemy2를 통해 MySQL의 Geometry 컬럼에 MultiPolygon 형태로 저장합니다. 수도권 경계 데이터는 MultiPolygon 조각 1,234개, 외곽선 하나당 평균 2,125개 점으로 들어옵니다.

실제로 shapefile 속성 한 줄(시군구, 지오메트리는 EPSG:5179 폴리곤)은 이렇게 들어옵니다.

SIG_CD = 11680 | SIG_KOR_NM = 강남구 | geometry = POLYGON((...))

좌표 변환을 거쳐 경계 테이블에 다음처럼 저장됩니다. (아래 값은 공개된 행정구역 코드이며 예시입니다.)

행정구역 코드 경계(MULTIPOLYGON, srid=4326) 11680 MULTIPOLYGON(((127.047 37.463, ...)))

SRID 4326의 축 순서 함정

shapely는 도형 좌표를 (경도, 위도) 순서로 다룹니다. 반면 MySQL의 SRID 4326은 EPSG 정의대로 (위도, 경도) 순서를 받습니다. shapely로 만든 WKT(도형을 텍스트로 표현한 표준 형식)를 그대로 MySQL에 넣으면 좌표가 뒤집힌 채 저장됩니다. 그래서 저장 직전에 좌표 순서를 뒤집어 넣습니다.

인접의 정의: ST_Intersects vs ST_Touches

의미상 "경계가 맞닿은 지역"을 가장 정확히 잡는 함수는 ST_Touches입니다. 두 폴리곤이 내부 overlap 없이 경계만 공유하는 경우만 골라내니까요.

처음엔 ST_Touches로 갔습니다. 그런데 실제 데이터로 돌려보니 일부 인접 구가 결과에서 빠졌습니다. 공공 데이터의 폴리곤이 미세하게 overlap되는 정밀도 차이 때문이었습니다. 더 넓게 잡는 ST_Intersects로 바꿔서 누락을 막았고, 어차피 자기 자신은 필터로 제외하기 때문에 부작용도 없었습니다.

from shapely.wkt import dumps as wkt_dumps def get_nearby_references(session, base_location_code, base_shape): base_wkt = wkt_dumps(base_shape) return ( session.query(AddressReference) .join(Location, ...) .filter( func.ST_Intersects( Location.boundary, func.ST_GeomFromText(base_wkt, 4326) ), Location.location_code != base_location_code, Location.is_active == 1, # 개편으로 폐지된 지역 제외 ) .all() )

왜 사전 계산인가

이 공간 연산은 배치에서 한 번만 계산합니다. 결과는 (기준 행정구역 코드, 인접 행정구역 코드) 쌍으로 별도 테이블에 저장합니다. API 호출 시점엔 이 테이블을 단순 조회할 뿐이라, 대상이 수도권에서 전국으로 늘어도 API 응답 시간엔 영향이 없습니다.

기준 지역 한 곳의 공간 연산은 약 320ms인데, 사전 계산 테이블 조회는 약 10ms입니다. 31배 차이입니다. 전체 인접 관계 재계산도 배치에서 약 23초 한 번이면 끝납니다.

재계산은 기준 지역별로 기존 인접 쌍을 논리삭제한 뒤 다시 넣는 방식입니다. 같은 배치를 여러 번 돌려도 결과가 중복되지 않고, 이전 상태도 이력으로 남습니다.

인접 쌍 테이블에는 (기준 코드, 인접 코드)가 이렇게 쌓입니다. 현재 450쌍이 저장돼 있고, 기준 지역 한 곳당 평균 5.6개입니다.

기준 코드 인접 코드 11680 (강남구) 11650 (서초구) 11680 (강남구) 11710 (송파구) ...

덕분에 행정구역이 바뀌어도 대응은 배치 안에서 끝납니다. 실제로 최근 행정구역 개편이 있었을 때도, 전체분 대신 변동분만 받아 바뀐 시군구 경계를 갱신하고 폐지된 지역을 비활성화한 뒤 인접 관계를 다시 계산하면 됐습니다. 이미 계산된 테이블을 조회하는 API는 그동안 아무 영향도 받지 않았습니다.

이미지에 대한 설명을 써주세요.

추천 노출과 중복 방지

인접 후보를 사전에 계산해 두면, 이후 어떤 지역을 어떤 순서로 추천할지는 별도 정책 레이어에서 유연하게 다룰 수 있습니다.

같은 추천이 반복되면 피로감을 줍니다. 그래서 고수가 추천받은 지역을 활동 지역으로 추가하면 Redis에 30일 TTL로 기록해 같은 추천이 다시 뜨지 않게 하고, 전국을 활동 지역으로 등록한 고수에게는 추천 자체를 생략합니다.

이미지에 대한 설명을 써주세요.

완벽하진 않았다: 남은 두 가지 한계

모든 길이 완벽했던 건 아닙니다. 짚고 가고 싶은 두 가지가 있습니다.

좌표는 늘 있지만, 항상 지도찾기에 쓸 만큼 정밀하진 않습니다. 주소는 가입·프로필 수정 시점에 좌표와 함께 저장되므로 좌표가 비는 일은 없습니다.

다만 그 좌표의 정밀도가 낮으면 지도 위 표시 위치의 정확도가 떨어질 수 있습니다. 더 정밀한 좌표는 주소를 공공 데이터 기반 인덱스와 매칭해 보강하는데, 이 인덱스가 월 1회 배치로 갱신되다 보니 아주 최근에 생긴 주소는 정밀 보강이 한 박자 늦을 수 있습니다. 위치 자체가 사라지지는 않고, 정밀도만 조금 아쉬운 상태로 남게 됩니다.

상용 지오코딩 API도 검토했지만 보류했습니다. 카카오 로컬·VWorld 같은 OpenAPI 기반 지오코딩이 후보였습니다. 신선도 측면에서는 매력적입니다. 하지만 무료 한도가 숨고의 신규·변경 주소 이벤트 규모를 충분히 커버하지 못하고, 유료 전환 시 비용 부담이 적지 않아 현재는 공공 데이터 기반 구조를 유지하기로 했습니다. 이 결정은 트래픽이 더 커지면 다시 살펴 봐야 합니다.

마치며: 갈라진 길, 그리고 공유한 하나

두 기능의 선택을 나란히 놓으면, 질문의 모양이 어떻게 스택 전체를 갈랐는지 뚜렷이 보입니다. 점이냐 면이냐, 실시간이냐 사전 계산이냐. 이 두 축이 저장소부터 라이브러리, 갱신 주기까지 연쇄적으로 결정했습니다.

지도찾기 (수요)인접 지역 추천 (공급)
질문이 화면에 누가 있나옆 동네는 어디인가
실시간성필요불필요
데이터 형태점 (CSV)면 (Shapefile)
좌표 변환pyprojgeopandas
저장소ElasticsearchMySQL Geometry
공간 연산geo_bounding_box +
geohash_grid
ST_Intersects (사전 계산)
갱신월 1회 배치 + 실시간 Kafka 동기화연 1회 배치 + 개편 시 변동분

모든 게 갈라졌지만, 두 기능이 공유한 패턴은 딱 하나였습니다. 그리고 그 하나가, 새 인프라를 들이지 않아도 됐던 진짜 이유였습니다.

"무거운 공간 연산은 런타임에서 하지 않는다. 그러면 문제의 모양에 맞는 도구를 이미 가진 스택 안에서 고를 수 있다."

  • 지도찾기: 좌표 변환과 ES 색인은 배치에서. API는 geo_bounding_box 조회만.
  • 인접 지역 추천: ST_Intersects는 배치에서 1회. API는 인접 쌍 테이블 조회만.

PostGIS를 들이고, 전용 지오 DB를 도입하고, 상용 지오코딩 API를 붙이는 등 더 묵직한 선택지들이 있었습니다. 그 길도 분명 옳을 수 있습니다. 다만 우리가 풀려는 문제 규모와 실시간성 요구를 한 번 더 들여다보면, 이미 가지고 있는 스택의 천장이 생각보다 높다는 걸 자주 발견하게 됩니다.

앞으로

지도찾기와 인접 지역 추천은 위치 데이터를 매칭에 쓰기 시작한 첫 두 걸음입니다.

이 구조 위에서 다듬어 갈 여지는 여전히 많습니다. 갱신 시점·매칭률·인접 관계 변화량 같은 신선도 지표를 정기적으로 들여다보고, 재계산 결과를 직전 버전과 diff해 이상 변동을 사전에 알아챌 수 있는 운영 도구를 갖춰 가려 합니다. 사전 계산 방식이라 인접 지역 추천을 수도권 밖으로 넓혀도 API 성능에는 영향이 없으니, 대상 범위도 자연스럽게 확장해 갈 계획입니다.

  • #backend
  • #elasticsearch
  • #mysql
  • #python
  • #soomgo
Gabi Oh
Gabi OhBackend Engineer

모두의 더 나은 삶을 위해
함께 변화를 만들어갈 동료를 기다립니다

채용중인 공고 보기
같은 위치 데이터, 다른 질문: 문제의 모양이 스택을 정한다 | 숨고 팀 블로그