Article

不動産業界のWebシステム事例:物件検索サイト構築で問い合わせ件数増加

高田晃太郎
不動産業界のWebシステム事例:物件検索サイト構築で問い合わせ件数増加

統計が示す現実は明快です。国土交通省の住宅市場動向調査では、物件情報の収集手段として“インターネット”の利用が主流となり、近年増加傾向にあると報告されています¹。Think with Googleの公開データでは、モバイルの読み込みが1秒から3秒に遅くなると直帰確率が上昇することが示され²、web.devの指標ではLCP(Largest Contentful Paint, 主要コンテンツの描画完了)の良好判定を2.5秒未満とする目安が示されています³。加えて、ページ速度は検索にも影響するシグナルの一つとされています⁴。つまり、不動産の物件検索サイトは「表示速度」と「検索精度(適合度)」の両輪で成果が決まり、単なるCMS刷新だけでは十分な効果に結びつかないことが多い。以下では、不動産業界のWebシステム開発における実装視点から、フレームワーク選定、データモデル、検索エンジン設計、キャッシュ戦略、そして計測とチューニングのループまでを具体的に共有します。目安としては、検索APIのサブ200ms応答やLCP約2秒を狙う設計が現実的なターゲットになり得ます(環境や要件により異なります)。

不動産の物件検索で成果を出す要件定義

不動産の検索は、単純な文字列一致だけでは不十分です。利用者が求めるのは、エリアや沿線、通勤時間、価格帯、築年数、間取り、さらに地図連動や学校区などの複合条件絞り込みであり、その裏側では正規化されたプロパティスキーマ(データの一貫性を保つ設計)と、検索エンジン向けの非正規化データ(検索最適化のための冗長構造)が共存する必要があります。典型的なプロジェクトでは、ポータル依存から自社集客への転換を志向し、KGI(最終目標の指標)として「問い合わせ数の増加」などを掲げます。KPI(達成度を測る指標)の例としては、検索応答時間150ms以下、p75のLCPを2.0秒以内³、インデキシングのレイテンシ10分以内、そしてインデクシング落ちの許容率0.1%以下などが挙げられます。これらの数値を初期段階で合意し、要件としてバックログに翻訳しておくと、実装や優先順位の判断がぶれにくくなります。

検索クエリの分布は、多くのサイトで「都市名×価格帯」の組み合わせが大きな割合を占め、次いで駅名や所要時間の条件が続く傾向が観測されます。ここから、都市や価格に強い複合インデックスはRDBに、自由語検索や類義語処理は検索エンジンに委譲する役割分担が自然に導かれます。地図検索と学区境界はジオクエリのボトルネックになりやすいため、ジオポリゴンの事前タイル化を行い、クエリ時はポリゴン直接ではなくバウンディングボックスまたはタイルIDで検索する戦略が有効です。こうした戦略上の設計は、後述のパフォーマンス最適化に直結します。

KPIから逆算するアーキテクチャの輪郭

バックエンドはPostgreSQLをソースオブトゥルース(唯一の正)に、Elasticsearchを検索アクセラレータ、Redisを結果キャッシュ、APIはNestJS、フロントはNext.jsでSSR(Server-Side Rendering)/ISR(Incremental Static Regeneration)を併用する構成が扱いやすい選択肢です。CDNと画像最適化を前提に、遅延読み込みやCritical CSSの抽出、地図は初期描画を静的画像で代替しユーザー操作でインタラクティブ化することで、LCPの抑制を狙います。パイプラインはCDC(変更データ取得)に近いバルク同期で「おおむね10分以内」の反映を目標とし、価格改定など高頻度イベントは差分アップサートで「秒オーダー」の反映を目指します。即時性が必要なフィールドはRDB直参照の補助APIを用意し、検索面はESをメインとする二層化で整合性と速度の均衡を取ります。

データモデルの骨格とインデックス戦略

アプリケーション側の扱いやすさを優先してRDBの正規化は最小限に留め、検索エンジン側ではタイトル、説明、駅名などを日本語形態素解析にかけ、価格や面積、築年は数値型、位置情報はgeo_pointに統一します。駅から徒歩時間は正規化時に分単位へ落とし込み、クエリ時のスカラー比較を高速化します。RDBでは都市と価格の複合インデックス、公開状態の部分インデックスを採用し、ESでは日本語トークナイザのkuromojiとキーワードフィールドの併用で、あいまい一致と完全一致の両方をサポートします。

検索と表示速度を両立させるアーキテクチャ

アプリはNext.jsでSSRを基本にしつつ、結果リストはストリーミングで段階的に描画し、フォールド上の1件目カードとキービジュアルをLCP要素に固定します。バックエンドではElasticsearchへのクエリ結果をRedisで短期キャッシュし、頻出クエリのヒット率を高めることでピーク時の負荷を吸収します。地図との連動はサーバーが返す初期HTMLに静的なタイル画像を含め、ユーザーがパン・ズームを始めた段階で動的ライブラリを読み込む設計とします。これにより、初期JavaScriptバンドルの削減(ケースによっては約30%の圧縮が見込めることがあります)と、インタラクティブになるまでの時間短縮が期待できます。

ボトルネックの見極めには、RUM(Real User Monitoring, 実ユーザー計測)と合成監視(シナリオを固定した擬似ユーザー計測)の併用が有効です。RUMではp75のLCP/INP/CLSをweb.devのガイドに沿って計測し³、合成監視ではk6等でシナリオベースのスループットとテールレイテンシ(p95/p99)を測定します。運用目安として、検索APIのp95応答300ms未満、キャッシュヒット時100ms前後の応答感、LCPは3秒台から2秒台前半への改善をターゲットに置くと、UXとSEO双方での底上げにつながりやすい。外部の調査でも、ページ速度の悪化が直帰やセッション長の低下に結びつく傾向が指摘されており⁵、速度改善がCVR(コンバージョン率)と相関するケースは広く報告されています。

RDBスキーマとインデックスの最小構成

-- PostgreSQL: 物件テーブルの骨格と実用インデックス
CREATE TABLE properties (
  id              bigserial PRIMARY KEY,
  title           text NOT NULL,
  description     text,
  price           integer NOT NULL CHECK (price > 0),
  address         text NOT NULL,
  city            text NOT NULL,
  prefecture      text NOT NULL,
  latitude        double precision,
  longitude       double precision,
  floor_plan      text,
  area_m2         numeric(10,2),
  built_year      integer,
  station         text,
  station_distance_min integer,
  published_at    timestamptz NOT NULL DEFAULT now(),
  status          text NOT NULL CHECK (status IN ('public','draft','archived')),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_properties_city_price ON properties(city, price);
CREATE INDEX idx_properties_published ON properties(published_at) WHERE status = 'public';
CREATE INDEX idx_properties_geo ON properties USING gist (ll_to_earth(latitude, longitude));

Elasticsearchの日本語対応マッピング

curl -X PUT "http://localhost:9200/properties_v1" \
  -H "Content-Type: application/json" -d '{
  "settings": {
    "analysis": {
      "analyzer": {
        "ja_search": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "kuromoji_baseform",
            "kuromoji_part_of_speech",
            "ja_stop",
            "kuromoji_stemmer",
            "lowercase"
          ]
        }
      }
    },
    "index": { "number_of_shards": 3, "number_of_replicas": 1 }
  },
  "mappings": {
    "properties": {
      "title":        { "type": "text", "analyzer": "ja_search" },
      "description":  { "type": "text", "analyzer": "ja_search" },
      "price":        { "type": "integer" },
      "city":         { "type": "keyword" },
      "prefecture":   { "type": "keyword" },
      "location":     { "type": "geo_point" },
      "area_m2":      { "type": "float" },
      "built_year":   { "type": "integer" },
      "station":      { "type": "text", "analyzer": "ja_search" },
      "published_at": { "type": "date" }
    }
  }
}'

実装の要点:同期・API・フロントの整合

最初に、RDBからESへの同期はバッチとストリーミングの中間に位置づけ、10分ごとの差分アップサートを基本に、価格更新や公開状態の切り替えはイベントドリブンで即時反映する設計が扱いやすい。障害時に整合が崩れないよう、全件リビルドが数時間で完了するバルクパイプラインを用意し、メトリクスで滞留検知を行います。APIは検索エンジンをフロントに出さず、NestJSのBFF層でクエリ正規化とバリデーション、レート制御、結果のシリアライズを担保します。フロントはSSRで初回レスポンスを高速化し、クライアント側はクエリをデバウンスして不要な呼び出しを抑制します。

差分同期バッチ(TypeScript, pg + Elasticsearch)

import { Client as PgClient } from 'pg';
import { Client as EsClient } from '@elastic/elasticsearch';
import pLimit from 'p-limit';

const pg = new PgClient({ connectionString: process.env.DATABASE_URL });
const es = new EsClient({ node: process.env.ELASTIC_URL || 'http://localhost:9200' });
const limit = pLimit(8);

async function fetchDiff(sinceIso: string) {
  const { rows } = await pg.query(
    `SELECT id, title, description, price, city, prefecture, latitude, longitude,
            area_m2, built_year, station, station_distance_min, published_at, status
     FROM properties
     WHERE updated_at >= $1 AND status = 'public'`,
    [sinceIso]
  );
  return rows.map(r => ({
    id: r.id,
    title: r.title,
    description: r.description,
    price: r.price,
    city: r.city,
    prefecture: r.prefecture,
    location: r.latitude && r.longitude ? { lat: r.latitude, lon: r.longitude } : undefined,
    area_m2: r.area_m2,
    built_year: r.built_year,
    station: r.station,
    published_at: r.published_at
  }));
}

async function bulkUpsert(docs: any[]) {
  if (docs.length === 0) return;
  const body = docs.flatMap(d => [{ index: { _index: 'properties_v1', _id: String(d.id) } }, d]);
  const res = await es.bulk({ refresh: false, body });
  if (res.errors) {
    const items = (res.items || []).filter((i: any) => i.index?.error);
    console.error('Bulk errors', items.slice(0, 5));
    throw new Error(`Bulk failed: ${items.length} errors`);
  }
}

async function main() {
  await pg.connect();
  const since = new Date(Date.now() - 10 * 60 * 1000).toISOString();
  const docs = await fetchDiff(since);
  const chunk = 1000;
  for (let i = 0; i < docs.length; i += chunk) {
    const part = docs.slice(i, i + chunk);
    await limit(() => bulkUpsert(part));
  }
}

main().catch(async (e) => {
  console.error(e);
  await pg.end().catch(() => void 0);
  process.exit(1);
});

検索API(NestJS + Redisキャッシュ)

import { Controller, Get, Query, InternalServerErrorException } from '@nestjs/common';
import { Client as EsClient } from '@elastic/elasticsearch';
import Redis from 'ioredis';

const es = new EsClient({ node: process.env.ELASTIC_URL! });
const redis = new Redis(process.env.REDIS_URL!);

@Controller('search')
export class SearchController {
  @Get()
  async search(
    @Query('q') q = '',
    @Query('city') city?: string,
    @Query('minPrice') minPrice?: string,
    @Query('maxPrice') maxPrice?: string,
    @Query('page') page = '1'
  ) {
    const pageNum = Math.max(1, parseInt(page, 10) || 1);
    const key = `s:${q}:${city}:${minPrice}:${maxPrice}:${pageNum}`;
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached);

    try {
      const must: any[] = [];
      if (q) must.push({ multi_match: { query: q, fields: ['title^2', 'description', 'station'] } });
      if (city) must.push({ term: { city } });
      const range: any = {};
      if (minPrice) range.gte = parseInt(minPrice, 10);
      if (maxPrice) range.lte = parseInt(maxPrice, 10);
      if (range.gte || range.lte) must.push({ range: { price: range } });

      const res = await es.search({
        index: 'properties_v1',
        from: (pageNum - 1) * 20,
        size: 20,
        query: { bool: { must } },
        sort: [{ published_at: 'desc' }],
      });

      const hits = (res.hits.hits || []).map((h: any) => ({ id: h._id, ...h._source }));
      const payload = { items: hits, total: res.hits.total?.value || 0 };
      await redis.set(key, JSON.stringify(payload), 'EX', 30);
      return payload;
    } catch (e) {
      console.error('ES error, degrade to minimal set', e);
      throw new InternalServerErrorException('temporary_unavailable');
    }
  }
}

Next.js(SSR + デバウンス検索)

import { useEffect, useMemo, useState } from 'react';
import type { GetServerSideProps } from 'next';
import Head from 'next/head';

export const getServerSideProps: GetServerSideProps = async ({ query }) => {
  const params = new URLSearchParams(query as any);
  const res = await fetch(`${process.env.API_BASE}/search?${params.toString()}`);
  const data = await res.json();
  return { props: { initial: data } };
};

export default function SearchPage({ initial }: { initial: any }) {
  const [q, setQ] = useState('');
  const [items, setItems] = useState(initial.items);

  const debounced = useMemo(() => {
    let t: any;
    return (val: string) => {
      clearTimeout(t);
      t = setTimeout(async () => {
        const url = `/api/search?q=${encodeURIComponent(val)}`;
        const r = await fetch(url);
        const d = await r.json();
        setItems(d.items);
      }, 300);
    };
  }, []);

  useEffect(() => { if (q.length >= 2) debounced(q); }, [q]);

  return (
    <>
      <Head>
        <title>物件検索</title>
        <meta name="description" content="高速な物件検索で最適な住まいを発見" />
      </Head>
      <main>
        <input
          placeholder="駅・エリア・キーワード"
          value={q}
          onChange={(e) => setQ(e.target.value)}
          aria-label="検索語"
        />
        <section>
          {items.map((p: any) => (
            <article key={p.id}>
              <h2>{p.title}</h2>
              <p>{p.city} / ¥{p.price.toLocaleString()}</p>
            </article>
          ))}
        </section>
      </main>
    </>
  );
}

距離・通勤時間を考慮した近傍検索(PostGIS)

-- 最寄り駅までの距離が近い順(地球半径換算)
SELECT id, title, station,
       earth_distance(ll_to_earth(latitude, longitude), ll_to_earth($1, $2)) AS meters
FROM properties
WHERE status = 'public'
ORDER BY meters ASC
LIMIT 20;

負荷試験シナリオ(k6)

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = { vus: 50, duration: '2m', thresholds: {
  http_req_duration: ['p(95)<300'],
}};

export default function () {
  const qs = `q=${encodeURIComponent('渋谷')}&minPrice=50000000&maxPrice=80000000&page=1`;
  const res = http.get(`https://example.com/api/search?${qs}`);
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(0.5);
}

成果と学び:KPIへの寄与とROIの考え方

計測は「事実に基づく改善」の起点です。検索APIはp95応答300ms未満、キャッシュヒット時は100ms前後、フロントはp75のLCPを2秒前後、INPは300ms未満、CLSは0.1未満といった水準を中期目標に据えると、Core Web Vitalsの“良好”判定を安定して狙いやすくなります³。ビジネス側では、ページ速度や検索体験の改善によりオーガニック流入や物件詳細の閲覧数、問い合わせの増加が期待でき、媒体依存の逓減やCPAの改善につながる可能性があります。速度はモバイル検索の評価にも関わるシグナル⁴であるため、UX改善はSEO(検索エンジン最適化)と整合的に働きます。ROIは、開発・運用コストと獲得効率の改善幅、成約転換率の想定を組み合わせて試算し、効果検証サイクル(A/Bテストや段階ロールアウト)で着実に検証するのが現実的です。

開発プロセス上のハイライトは三つあります。ひとつめは、検索をまず速くし、次に賢くするという優先順位の厳守です。ベクトル検索や高度なランキングは強力ですが、p95の遅延が300msを超える状況では、キャッシュ戦略とクエリ削減の方がインパクトが大きい。ふたつめは、ESとRDBの一貫性をKPI化することです。同期遅延の中央値とp95、ドロップ率をダッシュボード化し、しきい値超過でアラートを鳴らすだけで運用の安心度が段違いになります。みっつめは、パフォーマンス・バジェットをチーム全員で共有し、画像・フォント・サードパーティスクリプトの導入判断を数値で統制することです。これにより、LCPの悪化を未然に防ぎ、機能追加と速度の両立が現実的になります。

トラブルシュートとフォールバック設計

実装で起こりがちな事象として、検索キャッシュのキー設計の抜けによる取り違えが挙げられます。BFF層の正規化前にキーを生成すると境界条件で不整合が生じやすいため、正規化後に統一したパラメータ順序でキーを生成することで回避します。また、夜間のバルク同期でESクラスターのリソースが枯渇し、短時間のタイムアウトが散発するケースがあります。読み取り系のインデックス更新はエイリアスを用いたローリング・リインデックスとスロットリングで段階的に行い、API側は短期キャッシュのTTLを自動延長してユーザー影響を最小化します。こうしたフォールバックは事前に仕込むほど効果が高く、プロダクションの安定性は周到な最悪ケース想定によって守られます。

まとめ:検索は“速さ×適合度×見え方”の積

この内容が示すのは、検索の賢さだけでは問い合わせは増えにくいという単純な事実です。速さが担保され、ユーザーの意図に適合し、ページがすばやく美しく描画されること。三つが掛け算で効くとき、ビジネスの針は確実に動きます。KPIを最初に言語化し、アーキテクチャに落とし、実装と運用でブレさせない。その地味な反復が、LCP約2秒、検索応答サブ200msといった到達可能な目安に近づけます。あなたの組織では、まずどの数値を動かしますか。もし検索のp95応答が300msを超えるならキャッシュとクエリ正規化から、LCPが3秒を超えるなら画像最適化と初期HTMLのスリム化から始めてください。短期でも着手効果を実感できる領域です。

参考文献

  1. 国土交通省 報道発表資料「インターネットによる物件情報収集が大きく増加しています!~令和3年度住宅市場動向調査~(修正告知含む)」
    https://www1.mlit.go.jp/report/press/house02_hh_000173.html

  2. Think with Google「Mobile Page Speed: Load Time」
    https://business.google.com/in/think/marketing-strategies/mobile-page-speed-load-time/

  3. web.dev「Largest Contentful Paint (LCP)」
    https://web.dev/articles/lcp

  4. Google Search Central Blog「Using page speed in mobile search ranking」
    https://developers.google.com/search/blog/2018/01/using-page-speed-in-mobile-search

  5. MediaPost「Three Seconds And You’re Out」
    https://www.mediapost.com/publications/article/306009/three-seconds-and-youre-out.html