Scala의 부동산 플랫폼 아키텍처
다음과 같은 현대 부동산 플랫폼 질로우, 이상주의자, Immobiliare.it e 오른쪽으로 이동 수백만 개의 광고를 관리하고, 대행사, MLS에서 발생하는 수십억 건의 월별 검색 및 실시간 데이터 스트림 (다중 목록 서비스) 및 개인 소유자. 지속할 수 있는 시스템을 설계합니다. 이 부하는 스택의 각 계층에 대한 정확한 아키텍처 결정이 필요합니다. 데이터 모델링부터 지리공간 연구까지, 멀티미디어 파이프라인부터 시스템까지 실시간 알림.
이 기사에서는 대규모로 PropTech 플랫폼의 전체 아키텍처를 구축할 것입니다. 코드 예제를 사용하여 각 구성 요소를 분석합니다. 타입스크립트 e 파이썬, 생산에서 입증된 기술과 디자인 패턴 간의 비교.
무엇을 배울 것인가
- 도메인 분해를 통한 부동산 플랫폼을 위한 마이크로서비스 아키텍처
- 완전한 데이터 모델: 부동산, 목록, 대리인, 거래, 미디어
- Elasticsearch, PostGIS 및 H3를 사용한 고급 지리공간 검색
- 이기종 소스(MLS/RESO 웹 API, 스크래핑, XML 피드)의 광고 수집 파이프라인
- 미디어 파이프라인: 이미지 처리, 평면도, 3D 가상 투어
- 실시간 기능: 알림, 메시지, 가격 알림
- 규모에 따른 성능 전략: 캐싱, CDN, 샤딩, CQRS
- 규정 준수: GDPR, 공정 주택, 지역 규정
부동산 플랫폼의 풍경
아키텍처를 설계하기 전에 경쟁 환경과 기술적 선택을 안내하는 비즈니스 모델. 각 플랫폼에는 고유한 조합이 있습니다. 인프라 결정에 직접적인 영향을 미치는 기능입니다.
| 플랫폼 | 시장 | 활성 광고 | 독특한 특징 | 알려진 스택 |
|---|---|---|---|---|
| 질로우 | 미국 | ~1억 3,500만 개의 속성 | Zestimate(ML 등급) | 자바, 카프카, 엘라스틱서치 |
| 이상주의자 | EU(ES, IT, PT) | ~180만 개의 광고 | 대화형 지도 검색 | 자바, 솔러, 포스트그레SQL |
| Immobiliare.it | 이탈리아 | ~120만 개의 광고 | 자동 평가, 히트맵 | PHP/Go, 엘라스틱서치 |
| 오른쪽으로 이동 | UK | ~100만 개의 광고 | 검색 그리기(영역 그리기) | .NET, SQL 서버, 애저 |
| 레드핀 | 미국 | ~1억 속성 | 통합 에이전트, 3D 투어 | 자바, 리액트, 카프카 |
수익화 모델 및 기술적 영향
비즈니스 모델은 아키텍처에 직접적인 영향을 미칩니다. 플랫폼 스폰서 목록이 포함된 부분 유료화 광고 서비스 엔진 A/B가 필요합니다. 노출수를 테스트하고 추적합니다. 모델 납 기반 라우팅이 필요합니다 상담원과의 지능형 접촉 및 통합 CRM. 모델 거래상의 (iBuying)에는 ML 평가 파이프라인, 관리가 포함됩니다. 금융 및 고급 규제 준수.
- 스폰서 목록: ROI를 위한 광고 순위 엔진, 입찰 시스템 및 분석이 필요합니다.
- 대행사 구독: 계층 관리, 반복 청구, 상담원 대시보드
- 리드 생성: 리드 스코어링, 지능형 라우팅, 속성 추적
- 거래(iBuying): ML 평가 모델, 제안 관리, 법적 파이프라인
- 대행사를 위한 SaaS: CRM 통합을 위한 멀티 테넌시, 화이트 라벨, API
상위 수준 아키텍처
사다리형 부동산 플랫폼은 아키텍처를 채택합니다. 도메인 지향 마이크로서비스, 각 서비스에는 자체 데이터베이스가 있으며 비동기 이벤트를 통해 통신합니다. 이 접근법 더 큰 부하(일반적으로 검색 및 API)에서 구성 요소를 독립적으로 확장할 수 있습니다. 공개) 덜 요청된 서비스에 영향을 주지 않고.
지도 원리: 교살자 무화과 패턴
대부분의 부동산 플랫폼은 단일체로 시작됩니다. 마이크로서비스로의 마이그레이션 통해 점차적으로 발생한다. 교살자 무화과 패턴: 서비스가 먼저 추출됩니다. 높은 부하(연구, 미디어)를 수행한 다음 점진적으로 나머지 부하를 처리하여 모놀리스 기능을 유지합니다. 전체 전환 과정에서.
마이크로서비스로 분해
| 서비스 | 책임 | 데이터베이스 | 패턴 |
|---|---|---|---|
| 리스팅 서비스 | CRUD 광고, 검증, 게시 워크플로 | PostgreSQL + PostGIS | CQRS, 이벤트 소싱 |
| 검색 서비스 | 전체 텍스트 검색, 필터, 지리공간, 패싯 | 엘라스틱서치/오픈서치 | 모델 읽기(CQRS) |
| 사용자 서비스 | 인증, 프로필, 기본 설정, 저장됨 | 포스트그레SQL | OAuth 2.0 / OIDC |
| 에이전트 서비스 | 에이전트 프로필, 대행사, 평가, 가용성 | 포스트그레SQL | 도메인 서비스 |
| 미디어 서비스 | 업로드, 처리, 최적화, CDN | S3 + DynamoDB(메타데이터) | 비동기 파이프라인 |
| 메시징 서비스 | 상담원-사용자 채팅, 정보 요청, 알림 | 몽고DB/카산드라 | WebSocket + 이벤트 기반 |
| 알림 서비스 | 이메일, 푸시, SMS, 가격 알림, 새 광고 | 레디스 + 포스트그레SQL | 팬아웃, 템플릿 엔진 |
| 분석 서비스 | 방문 추적, 히트맵, 상담원 보고서 | ClickHouse / BigQuery | 이벤트 스트리밍 |
| 수집 서비스 | MLS, XML 피드, 외부 API에서 가져오기 | 스테이징 DB + 큐 | ETL 파이프라인 |
| 평가 서비스 | ML 가격 추정, 비교 대상, 시장 동향 | Feature Store + 모델 레지스트리 | ML 파이프라인 |
아키텍처 다이어그램
+------------------+
| API Gateway |
| (Kong / Envoy) |
+--------+---------+
|
+--------------------+--------------------+
| | |
+--------v------+ +--------v------+ +--------v------+
| Listing | | Search | | User |
| Service | | Service | | Service |
| (PostgreSQL) | | (Elasticsearch)| | (PostgreSQL) |
+--------+------+ +---------------+ +--------+------+
| |
| Events (Kafka / NATS) |
+--------------------+-------------------+
| | |
+--------v------+ +--------v------+ +--------v------+
| Media | | Messaging | | Notification |
| Service | | Service | | Service |
| (S3 + CDN) | | (MongoDB) | | (Redis) |
+---------------+ +---------------+ +---------------+
|
+--------v------+ +---------------+
| Ingestion | | Valuation |
| Service | | Service |
| (ETL Pipeline)| | (ML Models) |
+---------------+ +---------------+
데이터 모델: 플랫폼의 핵심
부동산 플랫폼의 데이터 모델은 놀라울 정도로 복잡합니다. 싱글 재산 물리학은 배수를 가질 수 있습니다 광고 시간이 지남에 따라 관리됩니다. 다른 자치령 대표, 올라가다 업무 그리고 수백 개의 멀티미디어 자산. 이러한 관계를 올바르고 중요하게 설계하십시오. 성능 및 데이터 일관성.
주요 엔터티 스키마
// Entità base: Proprietà fisica
interface Property {
readonly id: string;
readonly externalId?: string; // ID da MLS/fonte esterna
readonly source: DataSource;
readonly propertyType: PropertyType;
readonly address: Address;
readonly location: GeoPoint; // lat/lng per ricerca spaziale
readonly h3Index: string; // H3 cell index (risoluzione 9)
readonly characteristics: PropertyCharacteristics;
readonly amenities: ReadonlyArray<Amenity>;
readonly energyRating?: EnergyRating;
readonly cadastralRef?: string; // Riferimento catastale (IT)
readonly createdAt: Date;
readonly updatedAt: Date;
}
type PropertyType =
| 'apartment' | 'house' | 'villa' | 'penthouse'
| 'studio' | 'loft' | 'commercial' | 'land'
| 'garage' | 'office' | 'warehouse';
interface Address {
readonly street: string;
readonly streetNumber: string;
readonly city: string;
readonly province: string;
readonly region: string;
readonly postalCode: string;
readonly country: string;
readonly formattedAddress: string;
readonly neighborhood?: string;
}
interface GeoPoint {
readonly lat: number;
readonly lng: number;
}
interface PropertyCharacteristics {
readonly sqMeters: number;
readonly rooms: number;
readonly bedrooms: number;
readonly bathrooms: number;
readonly floor?: number;
readonly totalFloors?: number;
readonly hasElevator?: boolean;
readonly hasBalcony?: boolean;
readonly hasTerrace?: boolean;
readonly hasGarden?: boolean;
readonly gardenSqMeters?: number;
readonly yearBuilt?: number;
readonly condition: PropertyCondition;
readonly heatingType?: HeatingType;
readonly orientation?: ReadonlyArray<Orientation>;
}
// Annuncio: un'istanza di vendita/affitto
interface Listing {
readonly id: string;
readonly propertyId: string;
readonly agentId: string;
readonly agencyId?: string;
readonly listingType: 'sale' | 'rent' | 'auction';
readonly status: ListingStatus;
readonly price: Money;
readonly pricePerSqMeter: Money;
readonly condominiumFees?: Money;
readonly description: LocalizedText;
readonly media: ReadonlyArray<MediaAsset>;
readonly virtualTourUrl?: string;
readonly availableFrom?: Date;
readonly publishedAt?: Date;
readonly expiresAt?: Date;
readonly viewCount: number;
readonly favoriteCount: number;
readonly contactCount: number;
}
interface Money {
readonly amount: number;
readonly currency: CurrencyCode;
}
type CurrencyCode = 'EUR' | 'USD' | 'GBP' | 'CHF';
interface LocalizedText {
readonly [locale: string]: string; // { it: "...", en: "..." }
}
type ListingStatus =
| 'draft' | 'pending_review' | 'active'
| 'under_offer' | 'sold' | 'rented'
| 'expired' | 'withdrawn';
이 모델의 몇 가지 주요 측면은 다음과 같습니다.
- 부동산/목록 분리: 물리적 특성은 독립적으로 존재합니다. 광고에서. 동일한 아파트를 매물로 내놓았다가 철회한 다음 임대할 수 있습니다. 동일한 법인에 연결된 별도의 목록을 생성합니다.
-
불변성: 모든 인터페이스 사용
readonly방지하기 위해 우연한 돌연변이. 상태 전환은 새로운 객체를 생성합니다. - H3 지수: 지리공간 검색을 활성화하기 위해 삽입 시 사전 계산됨 육각형 집계를 통해 효율적입니다.
-
다중 통화 및 다중 언어: 기본 지원을 통해
MoneyeLocalizedText국제 시장을 위해.
광고 수집 파이프라인
부동산 플랫폼과 데이터 수집 파이프라인의 심장이 뛰고 있습니다. 광고 이기종 소스에서 유래: 피드 RESO 웹 API (미국/국제 표준), 독점 XML 피드, 대행사 API, 수동 업로드 및 파트너 포털 스크래핑. 모든 소스 자체 형식, 업데이트 속도 및 데이터 품질 수준이 있습니다.
RESO 웹 API: 업계 표준
Il RESO(부동산표준화기구) 웹 API 그리고 현대의 표준 JSON 페이로드가 포함된 REST/OData를 기반으로 하는 MLS 데이터 교환용입니다. 기존 RETS를 대체합니다. (현재는 더 이상 사용되지 않음) Data Dictionary 2.x는 필드(ListingId, StandardStatus, ListPrice, LivingArea) 시스템 간 상호 운용성을 보장합니다.
ETL 파이프라인 아키텍처
import { EventEmitter } from 'events';
// Interfaccia generica per sorgenti dati
interface ListingSource {
readonly sourceId: string;
readonly sourceType: 'reso_api' | 'xml_feed' | 'manual' | 'scraper';
fetch(since: Date): Promise<ReadonlyArray<RawListing>>;
}
// Dati grezzi da qualsiasi fonte
interface RawListing {
readonly externalId: string;
readonly source: string;
readonly rawData: Record<string, unknown>;
readonly fetchedAt: Date;
}
// Pipeline di ingestione con step chiari
class ListingIngestionPipeline {
private readonly events = new EventEmitter();
constructor(
private readonly sources: ReadonlyArray<ListingSource>,
private readonly normalizer: ListingNormalizer,
private readonly validator: ListingValidator,
private readonly deduplicator: ListingDeduplicator,
private readonly enricher: ListingEnricher,
private readonly repository: ListingRepository,
private readonly searchIndex: SearchIndexer,
) {}
async ingest(source: ListingSource): Promise<IngestionResult> {
const startTime = Date.now();
const result: IngestionResult = {
sourceId: source.sourceId,
processed: 0,
created: 0,
updated: 0,
skipped: 0,
errors: [],
};
// Step 1: Fetch dati grezzi
const rawListings = await source.fetch(
await this.getLastSyncTime(source.sourceId)
);
for (const raw of rawListings) {
try {
// Step 2: Normalizzazione (formato sorgente -> formato interno)
const normalized = this.normalizer.normalize(raw);
// Step 3: Validazione (campi obbligatori, range, formato)
const validation = this.validator.validate(normalized);
if (!validation.isValid) {
result.skipped++;
result.errors.push({
externalId: raw.externalId,
errors: validation.errors,
});
continue;
}
// Step 4: Deduplicazione (match per indirizzo, coordinate, ID esterno)
const existingId = await this.deduplicator.findDuplicate(normalized);
// Step 5: Arricchimento (geocoding, H3 index, neighborhood)
const enriched = await this.enricher.enrich(normalized);
// Step 6: Persistenza
if (existingId) {
await this.repository.update(existingId, enriched);
result.updated++;
} else {
await this.repository.create(enriched);
result.created++;
}
// Step 7: Indicizzazione per la ricerca (asincrona)
this.events.emit('listing:upserted', enriched);
result.processed++;
} catch (error) {
result.errors.push({
externalId: raw.externalId,
errors: [String(error)],
});
}
}
// Aggiorna timestamp ultima sync
await this.updateLastSyncTime(source.sourceId, new Date());
this.events.emit('ingestion:completed', {
...result,
durationMs: Date.now() - startTime,
});
return result;
}
private async getLastSyncTime(sourceId: string): Promise<Date> {
// Recupera da DB il timestamp dell'ultima sincronizzazione
return new Date(Date.now() - 24 * 60 * 60 * 1000); // fallback: 24h fa
}
private async updateLastSyncTime(sourceId: string, time: Date): Promise<void> {
// Persiste il timestamp per la prossima esecuzione
}
}
interface IngestionResult {
readonly sourceId: string;
processed: number;
created: number;
updated: number;
skipped: number;
errors: Array<{ externalId: string; errors: string[] }>;
}
정규화: RETURN에서 내부 형식으로
정규화는 파이프라인에서 가장 중요한 단계입니다. 모든 데이터 소스에는 형식이 있습니다. 다르며 정규화는 이질적인 필드를 균일한 모델로 매핑해야 합니다. 여기 RESO 형식에 대한 노멀라이저의 예:
class ResoListingNormalizer implements ListingNormalizer {
normalize(raw: RawListing): NormalizedListing {
const data = raw.rawData as ResoPropertyData;
return {
externalId: String(data.ListingId),
source: raw.source,
propertyType: this.mapPropertyType(data.PropertyType),
listingType: this.mapListingType(data.TransactionType),
status: this.mapStatus(data.StandardStatus),
price: {
amount: data.ListPrice,
currency: data.CurrencyCode ?? 'USD',
},
address: {
street: data.StreetName,
streetNumber: data.StreetNumber,
city: data.City,
province: data.StateOrProvince,
postalCode: data.PostalCode,
country: data.Country ?? 'US',
formattedAddress: this.buildFormattedAddress(data),
},
location: data.Latitude && data.Longitude
? { lat: data.Latitude, lng: data.Longitude }
: undefined,
characteristics: {
sqMeters: this.sqFeetToSqMeters(data.LivingArea),
rooms: data.RoomsTotal ?? 0,
bedrooms: data.BedroomsTotal ?? 0,
bathrooms: data.BathroomsTotalInteger ?? 0,
yearBuilt: data.YearBuilt,
},
description: {
en: data.PublicRemarks ?? '',
},
photos: (data.Media ?? []).map((m: ResoMedia) => ({
url: m.MediaURL,
order: m.Order,
caption: m.ShortDescription,
})),
rawData: raw.rawData,
fetchedAt: raw.fetchedAt,
};
}
private mapPropertyType(resoType: string): PropertyType {
const mapping: Record<string, PropertyType> = {
'Residential': 'house',
'Condominium': 'apartment',
'Townhouse': 'house',
'Land': 'land',
'Commercial': 'commercial',
};
return mapping[resoType] ?? 'apartment';
}
private mapStatus(resoStatus: string): ListingStatus {
const mapping: Record<string, ListingStatus> = {
'Active': 'active',
'Pending': 'under_offer',
'Closed': 'sold',
'Withdrawn': 'withdrawn',
'Expired': 'expired',
};
return mapping[resoStatus] ?? 'draft';
}
private sqFeetToSqMeters(sqFeet?: number): number {
return sqFeet ? Math.round(sqFeet * 0.092903 * 100) / 100 : 0;
}
private mapListingType(txType: string): 'sale' | 'rent' {
return txType === 'Lease' ? 'rent' : 'sale';
}
private buildFormattedAddress(data: ResoPropertyData): string {
return [data.StreetNumber, data.StreetName, data.City, data.StateOrProvince]
.filter(Boolean)
.join(', ');
}
}
검색 아키텍처: Elasticsearch 및 지리정보
모든 부동산 플랫폼에서 가장 중요한 검색 및 기능입니다. 사용자는 즉각적인 결과 기대, 결합 가능한 필터, 관련성/가격/거리별 정렬 실시간 업데이트로 지도 검색이 가능합니다. 엘라스틱서치 (또는 포크 오픈서치) 그리고 지원 덕분에 업계에서 가장 지배적인 선택이 되었습니다. 전체 텍스트, 지리공간 및 패싯 검색에 기본입니다.
광고용 Elasticsearch 인덱스
{
"mappings": {
"properties": {
"listingId": { "type": "keyword" },
"propertyType": { "type": "keyword" },
"listingType": { "type": "keyword" },
"status": { "type": "keyword" },
"price": { "type": "long" },
"pricePerSqm": { "type": "float" },
"currency": { "type": "keyword" },
"location": { "type": "geo_point" },
"geoShape": { "type": "geo_shape" },
"h3Index": { "type": "keyword" },
"h3Res7": { "type": "keyword" },
"city": { "type": "keyword" },
"neighborhood": { "type": "keyword" },
"province": { "type": "keyword" },
"postalCode": { "type": "keyword" },
"sqMeters": { "type": "integer" },
"rooms": { "type": "integer" },
"bedrooms": { "type": "integer" },
"bathrooms": { "type": "integer" },
"floor": { "type": "integer" },
"yearBuilt": { "type": "integer" },
"hasElevator": { "type": "boolean" },
"hasBalcony": { "type": "boolean" },
"hasGarden": { "type": "boolean" },
"energyRating": { "type": "keyword" },
"amenities": { "type": "keyword" },
"description": { "type": "text", "analyzer": "multilingual_analyzer" },
"title": { "type": "text", "analyzer": "multilingual_analyzer",
"fields": { "keyword": { "type": "keyword" } } },
"agentId": { "type": "keyword" },
"agencyId": { "type": "keyword" },
"publishedAt": { "type": "date" },
"updatedAt": { "type": "date" },
"viewCount": { "type": "integer" },
"photoCount": { "type": "integer" },
"hasVirtualTour": { "type": "boolean" }
}
},
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"multilingual_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "stop_it", "stop_en"]
}
},
"filter": {
"stop_it": { "type": "stop", "stopwords": "_italian_" },
"stop_en": { "type": "stop", "stopwords": "_english_" }
}
}
}
}
지리공간 필터를 사용한 검색 API
검색 쿼리는 부울 필터, 숫자 범위, 전체 텍스트 검색 및 제약 조건을 결합합니다. 지리공간. 우리는 세 가지 지리적 검색 모드를 지원합니다: 지리_거리 (점으로부터의 반경), geo_bounding_box (지도의 직사각형) e 지리_모양 (Rightmove의 "Draw-a-search"와 같은 사용자가 그린 다각형).
interface PropertySearchParams {
readonly query?: string;
readonly listingType: 'sale' | 'rent';
readonly propertyTypes?: ReadonlyArray<PropertyType>;
readonly priceMin?: number;
readonly priceMax?: number;
readonly sqMetersMin?: number;
readonly sqMetersMax?: number;
readonly bedroomsMin?: number;
readonly bathroomsMin?: number;
readonly amenities?: ReadonlyArray<string>;
readonly geoFilter?: GeoFilter;
readonly sortBy?: 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'distance';
readonly page?: number;
readonly pageSize?: number;
}
type GeoFilter =
| { type: 'radius'; center: GeoPoint; radiusKm: number }
| { type: 'bbox'; topLeft: GeoPoint; bottomRight: GeoPoint }
| { type: 'polygon'; points: ReadonlyArray<GeoPoint> };
class PropertySearchService {
constructor(private readonly esClient: ElasticsearchClient) {}
async search(params: PropertySearchParams): Promise<SearchResult> {
const must: any[] = [];
const filter: any[] = [];
// Filtro obbligatorio: tipo annuncio e stato attivo
filter.push({ term: { listingType: params.listingType } });
filter.push({ term: { status: 'active' } });
// Full-text search (titolo + descrizione)
if (params.query) {
must.push({
multi_match: {
query: params.query,
fields: ['title^3', 'description', 'city^2', 'neighborhood^2'],
type: 'best_fields',
fuzziness: 'AUTO',
},
});
}
// Filtri range prezzo
if (params.priceMin || params.priceMax) {
filter.push({
range: {
price: {
...(params.priceMin ? { gte: params.priceMin } : {}),
...(params.priceMax ? { lte: params.priceMax } : {}),
},
},
});
}
// Filtri superficie
if (params.sqMetersMin || params.sqMetersMax) {
filter.push({
range: {
sqMeters: {
...(params.sqMetersMin ? { gte: params.sqMetersMin } : {}),
...(params.sqMetersMax ? { lte: params.sqMetersMax } : {}),
},
},
});
}
// Tipo proprietà
if (params.propertyTypes?.length) {
filter.push({ terms: { propertyType: params.propertyTypes } });
}
// Camere minime
if (params.bedroomsMin) {
filter.push({ range: { bedrooms: { gte: params.bedroomsMin } } });
}
// Amenities (AND logic)
if (params.amenities?.length) {
for (const amenity of params.amenities) {
filter.push({ term: { amenities: amenity } });
}
}
// Filtro geospaziale
if (params.geoFilter) {
filter.push(this.buildGeoFilter(params.geoFilter));
}
const page = params.page ?? 0;
const pageSize = params.pageSize ?? 20;
const response = await this.esClient.search({
index: 'listings',
body: {
query: {
bool: {
must: must.length ? must : [{ match_all: {} }],
filter,
},
},
sort: this.buildSort(params.sortBy, params.geoFilter),
from: page * pageSize,
size: pageSize,
aggs: this.buildAggregations(),
},
});
return this.mapResponse(response, page, pageSize);
}
private buildGeoFilter(geo: GeoFilter): Record<string, unknown> {
switch (geo.type) {
case 'radius':
return {
geo_distance: {
distance: `${geo.radiusKm}km`,
location: { lat: geo.center.lat, lon: geo.center.lng },
},
};
case 'bbox':
return {
geo_bounding_box: {
location: {
top_left: { lat: geo.topLeft.lat, lon: geo.topLeft.lng },
bottom_right: { lat: geo.bottomRight.lat, lon: geo.bottomRight.lng },
},
},
};
case 'polygon':
return {
geo_shape: {
geoShape: {
shape: {
type: 'polygon',
coordinates: [
geo.points.map(p => [p.lng, p.lat]),
],
},
relation: 'within',
},
},
};
}
}
private buildAggregations(): Record<string, unknown> {
return {
price_stats: { stats: { field: 'price' } },
property_types: { terms: { field: 'propertyType', size: 15 } },
bedrooms: { terms: { field: 'bedrooms', size: 10 } },
price_ranges: {
range: {
field: 'price',
ranges: [
{ key: 'under_100k', to: 100000 },
{ key: '100k_200k', from: 100000, to: 200000 },
{ key: '200k_350k', from: 200000, to: 350000 },
{ key: '350k_500k', from: 350000, to: 500000 },
{ key: 'over_500k', from: 500000 },
],
},
},
neighborhoods: { terms: { field: 'neighborhood', size: 30 } },
energy_ratings: { terms: { field: 'energyRating', size: 10 } },
};
}
private buildSort(
sortBy?: string,
geoFilter?: GeoFilter
): Array<Record<string, unknown>> {
switch (sortBy) {
case 'price_asc':
return [{ price: 'asc' }];
case 'price_desc':
return [{ price: 'desc' }];
case 'newest':
return [{ publishedAt: 'desc' }];
case 'distance':
if (geoFilter?.type === 'radius') {
return [{
_geo_distance: {
location: { lat: geoFilter.center.lat, lon: geoFilter.center.lng },
order: 'asc',
unit: 'km',
},
}];
}
return [{ _score: 'desc' }];
default:
return [{ _score: 'desc' }, { publishedAt: 'desc' }];
}
}
private mapResponse(
response: any,
page: number,
pageSize: number
): SearchResult {
return {
listings: response.hits.hits.map((hit: any) => ({
...hit._source,
score: hit._score,
distance: hit.sort?.[0],
})),
total: response.hits.total.value,
page,
pageSize,
facets: {
priceStats: response.aggregations?.price_stats,
propertyTypes: response.aggregations?.property_types?.buckets,
bedrooms: response.aggregations?.bedrooms?.buckets,
priceRanges: response.aggregations?.price_ranges?.buckets,
neighborhoods: response.aggregations?.neighborhoods?.buckets,
energyRatings: response.aggregations?.energy_ratings?.buckets,
},
};
}
}
지리공간 인덱싱: PostGIS, H3 및 S2
Elasticsearch 외에도 기본 지속성 계층에는 지리공간 기능이 필요합니다. 정확한 거리 계산, 구역별 집계, 시장 분석. 세 가지 기술이 이 공간을 지배하고 있습니다.
| 기술 | 유형 | 주요 용도 | 장점 | 제한 |
|---|---|---|---|---|
| PostGIS | PostgreSQL 확장 | SQL 공간 쿼리, 복잡한 기하학 | 표준 OGC, 성숙, 관계형 데이터와 결합 | 수직 확장성, 기본 클러스터링 없음 |
| H3 (우버) | 계층적 육각형 그리드 | 지역별, 근접성, 분석별 집계 | 균일, 계층적, O(1) 조회 | 가장자리의 오각형, 경계 근사치 |
| S2 기하학 (Google) | 계층적 구형 셀 | 지역 적용 범위, 범위 쿼리, 샤딩 | BigQuery/Spanner에서 사용되는 정확한 범위 | 균일하지 않은 사각형 셀, 복잡한 API |
PostGIS를 사용한 지리공간 쿼리
-- Ricerca per raggio: proprietà entro 5km da un punto
SELECT
l.id,
l.title,
l.price,
p.property_type,
ST_Distance(
p.location::geography,
ST_MakePoint(12.4964, 41.9028)::geography
) AS distance_meters
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND l.listing_type = 'sale'
AND ST_DWithin(
p.location::geography,
ST_MakePoint(12.4964, 41.9028)::geography, -- Roma centro
5000 -- 5km in metri
)
AND l.price BETWEEN 150000 AND 400000
ORDER BY distance_meters ASC
LIMIT 50;
-- Ricerca per bounding box (viewport della mappa)
SELECT l.id, l.title, l.price,
ST_X(p.location) AS lng, ST_Y(p.location) AS lat
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND p.location && ST_MakeEnvelope(
12.45, 41.87, -- bottom-left (lng, lat)
12.55, 41.93, -- top-right (lng, lat)
4326 -- SRID WGS84
)
ORDER BY l.published_at DESC
LIMIT 200;
-- Clustering per H3: conteggio annunci per cella esagonale
SELECT
h3_lat_lng_to_cell(p.location, 7) AS h3_cell,
COUNT(*) AS listing_count,
AVG(l.price) AS avg_price,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l.price) AS median_price
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND l.listing_type = 'sale'
AND p.city = 'Milano'
GROUP BY h3_cell
HAVING COUNT(*) >= 3
ORDER BY avg_price DESC;
왜 PropTech에 H3가 필요한가요?
H3는 효율적인 공간 집합이라는 근본적인 문제를 해결합니다. 계산하는 대신
지도의 실시간 광고 클러스터에서 각 부동산의 H3 지수를 미리 계산합니다.
삽입 시. 해상도 7(~5.16km²)은 도시 전망에 적합하고 해상도 9는
동네 수준의 경우 (~0.105km²)입니다. 단순해진 집계 쿼리
GROUP BY h3_index, 기하학적 연산보다 훨씬 빠릅니다.
실시간으로.
미디어 파이프라인: 이미지, 평면도 및 가상 투어
이미지는 사용자의 광고 클릭 결정에 가장 큰 영향을 미치는 요소입니다. 대규모 플랫폼은 매일 수백만 개의 이미지를 다양한 형식으로 처리하고 결의안. 평균 파이프라인은 다음과 같아야 합니다. 비동기식, 탄력있는 그리고 최적화된 배송 속도 CDN을 통해.
미디어 처리 흐름
- 업로드: 에이전트는 미리 서명된 URL을 통해 원본 사진을 S3에 업로드합니다.
- 확인: 제어 형식, 크기, 내용 NSFW(ML)
- 처리: 변형 생성(썸네일 300px, 중간 800px, 대형 1600px, WebP/AVIF)
- 최적화: 지능형 압축, 민감한 EXIF 메타데이터 제거, 워터마크
- ML 강화: 객실 분류(주방, 욕실, 거실), 품질평가점수, 평면도 조사
- CDN 배포: 전 세계적으로 50ms 미만 전송을 위해 CDN 에지 노드로 푸시
- 인덱싱: 검색 색인의 메타데이터 업데이트
interface ImageVariant {
readonly suffix: string;
readonly width: number;
readonly quality: number;
readonly format: 'webp' | 'avif' | 'jpeg';
}
const IMAGE_VARIANTS: ReadonlyArray<ImageVariant> = [
{ suffix: 'thumb', width: 300, quality: 75, format: 'webp' },
{ suffix: 'medium', width: 800, quality: 80, format: 'webp' },
{ suffix: 'large', width: 1600, quality: 85, format: 'webp' },
{ suffix: 'avif-medium', width: 800, quality: 70, format: 'avif' },
{ suffix: 'original', width: 0, quality: 90, format: 'jpeg' },
] as const;
class ImageProcessingService {
constructor(
private readonly storage: ObjectStorage,
private readonly sharp: SharpInstance,
private readonly classifier: RoomClassifier,
private readonly queue: MessageQueue,
) {}
async processUploadedImage(event: ImageUploadEvent): Promise<ProcessedImage> {
const { listingId, imageKey, uploadedBy } = event;
// 1. Scarica l'originale
const originalBuffer = await this.storage.download(imageKey);
// 2. Valida (dimensione, formato, contenuto)
const metadata = await this.sharp(originalBuffer).metadata();
if (!metadata.width || metadata.width < 600) {
throw new ImageValidationError('Risoluzione minima: 600px di larghezza');
}
// 3. Genera tutte le varianti
const variants = await Promise.all(
IMAGE_VARIANTS.map(variant =>
this.generateVariant(originalBuffer, imageKey, variant)
)
);
// 4. Classifica la stanza (ML asincrono)
const classification = await this.classifier.classify(originalBuffer);
// 5. Rimuovi EXIF sensibili, mantieni orientamento
const sanitizedMetadata = this.sanitizeExif(metadata);
const processedImage: ProcessedImage = {
listingId,
originalKey: imageKey,
variants: variants.map(v => ({
key: v.key,
url: this.storage.getPublicUrl(v.key),
width: v.width,
height: v.height,
format: v.format,
sizeBytes: v.sizeBytes,
})),
classification: {
roomType: classification.label,
confidence: classification.score,
},
metadata: sanitizedMetadata,
processedAt: new Date(),
};
// 6. Invalida cache CDN per l'annuncio
await this.queue.publish('cdn:invalidate', {
patterns: [`/listings/${listingId}/images/*`],
});
return processedImage;
}
private async generateVariant(
buffer: Buffer,
originalKey: string,
variant: ImageVariant
): Promise<GeneratedVariant> {
let pipeline = this.sharp(buffer);
if (variant.width > 0) {
pipeline = pipeline.resize(variant.width, undefined, {
fit: 'inside',
withoutEnlargement: true,
});
}
const outputBuffer = await pipeline
.toFormat(variant.format, { quality: variant.quality })
.toBuffer({ resolveWithObject: true });
const variantKey = originalKey.replace(
/\.[^.]+$/, `-${variant.suffix}.${variant.format}`
);
await this.storage.upload(variantKey, outputBuffer.data, {
contentType: `image/${variant.format}`,
cacheControl: 'public, max-age=31536000, immutable',
});
return {
key: variantKey,
width: outputBuffer.info.width,
height: outputBuffer.info.height,
format: variant.format,
sizeBytes: outputBuffer.info.size,
};
}
private sanitizeExif(metadata: any): Record<string, unknown> {
// Mantieni solo dati non sensibili
return {
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
format: metadata.format,
};
}
}
실시간 기능
현대 부동산 플랫폼에는 그 이상의 실시간 기능이 필요합니다. 간단한 페이지 업데이트. 사용자들은 그것을 기대한다 푸시 알림 관심 지역의 부동산이 게시되면, 메시징 인스턴트 에이전트와 가격 알림 재산이 있을 때 저장된 내용이 변경되었습니다.
경고 및 알림 시스템
interface SavedSearch {
readonly userId: string;
readonly searchParams: PropertySearchParams;
readonly notifyVia: ReadonlyArray<'push' | 'email' | 'sms'>;
readonly frequency: 'instant' | 'daily' | 'weekly';
readonly createdAt: Date;
readonly lastNotifiedAt?: Date;
}
interface PriceAlert {
readonly userId: string;
readonly listingId: string;
readonly thresholdType: 'any_change' | 'decrease' | 'below_amount';
readonly thresholdAmount?: number;
readonly notifyVia: ReadonlyArray<'push' | 'email'>;
}
class PriceAlertService {
constructor(
private readonly alertRepo: PriceAlertRepository,
private readonly listingRepo: ListingRepository,
private readonly notifier: NotificationService,
private readonly searchService: PropertySearchService,
) {}
// Invocato dall'event bus quando un listing cambia prezzo
async onPriceChanged(event: ListingPriceChangedEvent): Promise<void> {
const { listingId, oldPrice, newPrice } = event;
// Trova tutti gli alert per questo listing
const alerts = await this.alertRepo.findByListingId(listingId);
const notifications = alerts
.filter(alert => this.shouldNotify(alert, oldPrice, newPrice))
.map(alert => ({
userId: alert.userId,
channels: alert.notifyVia,
template: 'price_change',
data: {
listingId,
oldPrice: oldPrice.amount,
newPrice: newPrice.amount,
changePercent: ((newPrice.amount - oldPrice.amount) / oldPrice.amount * 100).toFixed(1),
currency: newPrice.currency,
direction: newPrice.amount < oldPrice.amount ? 'decrease' : 'increase',
},
}));
await Promise.all(
notifications.map(n => this.notifier.send(n))
);
}
// Invocato su schedule per "nuovi annunci nelle ricerche salvate"
async processNewListingAlerts(): Promise<void> {
const searches = await this.getSearchesToProcess();
for (const savedSearch of searches) {
const results = await this.searchService.search({
...savedSearch.searchParams,
publishedAfter: savedSearch.lastNotifiedAt,
pageSize: 10,
});
if (results.total > 0) {
await this.notifier.send({
userId: savedSearch.userId,
channels: savedSearch.notifyVia,
template: 'new_listings',
data: {
count: results.total,
topListings: results.listings.slice(0, 3),
searchUrl: this.buildSearchUrl(savedSearch.searchParams),
},
});
await this.alertRepo.updateLastNotified(savedSearch.userId, new Date());
}
}
}
private shouldNotify(
alert: PriceAlert,
oldPrice: Money,
newPrice: Money
): boolean {
switch (alert.thresholdType) {
case 'any_change':
return oldPrice.amount !== newPrice.amount;
case 'decrease':
return newPrice.amount < oldPrice.amount;
case 'below_amount':
return newPrice.amount <= (alert.thresholdAmount ?? 0);
default:
return false;
}
}
private async getSearchesToProcess(): Promise<ReadonlyArray<SavedSearch>> {
// Recupera le ricerche salvate che necessitano di notifica
// basandosi sulla frequenza configurata
return this.alertRepo.findSearchesReadyForNotification();
}
private buildSearchUrl(params: PropertySearchParams): string {
const qs = new URLSearchParams();
if (params.listingType) qs.set('type', params.listingType);
if (params.priceMin) qs.set('price_min', String(params.priceMin));
if (params.priceMax) qs.set('price_max', String(params.priceMax));
return `/search?${qs.toString()}`;
}
}
API 디자인: RESTful 엔드포인트
플랫폼의 공개 API는 경로의 버전 관리, 페이지 매김을 통해 REST 원칙을 따릅니다.
커서 기반 결과 및 헤더를 통한 다국어 지원 Accept-Language.
도메인별로 정리된 주요 엔드포인트는 다음과 같습니다.
| 방법 | 엔드포인트 | 설명 | 인증 |
|---|---|---|---|
GET |
/api/v1/listings |
필터를 사용하여 광고 검색 | 공공의 |
GET |
/api/v1/listings/{id} |
단일 광고 세부정보 | 공공의 |
POST |
/api/v1/listings |
새 광고 만들기 | 대리인 |
PATCH |
/api/v1/listings/{id} |
광고 업데이트(가격, 상태, 사진) | 대리인(소유자) |
POST |
/api/v1/search |
지역 필터를 사용한 고급 검색 | 공공의 |
POST |
/api/v1/search/map |
지도 보기당 클러스터(H3 집계) | 공공의 |
GET |
/api/v1/search/suggest |
위치 및 인근 지역 자동 완성 | 공공의 |
POST |
/api/v1/users/{id}/favorites |
즐겨찾기에 광고 저장 | 사용자 |
POST |
/api/v1/users/{id}/saved-searches |
경고에 대한 검색 저장 | 사용자 |
POST |
/api/v1/messages |
상담원에게 메시지 보내기 | 사용자 |
GET |
/api/v1/agents/{id} |
등급이 있는 상담원 프로필 | 공공의 |
POST |
/api/v1/media/upload-url |
업로드를 위해 미리 서명된 URL 생성 | 대리인 |
GET |
/api/v1/valuations/estimate |
시장 가격 추정 | 공공의 |
커서 기반 페이지 매김
대규모 데이터세트의 경우 오프셋 기반 페이지 매김(page/pageSize) 성능이 저하됩니다. 빨리. 우리는 채택한다 커서 기반 페이지 매김 트래픽이 많은 API의 경우 페이지 깊이에 관계없이 일정한 성능을 보장합니다.
{
"data": [
{
"id": "lst_abc123",
"title": "Trilocale luminoso zona Brera",
"price": { "amount": 385000, "currency": "EUR" },
"propertyType": "apartment",
"bedrooms": 2,
"sqMeters": 95,
"location": { "lat": 45.4726, "lng": 9.1860 },
"thumbnail": "https://cdn.platform.com/lst_abc123/thumb.webp"
}
],
"pagination": {
"cursor": "eyJzIjoiMjAyNi0wMy0wOFQxMDozMDowMFoiLCJpIjoibHN0X2FiYzEyMyJ9",
"hasMore": true,
"totalEstimate": 1247
},
"facets": {
"propertyTypes": [
{ "key": "apartment", "count": 832 },
{ "key": "house", "count": 215 }
],
"priceStats": { "min": 85000, "max": 2500000, "avg": 342000 }
}
}
스칼라 성능
수백만 개의 매물이 있는 부동산 플랫폼은 상당한 트래픽 급증을 관리해야 합니다. 일요일 저녁과 계절 변화 기간에는 검색량이 300~400% 증가합니다. 전략 성능은 아키텍처의 모든 수준에서 작동해야 합니다.
다단계 캐싱
| 수준 | 기술 | TTL | 사용 | 일반적인 적중률 |
|---|---|---|---|---|
| L1 - 엣지/CDN | CloudFront / Fastly | 5~60분 | 이미지, CSS/JS, 정적 페이지 | 90-95% |
| L2 - API 게이트웨이 | 바니쉬 / 콩캐시 | 1~5분 | GET API 응답(목록 세부정보, 검색결과) | 60-75% |
| L3 - 응용 프로그램 | 레디스 클러스터 | 30초~15분 | 세션, 패싯 개수, 지오코딩, H3 집계 | 80-90% |
| L4 - 쿼리 | Elasticsearch 복제 | 거의 실시간 | 검색에 대한 답글 읽기, 제안에 대한 별도의 색인 | 해당 없음(읽기 스케일링) |
| L5 - 데이터베이스 | PostgreSQL + pgbouncer | 해당 없음 | 연결 풀링, 보고서당 읽기 복제본 | 해당 없음 |
지역별 데이터베이스 샤딩
수백만 개의 자산이 지리적으로 분산되어 있으며 지역 및 전략별로 샤딩되어 있습니다. 부동산 플랫폼에 더 자연스럽습니다. 각 샤드에는 매크로 지역의 데이터가 포함되어 있습니다. 수평적으로 확장하는 동시에 규정을 준수할 수 있습니다. 데이터 상주(GDPR).
- 샤드 키: 지리적 지역(예:
country:region=IT:lombardia) - 라우팅: 게이트웨이 API는 쿼리 위치를 기반으로 샤드를 결정합니다.
- 교차 샤드 쿼리: 다중 지역 검색을 위한 분산 수집(드물게)
- 재조정: 지역이 임계값을 초과하면 분할됩니다(예: 밀라노는 전용 샤드가 됨)
읽기/쓰기 분리를 위한 CQRS 패턴
패턴 CQRS(명령 쿼리 책임 분리) 그리고 특히 읽기/쓰기 비율이 일반적으로 100:1인 부동산 플랫폼에 효과적입니다. 그만큼 경전 (광고 제작, 가격 업데이트) 리스팅 서비스를 통과합니다. PostgreSQL을 정보 소스로 사용합니다. 그만큼 판독값 (연구, 목록 세부정보)이 유용했습니다. Elasticsearch 및 Redis에서 이벤트를 통해 비동기적으로 업데이트됩니다. 이 디커플링 두 경로를 독립적으로 최적화할 수 있습니다.
다중 언어 및 다중 통화
국제 시장에서 운영되는 플랫폼(예: 스페인, 이탈리아, 포르투갈의 Idealista) 여러 언어로 콘텐츠를 관리하고 다양한 통화로 가격을 책정해야 합니다. 기지마다 전략이 다름 콘텐츠 유형에.
콘텐츠 유형 전략
- 정적 UI: 프런트엔드에서 로드된 번역 파일(i18n), 언어 1개 번들당(Angular i18n, React i18next)
-
광고 설명: 필드
LocalizedTextDB에서 번역 "자동 번역" 플래그가 있는 DeepL/Google 번역을 통해 자동 - 구애: 번역되지 않았지만(현지 언어로 유지됨) 도시에서는 다음을 수행할 수 있습니다. 변형 있음(밀라노/밀라노, 로마/로마)
- 물가: 원래 통화로 저장되며 표시를 위해 즉시 변환됩니다. API로 업데이트된 환율(ECB, 공개 환율)
-
SEO: 현지화된 URL(
/it/vendita/milanovs/en/sale/milan) 태그 포함hreflang중복된 내용을 피하기 위해
규정 준수: GDPR 및 공정 주택
부동산 플랫폼은 주택 선호도, 역량 등 매우 민감한 데이터를 처리합니다. 재정, 연구 이력. 규정 준수는 선택이 아닌 아키텍처 요구 사항입니다. 이는 모든 수준에서 시스템 설계에 영향을 미칩니다.
GDPR: 아키텍처에 미치는 영향
-
데이터 최소화: 반드시 필요한 데이터만 수집하세요.
필드에 서비스를 제공하지 않음
viewCount공개 응답에서 위치를 추적하지 마세요 명시적인 동의 없이 사용자의 GPS를 사용합니다. -
삭제할 권리: 구현하다
DELETE /api/v1/users/{id}/data30일 이내에 프로필, 저장된 검색, 메시지를 삭제하고 로그를 익명화합니다. -
데이터 이동성:
GET /api/v1/users/{id}/export생성하다 모든 사용자 데이터가 포함된 JSON/CSV 아카이브. - 동의 관리: 각 추적기(GA4, 히트맵, 리마케팅)만 활성화됨 명시적인 동의 후 카테고리별로 세분화됩니다.
- 데이터 거주지: 지역별 샤딩으로 요구 사항 준수가 용이해집니다. EU 사용자 데이터는 EU 데이터 센터에 남아 있습니다.
공정 주택 및 차별
미국에서는 공정주택법 판매 및 임대에 있어 차별을 금지합니다. 인종, 피부색, 종교, 성별, 장애, 가족 상태 또는 출신을 기반으로 한 부동산 전국. 기술적 의미:
- 검색 필터: 프록시 역할을 할 수 있는 필터를 제공하지 마십시오. 보호되는 특성(예: '이웃의 인구통계학적 구성')
- 광고 타겟팅: 스폰서 광고 타겟팅은 사용하면 안 됩니다. 보호되는 인구통계 기준(Facebook은 이를 위해 2022년에 500만 달러를 지불했습니다)
- ML 모델: 평가 모델의 편향에 대한 정기 감사 e 추천. 인근 지역의 부동산을 체계적으로 과소평가하는 모델 소수 다수는 불공평할 뿐만 아니라 불법적입니다.
- 광고 설명: 차별적 언어 자동 필터링 설명(예: '자녀가 없는 커플에게 이상적'은 공정주택법을 위반함)
경고: 평가 모델의 편견
부동산 평가 ML 모델(예: "Zestimate")은 과거 편향을 증폭시킬 수 있습니다. 훈련 데이터가 시장에서 수십 년간의 차별을 반영하는 경우(수정판), 모델은 이러한 불평등을 재현할 것입니다. 구현하는 것이 필수적입니다. 공정성 감사 인구 통계 및 지속적인 모니터링을 통한 형평성 지표 포함 지역별 예측.
결론 및 참조 아키텍처
대규모 부동산 플랫폼 구축 및 실행 시스템 설계 복잡한 도메인 모델링부터 소프트웨어 엔지니어링의 거의 모든 영역을 다룹니다. 멀티미디어 파이프라인 관리부터 규정 준수까지 지리공간 연구까지 엄격하다.
이 분석을 통해 밝혀진 주요 원칙은 다음과 같습니다.
- 도메인 분해: 리스팅, 검색, 미디어, 전용 데이터베이스를 갖춘 독립 서비스의 사용자 및 알림
- 기본 패턴으로서의 CQRS: 읽기/쓰기 비율은 100:1, 최적화가 아닌 별도의 읽기(Elasticsearch) 및 쓰기(PostgreSQL) 경로 시기상조이고 건축학적 필요성
- 일류 시민으로서의 지리공간: 집계용 H3, PostGIS 정밀도, 전체 스택 검색을 위한 Elasticsearch geo_point
- 비동기 파이프라인: 광고 수집, 미디어 처리 및 알림 사용자 경험을 저하시키지 않고 급증을 처리하려면 이벤트 기반이어야 합니다.
- 설계 준수: GDPR과 공정 주택은 추가할 기능이 아닙니다. 하지만 첫날부터 선택을 안내하는 아키텍처 제약이 있습니다.
시리즈의 다음 기사에서는 평가 모델 머신러닝 기반 부동산, Zillow, Redfin 및 유럽 플랫폼은 AVM(자동 평가 모델)을 사용하여 시장 가격을 추정합니다. 이 기능이 수반하는 기술적, 윤리적 과제는 무엇입니까?
리소스 및 통찰력
- 반환 웹 API: reso.org - MLS 데이터 교환 표준
- H3: h3geo.org - Uber의 육각형 지리공간 색인 시스템
- Elasticsearch 지리공간: elastic.co/docs - 지역 쿼리 문서
- 포스트GIS: postgis.net - PostgreSQL용 지리공간 확장
- PropTech의 GDPR: 프로파일링 및 자동화된 의사결정에 대한 EDPB 지침
- 공정주택법: hud.gov - 미국 차별금지법







