전국 10만+ 공인중개사 검색 기능 구축기 (Elasticsearch + 좌표 검색)
부동산 중개 서비스에서 전국 10만 공인중개사사무소 검색 기능을 구축한 과정. V-World 데이터 적재부터 Elasticsearch 좌표 기반 검색 구현까지 실제 순서대로 정리한다.

부동산 중개 서비스에서 전국 공인중개사사무소를 검색할 수 있는 기능을 구축한 과정을 정리합니다. 공공데이터 수집부터 Elasticsearch 인덱싱, 검색 API 구현, 서버 배포까지 전체 흐름을 기록했습니다. 이번 작업의 목표는 단순 텍스트 검색이 아니라, 위치 기반으로 주변 중개사를 찾고, 아파트나 지역명을 검색하면 해당 위치 근처 중개사를 노출하는 기능을 만드는 것이었습니다.
왜 검색 엔진이 필요했는가
기존 서비스는 중개사가 링크를 생성해 고객을 유입하는 구조였습니다. 하지만 고객이 직접 중개사를 탐색할 수 있도록 구조를 바꾸려면, 사용자가 접속했을 때 GPS 또는 선택한 지역 기준으로 주변 중개사를 보여줘야 했습니다. 처음에는 단순 SQL 검색으로 해결하려 했지만 한계가 명확했습니다. 예를 들어 “센텀 부동산”을 검색했을 때 실제 데이터에는 “센텀공인중개사사무소” 같은 이름이 저장되어 있으면 LIKE 검색으로는 매칭되지 않습니다.
-- "센텀 부동산"으로 검색하면?
SELECT * FROM realtor WHERE name LIKE '%센텀 부동산%';
-- 결과: 0건 (정확히 "센텀 부동산"이라는 문자열이 없으므로)
필요했던 기능은 다음 세 가지였습니다.
- 한국어 형태소 분석 기반 전문검색 (“센텀 부동산” → ”센텀” + ”부동산” 각각 매칭)
- 좌표 기반 반경 검색 (“내 위치 500m 이내 중개사”)
- 장소 검색 후 주변 중개사 조회
이 요구사항을 만족하려면 전문 검색 엔진이 필요하다고 판단했습니다.
Elasticsearch vs PostgreSQL 고민
기술 선택 단계에서 PostgreSQL 확장과 Elasticsearch를 비교했습니다. PostgreSQL의 pg_bigm + PostGIS 조합도 충분히 가능했지만, 한국어 검색 품질과 향후 확장성을 고려해 Elasticsearch를 선택했습니다.
| Elasticsearch + Nori | PostgreSQL + pg_bigm + PostGIS | |
| 한국어 검색 | 형태소 단위 (의미 분석) | 바이그램 (2글자 쪼갬, 노이즈 가능) |
| Geo 검색 | geo_point 내장 | PostGIS (더 강력하지만 별도 확장) |
| 인프라 | 별도 ES 서버 필요 | DB 하나로 해결 |
| 확장성 | 수평 확장 (클러스터) | 단일 서버 한계 |
특히 한국어 검색에서 차이가 컸습니다. ”공인중개사사무소”를 토큰화하면 형태소 분석기(Nori)는 공인 / 중개사 / 사무소, 바이그램(pg_bigm)은 공인 / 인중 / 중개 / 개사 / 사사 / 사무 / 무소 처럼 의미 없는 조합이 포함됩니다. 현재 데이터 규모에서는 큰 차이가 없지만, 향후 매물 설명 같은 자유 텍스트 검색이 추가되면 검색 품질 차이가 커질 것으로 판단했습니다.
"공인중개사사무소" 토큰화:
Nori: ["공인", "중개사", "사무소"] ← 의미 단위
pg_bigm: ["공인", "인중", "중개", "개사", "사사", "사무", "무소"] ← 무의미한 조합 포함
"래미안" 검색 시:
Nori: "래미안" 정확 매칭
pg_bigm: "래미" + "미안" → 이론적으로 "미안해요" 같은 텍스트도 히트 가능
중개사 데이터는 상호명+주소가 전부라 pg_bigm 노이즈가 실질적으로 체감되지 않지만, **매물 설명 텍스트**에서는 차이가 날 수 있어 ES를 선택했습니다.
데이터 수집: 전국 중개사 좌표 확보
전국 중개사 데이터를 확보하기 위해 여러 공공 데이터를 조사했습니다. 통합 CSV 데이터는 5만 건 제한으로 잘렸고 지자체별 데이터는 수집 비용이 컸으며 일부 데이터는 좌표가 없었습니다. 최종적으로 전국 데이터와 좌표가 모두 포함된 데이터를 선택했습니다. CSV와 SHP 파일이 함께 제공되어 좌표를 정확하게 확보할 수 있었습니다.
좌표계 변환
SHP 파일의 좌표계는 EPSG:5186 (Korea 2000 Central Belt 2010)입니다. 미터 단위라 구글맵에서 쓰는 WGS84(위도/경도)와 다릅니다.
from pyproj import Transformer
transformer = Transformer.from_crs('EPSG:5186', 'EPSG:4326', always_xy=True)
lon, lat = transformer.transform(x, y)
처음에 EPSG:5174(구 한국 측지계)로 잘못 적용하여 전국 좌표가 ~100km 틀어졌습니다. 서울 강서구가 위도 38.4(북한 개성 부근)로 찍혔습니다. PRJ 파일을 확인한 후 5186으로 수정하고 전체 재변환했습니다. 공간 데이터는 반드시 PRJ 메타데이터를 확인하고, 변환 후 실제 지도에서 검증해야 합니다.
정제 결과
원본 CSV: 108,826건
↓ 영업중만 필터: 108,172건
↓ SHP 좌표 매칭 (등록번호 기준 JOIN): 101,353건
↓ WGS84 좌표 변환 완료
최종: 전국 17개 시도, 101,353건
Elasticsearch 설정
한국어 검색을 위해 Nori 형태소 분석기를 추가했습니다.
FROM docker.elastic.co/elasticsearch/elasticsearch-wolfi:9.3.0
RUN bin/elasticsearch-plugin install --batch analysis-nori
{
"settings": {
"analysis": {
"tokenizer": {
"nori_mixed": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
}
},
"analyzer": {
"nori_analyzer": {
"type": "custom",
"tokenizer": "nori_mixed",
"filter": ["nori_readingform", "lowercase"]
}
}
}
}
}
복합어 처리를 위해 decompound_mode를 mixed로 설정했습니다. 이렇게 설정하면 “공인중개사사무소”가
공인중개사사무소 / 공인 / 중개사 / 사무소
모두 인덱싱됩니다.
좌표 검색을 위해 geo_point 필드도 함께 구성했습니다.
상호명 / 주소 / 좌표
세 필드를 중심으로 검색 인덱스를 구성했습니다.
geo_point로 좌표 인덱싱
{
"mappings": {
"properties": {
"사업자상호": { "type": "text", "analyzer": "nori_analyzer" },
"도로명주소": { "type": "text", "analyzer": "nori_analyzer" },
"location": { "type": "geo_point" }
}
}
}
검색 API 구현
기존 백엔드(FastAPI)에 통합
아래와 같은 이유로 별도 서비스로 분리하지 않고, 기존 FastAPI 백엔드에 ES 클라이언트를 추가했습니다.
- 검색 결과에서 가입 여부를 확인하려면 MySQL User 테이블 조회가 필요
- 서비스 하나 더 = 배포/모니터링 포인트 증가
- dependency-injector로 DI가 잘 구성되어 있어서 ES 클라이언트를 자연스럽게 추가 가능
구현된 API 4개
텍스트 검색: GET /search/realtor?q=강서구&sort=distance&cursor=xxx&limit=20
{
"multi_match": {
"query": "강서구",
"fields": ["사업자상호^3", "도로명주소^2", "지번주소", "법정동명"]
}
}
상호명 매칭에 3배 가중치를 줘서, ”강서구공인중개사”가 주소에만 ”강서구”가 있는 것보다 상위에 노출됩니다.
GPS 반경 검색: GET /search/realtor/nearby?lat=37.5372&lon=126.8394&distance=500m
{
"geo_distance": {
"distance": "500m",
"location": { "lat": 37.5372, "lon": 126.8394 }
}
}
장소 기반 검색: GET /search/realtor/by-place?query=래미안 아파트 강서구
내부 동작
- 장소 검색 API로 “래미안 아파트 강서구” → 좌표 획득
2. 획득한 좌표로 nearby 검색 실행
기존 프로젝트에서 사용 중이던 지도 API 키를 그대로 활용했습니다.
상세 조회: GET /search/realtor/{registration_number}
가입 중개사 데이터 보강
검색 결과에서 is\_member 플래그를 확인하고, 가입 중개사는 User 테이블의 풍부한 정보로 덮어씌웁니다
is_member=false → 공공 데이터 (상호명, 주소, 좌표)
is_member=true → User 테이블 데이터 (프로필 이미지, 소개글, 전문 분야, 별점)
좌표는 ES 공공 데이터 유지
매칭 키는 ES 등록번호 ↔ User realtor\_number.
커서 기반 페이지네이션
기존 프로젝트가 커서 기반 페이지네이션을 사용하고 있어서, ES의 search\_after를 활용했습니다:
## 커서 = 마지막 히트의 sort 값을 base64 인코딩
cursor = base64.urlsafe_b64encode(json.dumps(last_hit["sort"]).encode()).decode()
## 다음 페이지 요청 시
body["search_after"] = json.loads(base64.urlsafe_b64decode(cursor))
offset 기반(page=1,2,3)보다 안정적이고, 깊은 페이지에서도 성능이 일정합니다.
별점순 정렬
별점 데이터는 MySQL Review 테이블에만 있어서, ES에서 결과를 가져온 후 Python에서 재정렬합니다:
sorted(items, key=lambda x: (
x.avg_rating is not None, # 별점 있는 중개사 우선
x.avg_rating or 0, # 높은 별점 순
x.review_count # 같은 별점이면 리뷰 수 순
), reverse=True)
비가입 중개사(10만건)는 별점이 없으므로 자연스럽게 뒤로 밀립니다.
AWS 서버 배포
인프라 구조
EC2 (Container Environment)
├── application stack
│ ├── backend (FastAPI)
│ ├── frontend (Flutter Web)
│ ├── bff (Express)
│ ├── admin
│ ├── worker
│ └── …
├── infra
│ ├── redis
│ ├── object-storage
│ └── elasticsearch ← 새로 추가
배포 과정에서 겪은 문제들
1. SSM 터미널에서 heredoc 안 먹힘
AWS SSM Session Manager 터미널에서 여러 줄 입력(heredoc, 긴 echo)이 제대로 동작하지 않았습니다.
줄바꿈이 깨지거나 JSON이 잘렸습니다.
해결: python3 한 줄 명령으로 JSON 파일을 생성하거나, S3를 경유하여 파일을 전송했습니다.
## heredoc 대신
python3 -c "import json; d={...}; json.dump(d, open('index.json','w'), ensure_ascii=False); print('OK')"
2. Docker Swarm에서 build 지시어 무시
Swarm 환경에서는 docker-compose.yaml의 build 설정이 무시됩니다.
이미지를 먼저 빌드하고 image: 태그를 명시해야 했습니다.
docker compose build elasticsearch
docker stack deploy -c docker-compose.yaml production
3. 기존 서비스와 포트 충돌
기존 object storage 서비스가 9000 포트를 점유하고 있어서 docker stack deploy가 실패했습니다.
compose에서 ES만 분리하여 직접 서비스로 생성했습니다
docker service create \
--name elasticsearch \
--hostname elasticsearch \
--network internal_net \
--env "discovery.type=single-node" \
--env "xpack.security.enabled=false" \
--env "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
--mount type=volume,source=esdata,target=/usr/share/elasticsearch/data \
elasticsearch:latest
4. ES 컨테이너에 python3 없음
ES 컨테이너에서 벌크 로드 스크립트를 실행하려 했으나 python3이 없었습니다.
대신 백엔드 컨테이너에서 ES 내부 네트워크(http://elasticsearch:9200)로 접근하여 실행했습니다.
docker cp load.py {BACKEND_CONTAINER_ID}:/tmp/load.py
docker exec {BACKEND_CONTAINER_ID} python3 /tmp/load.py
5. CSV 파일 서버 전송 (scp 불가)
SSM 환경에서는 scp가 안 됩니다. S3를 경유하여 파일을 전송했습니다:
로컬 → S3 (콘솔에서 업로드) → EC2 (aws s3 cp)
CI/CD 연동
CI/CD 파이프라인이 자동으로 빌드 + 배포합니다
develop merge
→ backend-build (Docker 이미지 빌드 → registry push)
→ backend-deploy (서버에서 docker pull → stack deploy)
→ 알림 전송
Elasticsearch 인프라와 데이터는 수동으로 사전 세팅했고, 백엔드 코드 배포는 CI/CD가 처리합니다.
결과
검색 성능
“강서구” 텍스트 검색 약 1,700건, 10ms대 반경 500m 검색 수십 건, 거리순 정렬 아파트 장소 검색 해당 위치 주변 중개사 조회 등록번호 상세 조회 단건 즉시 반환
최종 아키텍처
Flutter 앱
↓
BFF (Express)
↓
Backend (FastAPI)
├── MySQL (사용자, 예약, 매물)
├── Redis (캐싱, 세션)
├── Elasticsearch (중개사 검색, 10만건)
└── 지도 검색 API (장소 검색)
돌아보며
잘한 것
- V-World 데이터 선택: 전국 데이터 + 좌표 100% 포함으로 별도 Geocoding API 없이 좌표 확보
- 기존 백엔드에 통합: 별도 서비스 대신 기존 FastAPI에 합쳐서 인프라 복잡도 최소화
- 기존 지도 검색 API 재활용: 이미 연동된 API를 활용해 별도 관리 포인트 없이 구현. 관리 포인트 0 추가
실수하고 배운 것
- 좌표계 확인 필수: EPSG:5174와 5186을 혼동하여 전국 좌표가 틀어짐. PRJ 파일을 반드시 확인
- SSM 터미널의 한계: heredoc, 긴 줄 입력이 깨짐. python3 한 줄 명령이나 S3 경유로 우회
ES가 정말 필요했나?
솔직히 지금 규모(10만건, 가입 중개사 7명)에서는 PostgreSQL로 충분했을 수 있습니다. 하지만 매물 검색 확장을 고려하면 ES가 더 나은 투자였고, 무엇보다 이 작업을 통해 ”어떤 상황에서 ES가 적절하고 어떤 상황에서 과한지”를 직접 체감할 수 있었습니다.