Article
💰 JavaのBigDecimalで金額計算を壊さない:equals・compareTo・丸めの落とし穴
請求・割引・手数料を扱うバックエンドでは、BigDecimalを使っていても「同じ金額なのに一致しない」「割り算で例外が発生する」という不具合が起こります。原因は値の比較方法、数値の作り方、丸めの仕様が混ざってしまうことです。
この記事ではJavaの標準APIだけで、再現コードと実装上の判断ポイントを整理します。
1. 1.0と1.00はequalsでは別物
BigDecimalは10進数を「整数値とscale(小数点位置を表す値)」で保持します。数値が等しくてもscaleが違うことがあります。
import java.math.BigDecimal;
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b)); // false
System.out.println(a.compareTo(b) == 0); // true
System.out.println(a.scale()); // 1
System.out.println(b.scale()); // 2
equalsは値とscaleの両方、compareToは数値の大小を比較します。金額の値が等しいかを判定する用途では、compareToが適切です。
2. HashSetとTreeSetで重複判定が違う
import java.util.HashSet;
import java.util.List;
import java.util.TreeSet;
var values = List.of(a, b);
System.out.println(new HashSet<>(values).size()); // 2
System.out.println(new TreeSet<>(values).size()); // 1
HashSetはequalsとhashCodeを使います。TreeSetは通常compareToによる並び順で重複を判断します。金額をMapのキーなどに使う場合、「1.0と1.00を同じものとするか」を決めて正規化してください。
3. doubleから直接生成すると小数誤差を持ち込む
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal("0.1")); // 0.1
System.out.println(BigDecimal.valueOf(0.1)); // 0.1
2進浮動小数点型のdoubleは0.1を厳密に表現できません。new BigDecimal(double)はその誤差を含む値を忠実に10進数へ変換します。
十進数で受け取れる金額はnew BigDecimal("0.1")のように文字列から作ります。valueOf(double)はDouble.toStringを利用しますが、変換前の演算誤差は直せません。
System.out.println(BigDecimal.valueOf(0.1 + 0.2)); // 0.30000000000000004
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3
外部API・DB・計算処理の途中で不用意にdoubleへ戻さないことも重要です。
4. divideには桁数と丸め方を明示する
import java.math.RoundingMode;
BigDecimal one = new BigDecimal("1");
BigDecimal three = new BigDecimal("3");
// one.divide(three); // ArithmeticException
System.out.println(one.divide(three, 4, RoundingMode.HALF_UP)); // 0.3333
1÷3は小数が無限に続くため、厳密な結果を求めるdivide(BigDecimal)はArithmeticExceptionを投げます。上の4は返す結果の小数桁数、HALF_UPは丸め方法です。
請求や税などの用途では、小数何桁にするか・明細ごとに丸めるか・合計後に丸めるかを業務仕様として決めてください。HALF_UPがすべての制度や契約で正しいとは限りません。
5. HALF_UPとHALF_EVENは結果が変わる
HALF_EVENは、ちょうど中間のときに丸め後の末尾が偶数になる側を選ぶ方式です。
BigDecimal value = new BigDecimal("1.005");
System.out.println(value.setScale(2, RoundingMode.HALF_UP)); // 1.01
System.out.println(value.setScale(2, RoundingMode.HALF_EVEN)); // 1.00
BigDecimalは不変(immutable)です。setScaleは新しい値を返し、元のインスタンスは変更しません。戻り値を捨てるミスに注意しましょう。
6. 丸めずに入力を拒否する
金額の許容桁数が小数2桁までと決まっている場合、UNNECESSARYで検証できます。
static BigDecimal parseAmount(String raw) {
return new BigDecimal(raw).setScale(2, RoundingMode.UNNECESSARY);
}
// "1.0" → 1.00
// "1.234" → ArithmeticException
UNNECESSARYは丸めが必要ならエラーにする指定です。APIなら例外を入力エラーへ変換してください。負数可否、上限額、通貨ごとの桁数などの検証も別に必要です。
7. Java 21で再現できる最小テスト
import java.math.BigDecimal;
import java.math.RoundingMode;
public class MoneyChecks {
public static void main(String[] args) {
var a = new BigDecimal("1.0");
var b = new BigDecimal("1.00");
assert !a.equals(b);
assert a.compareTo(b) == 0;
assert new BigDecimal("0.1").add(new BigDecimal("0.2"))
.compareTo(new BigDecimal("0.3")) == 0;
assert new BigDecimal("1.005").setScale(2, RoundingMode.HALF_UP)
.toPlainString().equals("1.01");
assert new BigDecimal("1").divide(new BigDecimal("3"), 4, RoundingMode.HALF_UP)
.toPlainString().equals("0.3333");
System.out.println("OK");
}
}
javac MoneyChecks.javaでコンパイルし、java -ea MoneyChecksで実行します。-eaはJavaのassert(検証用の条件式)を有効化するオプションです。境界値をCIの自動テストに入れると、仕様変更時に差分を検出しやすくなります。
実務での使い分け
| 目的 | 方法 |
|---|---|
| 数値的な同一性 | compareTo(...) == 0 |
| 小数桁まで含む一致 | equals(...) |
| 十進数の生成 | new BigDecimal(String) |
| 割り算の端数処理 | divide(除数, scale, RoundingMode) |
| 丸め禁止の入力検証 | setScale(scale, UNNECESSARY) |
BigDecimalを使えば金額の業務仕様まで自動的に決まるわけではありません。比較ルール、入力表現、丸める位置を明文化し、テストとして固定するのが安全です。