先に送ったリクエストが後から届く
一言でいうと
非同期の更新では、あとから送ったリクエストがあとに届くという保証はありません。 だから 状態を書き換える場所には、「このレスポンスはまだ有効か」を問う仕組みが必ず必要です。
なぜ必要なのか
検索ボックスに서、서울、서울역と順に入力します(韓国語の「ソウル駅」を1文字ずつ入力していく例です)。リクエストは3回送られ、画面には
最後の結果が残るはずです。ところが、ある日は서の結果が画面に残ります。
理由は単純です。最初のリクエストはキャッシュに当たらず800msかかり、3つ目のリクエストは 40msで返ってきました。3つ目の結果が先に画面に入り、800ms後に最初の レスポンスが届いてその上に上書きしました。 コードには誤りがないように見えます。
const res = await fetch("/search?q=" + q)
setResults(await res.json()) // 누가 언제 도착했는지 아무도 묻지 않는다
このバグの厄介な点は、開発者のマシンで再現しないことです。ローカルでは レスポンスが5msで返るので、順序が入れ替わる隙がありません。地下鉄で使っているユーザーにだけ見えます。
同じ種類の事故がほかに3つあります。同じデータを画面の4か所がそれぞれ要求して同じ リクエストが4回送られること、ロールバックの作りが悪くて他人の更新まで巻き戻してしまうこと、 キャンセルしなかったリクエストが、消えた画面の状態を触り続けることです。
どう動くのか
最新優先: 連番を付ける
いちばん安くて確実な方法は、リクエストごとに番号を振り、レスポンスを反映する前にその番号が まだ最新かどうかを確認することです。
let seq = 0
async function run(task) {
const my = ++seq
const value = await task()
if (my !== seq) return { applied: false } // 나는 이미 낡았다
return { applied: true, value }
}
大事なのは、捨てるほうがデフォルトだという点です。「遅れて届いたものを無視する」ではなく、 「自分が最新のときだけ反映する」と書くことで、リクエストが3つでも10個でも同じ ルールが成り立ちます。
キャンセル: 結果を捨てることと処理を止めることは別です
番号で弾けば画面は正しくなりますが、ネットワークとサーバーは働き続けます。ブラウザの標準は
そのためにAbortControllerを用意しています。MDNは、abort()が
「fetchリクエスト、レスポンス本文の消費、ストリーム」を中断すると書いています
(AbortController)。
新しいリクエストを始めるときに前のリクエストのsignalを切っておけば、2つが同時に解決します。
古いレスポンスが届かなくなり、サーバー側の枠も返せます。
重複排除: 同じキーは1回だけ
画面の4か所が同じuser/42を必要とすると、リクエストも4回送られます。進行中の
リクエストをキーで覚えておき、同じ約束(Promise)を分け合うようにすれば1回に
減ります。TanStack Queryが標準で行っているのがこれで、同じドキュメントは、失敗したクエリを
指数バックオフで3回リトライすると書いています
(Important Defaults)。
楽観的更新: ロールバックは引き算ではない
「いいね」ボタンを押すと、サーバーのレスポンスを待たずに先に画面を変えます。失敗したら 元に戻します。ここで、ほとんど誰もが同じ間違いをします。ロールバックを逆演算で組んでしまうのです。
like() // count + 1
unlike() // 실패하면 count - 1
その間に別の人が「いいね」を押してサーバーの値が変わっていたら、引き算はおかしな値を
作ります。順序のある更新なら、もっと悪くなります。ラベルをAに変える更新とBに
変える更新が両方とも待機中のとき、前のほうをロールバックするとラベルはBになるべきなのに、
逆演算はAより前に戻してしまいます。
正しい方法は、確定したベース値(base)と待機中の更新リストを別々に持ち、画面は
常にベース値の上に待機リストを順番に重ね直して計算することです。ロールバックは
リストからその項目を取り除くことで、サーバーのレスポンスが来たらベース値を差し替えてから、残りの
待機リストを重ね直します。ReactのuseOptimisticも同じ形です。確定値と
リデューサーを渡すと、待機中の更新をその上に重ねて見せてくれます
(useOptimistic)。
現場での姿
タブの切り替えが速いダッシュボードで、「たまに古いタブのデータが見える」という報告が入りました。 タブごとにリクエストを送り、レスポンスをそのまま状態に入れていたのですが、2つのタブを素早く行き来すると 最初のタブのレスポンスがあとから届いていました。再現できないまま1か月を超え、最終的に ネットワークを遅く設定したブラウザで一発で再現できました。直したのは、連番の比較3行でした。
別のチームは、「いいね」のロールバックを引き算で組んでいたため、サーバーが500を返した数秒間に 他のユーザーの「いいね」まで減らしてしまい、カウントがマイナスになりました。ロールバックが「引き算」ではなく 「待機リストから取り除いて再計算」だったなら、起きなかったことです。
2つの事故とも、画面のコードではなく、状態を書き換える場所に順序の概念がなかったために 起きました。ライブラリを変えても、その場所に順序がなければそのままついてきます。
次のラボですること
lane.mjs1つに、4つのものを自分で作ります。最新優先のレーン、キャンセルシグナル、キーごとの
重複排除、ベース値と待機リストで計算する楽観的更新です。そして最後に、
同じシナリオを自分で実行して数値を書き、採点ツールがその場で測り直して照合します。
でっち上げた数値では通りません。