設計問題: 通知システム — 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: 高レベル設計
通知トリガーの種類
アプリ内のあらゆるイベントが通知のトリガーになる。重要なのは優先度の設計だ。
# 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
endStep 4: 通知の優先度システム
「面接官によく聞かれる。Critical/High/Normal/Low をどう実装するか。」
優先度別に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
endWARNING
Critical キューに大量のメッセージが滞留すると、SQS の VisibilityTimeout 内に処理できないメッセージが再度見え始め(re-delivery)、重複処理が起きる。冪等性チェック(Redis の NX フラグ)は必ず Critical キューにも実装すること。
Step 5: プッシュ通知のデバイストークン管理
「プッシュ通知で一番ハマるのがデバイストークンの管理です。」とレイカが言った。
「ユーザーがアプリを削除すると、そのトークンは無効になる。でも、こちらからはわからない。APNsに送ってみて、エラーが返ってきて初めてわかる。」
# 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
endStep 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の設計に影響する。」
# 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
endINFO
スケジューラー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がブラックリスト入りする。」
# 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
endWARNING
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
endStep 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
endStep 13: AWSアーキテクチャの詳細
# インフラ構成サマリー
メッセージキュー:
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
endStep 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メッセージ数 | >= 1 | Slack通知 |
| キュー深度 (Critical) | > 10,000 | Auto 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: ボトルネック対策
# 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テストで継続的に改善
「ここまで話せれば、通知システムの設計問題は満点に近い。」とレイカが締めた。
ソウタはノートを閉じた。確かに、「ただメールを送るだけ」ではなかった。