Skip to main content
JavaScript로 클러스터링이 있는 부동산 지도 만들기
Tutorials

JavaScript로 클러스터링이 있는 부동산 지도 만들기

MapAtlas Maps API를 사용하여 가격 라벨 마커, 클러스터링, 주소 검색, 가격 범위 필터를 갖춘 매물 목록 지도를 순수 JavaScript 또는 React로 구축하세요.

Brent van der Heiden15 min read
#real estate map#property map#map clustering#javascript#proptech#maps api

부동산 검색은 공간의 문제입니다. 매수자와 임차인은 우편번호나 도로명 목록이 아니라 동네, 통근 시간, 학교와의 거리를 기준으로 생각합니다. 지도 중심 인터페이스는 사람들이 집을 찾는 방식에 잘 맞습니다. 사용자가 가격, 방 개수 같은 속성으로 필터링하기 전에 먼저 지도 위에서 검색 범위를 정할 수 있기 때문입니다.

이 튜토리얼에서는 프로덕션에 바로 쓸 수 있는 매물 지도를 만듭니다. 낮은 줌 레벨에서 묶이는 클러스터 마커, 개별 마커의 가격 라벨, 클릭하면 매물 정보를 보여 주는 팝업, 지도 중심을 옮기는 우편번호 검색, 표시되는 매물을 실시간으로 걸러 내는 가격 범위 슬라이더가 포함됩니다. React 컴포넌트 자체는 약 65줄이며, 레이어와 클릭 핸들러까지 포함한 전체 구현은 약 150줄입니다. 프레임워크를 쓰지 않으려면 같은 로직을 바닐라 JavaScript로도 구현할 수 있습니다.

EU PropTech 플랫폼에 적용되는 GDPR 의무도 함께 정리했습니다. 매물 검색에서 생성되는 위치 데이터는 개인 데이터이므로 그에 맞게 처리해야 합니다.

지도 API가 처음이라면 웹사이트에 인터랙티브 지도를 추가하는 방법 튜토리얼에서 기본기를 먼저 익힌 뒤 이 튜토리얼로 넘어오세요.

매물 데이터 구조

각 매물에는 좌표, 가격, 팝업에 표시할 메타데이터가 필요합니다. 데이터를 GeoJSON FeatureCollection으로 유지하면 변환 없이 지도 소스에 바로 연결할 수 있습니다.

const properties = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",
      geometry: { type: "Point", coordinates: [4.8952, 52.3702] },
      properties: {
        id: "prop-001",
        price: 485000,
        bedrooms: 3,
        sqm: 112,
        address: "Keizersgracht 142, Amsterdam",
        type: "apartment",
        status: "for-sale"
      }
    },
    {
      type: "Feature",
      geometry: { type: "Point", coordinates: [4.9123, 52.3601] },
      properties: {
        id: "prop-002",
        price: 1250,
        bedrooms: 2,
        sqm: 78,
        address: "Sarphatistraat 58, Amsterdam",
        type: "apartment",
        status: "for-rent"
      }
    },
    {
      type: "Feature",
      geometry: { type: "Point", coordinates: [4.8801, 52.3780] },
      properties: {
        id: "prop-003",
        price: 720000,
        bedrooms: 4,
        sqm: 195,
        address: "Herengracht 380, Amsterdam",
        type: "house",
        status: "for-sale"
      }
    }
  ]
};

실제 플랫폼에서는 이 배열을 매물 API에서 가져옵니다. 지도가 로드될 때나 쿼리 파라미터가 바뀔 때 fetch를 호출하면 됩니다.

지도 설정

대상 부동산 시장이 한눈에 들어오는 위치를 중심으로 지도를 초기화합니다. MapAtlas Maps API는 Mapbox GL JS와 호환되므로, 초기화 코드는 Mapbox용으로 작성하는 코드와 같고 타일 URL만 MapAtlas 주소를 사용합니다.

import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';

const map = new mapmetricsgl.Map({
  container: 'map',
  style: 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/style.json&token=YOUR_API_KEY',
  center: [4.9041, 52.3676],
  zoom: 13
});

밝고 대비가 낮은 스타일은 매물 지도에 특히 잘 맞습니다. 밝은 바탕 덕분에 가격 라벨과 색상 마커가 시각적으로 복잡하지 않게 눈에 띕니다.

가격 라벨이 있는 클러스터링

매물 지도의 핵심 UI 패턴은 개별 마커에 가격 라벨을, 클러스터 원에 매물 수를 표시하는 것입니다. 소스 옵션 cluster: true를 설정하면 가까운 지점이 자동으로 묶입니다. 클러스터와 개별 마커용 레이어는 따로 추가합니다.

map.on('load', () => {
  map.addSource('properties', {
    type: 'geojson',
    data: properties,
    cluster: true,
    clusterMaxZoom: 14,
    clusterRadius: 60
  });

  // 클러스터 원, 매물 수에 따라 크기 조정
  map.addLayer({
    id: 'clusters',
    type: 'circle',
    source: 'properties',
    filter: ['has', 'point_count'],
    paint: {
      'circle-color': '#2563EB',
      'circle-radius': [
        'step', ['get', 'point_count'],
        22, 5, 30, 20, 38
      ],
      'circle-stroke-width': 3,
      'circle-stroke-color': '#ffffff'
    }
  });

  // 클러스터 매물 수 라벨
  map.addLayer({
    id: 'cluster-label',
    type: 'symbol',
    source: 'properties',
    filter: ['has', 'point_count'],
    layout: {
      'text-field': '{point_count_abbreviated}',
      'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
      'text-size': 14
    },
    paint: { 'text-color': '#ffffff' }
  });

  // 개별 매물 마커, 흰색 후광이 있는 가격 텍스트
  map.addLayer({
    id: 'property-price',
    type: 'symbol',
    source: 'properties',
    filter: ['!', ['has', 'point_count']],
    layout: {
      'text-field': [
        'concat',
        '€',
        ['to-string', ['round', ['/', ['get', 'price'], 1000]]],
        'k'
      ],
      'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
      'text-size': 12
    },
    paint: {
      'text-color': '#1e293b',
      'text-halo-color': '#ffffff',
      'text-halo-width': 2
    }
  });
});

클릭 인터랙션

클러스터를 클릭하면 지도가 확대되어 그 안의 개별 매물이 드러납니다. 개별 매물을 클릭하면 상세 정보 팝업이 열립니다.

// 클릭한 클러스터로 확대
map.on('click', 'clusters', (e) => {
  const [feature] = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
  map.getSource('properties').getClusterExpansionZoom(
    feature.properties.cluster_id,
    (err, zoom) => {
      if (!err) map.easeTo({ center: feature.geometry.coordinates, zoom });
    }
  );
});

// 개별 매물의 상세 정보 팝업
map.on('click', 'property-price', (e) => {
  const { address, price, bedrooms, sqm, status, id } = e.features[0].properties;
  const coords = e.features[0].geometry.coordinates.slice();
  const formattedPrice = status === 'for-rent'
    ? `€${price.toLocaleString()}/mo`
    : `€${price.toLocaleString()}`;

  new mapmetricsgl.Popup({ offset: 10 })
    .setLngLat(coords)
    .setHTML(`
      <div style="min-width:200px">
        <strong style="font-size:15px">${formattedPrice}</strong>
        <p style="margin:4px 0">${address}</p>
        <p style="margin:4px 0;color:#64748b">${bedrooms} bed · ${sqm} m²</p>
        <a href="/listings/${id}" style="color:#2563EB;font-weight:600">View listing →</a>
      </div>
    `)
    .addTo(map);
});

// 마우스를 올리면 커서 변경
map.on('mouseenter', 'property-price', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'property-price', () => { map.getCanvas().style.cursor = ''; });
map.on('mouseenter', 'clusters',       () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'clusters',       () => { map.getCanvas().style.cursor = ''; });

가격 범위 필터

표시되는 매물을 걸러 내는 범위 슬라이더는 매물 지도에 추가할 수 있는 UI 중 가치가 가장 높은 요소입니다. Mapbox GL JS 표현식 시스템을 사용하면 네트워크 왕복 없이 클라이언트에서 레이어 필터를 바로 갱신할 수 있습니다. 필터링은 이미 로드된 GeoJSON을 대상으로 브라우저 안에서 처리됩니다.

function applyPriceFilter(min, max) {
  const filter = ['all',
    ['!', ['has', 'point_count']],
    ['>=', ['get', 'price'], min],
    ['<=', ['get', 'price'], max]
  ];
  map.setFilter('property-price', filter);
}

// 범위 입력 요소 연결
const minInput = document.getElementById('price-min');
const maxInput = document.getElementById('price-max');

function onRangeChange() {
  applyPriceFilter(Number(minInput.value), Number(maxInput.value));
}

minInput.addEventListener('input', onRangeChange);
maxInput.addEventListener('input', onRangeChange);

클러스터는 레이어 필터가 아니라 소스 데이터를 기준으로 계산됩니다. 따라서 슬라이더로 숨긴 매물도 클러스터 수에는 계속 포함됩니다. 둘을 맞추려면 JavaScript에서 매물을 필터링한 뒤 그 결과를 map.getSource('properties').setData()에 전달하세요.

Geocoding API로 주소 검색

사용자가 동네 이름이나 우편번호를 입력하면 지도 중심이 이동하도록 만듭니다. Geocoding API는 GeoJSON feature를 반환하므로 좌표를 그대로 map.flyTo에 넘길 수 있습니다.

async function searchArea(query) {
  const url = new URL('https://gateway.mapmetrics-atlas.net/forward-geocode/');
  url.searchParams.set('text', query);
  url.searchParams.set('token', 'YOUR_API_KEY');
  url.searchParams.set('size', '1');

  const res  = await fetch(url);
  const data = await res.json();

  if (!data.features.length) return;

  const [lng, lat] = data.features[0].geometry.coordinates;
  map.flyTo({ center: [lng, lat], zoom: 13 });
}

document.getElementById('area-search').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') searchArea(e.target.value.trim());
});

"이 학교에서 20분 이내에 있는 매물 보기" 같은 이동 시간 기반 검색에는 MapAtlas Travel Time API로 등시선 오버레이를 추가하세요. 등시선 지도 설명 글에서 해당 폴리곤을 가져와 표시하는 방법을 다룹니다.

완성된 React 컴포넌트

위의 모든 코드를 생명주기 관리가 적용된 React 컴포넌트로 감쌉니다.

import { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';

export function PropertyMap({ listings, apiKey }) {
  const containerRef = useRef(null);
  const mapRef       = useRef(null);
  const [priceRange, setPriceRange] = useState([0, 2000000]);

  useEffect(() => {
    const map = new mapmetricsgl.Map({
      container: containerRef.current,
      style: `https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/style.json&token=${apiKey}`,
      center: [4.9041, 52.3676],
      zoom: 13
    });
    mapRef.current = map;

    map.on('load', () => {
      map.addSource('properties', {
        type: 'geojson',
        data: { type: 'FeatureCollection', features: listings },
        cluster: true,
        clusterMaxZoom: 14,
        clusterRadius: 60
      });
      // cluster, cluster-label, property-price 레이어 추가(위 코드 참고)
      // 클릭 핸들러 추가(위 코드 참고)
    });

    return () => map.remove();
  }, [apiKey]);

  // 가격 범위 슬라이더가 바뀌면 필터 갱신
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !map.getLayer('property-price')) return;
    map.setFilter('property-price', [
      'all',
      ['!', ['has', 'point_count']],
      ['>=', ['get', 'price'], priceRange[0]],
      ['<=', ['get', 'price'], priceRange[1]]
    ]);
  }, [priceRange]);

  return (
    <div>
      <div style={{ padding: '12px 0', display: 'flex', gap: 12 }}>
        <label>
          Min €
          <input type="range" min={0} max={2000000} step={10000}
            value={priceRange[0]}
            onChange={e => setPriceRange([+e.target.value, priceRange[1]])} />
        </label>
        <label>
          Max €
          <input type="range" min={0} max={2000000} step={10000}
            value={priceRange[1]}
            onChange={e => setPriceRange([priceRange[0], +e.target.value])} />
        </label>
      </div>
      <div ref={containerRef} style={{ width: '100%', height: '600px' }} />
    </div>
  );
}

이것이 컴포넌트의 기본 뼈대입니다. 가격 범위 슬라이더와 생명주기 정리 코드를 포함해 약 65줄이며, 위 섹션의 레이어 및 클릭 핸들러 코드를 추가하면 완성됩니다.

EU PropTech를 위한 GDPR 참고 사항

매물을 찾는 사용자가 지도에 집 주소를 입력하거나 "내 위치 사용"을 클릭하면, 위치 데이터를 식별자로 명시한 GDPR 제4조 제1호에 따른 개인 데이터를 공유하는 것입니다. 이 위치 데이터는 사용자가 사는 곳을 드러내며, 이를 통해 다른 민감한 속성까지 추론될 수 있습니다.

EU 규정 준수를 위한 실무 단계는 다음과 같습니다.

  • 브라우저에서 Geocoding API를 직접 호출하지 말고 백엔드를 거쳐 지오코딩 요청을 보내세요. 백엔드는 요청을 전달하기 전에 식별 가능한 헤더를 제거할 수 있습니다.
  • 동의와 같이 GDPR 제6조에 따른 문서화된 법적 근거가 없다면, 세션이 끝난 뒤에 검색 좌표를 로그로 남기거나 저장하지 마세요.
  • "내 주변 매물이 조건에 맞으면 알림 받기" 기능을 구현한다면, 저장된 검색 영역을 보존 기간이 정해진 개인 데이터로 취급하세요.

GDPR을 준수하는 지도 API를 위한 EU 개발자 가이드에서 처리자 계약과 데이터 저장 위치 요건을 포함해 이 내용을 자세히 다룹니다. MapAtlas는 데이터를 EU 안에서 처리하며, GDPR 제28조 제3항이 컨트롤러와 처리자 사이에 요구하는 데이터 처리 계약(DPA)을 제공합니다.

부동산 산업 솔루션 페이지에서 PropTech 플랫폼이 프로덕션 환경에서 MapAtlas를 어떻게 사용하는지 더 확인할 수 있습니다.

다음 단계

자주 묻는 질문

매물 목록 웹사이트에 MapAtlas를 사용할 수 있나요?

네. MapAtlas는 매물 검색 지도, 투자 대시보드, 임대 매물 지도처럼 부동산 사이트에 필요한 기능을 제공합니다. Maps API는 Mapbox GL JS와 호환되므로, 기존 Mapbox 기반 매물 지도 코드를 최소한의 수정으로 옮길 수 있습니다.

부동산 지도에서 마커 클러스터링은 어떻게 작동하나요?

클러스터링은 지도를 축소했을 때 가까운 매물 마커를 매물 수가 표시된 원 하나로 묶습니다. 사용자가 확대하면 클러스터가 개별 마커로 나뉩니다. MapAtlas는 GeoJSON 소스 클러스터링을 기본으로 지원합니다. 소스에 cluster: true를 설정하면 나머지는 SDK가 처리합니다.

매물 검색 사용자의 위치 데이터는 GDPR 적용 대상인가요?

네. 사용자가 부동산 사이트에서 집 주소나 현재 위치로 검색하면, 이는 GDPR 제4조에 따른 개인 데이터에 해당합니다. EU PropTech 플랫폼은 사용자 위치를 제3자 서버에 기록하는 클라이언트 측 API 호출 대신, 백엔드 프록시를 거쳐 지오코딩 요청을 보내고 적절한 데이터 최소화 및 보존 정책을 적용해야 합니다.

자주 묻는 질문

매물 목록 웹사이트에 MapAtlas를 사용할 수 있나요?

네. MapAtlas는 매물 검색 지도, 투자 대시보드, 임대 매물 지도처럼 부동산 사이트에 필요한 기능을 제공합니다. Maps API는 Mapbox GL JS와 호환되므로, 기존 Mapbox 기반 매물 지도 코드를 최소한의 수정으로 옮길 수 있습니다.

부동산 지도에서 마커 클러스터링은 어떻게 작동하나요?

클러스터링은 지도를 축소했을 때 가까운 매물 마커를 매물 수가 표시된 원 하나로 묶습니다. 사용자가 확대하면 클러스터가 개별 마커로 나뉩니다. MapAtlas는 GeoJSON 소스 클러스터링을 기본으로 지원합니다. 소스에 `cluster: true`를 설정하면 나머지는 SDK가 처리합니다.

매물 검색 사용자의 위치 데이터는 GDPR 적용 대상인가요?

네. 사용자가 부동산 사이트에서 집 주소나 현재 위치로 검색하면, 이는 GDPR 제4조에 따른 개인 데이터에 해당합니다. EU PropTech 플랫폼은 사용자 위치를 제3자 서버에 기록하는 클라이언트 측 API 호출 대신, 백엔드 프록시를 거쳐 지오코딩 요청을 보내고 적절한 데이터 최소화 및 보존 정책을 적용해야 합니다.

유용하셨나요? 공유해 보세요.

저자 소개

Brent van der Heiden

작성자

Brent van der Heiden

Co-Founder & CEO at MapAtlas

Brent built MapAtlas out of a conviction that developers deserve location APIs with fair pricing and genuine end-user privacy. Before that he co-founded MapMetrics, a community mapping and navigation app. He writes about geospatial infrastructure, map data, AI search visibility, and how location data powers the products people rely on every day.

모든 기사 보기 →
블로그로 돌아가기