このページの本文へ

FIXER Tech Blog - Development

大量のSMS送信・自動架電システムを運用するポイントをTwilio×BullMQで学ぶ

2026年04月06日 17時00分更新

文● 川村達矢/FIXER

  • この記事をはてなブックマークに追加
  • 本文印刷

 本記事はFIXERが提供する「cloud.config Tech Blog」に掲載された「Twilio×NestJS による SMS&架電」を再編集したものです。

※この記事はこちらの記事の続きです。

Part 2: 運用したい人へ

ここからは、本番運用で求められる要件を解決するアーキテクチャを解説します。

※このあたりから先は、私(新卒)が実務で触ったコードの抜粋です。環境依存の部分や省略があるので、そのままコピペで動くことは保証できませんが、設計の考え方・組み立て方の参考になれば幸いです。

ここでは BullMQ(Redis ベースのジョブキュー)を中心に、 Producer-Consumer パターンで構築していきます。

このアーキテクチャのメリット:

非同期処理: API レスポンスを送信完了まで待たせない
自動リトライ: 指数バックオフ(2s → 4s → 8s)で最大 3 回
水平スケーリング: Worker を複数台起動可能
送達確認: ステータスチェックジョブで配信成否を自動追跡

6. なぜキューが必要か — BullMQ でバッチ処理・リトライ

SMS 100 件を同期的に送ると、API レスポンスが数十秒〜数分ブロックされます。
途中で 1 件失敗しただけで全体がロールバック...というのも避けたい。
ジョブキューに投げれば、API は即座にレスポンスを返し、送信・リトライは Worker が非同期で処理します。

BullMQ は Redis ベースのジョブキューで、 NestJS とは @nestjs/bullmq でシームレスに統合できます。

セットアップ

npm install @nestjs/bullmq bullmq


モジュール設定 — リトライ・自動クリーンアップ

BullModule.forRootAsync({
  useFactory: async (configService: ConfigService) => ({
    connection: configService.getRedisConnectionConfig(),
    defaultJobOptions: {
      removeOnComplete: { age: 3600, count: 100 }, // 1h 後削除, 100件保持
      removeOnFail:     { age: 86400, count: 500 }, // 24h 後削除, 500件保持
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 }, // 2s → 4s → 8s
    },
  }),
});

 
キュー定義
この例では 18 キューを用途別に分離しています。 SMS・架電に関わるものは以下の 4 つです。

export const QUEUE_NAMES = {
  SMS_NOTIFICATION:          'sms_notification',          // 単発 SMS・架電
  PRESCRIPTION_SMS_REMINDER:  'prescription_sms_reminder',  // 定期リマインダー (SMS)
  PRESCRIPTION_CALL_REMINDER: 'prescription_call_reminder', // 定期リマインダー (架電)
  TWILIO_STATUS_CHECK:        'twilio_status_check',        // 送達確認バッチ
  // ... LINE, Email, 決済, レポート等のキュー
} as const;

 
Processor — ジョブを受け取って送信
@Processor デコレータでキューと紐づけると、
ジョブが投入されたタイミングで process() が自動的に呼ばれます。

@Processor(QUEUE_NAMES.SMS_NOTIFICATION)
export class SmsNotificationProcessor extends WorkerHost {

  constructor(
    private readonly twilioService: TwilioService,
    private readonly logService: AutoReminderLogService,
    @InjectQueue(QUEUE_NAMES.TWILIO_STATUS_CHECK)
    private readonly statusCheckQueue: Queue,
  ) { super(); }

  async process(job: Job) {
    switch (job.name) {
      case 'send-sms':   return this.handleSms(job.data);
      case 'make-call':  return this.handleCall(job.data);
      case 'send-bulk-sms': return this.handleBulkSms(job);
    }
  }

  private async handleSms(data) {
    // ① 送信
    const result = await this.twilioService.sendSms(data.to, data.body);

    // ② ログ保存(PENDING で記録)
    await this.logService.logSmsSend({
      phoneNumber: toLocal(data.to),
      message: data.body,
      success: !!result.sid,
      twilioSid: result.sid,
    });

    // ③ 5分後にステータスチェックを予約
    //    直近 PENDING のログを一括チェックする設計なので、
    //    ジョブデータには起点時刻だけ渡せば十分
    if (result.sid) {
      await this.statusCheckQueue.add('check-status', {
        triggeredAt: new Date().toISOString(),
      }, {
        delay: 5 * 60 * 1000,  // 5分後
      });
    }

    return result;
  }

  // バルク送信時は進捗をリアルタイム更新
  private async handleBulkSms(job: Job) {
    const recipients = job.data.to;
    for (let i = 0; i < recipients.length; i++) {
      await job.updateProgress((i / recipients.length) * 100);
      await this.handleSms({ to: recipients[i], body: job.data.body });
    }
    await job.updateProgress(100);
  }
}


job.updateProgress() の活用

バルク送信時に進捗率を更新しておくと、管理画面側で QueueEvents の progress イベントをリッスンして
プログレスバーを表示できます。

7. なぜ PENDING で保存するか — 非同期ログと送達追跡

Twilio の送信 API が 200 OK を返しても、それは「Twilio が受け付けた」だけで「届いた」ではありません。
送信直後のステータスは PENDING で保存し、あとから確定させるのがこの設計のポイントです。

@Injectable()
export class AutoReminderLogService {

  async logSmsSend(params: {
    phoneNumber: string;
    message: string;
    success: boolean;
    twilioSid?: string;
    targetId?: number;
    templateId?: number;
  }) {
    // SID がある = Twilio が受け付けた → まだ配信未確定 → PENDING
    // SID がない = API エラー → 即 FAILED
    const status = params.twilioSid
      ? 'PENDING'
      : 'FAILED';

    return this.logRepository.save({
      channel: 'SMS',
      status,
      phone_number: params.phoneNumber,
      message: params.message,
      twilio_sid: params.twilioSid,
      sent_at: new Date(),
    });
  }

  // 架電も同じパターン
  async logCallSend(params) {
    const status = params.twilioSid ? 'PENDING' : 'FAILED';
    return this.logRepository.save({
      channel: 'CALL', status, ...
    });
  }
}

 
テーブル構造

カラム 説明
id SERIAL 主キー
channel ENUM SMS / CALL / LINE_TEXT / LINE_CARD
status ENUM PENDING / SUCCESS / FAILED
twilio_sid VARCHAR(50) Twilio SID(ステータス照合のキー)
phone_number VARCHAR(20) 国内形式で保存
message TEXT 送信内容
twilio_error_code VARCHAR(20) Twilio エラーコード
error_message TEXT 失敗理由(日本語)
sent_at TIMESTAMP 送信日時

8. 送達ステータスを確定する — Webhook と TEMP TABLE

本来の正攻法: StatusCallback Webhook

Twilio には、SMS や架電のステータスが変わったタイミングで こちらのサーバーに HTTP POST を送ってくれる Webhook(StatusCallback)の仕組みがあります。
送信時に statusCallback パラメータで URL を指定するだけです。

// SMS 送信時に StatusCallback を指定
const message = await client.messages.create({
  body: 'テスト',
  from: process.env.TWILIO_PHONE_NUMBER,
  to:   '+8190XXXXXXXX',
  statusCallback: 'https://your-server.com/api/twilio/webhook/sms-status',
});

// 架電時に StatusCallback を指定
const call = await client.calls.create({
  to:   '+8190XXXXXXXX',
  from: process.env.TWILIO_PHONE_NUMBER,
  twiml: '...',
  statusCallback: 'https://your-server.com/api/twilio/webhook/call-status',
  statusCallbackEvent: ['completed', 'busy', 'no-answer', 'failed'],
});


Twilio がステータス変更のたびに POST してくれるので、 バックエンド側は受け取ったデータで DB を更新するだけです。
リアルタイムにステータスが反映され、ポーリングの遅延やAPIコール数の無駄もありません。

Webhook が使えるなら、バックエンドからのポーリングは避けるべき

この後紹介する「TEMP TABLE で一括ステータス更新」は、 バックエンドから Twilio の List API を定期的に叩いてステータスを取得する方式です。
StatusCallback Webhook が利用できる環境であれば、Webhook を使うのが正攻法です。
ポーリング方式には以下のデメリットがあります。

・ステータス反映にタイムラグがある(ジョブの実行間隔に依存)
・Twilio API の呼び出し回数が増え、レートリミットに抵触するリスク
・送信件数が多いほど List API のレスポンスが大きくなる

以下のパターンは、Webhook を受けられない環境(ファイアウォール制約、ローカル開発など)や Webhook の取りこぼしに対するフォールバックとして参考にしてください。

フォールバック: TEMP TABLE で一括更新

送信から 5 分後に BullMQ のジョブが起動し、Twilio の List API でステータスを一括取得。
PostgreSQL の TEMP TABLE を使って効率的にバルク更新します。

なぜ TEMP TABLE?
100 件の SMS を送ったら、ステータス確認も 100 回 UPDATE? ...いいえ。
TEMP TABLE にまとめて INSERT → 本テーブルと JOIN して 1 回の UPDATE で済ませます。

1. Twilio List API でメッセージ/通話を一括取得
2. CREATE TEMP TABLE ... ON COMMIT DROP で一時テーブル作成
3. 取得結果をバルク INSERT
4. 本テーブルと JOIN して 1 回の UPDATE
5. DB にない SID は INSERT で補完(安全弁)
 
※ SQLインジェクションに注意してください。

ステータスチェックで TEMP TABLE にデータを挿入する際、Twilio からの値を SQL に埋め込んでいます。
SID のフォーマットバリデーション (/^(SM|CA)[0-9a-f]{32}$/) を追加するか、 パラメータ化クエリを使うことを推奨します。

@Processor(QUEUE_NAMES.TWILIO_STATUS_CHECK)
export class TwilioStatusCheckProcessor extends WorkerHost {

  async process(job: Job) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();

    // ① Twilio から一括取得
    const messages = await this.twilioService.listMessages({
      from: this.fromPhoneNumber,
      dateSentAfter: startTime,
      limit: fetchLimit,
    });

    await queryRunner.startTransaction();
    try {
      // ② TEMP TABLE 作成(トランザクション終了で自動削除)
      await queryRunner.query(`
        CREATE TEMP TABLE temp_twilio_status (
          sid VARCHAR(50) PRIMARY KEY,
          status VARCHAR(20) NOT NULL,
          error_code VARCHAR(20),
          error_message TEXT
        ) ON COMMIT DROP
      `);

      // ③ バルク INSERT
      await queryRunner.query(`INSERT INTO temp_twilio_status VALUES ...`);

      // ④ 本テーブルを一括 UPDATE
      await queryRunner.query(`
        UPDATE auto_reminder_logs AS arl
        SET
          status = CASE
            WHEN tmp.status = 'delivered' THEN 'SUCCESS'
            WHEN tmp.status IN ('failed', 'undelivered') THEN 'FAILED'
            ELSE arl.status
          END,
          twilio_error_code = tmp.error_code,
          error_message = tmp.error_message,
          updated_at = NOW()
        FROM temp_twilio_status AS tmp
        WHERE arl.twilio_sid = tmp.sid
          AND arl.status = 'PENDING'
      `);

      await queryRunner.commitTransaction();
    } catch (e) {
      await queryRunner.rollbackTransaction();
      throw e;
    } finally {
      await queryRunner.release();
    }
  }
}

 
架電のステータス判定

架電の場合、completed でも通話時間 0 秒なら実質「出ていない」可能性があります。
また、失敗理由を日本語で保存して管理画面の可読性を上げています。

status = CASE
  WHEN tmp.status = 'completed' AND tmp.duration > 0 THEN 'SUCCESS'
  WHEN tmp.status IN ('busy', 'no-answer', 'failed', 'canceled') THEN 'FAILED'
  ELSE arl.status
END,
error_message = CASE
  WHEN tmp.status = 'busy'      THEN '通話中'
  WHEN tmp.status = 'no-answer' THEN '応答なし'
  WHEN tmp.status = 'failed'    THEN '通話失敗'
  WHEN tmp.status = 'canceled'  THEN 'キャンセル'
END

QueryRunner が必要な理由

TEMP TABLE は作成したコネクション内でしか見えません。
TypeORM のデフォルトではクエリごとにコネクションが変わりうるため、 createQueryRunner() で同一コネクションを保証しています。

9. なぜ DB 駆動か — 動的 Cron スケジューラ

Cron 式をコードにハードコーディングすると、スケジュール変更のたびにデプロイが必要になります。
スケジュールを DB テーブルで管理すれば、管理画面から自由に追加・変更・停止でき、デプロイ不要です。

@Injectable()
export class AutoReminderSchedulerService implements OnModuleInit {

  // Worker 起動時に DB のスケジュールを読み込んで Cron ジョブに変換
  async onModuleInit() {
    await this.syncSchedulesFromDB();
  }

  async syncSchedulesFromDB() {
    // 既存のリピートジョブをクリア
    await this.clearAllAutoReminderJobs();

    // DB からアクティブなスケジュールを取得
    const schedules = await this.dataSource.query(`
      SELECT id, channel, execution_time, template_id
      FROM auto_reminder_schedules
      WHERE is_active = true
    `);

    // 各スケジュールを BullMQ の Cron ジョブとして登録
    for (const s of schedules) {
      const cron = this.timeToCron(s.execution_time);
      // "10:30:00" → "30 10 * * *"

      const queue = this.getQueueByChannel(s.channel);
      await queue.add('auto-reminder', {
        scheduleId: s.id,
        channel: s.channel,
        templateId: s.template_id,
      }, {
        repeat: { pattern: cron, tz: 'Asia/Tokyo' },
      });
    }
  }

  private timeToCron(time: string): string {
    const [h, m] = time.split(':').map(Number);
    return `${m} ${h} * * *`;
  }
}


tz: 'Asia/Tokyo' を忘れずに

BullMQ の repeat.tz を省略すると UTC 解釈になります。
10:30 JST のつもりが 19:30 JST に実行される...という事故が起きます。

管理画面からスケジュールが変更された際は、API 側から syncSchedulesFromDB() を再度呼び出すことで、
Cron ジョブを動的に再登録しています。

Worker 複数台構成での注意

onModuleInit() は各 Worker インスタンスで実行されるため、 複数台構成では同じ Repeat ジョブが重複登録される可能性があります。
BullMQ の Repeat は同一キー(pattern + jobName)なら重複しない設計ですが、 clearAllAutoReminderJobs() と再登録が競合するとタイミング次第でジョブが消えるケースもあります。
本番では分散ロック(Redlock 等)やリーダー選出で、 同期処理を 1 インスタンスだけに限定することを検討してください。

10. セキュリティ・PII・コンプライアンス

Twilio を運用する上で押さえておくべきセキュリティと個人情報保護のポイントをまとめます。

クレデンシャルの管理

・.env は .gitignore に含め、Git に絶対にコミットしない
・ConfigService 経由で読み込み、コード中にハードコーディングしない
・本番環境では AWS Secrets Manager 等の秘密管理サービスを利用
・ログに Auth Token を出力しない(Logger のフィルタリング)

電話番号のバリデーション

不正な番号が Twilio に渡されると、予期しない国際通話やエラーの原因になります。
送信前に必ず E.164 形式のバリデーションを行いましょう。

レートリミット

意図しない大量送信を防ぐために、送信間隔を制御しています。
 

チャネル 送信間隔 理由
SMS 100ms Twilio API Concurrency 上限の回避
架電 500ms 同上 + 架電は API 処理が重い

PII(個人情報)の扱い

電話番号は個人情報です。ログ出力・DB 保存の両面でルールを決めておきましょう。

項目 ログ出力 理由
TWILIO_ACCOUNT_SID 禁止 認証情報
TWILIO_AUTH_TOKEN 禁止 認証情報
電話番号(フル) マスク推奨 個人情報
SMS 本文(フル) 先頭50文字まで 機微情報を含みうる
Twilio SID OK ステータス追跡に必要
エラーコード OK 障害調査に必要

電話番号のマスキング例:

/** 電話番号の末尾 4 桁だけ表示し、残りをマスクする */
function maskPhone(phone: string): string {
  if (phone.length <= 4) return '****';
  return '*'.repeat(phone.length - 4) + phone.slice(-4);
}
// "09012345678" → "*******5678"

// ログ出力
this.logger.log(`SMS sent to ${maskPhone(to)}: ${body.substring(0, 50)}...`);


保存の最小化 — テンプレート ID パターン

SMS 本文をそのまま DB に全文保存するのではなく、 テンプレート ID + パラメータの形で保持する設計も有効です。
例: { templateId: 3, params: { name: "山田", date: "3/5" } }
ログテーブルに生の本文を持たないことで、PII の保存範囲を最小化できます。

おわりに

この記事で扱った内容を振り返ります。
 

パート できるようになったこと
Part 1 Twilio SDK で SMS 送信・架電を最小コードで実行
Part 1 E.164 変換・escapeXml で安全に送信
Part 1 NestJS の Injectable サービスとして DI 可能に
Part 2 BullMQ で非同期バッチ送信 + 指数バックオフリトライ
Part 2 PENDING → SUCCESS/FAILED の非同期ステータス追跡
Part 2 StatusCallback Webhook(正攻法)+ TEMP TABLE(フォールバック)
Part 2 DB 駆動の動的 Cron スケジューラ


Part 1 で見たとおり、Twilio を使えば ほんの十数行で SMS も架電も送れます
「まず自分のスマホに送ってみる」ところから始めれば、ハードルは思ったより低いはずです。

一方で、プロダクションに載せるにはリトライ・ログ・送達確認・スケジューリング・セキュリティと 考えることが一気に増えます。
Part 2 で紹介した BullMQ + Webhook + DB スケジューラのパターンが、 その課題に対するひとつの設計例として参考になれば幸いです。

川村達矢/FIXER
2025年4月入社の川村です!
伊藤達矢さんと同じ漢字なので、覚えていただけると嬉しいです!

カテゴリートップへ

本記事はアフィリエイトプログラムによる収益を得ている場合があります

この連載の記事