mybook

設計問題: 通知システム — Push/Email/SMSの統合配信

「通知は地味だが奥深い」

「今日は通知システム。地味に見えるけど、これを正しく設計できるエンジニアは少ない」とレイカが切り出した。

「ただメールを送るだけじゃないですか?」とソウタは軽く言った。

「そう思う人が多い。だから差がつく。」レイカはホワイトボードに書き始めた。「私が以前いた会社で、プロモーションメールが一人のユーザーに3000通届いたことがあった。バグで無限ループに入って。深夜2時に電話がかかってきた。」

「3000通……」

「受信箱がほぼ壊れた。そのユーザーはすぐ退会した。苦情が殺到して、SESのアカウントがAWSに一時停止された。そこから再開するまで2日かかった。」

ソウタは背筋が伸びた。

「通知の設計は、ユーザー体験・法的要件・インフラ信頼性が全部絡む。面接でこれを深く話せる人は少ない。だからこそ、ちゃんと設計できると評価が高い。」

「具体的にはどんな難しさがあるんですか?」

「プッシュ通知は iOS/Android で仕様が違う。メールはバウンス(返送)とスパム判定がある。SMSは国ごとに法律が違う。しかも全部を統合して100万通/分をさばく必要がある。さらに——ユーザーがオプトアウトしていれば送らない、重複送信しない、失敗したら再送する。これを全部設計する。」

ソウタはノートを開いた。


Step 1: 要件の確認

「まず面接では要件から確認する。面接官が何を聞きたいかを探る時間でもある。」

機能要件:
- プッシュ通知: iOS(APNs)、Android(FCM)
- メール通知
- SMS通知
- アプリ内通知(In-App)
- ユーザーが通知設定をカスタマイズできる
- 通知テンプレート管理(多言語・パーソナライズ対応)
- 通知のスケジューリング(特定日時に送信)
- A/Bテスト対応(開封率の測定)

非機能要件:
- スループット: 100万通/分(ピーク時)
- 配信速度: Critical通知は5秒以内、Normal通知は30秒以内
- 配信保証: At-least-once(冪等性で重複防止)
- オプトアウト対応(CAN-SPAM / GDPR / 特定電子メール法)
- 重複通知の防止
- 配信ステータスの追跡(送信済み/失敗/開封/クリック)
- バウンス処理(不正アドレスの自動無効化)
- レート制限(同一ユーザーへの過剰送信防止)
- Webhook配信(Slack/Teamsなど外部サービス)

INFO

面接で要件確認をするとき、「それは機能要件ですか、非機能要件ですか?」と分類しながら確認すると整理できている印象を与える。特に「配信保証のレベル(At-least-once か Exactly-once か)」を聞くと、分散システムの理解があることが伝わる。


Step 2: 規模の概算

「次にスケールを確認する。設計の選択は数字によって変わる。」

チャネル別の通知量(1日):
  プッシュ通知: 5億通(500M)
  メール:       1億通(100M)
  SMS:          1,000万通(10M)
  Webhook:      5,000万通(50M)
  合計: 約6.6億通/日

QPS:
  平均: 6.6億 / 86,400 ≒ 7,600/sec
  ピーク(夕方集中): 100,000/sec

ストレージ(通知ログ):
  1件 = 500バイト
  1日: 6.6億 × 500B = 330GB/day
  1年: 330GB × 365 ≒ 120TB

デバイストークン(プッシュ通知用):
  MAU: 1億人、1人平均2デバイス = 2億トークン
  1トークン = 200バイト → 40GB(メモリ or Redis に保持)

「このスケールだと、単一のDBへの書き込みは無理。ログはDynamoDB一択です。」とソウタが言った。

「正解。書き込みスループットの設計まで考えられているとさらに良い。」


Step 3: 高レベル設計

Loading diagram...

通知トリガーの種類

アプリ内のあらゆるイベントが通知のトリガーになる。重要なのは優先度の設計だ。

# app/services/notification_service.rb
class NotificationService
  PRIORITY_TOPICS = {
    critical: 'notifications-critical',
    high:     'notifications-high',
    normal:   'notifications-normal',
    low:      'notifications-low'
  }.freeze
 
  def self.send(user_id:, type:, priority: :normal, channels:, data: {})
    user = User.find(user_id)
 
    # 1. オプトアウト・通知設定チェック
    settings = UserNotificationSetting.find_or_initialize_by(
      user_id: user_id,
      notification_type: type
    )
    return if settings.globally_opted_out?
 
    # 2. レート制限チェック(同一ユーザーへの過剰送信を防ぐ)
    return unless RateLimiter.allow?(user_id: user_id, priority: priority)
 
    # 3. 有効なチャネルにフィルタリング
    valid_channels = channels.select do |ch|
      settings.channel_enabled?(ch) && user.token_exists_for?(ch)
    end
    return if valid_channels.empty?
 
    # 4. 冪等性キーを生成(重複送信防止)
    idempotency_key = IdempotencyKeyGenerator.call(
      user_id: user_id, type: type, data: data
    )
    return unless DeduplicationService.check_and_mark(idempotency_key)
 
    # 5. 通知ログを作成
    notification = Notification.create!(
      user_id:           user_id,
      notification_type: type,
      idempotency_key:   idempotency_key,
      status:            'pending',
      priority:          priority,
      payload:           data
    )
 
    # 6. 優先度別キューへ publish
    valid_channels.each do |channel|
      SqsPublisher.publish(
        queue_url: PRIORITY_TOPICS[priority],
        message: {
          notification_id: notification.id,
          channel:         channel,
          user_id:         user_id
        }
      )
    end
  end
end

Step 4: 通知の優先度システム

「面接官によく聞かれる。Critical/High/Normal/Low をどう実装するか。」

Loading diagram...

優先度別にSQSキューを分け、Workerのコンシューマ数を変える。トラフィック急増時もCriticalキューには専用のWorkerプールが割り当たるため、決済失敗通知などが遅延しない。

# config/workers/notification_worker_config.rb
WORKER_CONFIG = {
  critical: { concurrency: 20, visibility_timeout: 30 },
  high:     { concurrency: 10, visibility_timeout: 60 },
  normal:   { concurrency: 5,  visibility_timeout: 120 },
  low:      { concurrency: 2,  visibility_timeout: 300 }
}.freeze
 
# 優先度の判定ロジック
module NotificationPriority
  CRITICAL_TYPES = %i[
    payment_failed account_suspended security_alert
    password_changed two_factor_disabled
  ].freeze
 
  HIGH_TYPES = %i[
    new_message mention direct_reply order_shipped
  ].freeze
 
  def self.resolve(type)
    return :critical if CRITICAL_TYPES.include?(type.to_sym)
    return :high     if HIGH_TYPES.include?(type.to_sym)
    :normal
  end
end

WARNING

Critical キューに大量のメッセージが滞留すると、SQS の VisibilityTimeout 内に処理できないメッセージが再度見え始め(re-delivery)、重複処理が起きる。冪等性チェック(Redis の NX フラグ)は必ず Critical キューにも実装すること。


Step 5: プッシュ通知のデバイストークン管理

「プッシュ通知で一番ハマるのがデバイストークンの管理です。」とレイカが言った。

「ユーザーがアプリを削除すると、そのトークンは無効になる。でも、こちらからはわからない。APNsに送ってみて、エラーが返ってきて初めてわかる。」

Loading diagram...
# app/models/device_token.rb
class DeviceToken < ApplicationRecord
  belongs_to :user
  enum platform: { ios: 0, android: 1 }
  enum status: { active: 0, expired: 1, invalid: 2 }
 
  # ユーザーが複数デバイスを持つことを前提に設計
  scope :active_for_user, ->(user_id) {
    where(user_id: user_id, status: :active)
  }
 
  # トークン登録(同一デバイスの重複登録を防ぐ)
  def self.register(user_id:, token:, platform:)
    find_or_initialize_by(token: token).tap do |dt|
      dt.update!(
        user_id:      user_id,
        platform:     platform,
        status:       :active,
        last_seen_at: Time.current
      )
    end
  end
 
  # APNs/FCM からの失敗フィードバックを処理
  def self.handle_delivery_failure(token:, error_code:)
    record = find_by(token: token)
    return unless record
 
    case error_code
    when 'BadDeviceToken', 'Unregistered', 'NotRegistered'
      record.update!(status: :invalid, invalidated_at: Time.current)
      Rails.logger.warn("Device token invalidated: #{token[0..8]}...")
    when 'DeviceTokenNotForTopic'
      # 別アプリのトークンが混入している(バグの可能性)
      record.update!(status: :invalid)
      Sentry.capture_message("Wrong topic for token: #{token[0..8]}")
    end
  end
end
# app/workers/push_notification_worker.rb
class PushNotificationWorker
  include Sidekiq::Worker
  sidekiq_options retry: 3, queue: 'push'
 
  def perform(notification_id)
    notification = Notification.find(notification_id)
    tokens = DeviceToken.active_for_user(notification.user_id)
 
    return notification.update!(status: 'no_token') if tokens.empty?
 
    results = tokens.map { |token| deliver_to_token(notification, token) }
 
    if results.any? { |r| r[:success] }
      notification.update!(status: 'delivered', delivered_at: Time.current)
    else
      notification.update!(status: 'failed')
      raise "All tokens failed for notification #{notification_id}"
    end
  end
 
  private
 
  def deliver_to_token(notification, device_token)
    result = case device_token.platform
             when 'ios'
               ApnsClient.send(
                 token: device_token.token,
                 title: notification.title,
                 body:  notification.body,
                 badge: notification.user.unread_notifications_count,
                 data:  notification.payload
               )
             when 'android'
               FcmClient.send(
                 token:        device_token.token,
                 title:        notification.title,
                 body:         notification.body,
                 data:         notification.payload,
                 click_action: 'FLUTTER_NOTIFICATION_CLICK'
               )
             end
 
    unless result[:success]
      DeviceToken.handle_delivery_failure(
        token:      device_token.token,
        error_code: result[:error_code]
      )
    end
 
    result
  end
end

Step 6: テンプレートエンジンと多言語対応

「通知の文言はハードコードしてはいけない。テンプレート管理が必要。」

# app/models/notification_template.rb
class NotificationTemplate < ApplicationRecord
  # DB設計: notification_templates
  #   id, notification_type, locale,
  #   title_template, body_template,
  #   email_subject_template, email_html_template,
  #   created_at, updated_at
 
  def self.render(type:, locale:, variables: {})
    template = find_by!(notification_type: type, locale: locale)
 
    {
      title: Liquid::Template.parse(template.title_template).render(variables),
      body:  Liquid::Template.parse(template.body_template).render(variables),
      email_subject: Liquid::Template.parse(
        template.email_subject_template
      ).render(variables),
      email_html: Liquid::Template.parse(
        template.email_html_template
      ).render(variables)
    }
  rescue ActiveRecord::RecordNotFound
    # フォールバック: 英語テンプレートを使用
    render(type: type, locale: 'en', variables: variables)
  end
end

テンプレートのサンプル(Liquid形式):

# title_template
{{ follower_name }} さんがあなたをフォローしました
 
# body_template
{{ follower_name }} さんが {{ app_name }} であなたをフォローし始めました。
プロフィールを確認しましょう。
 
# email_html_template
<h1>新しいフォロワー</h1>
<p>{{ follower_name }} さんがあなたをフォローしました。</p>
<a href="{{ profile_url }}">プロフィールを見る</a>
<hr>
<p><a href="{{ unsubscribe_url }}">通知設定を変更する</a></p>

Step 7: 通知のスケジューリング

「キャンペーンメールを特定の日時に送りたい、というユースケースがある。これはWorkerの設計に影響する。」

Loading diagram...
# app/models/scheduled_notification.rb
class ScheduledNotification < ApplicationRecord
  # scheduled_at: 送信予定時刻
  # timezone: ユーザーのタイムゾーン
  # status: pending / queued / cancelled
 
  scope :due, -> {
    where(status: 'pending')
      .where('scheduled_at <= ?', Time.current)
  }
end
 
# app/jobs/notification_scheduler_job.rb
class NotificationSchedulerJob < ApplicationJob
  queue_as :scheduler
 
  def perform
    ScheduledNotification.due.find_in_batches(batch_size: 1000) do |batch|
      batch.each do |scheduled|
        # アトミック更新で二重送信を防ぐ
        next unless ScheduledNotification
                      .where(id: scheduled.id, status: 'pending')
                      .update_all(status: 'queued') == 1
 
        NotificationService.send(
          user_id:  scheduled.user_id,
          type:     scheduled.notification_type,
          priority: scheduled.priority,
          channels: scheduled.channels,
          data:     scheduled.payload
        )
      end
    end
  end
end

INFO

スケジューラーJobは1分ごとにCronで実行する。複数インスタンスが並走しても二重送信しないよう、UPDATE ... WHERE status = 'pending' のアトミック更新で排他制御する。UPDATE が 1 件を返した場合のみ処理を続ける。


Step 8: Golangでの高速Notificationワーカー

「スループットが高い箇所はGoで実装するとCPUもメモリも大幅に節約できる。」

// internal/worker/push_worker.go
package worker
 
import (
    "context"
    "encoding/json"
    "log/slog"
 
    "github.com/aws/aws-sdk-go-v2/service/sqs"
    "github.com/aws/aws-sdk-go-v2/service/sqs/types"
)
 
type PushMessage struct {
    NotificationID int64  `json:"notification_id"`
    UserID         int64  `json:"user_id"`
    Channel        string `json:"channel"`
    Title          string `json:"title"`
    Body           string `json:"body"`
    DeviceToken    string `json:"device_token"`
    Platform       string `json:"platform"` // "ios" or "android"
}
 
type PushWorker struct {
    sqsClient   *sqs.Client
    queueURL    string
    apnsClient  APNSClient
    fcmClient   FCMClient
    concurrency int
}
 
func (w *PushWorker) Run(ctx context.Context) error {
    sem := make(chan struct{}, w.concurrency)
 
    for {
        select {
        case <-ctx.Done():
            return nil
        default:
        }
 
        output, err := w.sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            &w.queueURL,
            MaxNumberOfMessages: 10,
            WaitTimeSeconds:     20, // Long polling
        })
        if err != nil {
            slog.Error("SQS receive error", "err", err)
            continue
        }
 
        for _, msg := range output.Messages {
            sem <- struct{}{}
            go func(m types.Message) {
                defer func() { <-sem }()
                w.processMessage(ctx, m)
            }(msg)
        }
    }
}
 
func (w *PushWorker) processMessage(ctx context.Context, msg types.Message) {
    var pm PushMessage
    if err := json.Unmarshal([]byte(*msg.Body), &pm); err != nil {
        slog.Error("unmarshal error", "err", err)
        return
    }
 
    var err error
    switch pm.Platform {
    case "ios":
        err = w.apnsClient.Send(ctx, APNSPayload{
            DeviceToken: pm.DeviceToken,
            Title:       pm.Title,
            Body:        pm.Body,
        })
    case "android":
        err = w.fcmClient.Send(ctx, FCMPayload{
            Token: pm.DeviceToken,
            Notification: FCMNotification{
                Title: pm.Title,
                Body:  pm.Body,
            },
        })
    }
 
    if err != nil {
        slog.Error("push delivery failed",
            "notification_id", pm.NotificationID,
            "platform", pm.Platform,
            "err", err,
        )
        // DLQへ(SQS の maxReceiveCount を超えると自動で移動)
        return
    }
 
    // 成功: メッセージを削除
    w.sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
        QueueUrl:      &w.queueURL,
        ReceiptHandle: msg.ReceiptHandle,
    })
 
    slog.Info("push delivered",
        "notification_id", pm.NotificationID,
        "platform", pm.Platform,
    )
}
// internal/worker/email_worker.go
package worker
 
import (
    "context"
    "fmt"
 
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/ses"
    "github.com/aws/aws-sdk-go-v2/service/ses/types"
)
 
type EmailWorker struct {
    sesClient   *ses.Client
    fromAddress string
}
 
func (w *EmailWorker) SendEmail(ctx context.Context, msg EmailMessage) error {
    input := &ses.SendEmailInput{
        Source: aws.String(w.fromAddress),
        Destination: &types.Destination{
            ToAddresses: []string{msg.ToEmail},
        },
        Message: &types.Message{
            Subject: &types.Content{
                Data:    aws.String(msg.Subject),
                Charset: aws.String("UTF-8"),
            },
            Body: &types.Body{
                Html: &types.Content{
                    Data:    aws.String(msg.HtmlBody),
                    Charset: aws.String("UTF-8"),
                },
                Text: &types.Content{
                    Data:    aws.String(msg.TextBody),
                    Charset: aws.String("UTF-8"),
                },
            },
        },
        ConfigurationSetName: aws.String("notification-config"),
    }
 
    _, err := w.sesClient.SendEmail(ctx, input)
    if err != nil {
        return fmt.Errorf("SES send failed: %w", err)
    }
    return nil
}

Step 9: バウンス処理とメール品質管理

「メールを大量送信すると、存在しないアドレスへの送信(ハードバウンス)が起きる。これを放置するとSESがブラックリスト入りする。」

Loading diagram...
# app/services/bounce_handler_service.rb
class BounceHandlerService
  # Amazon SES の SNS Webhook を受け取る
  def self.process(sns_payload)
    message = JSON.parse(sns_payload['Message'])
    bounce  = message['bounce']
    return unless bounce
 
    bounce['bouncedRecipients'].each do |recipient|
      email = recipient['emailAddress']
 
      case bounce['bounceType']
      when 'Permanent'
        # ハードバウンス: 即座に無効化
        EmailSuppression.create!(
          email:      email,
          reason:     'hard_bounce',
          bounced_at: Time.current
        )
      when 'Transient'
        # ソフトバウンス: 回数カウント
        record = EmailBounceRecord.find_or_create_by(email: email)
        record.increment!(:bounce_count)
 
        if record.bounce_count >= 3
          EmailSuppression.create!(
            email:      email,
            reason:     'soft_bounce_threshold',
            bounced_at: Time.current
          )
        end
      end
    end
  end
end
 
# 送信前にバウンスリストをチェック
class EmailNotificationWorker
  include Sidekiq::Worker
 
  def perform(notification_id)
    notification = Notification.find(notification_id)
    email = notification.user.email
 
    if EmailSuppression.exists?(email: email)
      notification.update!(status: 'suppressed')
      return
    end
 
    SES_CLIENT.send_email(
      destination:  { to_addresses: [email] },
      source:       'noreply@myapp.com',
      message: {
        subject: { data: notification.email_subject },
        body: {
          html: { data: notification.email_html_body },
          text: { data: notification.email_text_body }
        }
      },
      client_token: notification.idempotency_key
    )
 
    notification.update!(status: 'delivered', delivered_at: Time.current)
  end
end

WARNING

SES のバウンス率が 5% を超えると SES アカウントが一時停止される。ハードバウンス 2%、苦情率 0.1% が Amazon の推奨閾値。本番運用では CloudWatch アラームでバウンス率を監視し、閾値を超えたら自動で送信を停止する仕組みが必要。


Step 10: Webhook配信(Slack/Teams/外部サービス)

「最近の SaaS では、Slack や Teams への通知も求められる。これは Webhook 配信として統合すると良い。」

# app/models/webhook_subscription.rb
class WebhookSubscription < ApplicationRecord
  # user_id, url, events (array), secret, status, failure_count
  encrypts :secret
 
  def self.notify(event_type:, payload:)
    active.where("? = ANY(events)", event_type).find_each do |sub|
      WebhookDeliveryJob.perform_later(
        subscription_id: sub.id,
        event_type:      event_type,
        payload:         payload
      )
    end
  end
end
 
# app/jobs/webhook_delivery_job.rb
class WebhookDeliveryJob < ApplicationJob
  queue_as :webhooks
  retry_on Net::TimeoutError, wait: :polynomially_longer, attempts: 5
 
  def perform(subscription_id:, event_type:, payload:)
    sub  = WebhookSubscription.find(subscription_id)
    body = payload.to_json
 
    # HMAC署名で受信側が真正性を検証できる
    signature = OpenSSL::HMAC.hexdigest('SHA256', sub.secret, body)
 
    response = HTTP.timeout(10)
      .headers(
        'Content-Type'        => 'application/json',
        'X-Webhook-Event'     => event_type,
        'X-Webhook-Signature' => "sha256=#{signature}"
      )
      .post(sub.url, body: body)
 
    unless response.status.success?
      sub.increment!(:failure_count)
      raise "Webhook failed: #{response.status} for #{sub.url}"
    end
 
    sub.update!(last_delivered_at: Time.current, failure_count: 0)
  end
end

Step 11: レート制限(過剰送信の防止)

「同一ユーザーに30秒で100通送ったらどうなるか?ユーザーはアプリを削除する。レート制限は必須。」

# app/services/rate_limiter.rb
class RateLimiter
  # ユーザーごとのレート制限ルール
  LIMITS = {
    critical: { count: 10, window: 1.hour   },
    high:     { count: 20, window: 1.hour   },
    normal:   { count: 50, window: 24.hours },
    low:      { count: 5,  window: 24.hours }
  }.freeze
 
  def self.allow?(user_id:, priority:)
    rule = LIMITS[priority.to_sym]
    return true unless rule
 
    key   = "rate_limit:#{user_id}:#{priority}"
    count = redis.incr(key)
 
    # 初回のみ TTL をセット
    redis.expire(key, rule[:window].to_i) if count == 1
 
    count <= rule[:count]
  end
 
  private_class_method def self.redis
    Redis.current
  end
end
// Go版: Sliding Window でより精密なレート制限
// internal/ratelimit/limiter.go
package ratelimit
 
import (
    "context"
    "fmt"
    "time"
 
    "github.com/redis/go-redis/v9"
)
 
type Limiter struct {
    rdb *redis.Client
}
 
func (l *Limiter) Allow(ctx context.Context, userID int64, priority string) (bool, error) {
    limits := map[string]struct {
        count  int
        window time.Duration
    }{
        "critical": {10, time.Hour},
        "high":     {20, time.Hour},
        "normal":   {50, 24 * time.Hour},
        "low":      {5, 24 * time.Hour},
    }
 
    rule, ok := limits[priority]
    if !ok {
        return true, nil
    }
 
    key := fmt.Sprintf("rate_limit:%d:%s", userID, priority)
    now := time.Now()
    windowStart := now.Add(-rule.window).UnixMilli()
 
    pipe := l.rdb.Pipeline()
    pipe.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%d", windowStart))
    pipe.ZCard(ctx, key)
    pipe.ZAdd(ctx, key, redis.Z{
        Score:  float64(now.UnixMilli()),
        Member: now.UnixNano(),
    })
    pipe.Expire(ctx, key, rule.window)
 
    results, err := pipe.Exec(ctx)
    if err != nil {
        return false, err
    }
 
    currentCount := results[1].(*redis.IntCmd).Val()
    return currentCount < int64(rule.count), nil
}

Step 12: 重複通知の防止(冪等性)

# app/services/deduplication_service.rb
class DeduplicationService
  DEDUP_WINDOW = 1.hour
 
  # true: 新規(送信OK)、false: 重複(スキップ)
  def self.check_and_mark(idempotency_key)
    redis.set(
      "notif:dedup:#{idempotency_key}",
      "1",
      nx: true,
      ex: DEDUP_WINDOW.to_i
    )
  end
 
  def self.generate_key(user_id:, type:, event_id:)
    Digest::SHA256.hexdigest("#{user_id}:#{type}:#{event_id}")
  end
 
  # Redisがダウンした場合のフォールバック
  def self.check_and_mark_with_fallback(idempotency_key)
    check_and_mark(idempotency_key)
  rescue Redis::CannotConnectError
    # DBレベルのユニーク制約にフォールバック
    Notification.create!(idempotency_key: idempotency_key)
    true
  rescue ActiveRecord::RecordNotUnique
    false
  end
end

Step 13: AWSアーキテクチャの詳細

Loading diagram...
# インフラ構成サマリー
 
メッセージキュー:
  SQS FIFO (Critical):
    VisibilityTimeout: 30s
    MessageRetentionPeriod: 4 days
    DLQ: sqs-notifications-critical-dlq
    maxReceiveCount: 3  # 3回失敗でDLQへ
 
  SQS Standard (Normal/Low):
    VisibilityTimeout: 120s
    DLQ: sqs-notifications-normal-dlq
    maxReceiveCount: 5
 
メール配信:
  Amazon SES:
    Dedicated IP: バウンス率改善
    Configuration Set: 開封/クリック追跡
    SNS Topic: ses-bounces → Lambda でバウンス処理
    SNS Topic: ses-complaints → 苦情処理
 
プッシュ通知:
  Amazon SNS:
    Platform Application: APNs / FCM を一元管理
    Endpoint ARN: デバイストークンと1対1対応
 
ストレージ:
  DynamoDB (通知ログ):
    PK: user_id
    SK: created_at#notification_id
    TTL: 90日でログを自動削除
    GSI: notification_type + status(集計用)
 
  ElastiCache Redis (Cluster Mode ON):
    用途1: 冪等性チェック(NX フラグ)
    用途2: レート制限(Sliding Window)
    用途3: デバイストークンキャッシュ
 
監視:
  CloudWatch Metrics:
    SQS ApproximateAgeOfOldestMessage: >= 60秒でアラーム
    SES BounceRate: >= 2% でアラーム
    SES ComplaintRate: >= 0.1% でアラーム
    DLQ MessageCount: >= 1 でPagerDuty通知
 
  Kinesis Data Firehose:
    通知イベントをS3へストリーミング
    Athena でアドホッククエリ(開封率・クリック率分析)

INFO

Amazon SNS は APNs(iOS)と FCM(Android)への送信を統合できる。デバイストークンを Endpoint ARN として登録しておけば、SNS が適切なプロバイダに自動でルーティングしてくれる。マルチプラットフォームのプッシュ通知をシンプルに管理できる。


Step 14: 通知のA/Bテスト設計

「開封率を上げるために、件名や文言のA/Bテストをしたい、という要件が出てくる。」

# app/models/notification_ab_test.rb
class NotificationAbTest < ApplicationRecord
  # id, notification_type, variant_a_template_id,
  # variant_b_template_id, traffic_split (0.5 = 50/50),
  # status (active/paused/completed), started_at, ended_at
 
  def self.assign_variant(user_id:, notification_type:)
    test = active.find_by(notification_type: notification_type)
    return :control unless test
 
    # ユーザーIDのハッシュで一貫したバリアント割り当て
    # 同じユーザーは常に同じバリアントを受け取る
    hash = Digest::CRC32.checksum("#{test.id}:#{user_id}")
    fraction = (hash % 100) / 100.0
 
    fraction < test.traffic_split ? :variant_a : :variant_b
  end
end
 
# 開封追跡(1px トラッキングピクセル)
class EmailTrackingController < ApplicationController
  skip_before_action :authenticate_user!
 
  def open
    event = EmailEvent.find_by!(token: params[:token])
    event.update!(opened_at: Time.current) unless event.opened_at
 
    # 1x1 透明GIF を返す
    send_data "\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00".b,
              type: 'image/gif', disposition: 'inline'
  end
 
  def click
    event = EmailEvent.find_by!(token: params[:token])
    event.update!(clicked_at: Time.current, clicked_url: params[:url])
 
    redirect_to params[:url], allow_other_host: true
  end
end

Step 15: 監視とダッシュボード

「通知システムの健全性は5つの指標で測る。」

// internal/metrics/notification_metrics.go
package metrics
 
import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)
 
var (
    NotificationsSent = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "notifications_sent_total",
        Help: "Total notifications sent",
    }, []string{"channel", "priority", "status"})
 
    NotificationLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "notification_delivery_latency_seconds",
        Help:    "Notification delivery latency",
        Buckets: []float64{0.1, 0.5, 1, 5, 10, 30, 60},
    }, []string{"channel", "priority"})
 
    BounceRate = promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name: "email_bounce_rate",
        Help: "Email bounce rate (rolling 24h)",
    }, []string{"bounce_type"})
 
    QueueDepth = promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name: "notification_queue_depth",
        Help: "Number of messages in queue",
    }, []string{"queue", "priority"})
)

監視すべき主要アラーム:

指標閾値アクション
Critical通知レイテンシ p99> 5秒PagerDuty
メールバウンス率> 2%送信一時停止
DLQメッセージ数>= 1Slack通知
キュー深度 (Critical)> 10,000Auto Scaling
配信成功率< 99%PagerDuty

Step 16: 法的要件(CAN-SPAM / GDPR / 特定電子メール法)

「面接でここを話せると、ビジネス感覚があると評価される。」とレイカが言った。

# app/models/user_notification_setting.rb
class UserNotificationSetting < ApplicationRecord
  # GDPR: 明示的なオプトインを記録
  # opted_in_at, opt_in_ip, opt_in_user_agent
 
  scope :globally_opted_out, -> { where(global_opt_out: true) }
 
  def globally_opted_out?
    global_opt_out
  end
 
  def channel_enabled?(channel)
    preferences.fetch(channel.to_s, true)
  end
 
  # 特定電子メール法(日本): 受信拒否を即時反映
  def opt_out_email!
    update!(
      email_notifications: false,
      email_opted_out_at:  Time.current
    )
  end
end
 
# CAN-SPAM 準拠: メールフッターに配信停止リンク必須
# app/controllers/unsubscribes_controller.rb
class UnsubscribesController < ApplicationController
  skip_before_action :authenticate_user!
 
  def create
    user_id = UnsubscribeToken.decode!(params[:token])
    UserNotificationSetting
      .find_or_create_by(user_id: user_id)
      .update!(global_opt_out: true, opted_out_at: Time.current)
 
    render :success
  rescue JWT::DecodeError
    render :invalid_token, status: :bad_request
  end
end

法的チェックリスト:

法律要件実装
特定電子メール法(日本)広告メールはオプトイン必須opted_in_at を記録
CAN-SPAM(米国)配信停止リンク必須・10日以内反映Unsubscribeコントローラ
GDPR(EU)同意の記録・削除権opt_in_ip, opted_out_at
TCPA(米国SMS)SMSは書面同意が必要sms_consent_at

WARNING

日本でSMSを送信する際はNTTドコモ・au・ソフトバンク各社のレギュレーションがある。また、広告メールはオプトイン(事前同意)が特定電子メール法で義務付けられている。面接でこれを触れると「法的知識がある」と評価されることがある。


Step 17: 面接官との深掘り会話

「実際の面接ではここからが本番。設計を突いてくる。」

面接官: 「通知システムで一番難しいことは何だと思いますか?」

ソウタ: 「信頼性と規模のバランスです。At-least-once 保証のために再送をするが、それが重複通知になるリスクがある。Redis の NX フラグで冪等性を担保しますが、Redisがダウンした場合のフォールバックが必要で、その設計が難しいです。」

面接官: 「Redisが落ちたらどうしますか?」

ソウタ: 「まずRedisをマルチAZ構成にして可用性を上げます。それでもダウンした場合は、PostgreSQLの INSERT ... ON CONFLICT DO NOTHING でDBレベルの重複防止にフォールバックする。スループットは下がるが、通知は送れる。DBに idempotency_key のユニーク制約を持たせておけば実装できます。」

面接官: 「プッシュ通知の開封率を上げるにはどうしますか?」

ソウタ: 「3つのアプローチがあります。1つ目は送信時刻の最適化——ユーザーの過去の開封ヒストリーから、開封しやすい時間帯に送る。2つ目はリッチ通知——画像やアクションボタンを付けてエンゲージメントを高める。3つ目はパーソナライズ——名前や具体的な内容を含めることで開封率が上がる。A/Bテストでどの要素が効くか継続的に測定します。」

面接官: 「100万通/分のピーク時に、SESの送信レート上限に引っかかったらどうする?」

ソウタ: 「SES はデフォルトで14通/秒の上限がありますが、申請でリフトアップできる。それでも足りない場合は、SES をマルチリージョンで利用する——us-east-1 と eu-west-1 を両方使って負荷分散する。また、Dedicated IP Pool を使って送信ドメインの評判を分離し、トランザクションメールとプロモーションメールで IP を分けることも重要です。」

面接官: 「大規模なキャンペーンで1億通を一気に送る場合、どう設計しますか?」

ソウタ: 「一気に送ると ISP のスパムフィルターに引っかかります。ウォームアップ戦略が必要です。最初の1時間は全体の1%、次の1時間は5%、と段階的に増やしながら、バウンス率と苦情率を監視する。閾値を超えたら自動で停止する。SES の Configuration Set にイベント通知を設定して、CloudWatch でリアルタイムに監視します。」


Step 18: ボトルネック対策

Loading diagram...
# ECS Auto Scaling 設定
notification-push-worker:
  desired: 10
  min: 5
  max: 100
  scaling_policy:
    metric: SQS_ApproximateNumberOfMessagesVisible
    target_value: 1000  # キューに1000件溜まったらスケールアウト
    scale_out_cooldown: 60
    scale_in_cooldown: 300
 
notification-email-worker:
  desired: 5
  min: 2
  max: 50
  scaling_policy:
    metric: SQS_ApproximateNumberOfMessagesVisible
    target_value: 500
// APNs / FCM への接続プールを再利用する
// internal/apns/connection_pool.go
package apns
 
import "sync"
 
type ConnectionPool struct {
    mu      sync.Mutex
    conns   []*Connection
    maxSize int
    idx     int
}
 
func (p *ConnectionPool) Get() *Connection {
    p.mu.Lock()
    defer p.mu.Unlock()
 
    conn := p.conns[p.idx%len(p.conns)]
    p.idx++
    return conn
}

INFO

面接の最後に「設計のトレードオフを教えてください」と聞かれたら:「At-least-once vs Exactly-once」「レート制限によるユーザー体験 vs システム保護」「リアルタイム配信 vs バッチ配信のコスト差」を答えると、システム設計の本質を理解していることが伝わる。


まとめ: 面接でのチェックリスト

「最後に、面接で通知システムを答えるときのチェックリストをまとめておく。」

要件確認:
  - 4チャネル(Push/Email/SMS/In-App)を確認したか
  - 配信量の規模(QPS・1日総量)を確認したか
  - 配信保証レベル(At-least-once)を確認したか

設計の核心:
  - 優先度別キュー(Critical/High/Normal/Low)
  - 冪等性チェック(Redis NX または DB UNIQUE制約)
  - デバイストークン管理(複数デバイス・トークン失効)
  - バウンス処理(ハードバウンス即時無効化)
  - レート制限(Sliding Window)

AWS構成:
  - SQS(優先度別 + DLQ)
  - SES(Dedicated IP + Configuration Set)
  - SNS(プッシュ通知のアグリゲーター)
  - DynamoDB(通知ログ)
  - ElastiCache Redis(冪等性・レート制限)
  - CloudWatch(バウンス率・遅延アラーム)

法的要件:
  - オプトアウト(配信停止)の即時反映
  - 特定電子メール法・CAN-SPAM・GDPR対応
  - 同意記録の保持

運用:
  - バウンス率の監視(2%未満を維持)
  - DLQ蓄積でアラート
  - A/Bテストで継続的に改善

「ここまで話せれば、通知システムの設計問題は満点に近い。」とレイカが締めた。

ソウタはノートを閉じた。確かに、「ただメールを送るだけ」ではなかった。