サイトアイコンmaita tomoya dev io

#95 Webパフォーマンス最適化ガイド — 計測から改善まで体系的アプローチ

はじめに

Webパフォーマンスはユーザー体験とビジネス成果に直結する。Googleの調査によれば、ページ読み込みが1秒から3秒に遅延するとバウンス率が32%増加する。本記事では、計測→ボトルネック特定→改善→検証のサイクルを体系的に解説する。

パフォーマンス指標(Core Web Vitals + α)

指標正式名良好の基準測定対象
TTFBTime to First Byte≤ 800msサーバー応答速度
FCPFirst Contentful Paint≤ 1.8s最初のコンテンツ描画
LCPLargest Contentful Paint≤ 2.5s最大コンテンツ描画
CLSCumulative Layout Shift≤ 0.1レイアウトのずれ
INPInteraction to Next Paint≤ 200msインタラクション応答性

LCP・CLS・INPがCore Web Vitalsであり、SEOランキングにも影響する。TTFBはサーバーサイドの健全性指標、FCPは体感速度の初期指標として併用する。

計測ツール

Lab Data(開発時計測)

Lighthouse

# CLI実行(ヘッドレス)
npx lighthouse https://example.com --output=json --output-path=./report.json

# 特定カテゴリのみ
npx lighthouse https://example.com --only-categories=performance

Lighthouseスコアは75以上を最低ライン、90以上を目標とする。

WebPageTest

WebPageTest(webpagetest.org)は実際のネットワーク条件でのテストが可能である。3G回線・途上国サーバーからのテストでワーストケースを把握する。Waterfall Chartでリソース読み込み順を可視化できる。

Chrome DevTools Performance Panel

  1. DevTools → Performance → Record
  2. ユーザー操作を再現
  3. Main threadのlong task(50ms超)を特定
  4. Bottom-Up / Call Treeで関数単位の実行時間を確認

Field Data(実ユーザー計測 / RUM)

Real User Monitoring(RUM)はweb-vitalsライブラリで実装できる。

import { onLCP, onCLS, onINP } from 'web-vitals';

onLCP(metric => sendToAnalytics('LCP', metric));
onCLS(metric => sendToAnalytics('CLS', metric));
onINP(metric => sendToAnalytics('INP', metric));

p75値を基準にアラートを設定し、p95/p99でテールレイテンシを監視する。

サーバーサイド最適化

DBクエリ最適化

N+1問題の検出と解決

N+1問題は、1件の親クエリに対してN件の子クエリが発行されるアンチパターンである。

-- 悪い例:記事一覧で著者名を取得する際にN+1が発生
SELECT * FROM articles;           -- 1クエリ
SELECT * FROM users WHERE id = 1; -- N回繰り返し
SELECT * FROM users WHERE id = 2;
...

-- 良い例:JOINまたはIN句で1-2クエリに集約
SELECT a.*, u.name AS author_name
FROM articles a
JOIN users u ON a.user_id = u.id;

ORMを使用する場合はEager Loading(Railsならincludes、Laravelならwith)を適用する。

EXPLAINによるクエリ分析

EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 AND status = 'pending';

確認ポイント:

  • Seq Scan(フルスキャン)が大規模テーブルで発生していないか
  • rowsの見積もりと実際の乖離
  • Sortがメモリ内で完了しているか

インデックス設計

-- 複合インデックス(カーディナリティの高い列を先頭に)
CREATE INDEX idx_orders_user_status ON orders (user_id, status);

-- カバリングインデックス(SELECT対象列を含める)
CREATE INDEX idx_orders_covering ON orders (user_id, status) INCLUDE (total, created_at);

インデックスは書き込み性能とトレードオフである。UPDATE/INSERTが多いテーブルでは不要なインデックスを定期的に棚卸しする。

キャッシュ戦略

Redis(アプリケーションキャッシュ)

import redis

r = redis.Redis(host='localhost', port=6379)

def get_user_profile(user_id):
    cache_key = f"user:profile:{user_id}"
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    
    profile = db.query_user_profile(user_id)
    r.setex(cache_key, 300, json.dumps(profile))  # TTL 5分
    return profile

キャッシュ戦略の選択:

  • Cache-Aside: 読み取り頻度が高く、更新頻度が低いデータ
  • Write-Through: データ整合性が重要な場合
  • TTL設計: アクセス頻度と許容される古さのバランス

CDN(静的アセット + API応答)

# nginx設定例
location /static/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

location /api/public/ {
    add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}

HTTP Cache-Control設計

リソース種別Cache-Control理由
ハッシュ付き静的アセットpublic, max-age=31536000, immutableファイル名にハッシュがあるため永続キャッシュ可能
HTMLno-cache常に再検証し最新を返す
API(公開データ)public, max-age=60, s-maxage=300CDNで5分、ブラウザで1分
API(個人データ)private, no-storeキャッシュ禁止

レスポンス圧縮

# nginx.conf
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1024;

# Brotli(gzipより15-20%高圧縮率)
brotli on;
brotli_types text/plain text/css application/json application/javascript;
brotli_comp_level 6;

Brotliは静的アセットの事前圧縮に最適である。動的レスポンスではCPUコストとのバランスでレベル4-6を選択する。

# 事前圧縮(ビルド時)
brotli -q 11 dist/main.js  # 最高圧縮率で事前生成

外部API呼び出しの並列化

直列呼び出しは各APIのレイテンシが累積する。独立したAPI呼び出しは並列化する。

// 悪い例:直列(合計レイテンシ = A + B + C)
const userProfile = await fetchUserProfile(userId);
const orders = await fetchOrders(userId);
const recommendations = await fetchRecommendations(userId);

// 良い例:並列(合計レイテンシ = max(A, B, C))
const [userProfile, orders, recommendations] = await Promise.all([
  fetchUserProfile(userId),
  fetchOrders(userId),
  fetchRecommendations(userId),
]);

タイムアウトとフォールバックも設定する。

const withTimeout = (promise, ms) =>
  Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms))]);

const profile = await withTimeout(fetchUserProfile(userId), 3000);

フロントエンド最適化

バンドルサイズ削減

Tree Shaking

ES Modulesのimport/export構文を使用し、未使用コードをビルド時に除去する。

// 悪い例:名前空間全体をインポート
import _ from 'lodash';
_.debounce(fn, 300);

// 良い例:必要な関数のみインポート
import debounce from 'lodash/debounce';
debounce(fn, 300);

バンドルサイズの確認:

# webpack
npx webpack-bundle-analyzer dist/stats.json

# vite
npx vite-bundle-visualizer

Code Splitting + Dynamic Import

// ルートベースの分割(React)
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

初期バンドルは200KB(gzip後)以下を目標とする。

画像最適化

次世代フォーマット(WebP / AVIF)

フォーマット圧縮率(JPEGの比)ブラウザ対応
WebP25-35%削減97%+
AVIF50%削減92%+
<picture>
  <source srcset="hero.avif" type="image/avif">
  <source srcset="hero.webp" type="image/webp">
  <img src="hero.jpg" alt="Hero image" width="1200" height="600">
</picture>

Lazy Loading + レスポンシブ画像

<img
  src="placeholder.jpg"
  srcset="image-400w.webp 400w, image-800w.webp 800w, image-1200w.webp 1200w"
  sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
  loading="lazy"
  decoding="async"
  alt="Description"
  width="1200"
  height="600"
>

LCP対象の画像はloading="lazy"を付けないこと。代わりにfetchpriority="high"を指定する。

<img src="hero.webp" fetchpriority="high" alt="Hero" width="1200" height="600">

Critical Rendering Path最適化

リソースヒント

<head>
  <!-- DNS事前解決 -->
  <link rel="dns-prefetch" href="https://cdn.example.com">
  <!-- 接続の事前確立 -->
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
  <!-- 重要リソースの事前読み込み -->
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
  <!-- 次ページの事前取得 -->
  <link rel="prefetch" href="/next-page.js">
</head>

CSSの最適化

<!-- Critical CSSをインライン化 -->
<style>
  /* Above-the-fold に必要な最小限のCSS */
  body { margin: 0; font-family: system-ui; }
  .header { height: 64px; background: #fff; }
</style>

<!-- 残りのCSSは非同期読み込み -->
<link rel="stylesheet" href="main.css" media="print" onload="this.media='all'">

JavaScript実行コスト削減

Long Taskの分割

50msを超えるタスクはメインスレッドをブロックし、INPを悪化させる。

// 重い処理をyieldで分割
function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    if (i % 100 === 0) {
      await yieldToMain(); // 100件ごとにメインスレッドに制御を返す
    }
  }
}

Web Workerによるオフロード

// worker.js
self.onmessage = (e) => {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};

// main.js
const worker = new Worker('/worker.js');
worker.postMessage(largeDataset);
worker.onmessage = (e) => updateUI(e.data);

サードパーティスクリプトの制御

<!-- 遅延読み込み(DOMContentLoaded後) -->
<script src="analytics.js" defer></script>

<!-- ユーザー操作後に読み込み -->
<script>
  document.addEventListener('scroll', () => {
    import('./chat-widget.js');
  }, { once: true });
</script>

p99レスポンスタイムの改善アプローチ

p99(99パーセンタイル)は100リクエスト中最も遅い1件の応答時間を示す。平均値では見えないテールレイテンシの問題を捉える。

p99が悪化する典型的原因

  1. GC(ガベージコレクション)停止: ヒープサイズの肥大化
  2. コネクションプール枯渇: DB接続待ちのキューイング
  3. 外部APIのタイムアウト待ち: リトライ無しの同期呼び出し
  4. ロック競合: 排他制御による待機
  5. コールドスタート: コンテナ/Lambda初期化

改善手法

# コネクションプール設定例(HikariCP)
maximumPoolSize: 20
minimumIdle: 5
connectionTimeout: 3000   # 3秒でタイムアウト
idleTimeout: 600000       # 10分でidle接続を回収

外部APIには必ずタイムアウトとCircuit Breakerを設定する。

// Circuit Breaker パターン(概念)
const breaker = new CircuitBreaker(fetchExternalAPI, {
  timeout: 3000,        // 3秒タイムアウト
  errorThreshold: 50,   // エラー率50%でOpen
  resetTimeout: 30000,  // 30秒後にHalf-Open
});

ボトルネック特定の手順

1. プロファイリング(現状把握)

# サーバーサイド:レスポンスタイム分布の確認
# アクセスログからp50/p95/p99を算出
awk '{print $NF}' access.log | sort -n | awk '
  {a[NR]=$1}
  END {
    print "p50:", a[int(NR*0.5)];
    print "p95:", a[int(NR*0.95)];
    print "p99:", a[int(NR*0.99)];
  }'

フロントエンド:

  • Chrome DevTools → Performance → Record → ユーザー操作再現
  • Long Taskを特定し、Call Treeで原因関数を追跡

2. 仮説立案

データに基づき仮説を立てる。

観測事象仮説検証方法
TTFBが2秒超DBクエリが遅いSlow Query Logの確認
LCPが4秒超画像が未最適化Network panelで画像サイズ確認
INPが500ms超JSのLong TaskPerformance panelでTask確認
p99のみ悪いGC or 接続枯渇JVMメトリクス / プール状態確認

3. 検証(A/Bテスト or 段階的リリース)

  1. ステージング環境で改善を適用
  2. 負荷テストで効果を定量化
  3. カナリアリリース(5%→25%→100%)で本番検証
  4. RUMデータでp75/p99の変化を確認
# 負荷テスト(k6)
k6 run --vus 100 --duration 60s load-test.js

改善施策の優先順位付け

効果 × 工数マトリクス

施策を「効果(パフォーマンス改善度)」と「工数(実装コスト)」の2軸で分類する。

        効果 大
          │
    ②     │     ①
  重要だが  │  最優先
  要計画   │  (Quick Win)
          │
──────────┼──────────
          │
    ④     │     ③
  後回し   │  余裕があれば
          │
          │
        効果 小
   工数 大         工数 小

優先度別の施策例

優先度施策効果工数
① Quick Win画像のWebP変換LCP 30-50%改善数時間
① Quick WinCache-Controlヘッダー設定TTFB大幅改善数時間
① Quick Wingzip/Brotli有効化転送量60-80%削減1時間
② 計画的実施Code Splitting導入初期読み込み50%削減数日
② 計画的実施DBインデックス最適化クエリ10-100倍高速化数日
③ 余裕時Web Worker導入INP改善数日
④ 後回しフレームワーク移行全体改善数週間〜

実行の原則

  1. 計測なき最適化は行わない — 必ずベースラインを取得してから着手
  2. 1つずつ変更し効果を測定 — 複数施策を同時適用すると因果関係が不明になる
  3. ユーザー影響度で判断 — p75改善はp99改善より多くのユーザーに恩恵がある
  4. 自動化で退行を防ぐ — Lighthouse CIをパイプラインに組み込む
# Lighthouse CI(GitHub Actionsでの実行例)
npx lhci autorun --config=lighthouserc.json

まとめ

Webパフォーマンス最適化は、計測→分析→改善→検証の継続的サイクルである。

  • まず計測ツール(RUM + Lighthouse)でベースラインを確立する
  • Core Web Vitals(LCP ≤ 2.5s, CLS ≤ 0.1, INP ≤ 200ms)を目標に設定する
  • Quick Win(圧縮・キャッシュ・画像最適化)から着手し、早期に効果を出す
  • サーバーサイドはDBクエリとキャッシュ、フロントエンドはバンドルサイズとCritical Pathが主戦場である
  • p99の改善にはタイムアウト設定とCircuit Breakerが有効である
  • 改善は効果×工数マトリクスで優先順位を付け、1つずつ検証する