LESSON 30分

ストーリー

高橋アーキテクト
通知が届かない、そのたった1つの失敗で顧客を失う
高橋アーキテクト
決済完了、配送状況、セキュリティアラート…ミッションクリティカルな通知は確実に届けなければならない。しかしスパム通知はユーザーを遠ざける。この両立が設計のカギだ

要件の整理

interface NotificationSystemRequirements {
  channels: {
    push: "iOS/Android プッシュ通知 (APNs/FCM)";
    email: "Eメール通知";
    sms: "SMSテキスト通知";
    inApp: "アプリ内通知";
    webhook: "外部サービスへのWebhook";
  };
  nonFunctional: {
    deliveryRate: "> 99.9%(通知ロスの許容範囲)";
    latency: "リアルタイム通知は5秒以内に配信";
    scale: "1日10億通知";
    dedup: "同一通知の重複配信を防止";
    rateLimit: "ユーザーあたりの通知頻度を制御";
    preference: "ユーザーの通知設定を尊重";
  };
}

ハイレベル設計

┌────────────────────────────────────────────────────────┐
│                                                        │
│  [各サービス] ──→ [通知API] ──→ [メッセージキュー]        │
│                                    │                   │
│                        ┌───────────┼───────────┐       │
│                        ▼           ▼           ▼       │
│                   [バリデーション] [重複排除] [レート制限]  │
│                        │           │           │       │
│                        └───────────┼───────────┘       │
│                                    ▼                   │
│                            [ルーティング]                │
│                        ┌───────┬───┴───┬────────┐      │
│                        ▼       ▼       ▼        ▼      │
│                     [Push]  [Email]  [SMS]   [InApp]   │
│                     Worker  Worker   Worker   Worker   │
│                        │       │       │        │      │
│                        ▼       ▼       ▼        ▼      │
│                     [APNs]  [SES]  [Twilio] [WebSocket]│
│                     [FCM]                              │
│                                                        │
│  [通知ログDB]  [ユーザー設定DB]  [分析サービス]            │
│                                                        │
└────────────────────────────────────────────────────────┘

詳細設計

通知パイプライン

// 通知処理のパイプライン
class NotificationPipeline {
  async process(notification: NotificationRequest): Promise<void> {
    // 1. バリデーション
    this.validate(notification);

    // 2. ユーザー設定の確認
    const preferences = await this.preferenceService
      .getPreferences(notification.userId);

    if (!preferences.isEnabled(notification.type, notification.channel)) {
      return; // ユーザーがオプトアウトしている
    }

    // 3. 重複排除(Idempotency Key)
    const isDuplicate = await this.deduplication
      .check(notification.idempotencyKey);
    if (isDuplicate) {
      return; // 既に処理済み
    }

    // 4. レート制限
    const isRateLimited = await this.rateLimiter
      .check(notification.userId, notification.type);
    if (isRateLimited) {
      // キューに入れて後で再試行
      await this.delayQueue.enqueue(notification, { delay: '1h' });
      return;
    }

    // 5. テンプレートのレンダリング
    const rendered = await this.templateEngine
      .render(notification.templateId, notification.data);

    // 6. チャネル別のワーカーに送信
    await this.channelRouter.route(notification.channel, {
      ...notification,
      content: rendered,
    });

    // 7. 通知ログの記録
    await this.notificationLog.record(notification);
  }
}

配信保証とリトライ

// 指数バックオフ付きリトライ
class NotificationWorker {
  private readonly MAX_RETRIES = 3;
  private readonly RETRY_DELAYS = [1000, 5000, 30000]; // 1s, 5s, 30s

  async deliver(notification: RenderedNotification): Promise<void> {
    for (let attempt = 0; attempt <= this.MAX_RETRIES; attempt++) {
      try {
        await this.send(notification);
        await this.updateStatus(notification.id, 'delivered');
        return;
      } catch (error) {
        if (attempt === this.MAX_RETRIES) {
          await this.updateStatus(notification.id, 'failed');
          await this.deadLetterQueue.enqueue(notification);
          await this.alertService.notify(`通知配信失敗: ${notification.id}`);
          return;
        }
        await this.sleep(this.RETRY_DELAYS[attempt]);
      }
    }
  }

  // チャネル別の配信処理
  private async send(notification: RenderedNotification): Promise<void> {
    switch (notification.channel) {
      case 'push':
        await this.pushService.send(notification);
        break;
      case 'email':
        await this.emailService.send(notification);
        break;
      case 'sms':
        await this.smsService.send(notification);
        break;
    }
  }
}

通知のグルーピングとダイジェスト

// 大量通知のグルーピング
class NotificationAggregator {
  // 「Aさん、Bさん、他3人があなたの投稿にいいねしました」
  async aggregate(userId: string, type: string): Promise<AggregatedNotification> {
    const pending = await this.getPendingNotifications(userId, type);

    if (pending.length <= 1) {
      return this.createSingleNotification(pending[0]);
    }

    // グルーピング
    return {
      type: 'aggregated',
      summary: `${pending[0].actorName}、${pending[1].actorName}、他${pending.length - 2}人があなたの投稿にいいねしました`,
      count: pending.length,
      items: pending.slice(0, 5), // 最新5件の詳細
    };
  }
}

トレードオフの整理

判断ポイント選択肢推奨理由
キューイングKafka vs SQSKafka: 高スループット、順序保証
テンプレートサーバーレンダリング vs クライアントサーバー: 一貫性、チャネル横断
重複排除Redis Set vs DBRedis: 高速チェック、TTL自動削除
レート制限Token Bucket vs Sliding WindowSliding Window: 公平性が高い

まとめ

ポイント内容
マルチチャネルPush/Email/SMS/InAppを統一パイプラインで処理
配信保証指数バックオフリトライ + デッドレターキュー
ユーザー体験通知設定、レート制限、グルーピングで制御
冪等性Idempotency Keyで重複配信を防止

チェックリスト

  • マルチチャネル通知のアーキテクチャを設計できた
  • 配信保証のリトライ戦略を理解した
  • 重複排除とレート制限の実装方法を把握した
  • 通知グルーピングの設計を理解した

次のステップへ

次は演習です。Step 2で学んだケーススタディの知識を活用して、自分でシステムを設計してみましょう。


推定読了時間: 30分