Webアプリの計算はJavaScriptとサーバーのどちらで行うべきか?保存する値の判断基準を初心者向けに解説

こんにちは。ゆうせいです。

Webアプリケーションを作っていると、「合計金額」「税額」「得点」などの計算をどこで行うべきか迷う場面があります。

  • ブラウザのJavaScriptで計算して、その結果をデータベースに書き込む
  • 入力値をいったんサーバーに送り、サーバーで計算した結果をフロントエンドに返す

どちらも動くアプリは作れます。しかし、選び方を間違えると、画面は正しく見えるのに、データベースには改ざんされた金額が保存される、といった深刻な問題につながります。

今回は、この2つの方法を比較し、実務で使える判断基準と、Spring BootとJavaScriptを使った実装例を紹介します。

結論:保存・課金・判定に使う値は、必ずサーバーで計算する

先に結論をお伝えします。

  • 画面に表示するだけの計算(入力中のプレビューなど)は、JavaScriptで行ってかまいません
  • データベースに保存する値、お金に関わる値、合否や権限の判定に使う値は、必ずサーバーで計算します
  • 実務で最もおすすめなのは両方を組み合わせる方法です。JavaScriptで即座に表示し、送信時にはサーバーが入力値から再計算して、その結果だけを保存します

判断の基準は、「その計算結果を信用して使うかどうか」です。ブラウザから届く値は、利用者が自由に書き換えられるため、信用してはいけません。

ここから、理由を順番に確認していきます。

用語の確認

  • フロントエンド:利用者のブラウザで動く部分です。HTML、CSS、JavaScriptで作られています
  • サーバーサイド:サーバーで動く部分です。今回の例ではJavaとSpring Bootで作ります。データベースへの読み書きもサーバーサイドが担当します
  • リクエスト:ブラウザからサーバーへ送られるデータです。フォームの送信や、JavaScriptのfetchによる通信がこれにあたります

なお、「JavaScriptで計算してデータベースに書き込む」といっても、ブラウザがデータベースに直接書き込むことは通常ありません。実際には、JavaScriptで計算した結果をサーバーに送り、サーバーがそのままデータベースに保存する、という流れになります。この「そのまま保存する」部分に問題が潜んでいます。

方法A:JavaScriptで計算した結果をそのまま保存する

処理の流れ

  1. 利用者が商品と数量を選ぶ
  2. JavaScriptが単価×数量で合計金額を計算する
  3. JavaScriptが合計金額をサーバーに送る
  4. サーバーは受け取った合計金額をデータベースに保存する

何が問題なのか

ブラウザで動くJavaScriptとその通信内容は、利用者が手元で自由に確認し、書き換えられます。ブラウザの開発者ツール(F12キーで開く画面)を使えば、特別な知識がなくても次のような操作ができます。

  • JavaScriptの変数の値を書き換える
  • 画面に表示されている単価を書き換える
  • サーバーに送るリクエストの内容を書き換えて再送信する

たとえば、本来246,000円の注文の合計金額を1円に書き換えて送信すれば、サーバーはその値を信じて保存してしまいます。ネットショップであれば、1円で商品が購入できることになります。

「うちのアプリは社内向けだから大丈夫」と考えがちですが、社内システムでも、勤怠時間、経費精算額、テストの得点などが書き換えられると業務に支障が出ます。悪意がなくても、JavaScriptの不具合で誤った値が保存される可能性もあります。

方法Aの利点

一方で、方法Aにも利点はあります。

  • サーバーとの通信を待たずに、入力した瞬間に結果を表示できる
  • サーバーの処理負荷が増えない
  • 実装が簡単

この利点は、「表示するだけの計算」では大きな価値があります。

練習問題1

JavaScriptで計算した合計金額をサーバーがそのまま保存する設計の問題点として、最も適切なものはどれでしょうか。

  1. JavaScriptは整数の計算ができないため、合計金額が必ず誤差を含む
  2. ブラウザで計算すると、サーバーよりも処理が遅くなり画面が固まる
  3. 利用者が送信内容を書き換えると、誤った金額が保存されてしまう
  4. JavaScriptで計算した値は、データベースの数値型に保存できない

解答:3

解説:ブラウザから送られる値は、開発者ツールなどで簡単に書き換えられます。サーバーが検証せずに保存すると、改ざんされた金額がそのまま記録されます。

  • 1は誤りです。JavaScriptでも一定の範囲の整数は正確に計算できます。誤差が出るのは小数を含む計算です
  • 2は誤りです。単価×数量程度の計算は、ブラウザでも一瞬で終わります
  • 4は誤りです。数値として送れば、データベースの数値型に問題なく保存できます

方法B:サーバーで計算して結果をフロントエンドに返す

処理の流れ

  1. 利用者が商品と数量を選ぶ
  2. JavaScriptは商品コードと数量だけをサーバーに送る
  3. サーバーはデータベースから正しい単価を取得し、合計金額を計算する
  4. サーバーは計算結果をデータベースに保存し、フロントエンドに返す
  5. JavaScriptは返ってきた金額を画面に表示する

方法Bの利点

  • 単価はサーバー側のデータベースから取得するため、利用者が単価を書き換えても影響しない
  • 計算のルール(割引、税率、端数処理)がサーバーの1か所にまとまり、修正しやすい
  • 料金の計算式など、利用者に見せたくないロジックを隠せる

方法Bの欠点

  • 計算のたびに通信が発生するため、数量を変えるたびに結果が表示されるまで待ち時間が生まれる
  • 入力のたびにリクエストを送ると、サーバーの負荷が増える
  • 通信が途切れると、計算結果を表示できない

練習問題2

サーバーで計算する方法の利点として、最も適切なものはどれでしょうか。

  1. 単価をサーバー側で取得するため、ブラウザでの書き換えの影響を受けない
  2. 通信を行わないので、通信環境が悪くても計算結果をすぐに表示できる
  3. 計算のたびにサーバーへ送るので、サーバーの処理負荷が小さくなる
  4. 計算ロジックをブラウザに配布するので、利用者が式を確認しやすい

解答:1

解説:サーバーはブラウザから届いた単価ではなく、データベースに保存された正しい単価を使って計算します。そのため、ブラウザ側での改ざんの影響を受けません。

  • 2は誤りです。サーバーで計算する方法は通信が必要で、通信環境が悪いと結果を表示できません
  • 3は誤りです。計算のたびにリクエストを受けるため、サーバーの負荷は増えます
  • 4は誤りです。計算ロジックはサーバー内にあり、ブラウザには配布されません

判断基準:計算の種類ごとに整理する

どちらで計算すべきかは、計算結果の使い道で決まります。代表的な例を表にまとめます。

計算の例結果の使い道計算する場所
入力中の文字数カウント表示のみJavaScript
BMIなどの簡易計算ツール表示のみ(保存しない)JavaScript
見積りの合計金額のプレビュー表示のみJavaScript(確定時はサーバー)
注文の合計金額、税額保存・請求サーバー
ポイントの付与数保存・権利の付与サーバー
テストの採点、合否保存・判定サーバー
勤怠の労働時間保存・給与計算サーバー
入力値の形式チェック操作性の向上両方(サーバー側は必須)

表のとおり、「保存」「お金」「判定」のどれかに関わる値は、すべてサーバーで計算します。

入力値のチェックも同じ考え方です。JavaScriptのチェックは「利用者にすぐ間違いを知らせるため」のもので、安全を守るためのものではありません。安全を守るチェックは、サーバー側で必ず行います。

推奨する方法:JavaScriptで表示し、サーバーで再計算する

処理の流れ

実務では、方法Aと方法Bの利点を組み合わせます。

  1. 利用者が数量を変えるたびに、JavaScriptが合計金額を計算して即座に表示する(プレビュー)
  2. 注文ボタンを押すと、JavaScriptは商品コードと数量だけをサーバーに送る(合計金額は送らない)
  3. サーバーはデータベースの単価を使って合計金額を再計算し、その結果を保存する
  4. サーバーは確定した金額を返し、画面の表示をサーバーの値で上書きする

ポイントは、サーバーに「計算結果」ではなく「計算に必要な入力値」だけを送ることです。仮にブラウザから合計金額が送られてきても、サーバーはその値を使いません。

計算ロジックが二重になる問題への対策

この方法では、同じ計算をJavaScriptとJavaの両方に書くことになります。片方だけ修正すると、プレビューと確定金額が食い違う可能性があります。次の対策をとりましょう。

  • 正しい値はサーバーの値と決めておく。食い違った場合は、サーバーの値を表示する
  • 画面に「表示中の金額は目安です。確定金額は注文確認画面で表示します」と示す
  • 同じテストデータ(例:数量2で246,000円)で、両方の計算結果を確認するテストを用意する
  • 割引ルールが複雑な場合や計算式を秘密にしたい場合は、JavaScriptで計算せず、プレビュー用のAPIをサーバーに用意する。入力のたびに送ると負荷が増えるため、入力が止まってから0.3秒後に送る(デバウンス)などの工夫を組み合わせる

練習問題3

JavaScriptでプレビューを表示し、サーバーで再計算する方法について、正しい説明はどれでしょうか。

  1. JavaScriptで計算した合計金額を送り、サーバーはそれを信用して保存する
  2. サーバーの計算結果が届くまでは、利用者に一切の金額を表示しない
  3. JavaScriptとサーバーで結果が違った場合は、JavaScriptの値を採用する
  4. サーバーには入力値だけを送り、サーバーが計算した値を正しい値とする

解答:4

解説:サーバーに送るのは商品コードや数量などの入力値だけです。保存する金額はサーバーが再計算し、その値を正しい値として扱います。

  • 1は、方法Aと同じ問題を抱えています。送られた金額は書き換えられている可能性があります
  • 2は誤りです。JavaScriptのプレビューで即座に目安の金額を表示するのが、この方法の利点です
  • 3は誤りです。JavaScriptの値は改ざんや不具合の可能性があるため、サーバーの値を採用します

そもそも「一時的な計算結果」はデータベースに保存すべきか

ここで、もう一歩踏み込んで考えてみます。計算結果をデータベースに保存する必要は、本当にあるのでしょうか。

保存しなくてよい計算結果

入力値から、いつでも同じ結果を計算できる値は、原則として保存しません。

  • 注文明細の単価と数量があれば、合計金額はいつでも計算できる
  • 生年月日があれば、年齢はいつでも計算できる

計算できる値を保存すると、元の値を修正したときに計算結果の更新を忘れ、2つの値が食い違う原因になります。入力中のプレビューのような一時的な結果は、なおさら保存する必要がありません。

保存すべき計算結果

一方で、次のような値は保存します。

  • その時点で確定した値:注文時の販売単価、適用した税率、確定した請求金額など。後から計算ルールが変わっても、過去の値を正しく再現する必要があります
  • 計算に時間がかかる値:大量のデータを集計した月次の売上など。毎回計算すると遅い場合は、集計結果を保存します

どちらの場合も、保存する値はサーバーで計算したものに限ります。

練習問題4

データベースへの保存について、最も適切な判断はどれでしょうか。

  1. 入力中に表示したプレビュー金額は、操作履歴としてすべて保存する
  2. 注文確定時にサーバーで計算した請求金額は、確定値として保存する
  3. 生年月日から計算した年齢は、毎日更新するために年齢の列に保存する
  4. JavaScriptで計算した値は、サーバーの負荷を減らすために保存する

解答:2

解説:注文確定時の請求金額は、後から税率や割引ルールが変わっても再現できるように、確定値として保存します。サーバーで計算した値なので信用できます。

  • 1は誤りです。プレビューは一時的な目安であり、保存すると不要なデータが増えるだけです
  • 3は誤りです。年齢は生年月日からいつでも計算できるため、保存すると更新漏れの原因になります
  • 4は誤りです。JavaScriptで計算した値は改ざんの可能性があるため、そのまま保存してはいけません

実装例:Spring BootとJavaScriptで注文金額を計算する

推奨する方法を、Java 17とSpring Boot 3.xで実装します。

ファイル構成

src/main/java/com/example/order/
├── OrderApplication.java
├── OrderItemRequest.java
├── OrderRequest.java
├── OrderResponse.java
├── OrderService.java
└── OrderApiController.java
src/main/resources/static/
└── order.html

前提条件

  • Java 17
  • Spring Boot 3.x
  • 依存ライブラリ:Spring Web(spring-boot-starter-web)、Validation(spring-boot-starter-validation)

Spring Initializrでプロジェクトを作成するときに、Spring WebとValidationを選択してください。今回は仕組みを理解しやすくするため、単価はデータベースではなくJavaのMapに持たせています。実際のアプリでは、この部分をRepositoryによるデータベース検索に置き換えます。

リクエストとレスポンスの型

package com.example.order;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

// 1商品分の入力値:商品コードと数量だけを受け取る(単価や金額は受け取らない)
public record OrderItemRequest(
        @NotBlank String productCode,
        @Min(1) @Max(99) int quantity) {
}

package com.example.order;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;

// 注文全体の入力値
public record OrderRequest(@NotEmpty List<@Valid OrderItemRequest> items) {
}

package com.example.order;

// サーバーが計算した確定金額を返す
public record OrderResponse(long subtotal, long tax, long total) {
}


計算を行うService

package com.example.order;

import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

@Service
public class OrderService {

    // 本来はデータベースから取得する単価(円)
    private static final Map<String, Long> PRICE_TABLE = Map.of(
            "P01", 120000L,
            "P02", 3000L);

    // 税率10%を「10/100」として整数で計算する
    private static final long TAX_RATE_PERCENT = 10L;

    public OrderResponse placeOrder(OrderRequest request) {
        long subtotal = 0L;
        for (OrderItemRequest item : request.items()) {
            Long unitPrice = PRICE_TABLE.get(item.productCode());
            if (unitPrice == null) {
                throw new ResponseStatusException(
                        HttpStatus.BAD_REQUEST, "存在しない商品コードです: " + item.productCode());
            }
            subtotal += unitPrice * item.quantity();
        }
        // 税額は1円未満切り捨て
        long tax = subtotal * TAX_RATE_PERCENT / 100;
        long total = subtotal + tax;

        // 実際のアプリでは、ここで注文と確定金額をデータベースに保存する

        return new OrderResponse(subtotal, tax, total);
    }
}

リクエストを受け取るController

package com.example.order;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderApiController {

    private final OrderService orderService;

    public OrderApiController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public OrderResponse createOrder(@Valid @RequestBody OrderRequest request) {
        return orderService.placeOrder(request);
    }
}

起動クラス

package com.example.order;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

画面とJavaScript(order.html)

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注文画面</title>
</head>
<body>
<h1>注文画面</h1>

<p>ノートPC(120,000円):<input type="number" id="qty-P01" min="0" max="99" value="0"></p>
<p>マウス(3,000円):<input type="number" id="qty-P02" min="0" max="99" value="0"></p>

<p>合計金額の目安(税込):<span id="preview">0</span>円</p>
<button type="button" id="order-button">注文する</button>
<p id="result"></p>

<script>
// プレビュー用の単価(表示のためだけに使う)
const PREVIEW_PRICES = { P01: 120000, P02: 3000 };

// 入力欄から商品コードと数量を集める
function collectItems() {
  const items = [];
  for (const code of Object.keys(PREVIEW_PRICES)) {
    const quantity = Number(document.getElementById("qty-" + code).value);
    if (Number.isInteger(quantity) && quantity > 0) {
      items.push({ productCode: code, quantity: quantity });
    }
  }
  return items;
}

// 入力のたびに、目安の金額を計算して表示する
function updatePreview() {
  let subtotal = 0;
  for (const item of collectItems()) {
    subtotal += PREVIEW_PRICES[item.productCode] * item.quantity;
  }
  // 小数を使わず整数で計算し、1円未満を切り捨てる(サーバーと同じルール)
  const tax = Math.floor(subtotal * 10 / 100);
  document.getElementById("preview").textContent = (subtotal + tax).toLocaleString();
}

// 注文時は、商品コードと数量だけをサーバーに送る
async function submitOrder() {
  const items = collectItems();
  const result = document.getElementById("result");
  if (items.length === 0) {
    result.textContent = "数量を1以上にしてください。";
    return;
  }
  try {
    const response = await fetch("/api/orders", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ items: items })
    });
    if (!response.ok) {
      result.textContent = "注文に失敗しました(エラーコード:" + response.status + ")。";
      return;
    }
    const data = await response.json();
    // 表示をサーバーの確定金額で上書きする
    document.getElementById("preview").textContent = data.total.toLocaleString();
    result.textContent = "注文が確定しました。確定金額(税込):" + data.total.toLocaleString() + "円";
  } catch (error) {
    result.textContent = "通信に失敗しました。時間をおいて再度お試しください。";
  }
}

document.getElementById("qty-P01").addEventListener("input", updatePreview);
document.getElementById("qty-P02").addEventListener("input", updatePreview);
document.getElementById("order-button").addEventListener("click", submitOrder);
</script>
</body>
</html>

実行方法と動作確認

  1. アプリを起動する(IDEでOrderApplicationを実行するか、Mavenの場合は ./mvnw spring-boot:run を実行)
  2. ブラウザで http://localhost:8080/order.html を開く
  3. ノートPCを2、マウスを2にすると、目安の金額に270,600円と表示される
  4. 注文ボタンを押すと、サーバーから同じ270,600円が確定金額として返ってくる

計算の内訳は、小計 120000 × 2 + 3000 × 2 = 246,000円、税額 246000 × 10 ÷ 100 = 24,600円、合計270,600円です。

改ざんへの強さも確認してみましょう。開発者ツールでPREVIEW_PRICESのP01を1に書き換えると、画面の目安の金額は変わります。しかし、注文ボタンを押すと、サーバーはMapに保存された120,000円で計算するため、確定金額は270,600円のままです。

コードのポイント

  • サーバーに送るJSONには、商品コードと数量しか含まれていません。単価や合計金額は送りません
  • サーバー側では、@Min、@Maxで数量の範囲をチェックし、存在しない商品コードはエラーにしています。JavaScript側の入力欄にもmin、maxを設定していますが、これは操作性のためで、安全を守るのはサーバー側のチェックです
  • 税額の計算では、0.1を掛けるのではなく、10を掛けて100で割っています。JavaScriptもJavaのdoubleも、0.1のような小数を正確に表せず、0.1 + 0.2 が 0.30000000000000004 になるような誤差が生じます。誤差によって切り捨ての結果が1円ずれることを防ぐため、金額は整数で計算します。小数を含む計算が必要な場合、Javaでは BigDecimal を使います

練習問題5

上の実装例で、利用者が開発者ツールを使い、送信するJSONの数量を2のまま、ノートPCの単価を1円にする項目を追加して送信しました。確定金額はどうなるでしょうか。

  1. 単価1円で計算され、マウスとの合計で6,602円になる
  2. 単価が不正なため、サーバーが必ずエラーを返して注文できない
  3. サーバーは単価の項目を使わず、確定金額は270,600円になる
  4. 画面の目安の金額がそのまま保存され、確定金額は0円になる

解答:3

解説:サーバーのOrderItemRequestは商品コードと数量しか受け取りません。JSONに単価の項目が追加されていても、Spring Bootは標準の設定では未知の項目を無視し、単価はサーバーのMapから取得します。そのため、確定金額は正しい270,600円になります。

  • 1は、送られた単価を信用して計算した場合の結果です(小計 1 × 2 + 3000 × 2 = 6,002円、税額600円)。この実装では起こりません
  • 2は誤りです。標準の設定では未知の項目は無視されるため、エラーにはならず正しい金額で注文されます
  • 4は誤りです。サーバーは画面の金額を受け取っておらず、保存もしていません

注意点

  • 端数処理のルールをそろえる:JavaScriptのMath.roundとJavaのRoundingModeでは、負の数の四捨五入などで結果が異なる場合があります。プレビューとサーバーで同じ結果にしたい場合は、切り捨てなど単純なルールに統一し、テストで確認しましょう
  • 日時の計算はサーバーで行う:利用者のパソコンの時計は変更できるため、締め切りの判定や有効期限の計算にブラウザの時刻を使ってはいけません
  • 計算式そのものが秘密の場合はJavaScriptに書かない:JavaScriptのコードは利用者がすべて読めます。独自の料金計算ロジックなどは、サーバーのプレビューAPIで計算します

まとめ

今回の内容を振り返ります。

  • ブラウザから届く値は利用者が書き換えられるため、信用してはいけません
  • 表示するだけの計算は、JavaScriptで行うと操作性が良くなります
  • 保存、お金、判定に関わる値は、必ずサーバーで計算します
  • 実務では、JavaScriptで目安を即座に表示し、サーバーには入力値だけを送り、サーバーが再計算した値を保存する方法がおすすめです
  • 入力値から計算できる一時的な結果は、原則としてデータベースに保存しません。保存するのは、確定値や集計に時間がかかる値です

理解度を確認するために、次の問いに自分の言葉で答えてみてください。

  • JavaScriptで入力チェックをしていても、サーバーでもチェックが必要なのはなぜですか
  • 注文時にサーバーへ送るべきデータと、送ってはいけないデータは何ですか
  • 金額の計算で、0.1を掛けずに整数で計算するのはなぜですか

次の学習ステップとしては、Spring Bootでの入力値検証(Bean Validation)とエラー処理(@RestControllerAdvice)を学ぶことをおすすめします。そのうえで、OWASP Top 10などのWebアプリケーションのセキュリティの基礎に進むと、「サーバーで必ず確認する」という考え方が、改ざん以外のさまざまな攻撃への対策にもつながっていることが理解できます。

投稿者プロフィール

山崎講師
山崎講師代表取締役
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
海外放浪の末、2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。

学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。