ウェブエンジニア問題集
第11章

バリデーション設計 — フロント・バックエンド・DBの3層で守る

11
この章の目次開く

「メールアドレスの形式チェックはフロントでやってるから、バックエンドは省略していいよね?」——この判断が、不正データの混入・セキュリティ事故・原因不明のバグの入り口になります。

バリデーション(入力値検証)は1か所で行うものではなく、フロントエンド・バックエンド・DBの3層それぞれに役割を持たせて多層で守るものです。この章では、各層が「何のために」「何を」検証するのかを整理します。

学習者学習者

同じチェックを3回もやるの?完全に二度手間…いや三度手間じゃない?

先生先生

実は「同じチェックを3回」ではないんだ。3つの層は目的が違う——フロントはユーザー体験のため、バックエンドは防御のため、DBは整合性のため。目的が違うから、チェックする内容も少しずつ違う。そこがこの章のポイントだよ。

なぜフロントエンドだけでは守れないのか

まず大前提を押さえます。フロントエンドのバリデーションは、セキュリティ的には存在しないのと同じです。

フロントエンドのコードはユーザーのブラウザで動きます。つまり、ユーザー(と攻撃者)の支配下にあります。

  • ブラウザの開発者ツールで required 属性やJavaScriptのチェックは無効化できる
  • そもそも画面を経由せず、curl などでAPIに直接リクエストを送れる
❌ フロントでチェック済みだからバックエンドは素通し、という設計

curl -X POST https://api.example.com/users \
  -d '{"email": "not-an-email", "age": -5, "role": "admin"}'

→ 画面のバリデーションを一切通らずにサーバーへ届く
クライアントから届くデータは、すべて「信頼できない入力」として扱う——これがバリデーション設計の大原則です。

では、フロントのバリデーションは無意味かというと、そうではありません。目的が違うのです。

第1層: フロントエンド — ユーザー体験のためのバリデーション

フロントエンドのバリデーションの目的は、ユーザーが間違いにすぐ気づき、すぐ直せるようにすることです。送信ボタンを押してサーバーに往復してから「メールアドレスが不正です」と言われるより、入力した瞬間に指摘される方が圧倒的に快適です。

HTMLの標準属性だけでも、基本的なチェックは宣言的に書けます。

<input type="email" required />
<!-- メール形式 + 必須 -->
<input type="number" min="0" max="120" />
<!-- 数値の範囲 -->
<input type="text" maxlength="50" pattern="[a-z0-9-]+" />
<!-- 長さと文字種 -->
html

より複雑な条件(パスワード確認の一致、他項目との相関チェックなど)はJavaScriptで実装します。設計として決めておくべきは、チェックの内容よりもフィードバックの出し方です。

  • いつ表示するか — 入力中(打鍵ごと)はうるさすぎることが多い。フォーカスが外れたとき、または送信時にまとめて表示するのが一般的
  • どこに表示するか — 問題のある入力欄の近くに表示する。画面上部にまとめて出すだけだと、どの欄を直せばいいのか探すことになる
  • 何を表示するか — 「不正な値です」ではなく「英小文字・数字・ハイフンのみ使用できます」のように、直し方が分かる文言にする

第2層: バックエンド — 防衛線の本体

バックエンドのバリデーションは省略できない防衛線です。フロントエンドの実装状況にかかわらず、APIに届いたデータはすべてここで検証します。

バックエンドの検証は、性質の違う2段階に分かれます。

形式チェック(構文的バリデーション)

「データの形が正しいか」の検証です。型・必須・長さ・範囲・形式(メール、日付など)をチェックします。

TypeScriptでは、Zodのようなスキーマバリデーションライブラリで宣言的に書くのが定番です。

import { z } from 'zod';
 
const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(50),
  age: z.number().int().min(0).max(120),
});
 
// リクエストボディを検証。失敗すると項目ごとのエラー情報が得られる
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
  // どの項目が・なぜ不正かを 400 で返す
  return res.status(400).json({
    error: { code: 'VALIDATION_ERROR', details: result.error.issues },
  });
}
// ここから先、result.data は検証済み + 型付きで使える
const user: { email: string; name: string; age: number } = result.data;
ts

スキーマ方式の利点は、バリデーションと型定義が一体化し、「検証を通ったデータには正しい型が付いている」ことをコンパイラが保証してくれる点です。検証ロジックがif文の羅列で散らばることもなくなります。

業務ルールチェック(意味的バリデーション)

「データの形は正しいが、業務として受け入れられるか」の検証です。

  • このメールアドレスは登録済みではないか(一意性)
  • 注文数が在庫数を超えていないか
  • クーポンの有効期限は切れていないか
  • このユーザーはこの操作をする権限があるか

形式チェックと違い、DBや他のデータとの照合が必要なのが特徴です。ここで弾かれるエラーは前章のエラー設計でいう「予期するエラー」なので、エラーコードを付けて「何が・なぜダメか」をフロントに返します。

// 業務ルールチェックの例: 形式チェックを通った後に行う
const existing = await userRepo.findByEmail(input.email);
if (existing) {
  throw new AppError('USER_EMAIL_TAKEN', 409, `email重複: ${input.email}`);
}
ts

第3層: DB制約 — 最後の砦

アプリケーションのコードにバグがあっても、DBの制約は破れませんER図とデータベース設計で扱ったスキーマ定義に、制約として不変条件を埋め込みます。

制約守るもの
NOT NULL必須項目の欠落email VARCHAR(255) NOT NULL
UNIQUE重複の禁止UNIQUE KEY uk_users_email (email)
外部キー(FOREIGN KEY存在しない親への参照存在しないユーザーIDの注文を禁止
CHECK値の範囲・条件CHECK (price >= 0)
ENUM / 参照テーブル想定外の区分値status に定義外の文字列が入るのを禁止

「バックエンドでチェックしているのにDB制約も必要なの?」と思うかもしれません。必要です。決定的な理由が同時実行です。

アプリ側の重複チェックだけでは防げないケース:

リクエストA: SELECT ... WHERE email = 'x@example.com' → 0件。OK、INSERTしよう
リクエストB: SELECT ... WHERE email = 'x@example.com' → 0件。OK、INSERTしよう
(AとBがほぼ同時に届いた場合、両方のSELECTが「重複なし」と判定する)
リクエストA: INSERT成功
リクエストB: INSERT成功 → 重複データが誕生

SELECTで確認してからINSERTするまでの間に、別のリクエストが割り込む余地があります。「絶対に重複してはいけない」ものはUNIQUE制約で守り、アプリ側のチェックは「事前にユーザーへ親切に伝えるため」と位置づけるのが正しい役割分担です。制約違反が起きたら、それを捕まえて業務エラーとして返します。

OKとNGの判定
最終判定はDBの制約が下す。アプリのチェックはその手前の親切

3層の役割分担まとめ

目的主なチェック内容省略すると
フロントエンドUX(すぐ気づける)必須・形式・範囲の即時フィードバック使いにくくなる(が、安全性は変わらない)
バックエンド防御(信頼境界)形式チェック + 業務ルールチェック不正データ・脆弱性が素通りする
DB整合性(最後の砦)NOT NULL・UNIQUE・外部キー・CHECKバグや同時実行で不整合データが混入する
学習者学習者

フロントとバックエンドで形式チェックの内容がズレたらどうなるの?フロントはOKなのにバックエンドで弾かれるとか…

先生先生

それは実際によく起きる問題。対策としては「バリデーションルールの定義を1か所にまとめて共有する」のが理想だね。TypeScriptでフロントとバックエンドを書いているなら、Zodのスキーマを共有パッケージに置いて両方から使う、という構成がよく採られるよ。共有できない構成なら、仕様書でルールを一元管理して、ズレたらバックエンドを正とする。

よくあるハマりどころ

「フロントでやってるから」とバックエンドを省略する

この章で繰り返してきたとおり、最も危険な省略です。バックエンドの形式チェックは、フロントの実装状況と無関係に全エンドポイントで必須と考えます。逆(バックエンドがあるからフロントを省略)は、UXが悪くなるだけで安全性には影響しません。省略するならフロント側です。

エラーを1件ずつしか返さない

フォームに5個の入力ミスがあるのに、送信するたびに1件ずつ指摘される——ユーザーは5回送信を繰り返すことになります。形式チェックは全項目を検証してから、項目ごとのエラーをまとめて返す設計にします(Zodの safeParseissues の配列を返すのはこのためです)。

過剰な正規表現でメールアドレスを弾く

メールアドレスの完全な仕様(RFC 5322)は非常に複雑で、自作の正規表現は大抵どこかが間違っています。「@. を含む」程度のゆるいチェック + 確認メールの送達確認で実在を検証する、という2段構えの方が実務的です。

DBの制約違反を500エラーのまま返す

UNIQUE制約違反はDBドライバの例外として飛んでくるため、何もしないと「予期しないエラー」扱いで500になります。制約違反の例外を捕まえて USER_EMAIL_TAKEN のような業務エラーコードに変換する処理を、エラーハンドリングの共通層に入れておきます。

ちゃんと使うためのポイント

  • クライアントから届くデータはすべて信頼できない——バックエンドの検証は省略できない
  • 3層は同じチェックの繰り返しではない:フロント=UX、バックエンド=防御、DB=整合性
  • バックエンドは「形式チェック(スキーマで宣言的に)」と「業務ルールチェック(DBと照合)」の2段構え
  • 予期しないフィールドは受け取らない(マスアサインメント対策)
  • 一意性・参照整合性は同時実行に耐えられるDB制約で守る。アプリのチェックは親切、DBの制約が最終判定
  • フロントとバックエンドのルールのズレはスキーマ共有か「バックエンドが正」ルールで防ぐ

ここまでで、データの入口を守る設計が揃いました。次の章では、設計を「伝える」技術——命名・コメント・ドキュメントの書き方を扱います。

参考リンク