E-Commerce

現場記録: バックエンド・Web・モバイルを横断してECプラットフォームを運用する

実際に稼働中のECプラットフォームで行った改修作業の記録——Django製バックエンド、Webストアフロント、CapacitorベースのiOS/Androidアプリ。すべて実際の顧客にサービスを提供しながらの作業だった。

2件の注文。同じ商品、同じ配送先住所、注文時刻の差はわずか1分。一方はウェブサイトから、もう一方はモバイルアプリから届いた。

ウェブサイトは配送業者を手配し、料金は37バーツ。アプリは自社配送(in-house)を選び、料金は25バーツ——しかもその住所には自社配送が届かない場所だった。エラーは発生しなかった。ログにも何も残らなかった。2人の顧客に、静かに異なる結果が届いていた。これに気づけた唯一の理由は、「片方のプラットフォームで動いているから大丈夫」と信じるのではなく、実際の注文データを互いに突き合わせて確認する習慣があったからだ。

これこそがマルチプラットフォームECを象徴する障害パターンだ。クラッシュするバグではなく、静かに食い違うバグ。価格・割引・送料を計算するバックエンド。それを正しく描画しなければならないWebストアフロント。まったく別のフレームワーク、まったく別のプラットフォームルールのもとで、独立に同じ答えへたどり着かなければならないモバイルアプリ。そして、メタデータファイルが一つ欠けているだけでビルドを却下してくる、2つのアプリストア。本稿では、実際に稼働中のプラットフォームでこの3つの層を横断し、こうした食い違いを一つひとつ洗い出した最近の改修作業を紹介する。これは私たちSimplicoがECクライアントに対して日常的に行っている仕事そのものだ——デモ環境ではなく、作業中も実際のチェックアウトが動き続けるプロダクション環境での話である。

バックエンド:正確さが実際に宿る場所

バックエンドは注文の送料・配送業者・割引を決定する場所であり、ここでの誤りは最も高くつく種類のバグだ。顧客の合計金額に直接影響するからだ。

送料見積もりが、カタログの大部分でひそかに失敗していた このプラットフォームは、カート内の商品を標準サイズの箱へ3次元的に詰め込む「ビンパッキング」でおおよその配送料金を見積もり、その結果をもとに配送業者のAPIへ料金を問い合わせている——ただし、カタログ上で商品の幅・高さ・奥行きのいずれかが欠けていると、パッキング処理は使える箱がゼロだったことにして、エラーも出さずに静かに終了してしまう。抜き取り調査では、556種類の商品バリエーションのうち485件がこの状態だった。箱がなければ見積もりもなく、チェックアウトはログに理由を残さないまま自社配送へフォールバックしていた。パッキング処理を、存在する寸法データだけから常に使える見積もりを算出する堅牢な集約ロジックに置き換えた。カタログの欠損データは、注文を静かに誤った経路へ振り分けるのではなく、緩やかに劣化するだけで済むようになった。

決済の突合には、単一の正しい情報源が必要だった 決済ゲートウェイは2系統、その間にフォールバック経路が1本。そして在庫の引き当て、確認メール、配送業者への予約手配といった決済後の処理は、実際にどちらのゲートウェイが処理したかにかかわらず、同じように動く必要があった。これらを1つの共有パスへ統合し、運用ダッシュボードに決済ゲートウェイを一目でわかるインジケーターを追加した。サポート担当者が、その注文をどちらのゲートウェイが処理したのか即座に確認できるようにするためだ。

チェックアウトのクラッシュは、コードではなくデータに原因があった プロモーションキャンペーンの検索処理が .get() を使っていたが、構造上は重複行が発生しうる作りになっていた——キャンペーンに実際に重複が生じるまでは何も問題なかったが、いざ発生すると、外から見れば決済失敗のように見える例外でチェックアウト全体を落としていた。この検索処理を、例外を起こしうる書き方から、常に決定的な結果を返す書き方へ修正した。

Web:インタラクティブなはずの層を、本当にインタラクティブにする

ストアフロントのチェックアウトページは、サーバーサイドでレンダリングされるDjangoテンプレートの上に、軽量なリアクティブフレームワーク(Alpine.js)を重ねる構成になっている——実用的な手法ではあるが、注意しないと陥る特有の落とし穴が1つある。インタラクティブなマークアップを文字列として組み立てて注入すれば、見た目は完璧にレンダリングされる。しかし中身は何ひとつ動いていない、ということが起こりうる。 配送方法を選ぶラジオボタンに、まさにこれが起きていた。生のHTML文字列として組み立てられ、innerHTML系のディレクティブで注入されていたため、その中に埋め込まれたインタラクティブなバインディングが二度と再解析されることはなかった。スクリーンショット上では常に正しく見えていたが、実際にはいつからか分からない期間、まったく機能していなかった。正しくコンパイルされるテンプレートマークアップとして作り直した。

同じ教訓のより見えにくいバージョンが、もう2箇所で見つかった。サーバー側でレンダリングする条件分岐と、クライアント側でレンダリングする条件分岐は、互いに入れ子にしても安全には成立しない。 1つは、保存済み住所が1件もない顧客にしか表示されない住所入力フォーム。もう1つは、選択可能なクーポンが存在する場合にしか表示されないクーポンブロック。どちらのケースも、クライアント側のフレームワークが、サーバーが実際にはレンダリングしていない要素への参照を持ち続けてしまっていた。ブロックが「存在するかどうか」はサーバー側が決め、クライアント側は「どう表示するか」だけを決める構成に整理し直した。

モバイル:2つのプラットフォーム、2つのルールブック、1つのプロダクト

モバイル開発には、Webチームが払わずに済む余分なコストがある。プラットフォームベンダー自身が門番でもあり、そのルールはこちらの与り知らぬところで変わっていくということだ。

App Store審査却下、ITMS-91061 Appleは現在、アプリが「悪用されやすい」特定のAPIを使う理由を宣言するプライバシーマニフェストを要求している——そして、依存関係に含まれる5つのSDKがそれを同梱していなかった。素直な解決策は、実際のビルドパイプラインでは通用しない。依存関係のインストールのたびに、ファイルを単純に置いたフォルダごと作り直されてしまうからだ。そこで、より恒久的な解決策を構築した。マニフェストをソース管理下に置き、インストールのたびに自動で該当ディレクトリへ注入・登録する仕組みだ。ビルドを何度繰り返しても同じ結果になることを確認済みだ。

Google Playのポリシーと、Android 16対応の期限が同じ週に重なった アプリのカメラ関連の依存ライブラリが、条件を問わず広範なギャラリーアクセス権限を宣言していた——これはPlayのレビュアーが積極的に指摘するようになったパターンだ。写真・動画の選択を、実行時の権限を一切必要としないプラットフォーム標準のシステムピッカーへ移行し、あわせてPlayの適用期限に間に合うよう、ターゲットSDKをAndroid 16へ引き上げた。いずれも実機のログで検証済みだ——旧プラグインの権限の挙動について最初に立てた仮説は間違っており、それに気づけたのは実機のログを追った結果だった。

サインインはAndroidでは動作し、iOSでは静かに失敗していた 設定ファイルのたった1行の不足——要求するIDトークンのオーディエンス設定の誤り——が原因で、実際にトークンの中身を確認するまでは見えてこない問題だった。

そして、冒頭で紹介した注文 モバイルのチェックアウトには、Web版にはないロジックが存在していた。2つの配送オプションのうち常に安い方を選ぶ、というものだ——安い方が実際には届かない住所であっても関係なく。プラットフォームを別々に作っていると見落としやすいが、実際の注文データを突き合わせてテストすれば見つけやすい。実際、この問題もまさにそうやって表面化した。

APIのドメイン変更に、もはやアプリストアの審査は不要になった アプリ起動時にリモート設定のステップを追加した。起動のたびに、軽量な設定用エンドポイントからAPIドメインを確認し、即座に適用する。確認に失敗した場合も安全にフォールバックする。かつてはビルドし直してストアへ再提出する必要があり——最低でも数日はかかっていた作業が——今ではサーバー側の1行の変更だけで済み、顧客が次にアプリを開いた瞬間から反映される。

パフォーマンス: 誰の目にも触れない部分

正確さに関するバグは報告される——顧客が誤った価格を見れば、何か言ってくる。だが表示が遅いページは報告すらされない。顧客はただ静かに離脱し、ログにも理由は何も残らない。だからこの領域の作業は、バグ探しではなく全体監査として始まった。データベースクエリ、キャッシュ、バックグラウンドジョブという3つの観点を並行して調査し、憶測ではなく実測された効果の大きさで修正の優先順位を決めた。

ある顧客の注文履歴ページは、読み込みに9.7秒かかり、データベースクエリを1,746回発行していた 2つのN+1問題が重なっていた。1つは注文明細とその商品情報がまったくプリフェッチされていなかったこと。もう1つ——より見つけにくかった方——は、プリフェッチされていたはずの関連が、その後に連結された何気ない .order_by() によって静かに無効化され、Djangoのプリフェッチキャッシュを素通りして結局データベースへ再度アクセスしていたことだ。両方を修正した結果、3.5秒、406クエリ——データベース負荷を77%削減した。

稼働中のフラッシュセールが、特定のページを致命的なほど遅くしていた カテゴリ一覧ページ上のすべての商品が、それぞれ独立して「現在有効なプロモーションキャンペーンは何か」を毎回計算し直していた——まったく同じ処理が、商品1つにつき1回、ページ読み込みのたびに繰り返されていたことになる。この答えをリクエストごとに1回だけキャッシュするように変更した。あるカテゴリページは10.2秒から0.85秒まで短縮された。

すべてのページで表示されているメニューが、実はサーバー上で2番目にコストの高い処理だった プロファイリングの結果、あるページの総レンダリング時間のおよそ4分の1が、209カテゴリからなるドロップダウンメニューを毎回ゼロから再帰的に組み立てる処理に費やされていることが判明した。カテゴリ構成はほとんど変わらないにもかかわらずだ。このメニューは全ページが継承するベーステンプレートに存在していたため、1つのキャッシュフラグメントで一度に解決でき、その効果はプロファイリング対象のページだけでなくサイト全体に及んだ。

そして、この記事の前半で触れた、リサイズされていないアップロード画像の件 ——3.9MBあったスライドショーバナーが今では14.9KBで配信されている、あの話——も、まったく同じ発想を別のレイヤーに適用したものだ。画像もまた一種の「クエリ」であり、キャッシュもリサイズもしなければ高くつく、という点では変わらない。

提案したすべての修正が成果を上げたわけではない。数字を良く見せかけるより、正直に言う方を選ぶ。あるキャッシュの変更は実装し、計測したが、対象ページでは何も改善しなかった——キャッシュした値がそもそもリクエストごとに一度しか読まれておらず、重複排除する余地がなかったのだ。それでもこの変更は残した。計測の過程で、実際に存在した正確性のバグを1つ見つけて修正できたからだ。パフォーマンスの数字としては正直ゼロであり、そのままそう記録している。

通底するもの

ここで紹介した内容に、突飛なものは1つもない。設定ファイルの1行漏れ、必要以上の権限を宣言するプラグイン、innerHTMLによるショートカット、対応範囲の半分にしか通用しない比較ロジック。それぞれが見つける価値を持っていた理由は、いずれも「継ぎ目」に潜んでいたからだ——バックエンドとフロントエンドの継ぎ目、Webとモバイルの継ぎ目、プラットフォームベンダーが今年求めることと、コードが去年前提としていたことの継ぎ目。1つひとつは小さい。しかし積み重なれば、「だいたい動いているストアフロント」と「確実に動作するストアフロント」の違いになる——同じ注文に対して、2人の顧客が異なる金額を提示される世界と、そうならない世界の違いになる。

この継ぎ目をつなぐ仕事——バックエンド、Web、モバイルを、実際のプロダクション環境の上で互いに整合させ続ける仕事——それが私たちSimplicoの仕事だ。もしあなたがECプラットフォームを運用していて、「1つのプラットフォームで動く」ことをマイルストーンではなくバグレポートとして扱うチームを求めているなら、まさにそのために私たちのECエンジニアリングサービスは存在している。