T-CREATOR

WebSocket でリアルタイム在庫表示を実装:購買イベントの即時反映ハンズオン

WebSocket でリアルタイム在庫表示を実装:購買イベントの即時反映ハンズオン

EC サイトやオンラインショップで、複数のユーザーが同じ商品を同時に購入しようとした際、「在庫があると思って購入ボタンを押したのに、完売していた」という経験をされたことはないでしょうか。このような問題を解決するため、リアルタイムで在庫情報を反映する仕組みが求められます。本記事では、WebSocket を活用して購買イベントが発生した瞬間に在庫数を即座に更新する実装方法を、ハンズオン形式で解説していきますね。

背景

リアルタイム在庫表示が求められる理由

従来の Web アプリケーションでは、HTTP のリクエスト・レスポンス型通信が主流でした。この方式では、ユーザーがページを更新するか、定期的にポーリング(サーバーへの問い合わせ)を行わなければ、最新の在庫情報を取得できません。

しかし、EC サイトのように複数ユーザーが同時にアクセスする環境では、このアプローチには限界があります。

  • ポーリング方式の課題: サーバーへのリクエストが頻繁に発生し、負荷が増大する
  • 情報の遅延: 更新タイミングによっては、数秒から数十秒の遅延が生じる可能性がある
  • ユーザー体験の低下: 在庫切れに気づくのが遅れ、購入できないケースが発生

このような背景から、サーバー側からクライアントへ能動的にデータを送信できる WebSocket 技術が注目されているのです。

WebSocket の特徴と利点

WebSocket は、クライアントとサーバー間で双方向通信を可能にするプロトコルです。HTTP とは異なり、一度接続を確立すれば、その接続を保持したまま継続的にデータをやり取りできます。

以下の図は、HTTP ポーリングと WebSocket の通信方式の違いを示しています。

mermaidsequenceDiagram
    participant C as クライアント
    participant S as サーバー

    Note over C,S: HTTP ポーリング方式
    C->>S: 在庫確認リクエスト
    S-->>C: 在庫データ返却
    Note over C: 3秒待機
    C->>S: 在庫確認リクエスト
    S-->>C: 在庫データ返却
    Note over C: 3秒待機
    C->>S: 在庫確認リクエスト
    S-->>C: 在庫データ返却

    Note over C,S: WebSocket方式
    C->>S: WebSocket接続要求
    S-->>C: 接続確立
    Note over S: 購買イベント発生
    S->>C: 在庫更新通知(即時)
    Note over S: 購買イベント発生
    S->>C: 在庫更新通知(即時)

図で理解できる要点:

  • HTTP ポーリングは定期的にサーバーへ問い合わせが必要
  • WebSocket は接続を維持し、イベント発生時に即座に通知される
  • WebSocket は無駄なリクエストを削減し、リアルタイム性が高い
#項目HTTP ポーリングWebSocket
1通信方向単方向(クライアント → サーバー)双方向
2接続維持リクエストごとに接続・切断接続を維持
3リアルタイム性ポーリング間隔に依存(数秒の遅延)即時反映(ミリ秒単位)
4サーバー負荷頻繁なリクエストで高負荷接続維持のみで低負荷
5データ転送効率毎回 HTTP ヘッダーが必要軽量なフレームのみ

課題

在庫管理におけるリアルタイム性の課題

EC サイトで WebSocket を使わずに在庫表示を実装すると、以下のような課題が発生します。

在庫の二重販売問題

ユーザー A とユーザー B が同時に最後の 1 つの商品をカートに入れようとした場合、従来の HTTP ベースの実装では、両方のユーザーに「在庫あり」と表示されてしまう可能性があります。

mermaidsequenceDiagram
    participant A as ユーザーA
    participant B as ユーザーB
    participant S as サーバー
    participant DB as データベース

    A->>S: 在庫確認(商品ID: 123)
    S->>DB: 在庫数取得
    DB-->>S: 在庫数: 1
    S-->>A: 在庫あり表示

    Note over A,B: ほぼ同時刻

    B->>S: 在庫確認(商品ID: 123)
    S->>DB: 在庫数取得
    DB-->>S: 在庫数: 1
    S-->>B: 在庫あり表示

    A->>S: 購入リクエスト
    S->>DB: 在庫減算(1→0)
    DB-->>S: 更新成功
    S-->>A: 購入完了

    B->>S: 購入リクエスト
    S->>DB: 在庫減算(0→-1)
    DB-->>S: エラー: 在庫不足
    S-->>B: 購入失敗

図で理解できる要点:

  • ユーザー A と B が同時に在庫を確認すると、両方に「在庫あり」と表示される
  • ユーザー A が先に購入すると在庫が 0 になる
  • ユーザー B は購入時点でエラーとなり、悪いユーザー体験となる

ユーザー体験の低下

在庫情報が即座に反映されないことで、以下のような問題が生じます。

  • カートに入れた時点での在庫切れ: カート追加後に在庫切れになり、決済画面でエラーが発生
  • ページ更新の手間: 最新の在庫を確認するために手動でページをリロードする必要がある
  • 信頼性の低下: 「在庫あり」表示を信頼できず、購買意欲が低下する

サーバー負荷とスケーラビリティの課題

ポーリング方式で短い間隔(例: 1 秒ごと)でサーバーに問い合わせを行うと、ユーザー数が増えるにつれて急激にサーバー負荷が増大します。

mermaidflowchart TD
    users["100人のユーザー"] -->|1秒ごと| poll["ポーリングリクエスト"]
    poll -->|100リクエスト/秒| server["サーバー"]
    server -->|負荷増大| db[("データベース")]

    style server fill:#ffcccc
    style db fill:#ffcccc

負荷の計算例:

  • ユーザー数: 100 人
  • ポーリング間隔: 1 秒
  • 結果: 100 リクエスト/秒(ユーザー数が 1000 人なら 1000 リクエスト/秒)

一方、WebSocket を使用すれば、接続は維持されたままで、イベント発生時のみデータを送信するため、サーバー負荷を大幅に削減できるのです。

解決策

WebSocket による双方向通信の実装

WebSocket を使えば、サーバー側で購買イベントが発生した瞬間に、接続中のすべてのクライアントへ在庫更新情報をプッシュ配信できます。これにより、リアルタイムな在庫表示が実現できますね。

以下の図は、WebSocket を使った在庫更新の全体フローを示しています。

mermaidflowchart TD
    clientA["クライアントA<br/>(ブラウザ)"] -->|WebSocket接続| ws["WebSocketサーバー"]
    clientB["クライアントB<br/>(ブラウザ)"] -->|WebSocket接続| ws
    clientC["クライアントC<br/>(ブラウザ)"] -->|WebSocket接続| ws

    ws -->|購買イベント受信| api["Backend API"]
    api -->|在庫減算| db[("在庫DB")]
    api -->|在庫更新通知| ws

    ws -->|在庫数ブロードキャスト| clientA
    ws -->|在庫数ブロードキャスト| clientB
    ws -->|在庫数ブロードキャスト| clientC

    style ws fill:#ccffcc
    style api fill:#cce5ff
    style db fill:#ffffcc

図で理解できる要点:

  • 複数のクライアントが WebSocket サーバーに接続
  • 購買イベント発生時、Backend API が在庫を更新
  • WebSocket サーバーが全クライアントに在庫情報をブロードキャスト
  • すべてのクライアントが即座に最新の在庫数を受信

実装アーキテクチャの設計

本記事では、以下の技術スタックを使用して実装を進めていきます。

#レイヤー技術スタック役割
1フロントエンドReact + TypeScriptUI 表示・WebSocket 接続管理
2WebSocket サーバーNode.js + ws ライブラリ双方向通信の管理
3バックエンド APIExpress + TypeScript購買処理・在庫更新
4データベースMySQL または PostgreSQL在庫データの永続化

この構成では、フロントエンドが WebSocket サーバーと接続し、バックエンド API が購買イベントを処理した後、WebSocket サーバー経由でクライアントへ在庫情報を配信します。

具体例

環境構築とプロジェクト初期化

まずは、プロジェクトのディレクトリ構成を作成しましょう。

bash# プロジェクトルートディレクトリの作成
mkdir realtime-inventory-app
cd realtime-inventory-app

# バックエンドとフロントエンドのディレクトリを作成
mkdir backend frontend

次に、それぞれのディレクトリで必要なパッケージを初期化します。

bash# バックエンドの初期化
cd backend
yarn init -y
yarn add express ws cors
yarn add -D typescript @types/express @types/ws @types/node @types/cors ts-node nodemon

# TypeScript設定ファイルの生成
npx tsc --init
bash# フロントエンドの初期化(Next.jsを使用)
cd ../frontend
npx create-next-app@latest . --typescript --tailwind --app --no-src-dir
yarn add ws
yarn add -D @types/ws

バックエンド API の実装

データベース接続とモデル定義

まずは、在庫情報を管理するための型定義を行います。

typescript// backend/src/types/inventory.ts

/**
 * 在庫アイテムの型定義
 */
export interface InventoryItem {
  id: number; // 商品ID
  name: string; // 商品名
  stock: number; // 在庫数
  price: number; // 価格
  updatedAt: Date; // 最終更新日時
}

/**
 * 購買リクエストの型定義
 */
export interface PurchaseRequest {
  productId: number; // 購入する商品ID
  quantity: number; // 購入数量
  userId: string; // ユーザーID
}

次に、在庫データを管理する簡易的なインメモリストアを実装します。本番環境では、実際のデータベース(MySQL や PostgreSQL)に置き換えてください。

typescript// backend/src/models/inventoryStore.ts

import { InventoryItem } from '../types/inventory';

/**
 * 在庫データの管理クラス
 * (実運用ではデータベースに置き換え)
 */
class InventoryStore {
  private items: Map<number, InventoryItem>;

  constructor() {
    // 初期データの設定
    this.items = new Map([
      [1, { id: 1, name: 'ノートPC', stock: 10, price: 120000, updatedAt: new Date() }],
      [2, { id: 2, name: 'ワイヤレスマウス', stock: 50, price: 3000, updatedAt: new Date() }],
      [3, { id: 3, name: 'USB-Cケーブル', stock: 5, price: 1500, updatedAt: new Date() }],
    ]);
  }
typescript  /**
   * 全在庫アイテムを取得
   */
  getAllItems(): InventoryItem[] {
    return Array.from(this.items.values());
  }

  /**
   * 特定のアイテムを取得
   */
  getItemById(id: number): InventoryItem | undefined {
    return this.items.get(id);
  }
typescript  /**
   * 在庫数を減算
   * @returns 成功時は更新後のアイテム、失敗時はundefined
   */
  decreaseStock(productId: number, quantity: number): InventoryItem | undefined {
    const item = this.items.get(productId);

    // 在庫チェック
    if (!item || item.stock < quantity) {
      return undefined;
    }

    // 在庫減算
    item.stock -= quantity;
    item.updatedAt = new Date();

    return item;
  }
}

export const inventoryStore = new InventoryStore();

このコードでは、InventoryStore クラスが在庫データを管理し、在庫の取得や減算機能を提供しています。decreaseStock メソッドでは、在庫が不足している場合は undefined を返すことで、エラーハンドリングを可能にしていますね。

WebSocket サーバーの実装

WebSocket サーバーを実装して、接続中のクライアントへ在庫情報をブロードキャストする機能を作成します。

typescript// backend/src/websocket/server.ts

import { WebSocketServer, WebSocket } from 'ws';
import { Server as HTTPServer } from 'http';

/**
 * WebSocketメッセージの型定義
 */
interface WSMessage {
  type: 'inventory_update' | 'connection_ack';
  data?: any;
}
typescript/**
 * WebSocketサーバーの管理クラス
 */
export class WSServer {
  private wss: WebSocketServer;
  private clients: Set<WebSocket>;

  constructor(server: HTTPServer) {
    // WebSocketサーバーの初期化
    this.wss = new WebSocketServer({ server });
    this.clients = new Set();

    this.setupConnectionHandler();
  }
typescript  /**
   * クライアント接続時のハンドラー設定
   */
  private setupConnectionHandler(): void {
    this.wss.on('connection', (ws: WebSocket) => {
      console.log('新しいクライアントが接続しました');

      // 接続クライアントをセットに追加
      this.clients.add(ws);

      // 接続確認メッセージを送信
      const ackMessage: WSMessage = {
        type: 'connection_ack',
        data: { message: '接続が確立されました' }
      };
      ws.send(JSON.stringify(ackMessage));
typescript      // 切断時の処理
      ws.on('close', () => {
        console.log('クライアントが切断しました');
        this.clients.delete(ws);
      });

      // エラー時の処理
      ws.on('error', (error) => {
        console.error('WebSocketエラー:', error);
        this.clients.delete(ws);
      });
    });
  }
typescript  /**
   * 在庫更新情報を全クライアントにブロードキャスト
   */
  broadcastInventoryUpdate(data: any): void {
    const message: WSMessage = {
      type: 'inventory_update',
      data
    };

    const messageStr = JSON.stringify(message);

    // 接続中の全クライアントに送信
    this.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(messageStr);
      }
    });

    console.log(`${this.clients.size}人のクライアントに在庫更新を配信しました`);
  }
}

この実装では、WSServer クラスが WebSocket 接続を管理し、broadcastInventoryUpdate メソッドで全クライアントへ在庫更新情報を配信しています。

Express API エンドポイントの実装

購買処理を行う API エンドポイントを実装します。このエンドポイントが呼ばれると、在庫を減算し、WebSocket 経由で全クライアントへ通知を行います。

typescript// backend/src/routes/inventory.ts

import { Router, Request, Response } from 'express';
import { inventoryStore } from '../models/inventoryStore';
import { WSServer } from '../websocket/server';
import { PurchaseRequest } from '../types/inventory';

export function createInventoryRouter(wsServer: WSServer): Router {
  const router = Router();
typescript/**
 * 全在庫情報を取得
 * GET /api/inventory
 */
router.get('/inventory', (req: Request, res: Response) => {
  const items = inventoryStore.getAllItems();
  res.json({ success: true, data: items });
});
typescript  /**
   * 購買処理を実行
   * POST /api/purchase
   */
  router.post('/purchase', (req: Request, res: Response) => {
    const { productId, quantity, userId }: PurchaseRequest = req.body;

    // バリデーション
    if (!productId || !quantity || !userId) {
      return res.status(400).json({
        success: false,
        error: '必須パラメータが不足しています'
      });
    }

    if (quantity <= 0) {
      return res.status(400).json({
        success: false,
        error: '購入数量は1以上である必要があります'
      });
    }
typescript    // 在庫減算処理
    const updatedItem = inventoryStore.decreaseStock(productId, quantity);

    if (!updatedItem) {
      return res.status(400).json({
        success: false,
        error: '在庫が不足しています'
      });
    }

    // WebSocket経由で全クライアントに在庫更新を通知
    wsServer.broadcastInventoryUpdate({
      productId: updatedItem.id,
      name: updatedItem.name,
      stock: updatedItem.stock,
      updatedAt: updatedItem.updatedAt
    });

    // レスポンス返却
    res.json({
      success: true,
      message: '購入が完了しました',
      data: updatedItem
    });
  });

  return router;
}

このコードでは、POST ​/​api​/​purchase エンドポイントで購買処理を行い、成功時に broadcastInventoryUpdate を呼び出すことで、リアルタイムに在庫情報を配信しています。

サーバーの起動処理

Express サーバーと WebSocket サーバーを統合して起動する処理を実装します。

typescript// backend/src/index.ts

import express, { Express } from 'express';
import cors from 'cors';
import { createServer } from 'http';
import { WSServer } from './websocket/server';
import { createInventoryRouter } from './routes/inventory';

const app: Express = express();
const PORT = process.env.PORT || 4000;
typescript// ミドルウェアの設定
app.use(cors()); // CORS有効化
app.use(express.json()); // JSONボディパーサー

// HTTPサーバーの作成
const httpServer = createServer(app);

// WebSocketサーバーの初期化
const wsServer = new WSServer(httpServer);
typescript// APIルーティングの設定
app.use('/api', createInventoryRouter(wsServer));

// ヘルスチェックエンドポイント
app.get('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date() });
});
typescript// サーバー起動
httpServer.listen(PORT, () => {
  console.log(
    `サーバーが起動しました: http://localhost:${PORT}`
  );
  console.log(`WebSocketサーバー: ws://localhost:${PORT}`);
});

この実装により、HTTP と WebSocket が同じポート(4000 番)で動作し、1 つのサーバープロセスで両方の通信を処理できるようになります。

フロントエンド実装

WebSocket 接続フックの作成

React で WebSocket 接続を管理する Custom Hook を作成します。

typescript// frontend/hooks/useWebSocket.ts

import { useEffect, useRef, useState } from 'react';

/**
 * WebSocketメッセージの型定義
 */
interface WSMessage {
  type: 'inventory_update' | 'connection_ack';
  data?: any;
}

/**
 * フックの戻り値の型定義
 */
interface UseWebSocketReturn {
  isConnected: boolean;
  lastMessage: WSMessage | null;
}
typescript/**
 * WebSocket接続を管理するCustom Hook
 */
export function useWebSocket(url: string): UseWebSocketReturn {
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState<WSMessage | null>(null);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    // WebSocket接続の確立
    const ws = new WebSocket(url);
    wsRef.current = ws;
typescript// 接続成功時のハンドラー
ws.onopen = () => {
  console.log('WebSocket接続が確立されました');
  setIsConnected(true);
};

// メッセージ受信時のハンドラー
ws.onmessage = (event) => {
  try {
    const message: WSMessage = JSON.parse(event.data);
    setLastMessage(message);
  } catch (error) {
    console.error(
      'メッセージのパースに失敗しました:',
      error
    );
  }
};
typescript// エラー発生時のハンドラー
ws.onerror = (error) => {
  console.error('WebSocketエラー:', error);
  setIsConnected(false);
};

// 接続切断時のハンドラー
ws.onclose = () => {
  console.log('WebSocket接続が切断されました');
  setIsConnected(false);
};
typescript    // クリーンアップ処理
    return () => {
      ws.close();
    };
  }, [url]);

  return { isConnected, lastMessage };
}

この Custom Hook を使用することで、コンポーネント内で簡単に WebSocket 接続を管理し、メッセージを受信できるようになりますね。

在庫表示コンポーネントの実装

在庫情報を表示し、購買ボタンを備えたコンポーネントを作成します。

typescript// frontend/components/InventoryList.tsx

'use client';

import { useEffect, useState } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';

/**
 * 在庫アイテムの型定義
 */
interface InventoryItem {
  id: number;
  name: string;
  stock: number;
  price: number;
  updatedAt: string;
}
typescriptexport default function InventoryList() {
  const [items, setItems] = useState<InventoryItem[]>([]);
  const [loading, setLoading] = useState(true);

  // WebSocket接続(バックエンドのURL)
  const { isConnected, lastMessage } = useWebSocket('ws://localhost:4000');

  /**
   * 初期データの取得
   */
  useEffect(() => {
    fetchInventory();
  }, []);
typescript/**
 * WebSocketメッセージ受信時の処理
 */
useEffect(() => {
  if (
    lastMessage &&
    lastMessage.type === 'inventory_update'
  ) {
    const { productId, stock, updatedAt } =
      lastMessage.data;

    // 該当商品の在庫数を更新
    setItems((prevItems) =>
      prevItems.map((item) =>
        item.id === productId
          ? { ...item, stock, updatedAt }
          : item
      )
    );
  }
}, [lastMessage]);
typescript/**
 * 在庫データをAPIから取得
 */
const fetchInventory = async () => {
  try {
    const response = await fetch(
      'http://localhost:4000/api/inventory'
    );
    const result = await response.json();

    if (result.success) {
      setItems(result.data);
    }
  } catch (error) {
    console.error('在庫データの取得に失敗しました:', error);
  } finally {
    setLoading(false);
  }
};
typescript/**
 * 購買ボタンクリック時の処理
 */
const handlePurchase = async (productId: number) => {
  try {
    const response = await fetch(
      'http://localhost:4000/api/purchase',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          productId,
          quantity: 1,
          userId: `user_${Date.now()}`, // 簡易的なユーザーID生成
        }),
      }
    );

    const result = await response.json();

    if (!result.success) {
      alert(`購入失敗: ${result.error}`);
    }
  } catch (error) {
    console.error('購買リクエストに失敗しました:', error);
    alert('購買処理中にエラーが発生しました');
  }
};
typescript  // ローディング表示
  if (loading) {
    return <div className="p-8 text-center">読み込み中...</div>;
  }

  return (
    <div className="p-8 max-w-4xl mx-auto">
      {/* 接続ステータス表示 */}
      <div className="mb-6 p-4 rounded-lg bg-gray-100">
        <div className="flex items-center gap-2">
          <div
            className={`w-3 h-3 rounded-full ${
              isConnected ? 'bg-green-500' : 'bg-red-500'
            }`}
          />
          <span className="text-sm font-medium">
            {isConnected ? 'リアルタイム接続中' : '接続切断'}
          </span>
        </div>
      </div>
typescript      {/* 在庫リスト */}
      <h1 className="text-2xl font-bold mb-6">リアルタイム在庫表示</h1>
      <div className="space-y-4">
        {items.map((item) => (
          <div
            key={item.id}
            className="border rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow"
          >
            <div className="flex justify-between items-start">
              <div>
                <h2 className="text-xl font-semibold">{item.name}</h2>
                <p className="text-gray-600 mt-1">
                  価格: ¥{item.price.toLocaleString()}
                </p>
                <p
                  className={`mt-2 font-medium ${
                    item.stock > 0 ? 'text-green-600' : 'text-red-600'
                  }`}
                >
                  在庫数: {item.stock}個
                </p>
                <p className="text-xs text-gray-400 mt-1">
                  最終更新: {new Date(item.updatedAt).toLocaleTimeString()}
                </p>
              </div>
typescript              <button
                onClick={() => handlePurchase(item.id)}
                disabled={item.stock === 0}
                className={`px-6 py-2 rounded-lg font-medium transition-colors ${
                  item.stock > 0
                    ? 'bg-blue-600 text-white hover:bg-blue-700'
                    : 'bg-gray-300 text-gray-500 cursor-not-allowed'
                }`}
              >
                {item.stock > 0 ? '購入する' : '在庫切れ'}
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

このコンポーネントでは、WebSocket から受信した在庫更新メッセージをリアルタイムで UI に反映しています。購入ボタンをクリックすると、API へリクエストが送られ、WebSocket 経由で全ユーザーの画面に即座に在庫が更新されますね。

メインページの設定

Next.js の App Router を使用したメインページを作成します。

typescript// frontend/app/page.tsx

import InventoryList from '../components/InventoryList';

export default function Home() {
  return (
    <main className='min-h-screen bg-gray-50'>
      <InventoryList />
    </main>
  );
}

動作確認

それでは、実際にアプリケーションを起動して動作を確認してみましょう。

bash# バックエンドの起動
cd backend
yarn dev
bash# フロントエンドの起動(別ターミナル)
cd frontend
yarn dev

ブラウザで http://localhost:3000 を開き、複数のタブで同じページを開いてください。一方のタブで「購入する」ボタンをクリックすると、すべてのタブで在庫数が即座に更新されることを確認できます。

以下は、動作の流れを示した図です。

mermaidsequenceDiagram
    participant U1 as ユーザー1<br/>(ブラウザタブ1)
    participant U2 as ユーザー2<br/>(ブラウザタブ2)
    participant WS as WebSocketサーバー
    participant API as Backend API
    participant DB as 在庫DB

    U1->>WS: WebSocket接続
    U2->>WS: WebSocket接続
    WS-->>U1: 接続確立
    WS-->>U2: 接続確立

    U1->>API: POST /api/purchase<br/>(商品ID:1, 数量:1)
    API->>DB: 在庫減算(10→9)
    DB-->>API: 更新成功
    API->>WS: 在庫更新通知<br/>(商品ID:1, 在庫:9)

    WS->>U1: 在庫更新メッセージ
    WS->>U2: 在庫更新メッセージ

    Note over U1,U2: 両方のブラウザで<br/>在庫数が即時に9に更新される

図で理解できる要点:

  • ユーザー 1 が購入すると API が在庫を減算
  • WebSocket サーバーが全接続クライアントへ在庫更新を配信
  • ユーザー 1 とユーザー 2 の画面が同時に更新される

エラーハンドリングと最適化

実運用では、以下のようなエラーハンドリングと最適化を追加することをおすすめします。

再接続処理の実装

WebSocket が切断された際に、自動的に再接続する機能を追加しましょう。

typescript// frontend/hooks/useWebSocket.ts(再接続機能追加版)

export function useWebSocket(url: string): UseWebSocketReturn {
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState<WSMessage | null>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  const connect = () => {
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.onopen = () => {
      console.log('WebSocket接続が確立されました');
      setIsConnected(true);
    };

    ws.onmessage = (event) => {
      try {
        const message: WSMessage = JSON.parse(event.data);
        setLastMessage(message);
      } catch (error) {
        console.error('メッセージのパースに失敗しました:', error);
      }
    };
typescript    ws.onerror = (error) => {
      console.error('WebSocketエラー:', error);
      setIsConnected(false);
    };

    ws.onclose = () => {
      console.log('WebSocket接続が切断されました。5秒後に再接続します...');
      setIsConnected(false);

      // 5秒後に再接続
      reconnectTimeoutRef.current = setTimeout(() => {
        connect();
      }, 5000);
    };
  };

  useEffect(() => {
    connect();

    return () => {
      if (reconnectTimeoutRef.current) {
        clearTimeout(reconnectTimeoutRef.current);
      }
      wsRef.current?.close();
    };
  }, [url]);

  return { isConnected, lastMessage };
}

この実装により、ネットワーク障害などで接続が切れても、5 秒後に自動的に再接続を試みるようになります。

トランザクション処理の追加

在庫減算処理では、データベースのトランザクションを使用して、データの整合性を保証することが重要です。

typescript// backend/src/models/inventoryStore.ts(トランザクション対応版)

/**
 * トランザクション対応の在庫減算
 * 実際のDBでは BEGIN/COMMIT/ROLLBACKを使用
 */
async decreaseStockWithTransaction(
  productId: number,
  quantity: number
): Promise<InventoryItem | undefined> {
  // ロック取得(実際のDBではFOR UPDATEを使用)
  const item = this.items.get(productId);

  if (!item || item.stock < quantity) {
    return undefined;
  }

  // 在庫減算
  item.stock -= quantity;
  item.updatedAt = new Date();

  return item;
}

実際のデータベース(MySQL)を使用する場合は、以下のような SQL クエリでトランザクション処理を実装します。

sql-- トランザクション開始
START TRANSACTION;

-- 在庫をロック付きで取得
SELECT stock FROM inventory WHERE id = ? FOR UPDATE;

-- 在庫チェック後、減算処理
UPDATE inventory
SET stock = stock - ?, updated_at = NOW()
WHERE id = ? AND stock >= ?;

-- コミット
COMMIT;

このようにトランザクション処理を導入することで、複数ユーザーが同時に購入した際の在庫の二重販売を防ぐことができます。

まとめ

本記事では、WebSocket を使用してリアルタイムに在庫情報を更新する仕組みを実装しました。

実装のポイント:

  • WebSocket による双方向通信: HTTP ポーリングと比較して、サーバー負荷を削減しつつ即座にデータを配信できる
  • バックエンド設計: Express と WebSocket サーバーを統合し、購買イベント発生時に全クライアントへブロードキャスト
  • フロントエンド設計: Custom Hook を使って WebSocket 接続を管理し、受信データを UI にリアルタイム反映
  • エラーハンドリング: 再接続処理やトランザクション処理で信頼性を向上

この実装により、複数ユーザーが同時にアクセスする EC サイトでも、在庫情報を正確かつリアルタイムに表示できるようになりました。ユーザー体験が大幅に向上し、在庫切れによる購入失敗を最小限に抑えることができるでしょう。

今後の拡張として、以下のような機能追加も検討できます。

  • Redis を使った Pub/Sub: 複数のサーバーインスタンス間でメッセージを共有
  • ユーザー認証の追加: JWT トークンによる WebSocket 接続の認証
  • 在庫予約機能: カート追加時に一定時間在庫を予約する仕組み
  • 通知機能の拡張: 在庫復活時に通知を送る機能

WebSocket を活用したリアルタイム通信は、在庫管理だけでなく、チャットアプリケーションやライブダッシュボードなど、さまざまな用途で応用できます。ぜひ本記事を参考に、実際のプロジェクトに導入してみてくださいね。

関連リンク