LINE ファネルを壊れにくく組む — FSM・冪等・seed-as-code・PC だけの通しテスト
LINE ファネルは運用中に静かに壊れる——二重配信、古い分岐のドリップ残留、管理画面直編集によるドリフト。原因は個々のバグではなく、状態の不変条件と冪等性を最初に設計していないことにあります。実運用ファネルから演繹した4つの原則(FSM・「上流の再選択は下流を上書き」・webhook 再送 dedup・seed-as-code)と、実機なしで通しテストする構築型を示します。
by Akisame
LINE ファネルは、なぜ運用が始まってから静かに壊れるのか。答えを先に言うと、壊れる原因は個々のバグではなく、状態の不変条件・冪等性(ある操作を1回でも複数回でも結果が同じになる性質)・定義の真実源を最初に設計していないことです。二重配信もドリップの残留も定義のドリフトも、エラーを出さずに起きるので、気づくのはユーザーからの指摘か数字の異変です。本記事では、実運用の LINE 公式アカウント(設問7〜12・分岐ルート3〜5本規模のファネル)の壊れ方から逆算した4つの設計原則と、スマホ実機なしに PC だけで通しテストする構築型を、動くコードで示します。
なお本記事の原則はツール非依存です。自作のファネル基盤でも、市販の LINE 構築系ツール(SaaS)でも、状態と配信がある限り同じ壊れ方をします。SaaS 利用者は「自分のツールはこの不変条件を守っているか」というチェックリストとして読んでください。
LINE ファネルの「静かな壊れ方」3パターン
まず敵を知るところからです。実運用で実際に踏んだ・観測した壊れ方は、ほぼこの3つに分類できます。
- 二重配信。 同じメッセージが同じユーザーに2通届く。原因は主に2系統あり、(a) webhook の再送や重複到達をそのまま処理して送信まで2回走る、(b) ユーザーがシナリオを乗り換えたのに旧ルートの配信予約が生きている。どちらも送信 API は正常応答するので、ログ上は「成功が2回」にしか見えません。
- タグ不整合とドリップ残留。 ユーザーが選択肢を選び直したとき、新しいセグメントタグが追加されるだけで古いタグが残る。結果、1人が矛盾する2セグメントに属し、古い分岐のステップ配信(ドリップ)が新しい分岐と並走し続ける。これもエラーは出ません。
- 定義のドリフト。 本番の管理画面でタグやシナリオを直接編集した結果、「本番に何が設定されているか」の記録がどこにも無くなる。数週間後に「このタグ、いつ誰が何のために変えた?」が誰にも答えられなくなり、変更のたびに壊す恐怖で運用が硬直します。
共通点は、すべて「エラーが出ない」ことです。例外もアラートも発生せず、システムは各時点で「言われたとおり」に動いています。壊れているのは個々の処理ではなく、処理をまたいだ整合性のほうです。これは計測が静かに壊れる話と同じ構造で、対策も同じ——事後の監視ではなく、壊れようがない構造を最初に選ぶことです。以下の4原則がその構造です。
原則1: ファネルは FSM として設計する
シナリオ図(「この選択肢ならこのルート」の絵)は、正常系しか描いていません。実運用のユーザーは、途中で放置し、戻ってやり直し、想定外のタイミングでメッセージを送ってきます。この全部を受け止めるには、ファネルを**有限状態機械(FSM: finite state machine)**として書き下すのが最短です。
- 状態を列挙する: 「未回答 / 設問n回答済み / ルートA進行中 / 完了 / 離脱」のように、ユーザーが取り得る状態を有限集合で定義する。
- 遷移を全域で定義する: すべての状態 × すべての入力(選択肢タップ・自由入力・友だち追加・ブロック)に対して遷移先を決める。「想定外の入力は現在状態を維持して案内を返す」も立派な遷移です。シナリオ図で描かれないのはこの全域性で、乗り換え・再選択・離脱が「例外処理」ではなく「設計対象の遷移」になります。
- 履歴と現在状態を分離する: イベントの履歴ログ(追記のみ)と、ユーザーの現在状態(1行)を別テーブルに持つ。「今どのルートにいるか」は現在状態を見ればよく、「なぜそうなったか」は履歴を辿ればよい。1つのテーブルで両方をやろうとすると、どちらも曖昧になります。
- 競合は時刻で解決する: 同一ユーザーへの更新が競合したら「最後の書き込みが勝つ」(LWW: last write wins)を基本にし、イベントの発生時刻(webhook の
timestamp)で順序づけます。LINE の webhook は再送時に到着順序が入れ替わり得るため、到着順を信用せず発生時刻で判断するのは公式ドキュメントも注意している点です。
FSM 化の実利は、次の原則2・3が「遷移に対する不変条件」として素直に書けるようになることです。
原則2: 不変条件「上流の再選択は下流を上書き」
トーク内の選択肢(ボタン付きメッセージ)でルート分岐するファネルには、構造的な宿命があります。過去に送ったカードはトーク履歴に残り続けるので、ユーザーはいつでも昔の選択肢を再タップできる。再選択は異常系ではなく、必ず起きる正常系です。
ここで守るべき不変条件が「上流の再選択は下流を上書きする」です。設問2で選び直したら、設問3以降の回答・タグ・配信予約は旧選択に基づくものなので、すべて無効化されるべきです。実装レシピは順序が命です。
選択肢の再タップを受けたとき(上から順に・1トランザクションで):
1. 旧選択に紐づくタグと、その下流のタグをすべて除去する
2. 旧ルートの配信予約(ドリップの残り)を停止する
3. 新選択のタグを付与する
4. 新ルートの配信を登録する
5. 次のカードを送信する1・2を飛ばして3から始めるのが、前節の壊れ方②(タグ二重・ドリップ並走)の正体です。「タグを付ける」処理は誰でも書きますが、「そのタグと矛盾するものを消す」処理は不変条件として明文化しないと実装されません。
なお、分岐の入力を LIFF の1画面フォームにすれば、送信は1回で完結するので再選択問題は原理的に起きません。ただしトーク内の選択肢より起動の摩擦が1段増え、途中離脱率とのトレードオフになります。方式はケースバイケースで選べばよく、どちらを選んでも不変条件そのものは守る——これが正しい整理です。SaaS 利用者への問いはこうなります: 「あなたのツールで、ユーザーが選択肢を選び直したとき、旧タグは消えますか? 旧ルートのステップ配信は止まりますか?」
原則3: 冪等 — 再送で「送信だけ」が二重になる
LINE の Messaging API には webhook 再送機能があります(既定で無効・LINE Developers コンソールで有効化)。bot サーバーが 2xx を返さなかった webhook を、LINE プラットフォームが一定期間・所定回数で再送してくれる——取りこぼし対策として有効化する価値が高い一方、同じイベントが2回届く世界を自分で選ぶことでもあります。さらに公式ドキュメントは、再送を有効化しなくてもネットワーク経路の問題などで同一イベントが複数回届き得ること、その検知に webhookEventId を使うことを明記しています。実質、webhook 処理は最初から重複前提(at-least-once 相当)で書くべきです。
ここで効くのが処理の非対称性です。タグ付与やシナリオ登録は、DB の UNIQUE 制約を張っておけば2回実行しても結果が変わりません(自然に冪等)。ところがメッセージ送信だけは非冪等です。送信 API を2回呼べば2通届く。だから重複イベントの被害は、ほぼ常に「送信だけが二重になる」形で現れます。壊れ方①(a) の正体です。
対策は claim-first dedup——「処理してから記録する」のではなく「記録できた者だけが処理する」順序にします。全 webhook イベントに共通で付いている webhookEventId(ULID 形式・イベントを一意に識別。再送でも同じ値)をキーに使います。
CREATE TABLE processed_webhook_events (
webhook_event_id TEXT PRIMARY KEY, -- LINE の webhookEventId(ULID・再送でも不変)
received_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);export async function handleEvent(event: WebhookEvent): Promise<void> {
// 1. 処理より先に webhookEventId を claim する(INSERT できた者だけが処理する)
const claimed = await db.run(
`INSERT INTO processed_webhook_events (webhook_event_id)
VALUES (?)
ON CONFLICT (webhook_event_id) DO NOTHING`,
event.webhookEventId,
)
// 2. claim できなかった=既に受理済みの重複 → 送信を含む処理を一切しない
if (claimed.changes === 0) return
// 3. claim できたときだけ処理(タグ付与・enroll・メッセージ送信)
await processEvent(event)
}順序が逆(処理してから記録)だと、処理と記録のあいだに落ちた場合に再送でもう一度送信が走ります。claim-first なら、claim 後に落ちたイベントは再送時にスキップされる——つまり取りこぼす側に倒れるので、「重要イベントの処理が claim 済みなのに未完了」を検知する補助(処理完了フラグの突き合わせ)を足せば穴も塞げます。逆に、webhookEventId が何らかの理由で取れない・dedup ストアが落ちているときは処理する側に倒す(fail-open)のが実務的です。重複配信は1通の恥、取りこぼしはファネル全体の欠損だからです。
原則4: seed-as-code — 定義の真実源をファイルに移す
壊れ方③(定義のドリフト)への根本対策は、タグ・シナリオ・自動化ルールの定義をリポジトリ管理のファイル(JSON 等)に置き、冪等な seeder で環境へ流し込むことです。インフラの Infrastructure as Code と同じ発想で、これを seed-as-code と呼んでいます。
- ID はリテラルで固定する: 自動採番に頼らず、
tag_route_aのような安定 ID を定義ファイルに書く。環境間(本番 / staging)で同じ定義が同じ ID になるので、コードから参照でき、差分も読めます。 - seeder は upsert で書く: 何度流しても同じ結果に収束する(冪等)。「初回は INSERT、変更時は別スクリプト」という分岐が要らなくなります。
- FK の依存順に流す: 親(タグ)→ 子(ステップ)の順。定義ファイルの構造をその順に持たせれば seeder は素直なループになります。
-- seeder の中身は upsert の羅列(何度流しても収束する)
INSERT INTO tags (id, name)
VALUES ('tag_route_a', 'ルートA')
ON CONFLICT (id) DO UPDATE SET name = excluded.name;
INSERT INTO drip_steps (id, tag_id, position, body)
VALUES ('step_route_a_1', 'tag_route_a', 1, '1通目の本文')
ON CONFLICT (id) DO UPDATE SET
tag_id = excluded.tag_id,
position = excluded.position,
body = excluded.body;こうすると、本番も staging も「同じ seed ファイルを materialize した結果」になり、環境差分が原理的に消えます。ファネルの変更は定義ファイルの diff としてレビューでき、「いつ誰が何のために変えたか」は git log が答えます。管理画面の直接編集は閲覧専用に格下げする——ここまでやって、初めてドリフトが止まります。
PC だけで通しテストする — staging+合成イベント+force-tick
最後は検証です。LINE ファネルのテストは「スマホで友だち追加して、タップして、届くのを待つ」になりがちで、1周に数分かかる上に再現性がありません。設問順・上書き・乗り換えのような状態遷移の回帰テストは、実機往復ゼロで回せる形にしておくべきです。構築型は4点セットです。
- staging は本番同一コード+別チャネルで立てる(cron などの時限処理は無効化しておく)。seed-as-code のおかげで、定義は本番と同じものが流せます。
- 合成イベントを実経路に流す。webhook ハンドラを関数として直接呼ぶ(モックする)のではなく、本物と同じ形の follow / message イベントを署名付きで staging のエンドポイントに POST します。署名検証・dedup・FSM・送信スキップまで、本番と同じコードパスを通ることが肝です(モックとの乖離がゼロになる)。
// staging の実 webhook エンドポイントに合成 follow イベントを流す
import { createHmac, randomUUID } from 'node:crypto'
const STAGING_URL = process.env.STAGING_WEBHOOK_URL!
const CHANNEL_SECRET = process.env.STAGING_CHANNEL_SECRET!
// テストユーザーは ID の接頭辞で判別する(実送信だけをスキップするため)
const TEST_USER_ID = 'Utest' + '0'.repeat(28)
function buildFollowEvent(userId: string) {
return {
destination: 'Uxxxxxxxx', // staging ボットの userId
events: [
{
type: 'follow',
mode: 'active',
timestamp: Date.now(),
source: { type: 'user', userId },
// 合成イベントにも一意な webhookEventId を振る → dedup も同じ経路で検証できる
webhookEventId: randomUUID(),
deliveryContext: { isRedelivery: false },
replyToken: 'synthetic-' + randomUUID(),
},
],
}
}
async function inject(): Promise<void> {
const body = JSON.stringify(buildFollowEvent(TEST_USER_ID))
// 署名も本物と同じ方式で付ける(署名検証ミドルウェアを素通しにしない)
const signature = createHmac('sha256', CHANNEL_SECRET).update(body).digest('base64')
const res = await fetch(STAGING_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-line-signature': signature },
body,
})
if (!res.ok) throw new Error(`webhook injection failed: ${res.status}`)
}- テストユーザーは接頭辞で判別し、実送信だけをスキップする。送信関数の最終段で「テスト ID なら API を呼ばずログに記録」と分岐させれば、それ以外の全ロジック(タグ・遷移・予約)は本物のまま検証できます。
- 時限配信は force-tick で進める。「翌日9時のドリップ」を待つのではなく、対象ユーザーの次回配信時刻を過去に書き換えてから配信処理を1回だけ手動起動する。これでドリップの何通目でも、1ステップずつ決定的に進められます。
この4点で、「設問を最後まで答える」「途中で選び直す」「別ルートへ乗り換える」「同じイベントを2回流す(dedup の検証)」が、PC のターミナルから数秒で1周する回帰テストになります。原則1〜4が守られているかを、リリースのたびに機械的に確かめられる——信頼性は設計とテストの両輪で初めて維持できます。
まとめ — 壊れにくさはチェックリストで演繹する
4原則を、運用中のファネルに当てるチェックリストとして畳んでおきます。自作でも SaaS でも問いは同じです。
- ファネルの状態と遷移が列挙されており、想定外入力の遷移先も決まっている(FSM・全域性)
- イベント履歴(追記)と現在状態(1行)が分離されている
- ユーザーが選択肢を選び直したとき、旧タグの除去と旧ルートの配信停止が新タグ付与より先に走る
- webhook 処理は重複前提で、
webhookEventIdによる claim-first dedup が送信より前にある - 送信・同期は
429前提のバックオフで組まれ、リミット値を決め打ちしていない - タグ・シナリオ・自動化の定義がリポジトリ管理のファイルにあり、seeder が冪等(本番の管理画面直編集をしていない)
- seed に PII が入っていない(定義のみ)
- 実機なしで「回答 → 選び直し → 乗り換え → 重複イベント」を通す回帰テストが staging で回る
このリストは「過去に壊れた1ケース」の帰納ではなく、状態整合・配信信頼性・定義管理・検証可能性という観点からの演繹で作っています。だから、まだ踏んでいない穴も先回りで塞げます。マーケターが実装まで持つことの価値は、施策を早く出せることだけでなく、この信頼性設計まで一人の頭の中で閉じられることにあります(1人のマーケターが実装まで持てる理由)。配信の中身の設計——出し分けと効果検証——は出し分けと holdout の回、動画を配信するときの計測設計は姉妹編のLINE 配信動画の完視聴計測へどうぞ。