Article

データ通信量を削減する設定と運用

高田晃太郎
データ通信量を削減する設定と運用

HTTP Archiveの統計では、2024年のモバイルページの中央値は約2.2MBで、そのうち画像が約1.0MB、JavaScriptが約0.6MBを占めています¹。参考としてエグレス(クラウドから外部へのデータ転送)単価を$0.085/GBと仮定すると、月間100TBのトラフィックは約$8,500規模のコストです。公開ベンチマークでは、Brotli(テキストの圧縮アルゴリズム)はgzipより約15〜25%小さく²、AVIF(次世代の画像フォーマット)はJPEGより30〜50%小さいという報告があります³⁵。つまり、設定と運用を少し見直すだけで、データ通信量を20〜40%削減し、レイテンシ(応答の遅延)と離脱率も同時に改善できるのが現在の標準です。この記事では、CTOやエンジニアリーダーが意思決定できる粒度で、設定と運用、そして成果指標の結び付けまでを実装例とともに解説します。“まず何から変えれば30%削減を現実にできるか”に絞り、すぐ試せるコードと、定量の出し方を提示します。

削減目標を数値化する:観測、指標、ガードレール

施策の前に、現状の可視化とKPIの定義が必要です。扱いやすい一次KPIは転送総バイトとユーザーあたりの転送バイトで、意思決定はパーセンタイル(P50=中央値、P95=重いほうの上位5%地点)で見るとぶれません。モバイルの現実を踏まえ、P50とP95の両輪で追うと、平均値に埋もれたロングテールの痛点も捉えられます。補助指標として、キャッシュヒット率、304比率(条件付きリクエストが再配信を避けた割合)、画像バイト比率、圧縮適用率、HTTP/2/3比率を並走させます。CDN(コンテンツ配信ネットワーク)のログやRUM(Real User Monitoring、実ユーザー計測)を使い、ページビュー単位のTransferredBytes、ResourceType別内訳、国別・キャリア別を出すと、意思決定は一段クリアになります。

現場での計測は、CDNのログから日次の総転送バイトとヒット率をダッシュボード化し、デプロイごとに差分を監視するのが定番です。フロントエンド側はNavigation TimingとResource Timingで実測し、バックエンドはエッジでの圧縮結果(配信時サイズ)とオリジンの未圧縮サイズを分けて保持すると、どこで何バイト減ったかが説明可能になります。SLOとしては、ユニーク訪問あたりの転送バイトを四半期で30%減、P95の転送バイトを20%減、キャッシュヒット率を+10ptといった置き方が現実的です。ROIは、直近3カ月平均のエグレス単価を固定し、試験導入後の差分GBに単価を掛け合わせ、テストコストと実装工数を控除して示します。例えば月間100TB、単価$0.085/GB、削減率30%なら、粗い見積もりで月$2,550の恒常的削減です。加えてCloudflareの公開データが示すHTTP/3によるモバイルのテールレイテンシ10〜15%改善は、直帰率低下という間接効果にもつながります⁴。

圧縮と配送の基盤を整える:Brotli、画像、HTTP/3

Brotliはテキスト資産(HTML/CSS/JS/JSON等)に強く、gzipより平均で15〜25%の追加削減が報告されています²。運用では静的アセットを事前圧縮し、動的レスポンスはレベル5〜6でCPUと圧縮率のバランスを取るのが定番です。CDNが自動圧縮する場合でも、事前圧縮を置けばオリジン帯域とTTFB(Time to First Byte、最初のバイトまでの時間)に効きます。画像はAVIFを第一選択にし、WebPをフォールバックに構成します。HTTP/3(QUICベースの新しいHTTP)は無線のロス環境で再送の影響を局所化し、再接続のオーバーヘッドを抑えるので、転送バイト自体を減らさなくても、同じバイトでの体感性能が上がります。配送の基盤を三点セットで整えると、静的テキストで15〜25%、画像で30〜50%、体感遅延で10%前後の改善が見込めます²³⁴。

NGINXでBrotli/gzipを適用する実装例

# /etc/nginx/nginx.conf(Brotliモジュール導入済み前提)
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

events { worker_connections 1024; }
http {
  include       mime.types;
  default_type  application/octet-stream;
  sendfile      on;

  # gzip のフォールバック
  gzip on;
  gzip_comp_level 6;
  gzip_min_length 1024;
  gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;

  # Brotli を優先(動的は5-6がCPUと好バランス)
  brotli on;
  brotli_comp_level 5;
  brotli_static on;     # .br 事前圧縮を優先配信
  brotli_types text/plain text/css application/javascript application/json application/xml image/svg+xml;

  server {
    listen 443 ssl http2;
    server_name example.com;
    ssl_certificate     /etc/ssl/certs/fullchain.pem;
    ssl_certificate_key /etc/ssl/private/privkey.pem;

    location /assets/ {
      root /var/www;
      add_header Vary "Accept-Encoding" always;
    }
  }
}

この設定は、事前に.br.gzが存在すればそれを優先し、なければ動的圧縮を実施します。CPU消費を抑えたい場合は、静的アセットはビルド時に必ず事前圧縮しておくと安定します。

画像パイプラインでAVIF/WebPに変換する

// tools/convert-images.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';

const inDir = process.argv[2] ?? './images-src';
const outDir = process.argv[3] ?? './public/images';

async function ensure(dir) { await fs.mkdir(dir, { recursive: true }); }

async function convertOne(srcPath) {
  const rel = path.relative(inDir, srcPath);
  const base = rel.replace(path.extname(rel), '');
  const outAvif = path.join(outDir, base + '.avif');
  const outWebp = path.join(outDir, base + '.webp');

  const buf = await fs.readFile(srcPath);
  const img = sharp(buf).rotate();

  await ensure(path.dirname(outAvif));

  // AVIF 高圧縮、品質は実測で調整
  await img.avif({ quality: 45, effort: 5 }).toFile(outAvif);
  // WebP フォールバック
  await img.webp({ quality: 70 }).toFile(outWebp);
}

async function walk(dir) {
  const ents = await fs.readdir(dir, { withFileTypes: true });
  for (const e of ents) {
    const p = path.join(dir, e.name);
    if (e.isDirectory()) await walk(p);
    else if (/\.(jpe?g|png)$/i.test(e.name)) {
      try { await convertOne(p); }
      catch (err) { console.error('convert failed:', p, err.message); }
    }
  }
}

walk(inDir).catch(e => { console.error(e); process.exit(1); });

運用では<img srcset><picture>でフォーマットと幅の両方を最適化します。画像はページ重量の大部分を占めやすく、適切なサイズ最適化と次世代フォーマットの採用で大幅な削減が期待できます⁶。

HTTP/3を有効化して配信効率を上げる

# nginx 1.25+(QUIC/HTTP/3対応ビルド前提)
server {
  listen 443 ssl http2;
  listen 443 quic reuseport;
  server_name example.com;

  ssl_certificate     /etc/ssl/certs/fullchain.pem;
  ssl_certificate_key /etc/ssl/private/privkey.pem;

  add_header Alt-Svc 'h3=":443"; ma=86400';
  add_header QUIC-Status $quic;  # ログや確認用

  location / { root /var/www; index index.html; }
}

Cloudflareの公開検証では、HTTP/3はモバイル環境でのテールレイテンシを10〜15%改善しました⁴。通信量自体は同じでも、アプリ体感の改善は離脱率やCVRに直結し、総転送量削減と合わせてROIを押し上げます。

転送しない工夫:キャッシュ制御と差分更新

キャッシュは最大の節約口です。長期キャッシュ可能な資産はURLにコンテンツハッシュを埋め込み、Cache-Control: public, max-age=31536000, immutableで配信します。一方でHTMLやAPIのように頻繁に変わるものは、ETag(内容の指紋)やLast-Modifiedを正しく返し、クライアントからの条件付きリクエストに304(変更なし)で応答できるようにします。stale-while-revalidatestale-if-errorは、帯域節約と可用性の両立に効く強力な指示子です。結果として、リピート訪問時の転送バイトは大幅に圧縮され、RUMでもリソース数とバイトが劇的に下がります。

Node.js/Expressで強いキャッシュと304を実装する

// server/cache.js
import express from 'express';
import crypto from 'node:crypto';

const app = express();

// ハッシュ付き静的アセットは1年キャッシュ
app.use('/assets', express.static('public/assets', {
  setHeaders: (res) => {
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
    res.setHeader('Vary', 'Accept-Encoding');
  }
}));

// 頻繁に変わるJSONはETag/304対応
app.get('/api/data', async (req, res, next) => {
  try {
    const payload = JSON.stringify({ now: Date.now(), items: [1,2,3] });
    const etag = 'W/"' + crypto.createHash('sha1').update(payload).digest('hex') + '"';

    if (req.headers['if-none-match'] === etag) {
      res.status(304).end();
      return;
    }

    res.setHeader('Cache-Control', 'private, max-age=60, stale-while-revalidate=30');
    res.setHeader('ETag', etag);
    res.type('application/json').send(payload);
  } catch (e) {
    next(e);
  }
});

// エラーハンドリング
app.use((err, req, res, _next) => {
  console.error(err);
  res.status(500).json({ error: 'internal_error' });
});

app.listen(3000, () => console.log('listening on :3000'));

条件付きリクエストを正しく処理すると、APIの304比率が上昇し、転送バイトは直線的に減ります。差分更新を導入できるプロトコルやAPIでは、サーバー側でIf-None-MatchIf-Modified-Sinceをより積極的に活用し、無駄な再配信を避けます。

# 例:条件付きGET(HTTP/1.1)
GET /api/data HTTP/1.1
Host: example.com
If-None-Match: W/"a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"

HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60, stale-while-revalidate=30

Service Workerでネットワークを“使わない”ルートを作る

// public/sw.js(Workbox使用)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.6.0/workbox-sw.js');

if (workbox) {
  workbox.core.setCacheNameDetails({ prefix: 'myapp' });

  // 事前キャッシュ(ビルドでmanifestを注入)
  workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []);

  // 静的資産はキャッシュ優先
  workbox.routing.registerRoute(
    ({ request }) => request.destination === 'script' || request.destination === 'style',
    new workbox.strategies.CacheFirst({
      cacheName: 'static-v1',
      plugins: [
        new workbox.expiration.ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 365 }),
      ],
    })
  );

  // APIはネットワーク優先+フォールバック
  workbox.routing.registerRoute(
    ({ url }) => url.pathname.startsWith('/api/'),
    new workbox.strategies.NetworkFirst({
      cacheName: 'api-v1',
      networkTimeoutSeconds: 3,
      plugins: [
        new workbox.cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200, 304] }),
      ],
    })
  );

  // エラーハンドリング
  self.addEventListener('fetch', (event) => {
    event.respondWith(
      (async () => {
        try { return await fetch(event.request); }
        catch { return caches.match('/offline.html'); }
      })()
    );
  });
}

Service Worker(ブラウザに常駐するスクリプト)は、再訪問時にほぼゼロバイトで画面を立ち上げられるため、ユーザー体験と通信量双方に効きます。Workboxのような実装フレームを使うとバグも減り、失敗時のフォールバックも一貫します。

クライアントとAPIの最適化:過剰なバイトを生まない

クライアント側では、不要なペイロードを最初から作らないことが重要です。GraphQLならフィールドの絞り込み、RESTならページングとサマリーエンドポイントの併用で、静的に送る必要がないデータは送らない方針を徹底します。JSONはテキスト圧縮が効きやすいものの、ネストの深い配列や冗長なキーは圧縮前のサイズを無駄に増やします。スキーマ設計時から冗長さを抑え、必要に応じてサーバーサイドで数値や小さな辞書コードに変換するだけでも、無駄を確実に削れます。

モバイルアプリでは、HTTP/2とBrotliを有効にし、適切なキャッシュディレクトリを設定します。ネットワーク品質が悪い環境を想定してリトライやバックオフを実装すると、無駄な再送を避ける上でも有効です。以下はOkHttpでの構成例です。

// build.gradle
// implementation("com.squareup.okhttp3:okhttp:4.12.0")
// implementation("com.squareup.okhttp3:okhttp-brotli:4.12.0")
// Kotlin: OkHttp with HTTP/2, Brotli, disk cache
import okhttp3.*
import okhttp3.brotli.BrotliInterceptor
import java.io.File
import java.util.concurrent.TimeUnit

fun httpClient(cacheDir: File): OkHttpClient {
  val cache = Cache(File(cacheDir, "http"), 50L * 1024L * 1024L) // 50MB
  return OkHttpClient.Builder()
    .cache(cache)
    .addInterceptor(BrotliInterceptor)
    .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
    .connectTimeout(5, TimeUnit.SECONDS)
    .readTimeout(10, TimeUnit.SECONDS)
    .addInterceptor { chain ->
      val req = chain.request().newBuilder()
        .header("Accept-Encoding", "br, gzip")
        .build()
      try {
        chain.proceed(req)
      } catch (e: Exception) {
        Response.Builder()
          .request(req)
          .protocol(Protocol.HTTP_1_1)
          .code(599)
          .message("network_error")
          .body("".toResponseBody(null))
          .build()
      }
    }
    .build()
}

この設定ではBrotli圧縮とHTTP/2を前提としつつ、ディスクキャッシュを有効化しています。サーバー側がCache-ControlETagを適切に返していれば、クライアント側の再訪問での転送量は大きく削減されます。

API設計では、変更検出を容易にするためにバージョンごとのETagを採用し、リスト系エンドポイントはsincecursorでのデルタ取得を標準化します。イベントソーシングや変更フィードを公開できるなら、ポーリングに比べてバイトは桁違いに下がります。データ圧縮をさらに進める必要がある場合は、サーバー・クライアント双方でCBORなどのバイナリエンコーディングを評価しますが、まずはテキスト圧縮とフィールド削減のほうが効果は出やすく、デバッグ容易性も保てます。

運用として根付かせる:CI、予算、レビュー、ROI

一度の改善で終わらせず、データ通信量(帯域コスト)をプロダクトの健康指標に組み込みます。CIでバンドルサイズと生成される事前圧縮ファイルの有無を検査し、Main branchに入る前に差分バイトをコメントで可視化します。CDNとアプリの両側でキャッシュ・キーと失効戦略が齟齬を起こさないよう、リリースノートにキャッシュ影響欄を設ける運用は簡単で効きます。RUMではリソースタイプ別の転送バイトをダッシュボード化し、四半期に一度は画像・JS・フォントの三領域でトップ重い資産の棚卸しを実施します。

成果数値の出し方はシンプルです。導入前後でのCDN転送総バイトを比較し、差分GBにエグレス単価を掛けます。例えばCDNのログで前月比で30TBの削減が見え、エグレス単価が$0.085/GBなら、純粋な通信費削減は約$2,550/月です。これにRUMのP95レイテンシ短縮や、セッション当たりの転送バイト減少がもたらす継続率の改善が乗れば、プロダクトの収益面でも支援できます。HTTP/3のような配送レイヤの改善は、通信量削減と異なるベクトルで体感を底上げし、総合的なROI向上に寄与します。社内の稟議資料には、実装ブロックごとの期待削減率、測定期間、影響範囲、ロールバック手順を添えると、意思決定は速くなります。

まとめ:設定と運用で、無駄なバイトを未来永劫増やさない

本稿で示した通り、圧縮の適正化、画像の次世代化、HTTP/3の採用、キャッシュ制御とService Workerの併用という定番パターンを積み上げれば、現実的に20〜40%の通信量削減が狙えます。しかも、同じ施策がレイテンシとユーザー体験を同時に底上げし、離脱率の抑制にも効いてきます。まずは現状のTransferredBytesをRUMとCDNログで見える化し、BrotliとAVIFの導入、強いキャッシュ、そしてHTTP/3の順で適用してみてください。二週間のA/Bで数字が出れば、そのままCIのガードレールと四半期のレビューに組み込み、継続的な改善サイクルに移行できます。あなたのプロダクトは、今日から無駄なバイトを運ばない設計に変えられます。次のスプリントの計画に、どの施策から入れますか。

参考文献

  1. HTTP Archive. Web Almanac 2024 – Page Weight
  2. Cloudflare. Results experimenting with Brotli
  3. Smashing Magazine. Modern Image Formats: AVIF And WebP
  4. Cloudflare. HTTP/3 vs. HTTP/2: Batteries Included
  5. Cloudflare. Generate AVIF images with Image Resizing
  6. Cloudflare. Optimizing images for the web