Property Listings Search (geo + filters)

System Design

Understand it step by step (한국어로 이해 → 영어로 말하기)

사용자가 지도에서 한 지역을 보면서 "마이애미, 침실 3개 이상, 50만 달러 이하" 같은 조건으로 매물을 찾는 검색 시스템을 설계한다. 핵심은 지도 박스(위/경도 범위) 안의 매물을 필터 + 정렬 + 페이지네이션해서 빠르게 돌려주는 것이다. 가격이나 상태가 바뀌면 검색 결과에도 몇 초 안에 반영돼야 한다.
  1. 1

    기능 요구사항은 3개로 좁힌다. (1) 지도 박스(북동/남서 위경도) 안의 매물 조회. (2) 가격/침실/타입/상태 필터 + 가격순·최신순 정렬 + 페이지네이션. (3) 가격이나 상태가 바뀌면 검색 결과에 반영. 비기능: 검색은 읽기 위주라 가용성과 낮은 지연(p99 200ms 목표)이 강력한 일관성보다 중요하다. 매물 가격이 1~2초 늦게 반영돼도 괜찮다 — 약간의 stale은 허용. 단, 'source of truth'인 Postgres의 매물 데이터 자체는 정확해야 한다.

    ⚖️ Trade-off: 검색 인덱스는 약간 stale해도 된다(eventual consistency). 대신 빠르고 가용성이 높다. 정확한 최신 가격이 꼭 필요하면 상세 페이지에서 Postgres를 직접 읽으면 된다.

    🔧 도구:PostgresElasticsearch

    🗣 영어로 말해

    Read-heavy search, so I optimize for low latency and availability; a one-second-stale price is acceptable.

    checking microphone…

  2. 2

    규모 추정으로 설계를 잡는다. 활성 매물 약 200만 건, 매물당 메타데이터 ~2KB → 약 4GB, 인덱스 포함 ~15GB. 한 노드에 충분히 들어가지만 가용성 위해 복제한다. 트래픽: 검색 QPS 평균 5,000, 피크 20,000. 쓰기(가격 변경, 신규 매물, 상태 전이)는 초당 ~200건. 읽기:쓰기 ≈ 25:1 (평균 5,000 ÷ 200). 즉 압도적으로 읽기 시스템이라 검색 전용 인덱스 + 캐시에 투자한다.

    ⚖️ Trade-off: 데이터가 한 노드에 들어가도, 피크 20K QPS와 가용성 때문에 읽기 복제본을 여러 개 둔다. 비용은 늘지만 단일 장애점을 없앤다.

    🔧 도구:read replicasElasticsearch

    active listings ~2M · ~2KB each → ~4GB data, ~15GB w/ index
    search QPS: avg 5K, peak 20K
    writes (price/status/new): ~200/s
    read:write ≈ 25:1 (avg)
    🗣 영어로 말해

    Two million listings, peak twenty thousand search QPS, about twenty-five-to-one read-to-write.

    checking microphone…

  3. 3

    API는 검색 하나에 집중한다. GET /listings/search 가 bbox(지도 박스), 필터, 정렬, 커서를 받는다. 오프셋 페이지 대신 커서를 쓴다 — 깊은 페이지에서도 일정하게 빠르고 결과가 흔들리지 않는다. 데이터 모델: Postgres의 listing 테이블이 source of truth(id, geo point, price, beds, type, status, updated_at). 검색은 Elasticsearch의 비정규화된 문서로 한다. 파티션(샤딩) 키는 지역(region/state) — 검색이 거의 항상 지리적으로 묶이기 때문에 한 지역 쿼리가 한 샤드 안에서 끝난다.

    ⚖️ Trade-off: 커서 페이지네이션은 '7페이지로 점프' 같은 임의 점프가 안 된다. 대신 깊은 페이지 비용이 일정하고, 새 매물이 들어와도 페이지가 밀리지 않는다.

    🔧 도구:Elasticsearchgeohash/quadtreesharding

    GET /listings/search?bbox=25.70,-80.30,25.80,-80.10
      &minPrice=&maxPrice=&minBeds=&type=&status=active
      &sort=price_asc&cursor=<opaque>&size=20
    
    // ES doc (denormalized, source = Postgres)
    { id, geo: {lat,lon}, price, beds, type, status, geohash, updated_at }
    // shard/routing key = region (e.g. state)
    🗣 영어로 말해

    Postgres is the source of truth; search runs on a denormalized Elasticsearch index sharded by region.

    checking microphone…

  4. 4

    요청 흐름: 클라이언트 → 로드밸런서 → Search 서비스 → (캐시 미스면) Elasticsearch → 결과 반환. 쓰기 흐름은 분리한다: 매물 변경은 먼저 Postgres에 커밋(source of truth)되고, 같은 트랜잭션에서 outbox 행을 쓴다. 워커가 outbox/CDC를 읽어 Elasticsearch 인덱스를 비동기로 갱신한다. 이렇게 하면 사용자 트랜잭션은 검색 인덱스 장애와 무관하게 안전하게 커밋된다. 인기 지역 검색은 Redis에 짧은 TTL(예: 10초)로 캐시한다.

    ⚖️ Trade-off: Postgres와 ES를 따로 두면 둘 사이에 짧은 지연(인덱스 lag)이 생긴다. 대신 쓰기 경로와 읽기 경로가 분리돼 한쪽 장애가 다른 쪽을 막지 않는다.

    ✅ Fix: outbox 패턴으로 Postgres 커밋과 '인덱스 갱신할 일' 기록을 한 트랜잭션에 묶는다. 워커가 at-least-once로 ES에 반영하되, doc id 기준 upsert라 중복 적용돼도 안전하다.

    🔧 도구:RedisCDCKafkaoutboxElasticsearchPostgres

    write: Postgres commit (listing + outbox row, one tx)
            → CDC/worker → ES upsert (idempotent by doc id)
    read:  client → LB → Search svc → Redis(10s TTL) → ES
    🗣 영어로 말해

    Writes commit to Postgres with an outbox row; a worker fans the change into Elasticsearch asynchronously.

    checking microphone…

  5. 5

    딥다이브 1 — 지리 검색을 빠르게. 지도 박스 쿼리를 매번 전체 스캔하면 느리다. Elasticsearch의 geo_point + BKD 트리(또는 geohash 버킷)로 bbox 안의 매물만 먼저 좁히고, 그 위에서 가격/침실 필터와 정렬을 적용한다. 핵심 순서: geo로 후보를 줄인 뒤(보통 수백~수천 건) 필터·정렬한다 — 좁은 후보 위에서만 정렬하니 비용이 일정하다. 라우팅 키가 region이라 한 지역 검색은 한 샤드만 친다.

    ⚖️ Trade-off: geohash 격자는 격자 경계에 걸친 매물을 위해 인접 셀까지 봐야 할 수 있다. 정밀도(격자 크기)를 올리면 정확하지만 셀이 많아지고, 낮추면 빠르지만 후보가 늘어난다.

    ✅ Fix: geo_point + BKD 트리로 bbox를 먼저 적용해 후보를 수천 건으로 줄이고, 그 다음에만 필터·정렬·페이지네이션을 한다. region 라우팅으로 fan-out을 한 샤드로 제한한다.

    🔧 도구:geohash/quadtreeElasticsearchBKD treesharding

    1) geo_bbox filter  → ~thousands of candidates
    2) range filters (price, beds, type, status)
    3) sort (price/updated_at) + cursor paginate
    // routing=region → single-shard query, no scatter-gather
    🗣 영어로 말해

    I narrow by the map box first, then filter and sort on that small candidate set, not the whole index.

    checking microphone…

  6. 6

    딥다이브 2 — 핫 지역 + 인덱스 lag. 마이애미·LA 같은 인기 지역은 같은 검색이 폭주하는 hot key가 된다. Redis에 정규화된 쿼리 키로 짧은 TTL 캐시를 두고, 캐시 미스가 동시에 몰릴 때는 single-flight(요청 하나만 ES를 치고 나머지는 대기)로 cache stampede를 막는다. 인덱스 lag 문제(가격 바꿨는데 검색에 안 보임)는 워커 처리량을 키우고, 상세 페이지는 ES가 아니라 Postgres에서 직접 읽어 항상 최신 가격을 보장한다.

    ⚖️ Trade-off: TTL을 길게 하면 캐시 적중률이 오르지만 가격 변경이 더 늦게 보인다. 짧게 하면 신선하지만 ES 부하가 는다. 핫 지역은 10초 정도로 타협한다.

    ✅ Fix: 정규화된 쿼리 → Redis 캐시 + single-flight로 stampede 차단. 신선도가 중요한 가격은 상세 화면에서 Postgres를 직접 읽어 검색 lag과 분리한다.

    🔧 도구:Redissingle-flightcache stampedePostgres

    key = hash(normalized query)  // bbox+filters+sort+cursor
    Redis GET → hit: return
            → miss: single-flight → ES → SET (TTL 10s)
    detail page price → read Postgres directly (always fresh)
    🗣 영어로 말해

    Hot regions get a short-TTL Redis cache with single-flight, so a cache miss never stampedes Elasticsearch.

    checking microphone…

  7. 7

    트레이드오프 정리 + 모니터링. 포기한 것: 검색은 강한 일관성이 아니라 eventual consistency다(인덱스가 1~2초 늦을 수 있음). 커서 페이지네이션이라 임의 페이지 점프는 못 한다. Postgres와 ES를 따로 둬서 운영 복잡도와 동기화 부담이 생겼다. 감시할 지표: 검색 p99 지연, ES 인덱스 lag(Postgres 커밋 → ES 반영까지 시간), outbox 워커 적체(backlog) 길이, Redis 적중률, 그리고 '결과 0건' 비율(인덱스가 비었거나 쿼리가 깨진 신호).

    ⚖️ Trade-off: 강한 일관성·임의 페이지 점프·단일 저장소의 단순함을 포기하고, 낮은 지연·높은 가용성·읽기/쓰기 분리를 얻었다. 실시간 정확도가 필요한 곳(상세 가격)만 Postgres 직독으로 보강한다.

    🔧 도구:ElasticsearchRedisoutboxPostgres

    watch: search p99 · ES index lag (commit→visible)
         · outbox worker backlog · Redis hit-rate
         · zero-result rate (broken index / query signal)
    🗣 영어로 말해

    I traded strong consistency and offset paging for low latency; I'd watch p99, index lag, and worker backlog.

    checking microphone…

7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

🎙 이 카드 AI랑 음성 대화 · 기록 저장