BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

小数点の罠:メンバーシップポイント計算の裏側

はじめに

この記事はBASEアドベントカレンダーの四日目の記事です。

こんにちは!私は@shiiyannnと申します。現在、メンバーシップ Appの開発に携わっています。メンバーシップ Appはショップオリジナルの「メンバーシップ」(会員制度)を作成することができる機能です。

2023年9月、メンバーシップ Appは大幅な機能アップデートを遂げました。今回のアップデートでは、ショップオーナーが商品購入時に独自のポイントを付与できるようになりました。付与されるポイントの量は、注文金額にショップオーナーが設定したポイント付与率を掛け合わせて計算されます。

この記事では、ポイント付与機能の開発中に直面した、浮動小数点計算の問題とその解決策についてお話しします。この問題を深掘りすることで、料率計算や金額処理に取り組む開発者の皆さんに有益な情報を提供できればと考えています。

リリース直前に発見した浮動小数点問題

エラーの発生

メンバーシップ Appのリリースをスムーズかつ安全に行うために、私たちは一般公開に先立ち社内で限定公開するという二段階リリース戦略を取っています。社内限定公開中の機能検証テストで、予期せぬエラーが発生しました。具体的には、ショップオーナーが7%や14%の付与率を設定した場合、注文時の付与予定ポイントの計算処理が失敗し、結果として注文が完了できない状況が起こりました。

エラーの内容は、ポイント計算に利用される7%の付与率と、ショップオーナーが設定可能な7%の付与率がシステム上で不一致ということでした。メンバーシップポイントの計算をするために、ポイント付与率というバリューオブジェクトを構築する必要があります。計算用の付与率がコンストラクタの引数として渡される際、バリデーションエラーが発生しました。

以下に示すソースコードは説明のためのもので、本番環境でのメンバーシップ Appと同じ実装ではありません。

class PointRate {
    private const ALLOWED_VALUES = [6.0, 7.0, 8.0];

    function __construct(private float $value) {
        if (!in_array($value, self::$ALLOWED_VALUES, true)) {
            throw new Exception('ポイント付与率が不一致');
        }
        // ...
    }
}

エラーの解決

このエラーの根本的な原因は、浮動小数点の精度に関連するものでした。PHPで使用される浮動小数点数(float)は、IEEE754フォーマットを使っていて、その精度には限界があります。例えば、0.7という値は二進数の浮動小数点数として正確に表現することができず、その結果、丸めの誤差が発生します。

私たちのケースでは、ポイント計算のために小数点形式の付与率をパーセント形式に戻す処理があります。in_array関数を使って、パーセント形式に戻した値を元々の7.0と比較すると、(7.0 / 100) * 100 != 7.0という丸めの誤差により予期せぬ結果が生じました。

エラーの解決策は、PHPの公式マニュアルにも記載されている通り、浮動小数点数の比較に一定の誤差を許容することです。具体的には、私たちはイプシロンという丸めの単位を決めて、その許容範囲内であれば、2つの浮動小数点数を等しいと見なす方法を採用しました。

https://www.php.net/manual/ja/language.types.float.php

class PointRate {
    private const ALLOWED_VALUES = [6.0, 7.0, 8.0];
    private $epsilon = 0.00001;

    function __construct(private float $value) {
        foreach (self::ALLOWED_VALUES as $allowedValue) {
            if (abs($value - $allowedValue) < $this->epsilon) {
                return; // 許容範囲内であればOK
            }
        }

        throw new Exception('ポイント付与率が不一致');
    }
    // ...
}

今回の課題点

リリース直前に発見したこのエラーは、チームメンバー全員の協力により迅速に対処され、幸いにもメンバーシップ機能のアップデートを予定通りにリリースすることができました。エラーが解決された後日、私たちはこのエラーの背後に潜む課題点を改めて考えました。

  • 画面上の表示とシステム出力の内容が一致せず、エラーメッセージを正確に理解するのが困難でした。
  • エラーの原因を特定するためのデバッグ範囲が広く、問題発生の箇所を効率的に特定することが難しかったです。
  • リリース直前まで行われていたユニットテストや機能検証テストでは、このエラーを検出できませんでした。

具体的に説明します。

まず、エラーメッセージは直感と矛盾していました。ポイント付与率を設定する画面では、付与率が正確に7%として表示されているにもかかわらず、システム上では、付与率が不一致と判定されていました。そして、注文時のポイント計算においては、なぜか7%と14%の付与率を設定した場合のみ計算が失敗し、他の付与率では注文が正常に処理できました。問題の解決には、まずエラーメッセージの内容とシステム振る舞いとの間にあるギャップの正体を突き止める必要がありました。

リリースの直前に発見されたこの問題は、限られた時間内での対応が必要でした。特に挑戦的だったのは、デバッグの対象範囲が非常に広かったことです。リリース前の機能検証テストでは、メンバーシップポイント付与の全機能を対象としていたため、問題が発生しうる箇所が広範囲に渡ります。例えば、フロントエンドからリクエストされる際、データベースから設計された付与率を取得する際、あるいはアプリケーションロジックでポイント計算する際、これらのいずれかの段階でも付与率が変更された可能性があります。

私たちは、メンバーシップ Appの開発中、アジャイルテストの4象限理論に基づき、ユニットテストと機能検証テストを実施してきました。具体的に、クラスごとに単体テスト、ユーザーストーリーごとに機能検証テストと受け入れテストを含めました。問題が発生した付与率のバリューオブジェクトとポイント計算のユースケースに対しても、ユニットテストが定義されていました。しかし、これらのテストは全て問題なく通過していたにも関わらず、問題の発見と対処は遅れました。

これらの課題点を解決するために

浮動小数点数を扱う際のベストプラクティス

私たちは、今回のエラーを理解できなった主な原因の1つは、浮動小数点数の丸め誤差に対する理解が足りなかったと分析しました。そのため、エラー対処を終えた直後、浮動小数点数の丸め誤差の発生原因とその対処方法に焦点を当てた学習資料をまとめ、グループ内で浮動小数点数(float)に関する知識を補強しました。さらに、将来的に同じようなミスを避けるために、浮動小数点数を扱う際のベストプラクティスを以下のように定めました。

  1. 小数形式ではなくパーセント形式を利用するや、小数点以下を切り捨てるなど、可能な限り整数型(int)を使用します。
  2. PHPにおいて、float同士の比較や計算時には、丸め誤差を考慮して、イプシロン(許容誤差)の使用を推奨します。
  3. データベースでは、固定精度のDECIMAL型を利用し、必要な精度を明確に指定します。

問題の発生箇所を効率的に特定する工夫

問題の発生箇所がすぐに特定できない場合、特に広範囲にわたるデバッグが必要となります。そのような状況に対応するために、私たちのチームでは、PhpStormと連携したXDebugを活用してリモートデバッグを行うようにしました。これにより、エラーの発生箇所を特定する際に、PhpStormというIDEからブレークポイントを設定し、ステップイン(step into)やステップオーバー(step over)の機能を効率的に使用することが可能になりました。私たちの開発者のローカル環境では、XDebugはDockerで管理されており、DockerやPhpStormの更新によって機能しなくなる可能性もあるため、私は個人的に月に一度、XDebugが正常に機能するかどうかを確認するようにしています。

今回のエラーを特定する作業を困難にしたもう一つの要因は、バリューオブジェクトの値に対してバリデーションするタイミングに統一されたルールがなかったことでした。例えば、以下のようにバリデーションメソッドをstatic化し、任意のタイミングで実行するような実装が散見されていました。このアプローチでは、初期の有効な値がバリデーションを通過した後でも、後に不正な値に置き換わるリスクがあります。

class A {
    static function validate($value) {}
}

A::validate($valid);
new A($invalid);

この問題に対処するため、私たちのチームではオブジェクトのライフサイクル全体を通じてバリデーションを実施する方法を採用しました。これにより、オブジェクトが常に正しい状態でのみ生成されるようになります。この対処の一環として、staticなvalidateメソッドの使用を禁止するPHPStanルールを定義しました。これにより、バリデーションに失敗した場合でも、直前のインスタンス生成時に利用された値に問題があると素早く特定でき、問題のある値を探す範囲を効果的に縮めることが期待されます。

テストデータのカバレージも重要視

私たちのチームはこれまで、新規クラスを実装する際には必ずユニットテストを書くという文化を築いてきました。しかし、今回の件を受けて、ソースコードのカバレッジだけでなく、テストデータのカバレッジにも注目するようになりました。テストデータのカバレッジを上げるために、特に有効なアプローチとして、等価分割(Equivalence Partitioning)、境界値分析(Boundary Value Analysis)、状態遷移テスト(State Transition Testing)が挙げられます。

メンバーシップポイント付与率を例に取ると、等価分割の思想に基づいて、以下のように様々なグループに分けてテストすることが可能です。

  • 低い付与率のグループ:0.5%
  • 中間のグループ:7%
  • 高い付与率のグループ:15%

あるいは、

  • 二進数で表現できるグループ:0.5%
  • 二進数で表現しきれないグループ:7%

このようにグループを分けて機能検証テストのテストケースを作成・実施することで、問題をより早く発見する可能性があると考えています。ただし、テストパターンが増えると、当然テストコストも増加します。この点に対処するために、Playwrightで作成した自動E2Eテストも導入しています。今回の問題が発生した付与率はすでに自動E2Eテストでカバーされており、今後も時間とリソースを効率的に活用しながら、自動テストのパターンを増やす計画があります。

終わりに

この記事では、メンバーシップポイント付与機能開発中に発見した浮動小数点計算の問題とその解決策についてお話ししました。明日は、@Ayako Tanakaさんの記事です。お楽しみに。