
一方で、そのIDをURLやAPIレスポンスにそのまま出した瞬間、事情が変わる。/users/1、/users/2、/users/3のような値は、単なる識別子であると同時に、登録順やデータ量を推測する手がかりにもなるからだ。
ここでUUIDやULIDが候補に上がる。どちらも連番ではないため、外部からIDを推測されにくい。分散環境でIDを発行しやすいという利点もある。ただし、ランダムな値を主キーに採用すれば、データベースのインデックスや書き込み性能に別の負荷がかかる。
つまり、これは「UUIDにすれば安全」「整数IDなら古い」という話ではない。Laravelの実装しやすさ、データベースの構造、APIの公開範囲、将来の分散化を一つの設計として見る必要がある。
自動インクリメントIDの「安全神話」が揺らぐ瞬間
自動インクリメントIDの長所は、説明しやすさにある。
Laravelなら、通常のテーブルは次のような定義から始められる。
$table->id();
この一行で、整数型の主キーと自動採番の仕組みを用意できる。Eloquentモデル側でも、特別な設定をしなければ標準の主キーとして扱われる。リレーションの外部キーも整数型で揃えやすく、管理画面やバックオフィスの開発では、この単純さがそのまま生産性になる。
データベース側でも、整数の主キーは効率がよい。一般的な構成では、整数値はUUIDの文字列表現より小さく、主キーインデックスや外部キーのサイズを抑えられる。MySQLのInnoDBでは主キーがクラスタ化インデックスの基準になるため、連番で新しいレコードを追加する構成は、データをインデックスの末尾に寄せやすい。ランダムな位置への挿入に比べると、ページ分割や書き込み時の負荷を抑えやすい設計だ。
デバッグもしやすい。ログにuser_id=12345と出ていれば、SQLコンソールから該当レコードを探すのは簡単だ。テストデータの確認や、管理者からの問い合わせへの対応でも、短い数値は扱いやすい。
ただし、この扱いやすさは、IDを外部に公開しても問題がないことを意味しない。
たとえば、次のようなAPIがあるとする。
GET /api/orders/42
このエンドポイントで、認証済みユーザーが自分の注文だけを取得できるように実装されていれば、IDが連番であること自体は直ちに脆弱性ではない。しかし、認可処理が不足していれば、42を43や44に変えるだけで、別ユーザーの注文を取得できる可能性がある。これはIDの推測しやすさと、認可不備が組み合わさった問題だ。
連番IDは、リソースの存在確認も容易にする。存在しないIDなら404、存在するが権限がないIDなら403、といったレスポンスの違いがある場合、攻撃者はさらに情報を集めやすい。レスポンス時間やエラーメッセージの差まで加われば、IDの列挙は単純な番号合わせではなく、API全体の挙動を調べる作業になる。
ここで大切なのは、UUIDが認可の代わりにはならないということだ。UUIDに変更しても、認証されていないユーザーがデータを取得できる状態なら問題は残る。推測が難しくなることは、攻撃のコストを上げる対策ではあるが、アクセス制御そのものではない。
連番IDの問題は、数字であることではない。外部に見せる識別子と、内部で管理する主キーを同じものにしてしまうことにある。
また、連番から読み取れるのは個別のレコードだけではない。最大IDを見れば、登録件数の概算が分かる場合がある。欠番があれば削除や失敗した処理の存在を推測されることもある。IDの増加ペースから、サービスの利用規模やデータ投入の時期が見えるケースもある。
もちろん、すべてのサービスでこの情報が機密になるわけではない。社内ツール、管理画面、外部公開を前提としないバッチ処理なら、連番IDの単純さを優先してよいことも多い。問題は、公開範囲を検討しないまま、デフォルト設定をそのままAPIの設計に持ち込むことだ。
公開用IDと内部主キーを分ける方法もある
主キーをUUIDやULIDに変える以外にも、内部IDと公開IDを分ける設計がある。
データベース内部では自動インクリメントのidを使い、APIやURLには別カラムのUUIDを返す方法だ。この構成なら、内部のJOINや外部キーは整数のまま維持しつつ、公開する識別子だけを推測困難な値にできる。
ただし、カラムが増える。idとpublic_idのどちらをどの場面で使うのか、開発者間でルールを決めなければならない。管理画面のURL、APIのレスポンス、ログ、外部連携のすべてで使い分けを誤ると、設計が複雑になる。
主キーそのものをUUIDやULIDにするか、公開用の識別子だけを別に持つか。ここは性能だけでなく、チームの規模や運用ルールも含めて判断する部分だ。
UUIDとULIDの本質的な違い――「ランダム」と「時系列付きランダム」
UUIDは128ビットの識別子で、複数の場所で同時に生成しても衝突しにくいことを目的に設計されている。データベースに採番を依頼しなくても、アプリケーション側でIDを作れる。この性質は、複数のアプリケーション、キュー、バッチ、リージョンなどからデータを生成する構成と相性がよい。
代表的なUUIDv4は、ほぼランダムな値で構成される。外部から次の値を予測することは難しく、連番のように登録件数や作成順を直接読み取ることもできない。公開URLの識別子としては、整数IDより扱いやすい場面がある。
ただし、UUIDv4は作成順を持たない。主キーを見ても、どちらのレコードが先に作られたかは分からない。時系列順に並べたいなら、基本的にはcreated_atのような日時カラムを使う必要がある。
ULIDは、UUIDと同じ128ビットの識別子だが、値の構造が異なる。先頭側にタイムスタンプの情報を持ち、残りの部分にランダムな値を持つ。文字列として並べたときに、おおむね生成時刻の順に並ぶよう設計されている。
この性質により、ULIDはUUIDv4よりもデータベースの挿入順を保ちやすい。完全な連番ではないため外部から次の値を単純に推測できるわけではなく、時系列で扱いやすいというバランスがある。
ただし、ULIDにも注意点がある。タイムスタンプを含むため、値から作成時刻の情報が読み取られる可能性がある。作成日時を秘匿したいサービスや、ID自体からメタデータを推測されたくないサービスでは、この特徴が欠点になることもある。
また、同じ時刻に大量のULIDを生成した場合、ランダム部分によって値の順序が完全に処理順と一致するとは限らない。ULIDは「厳密な採番順」を保証する仕組みではない。監査ログの順番や業務上の確定的な並びをIDだけに任せる設計は避けたほうがよい。
| 比較項目 | 自動インクリメントID | UUIDv4 | ULID |
|---|---|---|---|
| 主な特徴 | データベースで連番を発行 | ランダムな128ビット識別子 | 時刻情報とランダム値を組み合わせた識別子 |
| 外部からの推測 | 容易 | 困難 | 困難だが時刻情報は含む |
| 作成順の把握 | IDの増加順で扱いやすい | IDからは判断できない | おおむね時系列で並べやすい |
| 主キーのサイズ | 整数型で小さい | 文字列保存では大きくなりやすい | 文字列保存では大きくなりやすい |
| 分散環境での発行 | データベース設計に依存 | アプリケーション側で発行しやすい | アプリケーション側で発行しやすい |
| Laravelでの導入 | 標準の流れが最も簡単 | HasUuidsを利用できる | HasUlidsを利用できる |
| インデックスの扱い | 末尾追加になりやすい | ランダム挿入の負荷に注意 | UUIDv4より挿入順を保ちやすい |
| デバッグ | 数字で追いやすい | 長い値で追いにくい | 長い値で追いにくい |
この表からも分かるように、UUIDとULIDは同じものではない。外部公開への耐性だけで比較するのではなく、並び順、作成時刻の露出、インデックスの挙動、ログの読みやすさまで見なければならない。
B-Treeインデックスとページ分割:UUIDが抱えるパフォーマンスの罠
UUIDの導入で最も誤解されやすいのが、「文字列になるから遅い」という一言で終わらせてしまうことだ。
実際には、複数の要素が関係する。保存形式、インデックスのサイズ、主キーの挿入順、外部キーの型、JOINの回数、バッファプールに収まるかどうか。単純にUUIDだから遅い、整数だから速い、と決められる話ではない。
InnoDBの主キーインデックスは、テーブルのデータ配置とも深く関係する。自動インクリメントの整数IDでは、新しいレコードがインデックスの末尾に追加されやすい。アクセスが局所化しやすく、書き込みのパターンも予測しやすい。
UUIDv4のようなランダムな値では、新しいレコードがインデックスのさまざまな位置に入る。該当するページに空きがなければ、ページ分割が発生する。ページ分割では、既存ページの内容を分けて再配置し、インデックス構造を更新する必要がある。書き込みが増えれば、キャッシュ効率やディスクI/Oにも影響する。
さらに、主キーが大きくなると、セカンダリインデックスにも影響する。InnoDBでは、セカンダリインデックスのリーフ側に主キー値が保持されるため、主キーが大きいほど、他のインデックスも膨らみやすい。検索条件がstatusやcreated_atであっても、そのインデックスに主キーが含まれる以上、主キーのサイズは無関係ではない。
この差は、小さなテーブルでは体感しにくい。データ量、同時書き込み数、インデックス数、ストレージ性能によって状況は変わる。開発環境の少量データだけで判断するのも、逆に一般論だけでUUIDを避けるのも危険だ。
保存形式は文字列かバイナリか
UUIDの保存方法も検討が必要になる。
文字列として保存する場合、UUIDの形式をそのままログやSQLで確認しやすい。Laravelのマイグレーションでは$table->uuid('id')->primary()のように定義でき、導入の分かりやすさは高い。一方で、文字列表現は整数やバイナリよりも領域を使う。照合順序や文字列比較の影響も受けるため、データベースの設定を確認しておきたい。
バイナリ形式で保存すれば、インデックスのサイズを抑えられる可能性がある。ただし、SQLコンソールやログで値を読むときに変換が必要になる。Laravelのモデル、キャスト、外部連携で形式が混在すると、実装者が値の扱いを誤りやすい。
個人開発では、最初から高度な圧縮や特殊な保存形式を導入するより、まずはアプリケーションとマイグレーションの挙動を明確にすることを優先したほうがよい。性能上の問題が確認できた段階で保存形式を見直すほうが、運用コストを抑えやすい。
ULIDなら問題が消えるわけではない
ULIDは時系列性を持つため、UUIDv4よりB-Treeへの挿入が安定しやすい。しかし、整数の自動採番と完全に同じではない。値が大きいことには変わりがなく、外部キーやセカンダリインデックスのサイズも増える。
また、分散環境で複数ノードが同時にULIDを生成する場合、生成時刻とランダム部分の扱いによって並び方が変わる。アプリケーションが複数台になるほど、「IDが大きい順なら厳密に作成順」とは考えないほうが安全だ。
インデックスの性能は、実際のクエリで確認するべきだ。主キーによる単一レコード取得だけでなく、次のような処理も見る必要がある。
- 外部キーを使ったJOINが多い画面
created_atや状態カラムによる絞り込み- 大量のINSERTを処理するキュー
- ページネーションで次の範囲を取得するAPI
- 複数のセカンダリインデックスを持つ大きなテーブル
ページネーションでも差が出る。連番IDでは、条件によってはIDを基準に次の範囲を取りやすい。UUIDv4では、作成順を主キーから得られないため、日時カラムとインデックスを組み合わせる設計が必要になる。ULIDならIDによる範囲指定を利用しやすいが、同一時刻に作られた値の扱いまで考える必要がある。
Laravel 9以降の標準機能:HasUuidsとHasUlidsの活用術
Laravelでは、UUIDやULIDを使うための仕組みがフレームワークに用意されている。独自のID生成処理をモデルごとに書き散らす必要がない点は、個人開発でも大きなメリットだ。
UUIDを主キーにするモデルでは、HasUuidsトレイトを利用できる。ULIDの場合はHasUlidsを使う。これらをモデルに追加することで、Eloquentが主キーの生成や文字列型の扱いを前提に動作する。
マイグレーション側では、UUIDなら$table->uuid('id')->primary()、ULIDなら$table->ulid('id')->primary()という形で定義する。既存の$table->id()から変更する場合、モデルだけを修正しても動かない。データベースのカラム型、外部キー、既存データ、APIの入出力まで一緒に確認する必要がある。
モデルだけ変更してはいけない
UUIDやULIDへの変更でありがちな失敗は、モデルにトレイトを追加しただけで完了したと思うことだ。
主キーを文字列に変えるなら、少なくとも次の項目を揃える必要がある。
- 主テーブルの主キーの型
- 関連テーブルの外部キーの型
- ピボットテーブルの各カラム
- フォームリクエストやバリデーション
- ルートモデルバインディング
- シーダーとファクトリ
- APIリソースやJSONスキーマ
- ログ、監視、管理画面の検索処理
- 既存データの移行手順
たとえば、親テーブルのidをUUIDに変更したのに、子テーブルのuser_idが整数のままなら、外部キー制約を作れない。制約を外して動かすことはできても、データの整合性をアプリケーションだけで保証する必要が出てくる。
LaravelのforeignId()は整数の外部キーを作るための便利な定義だ。UUIDやULIDでは、そのまま使えるとは限らない。foreignUuid()やforeignUlid()を使う構成、あるいはカラム型を明示してから外部キー制約を定義する構成に切り替える必要がある。Laravelのバージョンや利用するデータベースによって使えるメソッドの細部は異なるため、プロジェクトのバージョンに合わせて確認したい。
既存テーブルの移行は段階的に考える
運用中のテーブルの主キーを一度に置き換えるのは、見た目以上に大きな変更だ。主キーを参照するテーブルが一つならまだよいが、複数のリレーション、履歴、通知、ジョブ、外部連携が絡むと、変更範囲はすぐに広がる。
現実的には、次のような段階を取ることが多い。
1. 既存の主キーを維持したまま、公開用のUUIDまたはULIDカラムを追加する。
2. 新しいカラムに既存レコードの値を設定する。
3. 新規レコードの作成時に、公開用IDが必ず生成される状態にする。
4. URLやAPIレスポンスで使う値を公開用IDへ切り替える。
5. 関連テーブルや外部連携の参照先を確認する。
6. 必要性を見極めたうえで、主キー自体の変更を検討する。
この方法なら、内部の整数IDをすぐに捨てずに済む。主キーの変更が本当に必要なのか、公開用IDを分けるだけで目的を達成できるのかも、移行の途中で判断しやすい。
反対に、新規プロダクトでまだデータが少ないなら、最初からUUIDやULIDを選ぶ意味がある。後から全テーブルの外部キーを置き換える作業を避けられるからだ。特にAPIを中心に設計するサービスでは、公開IDのルールを初期段階で決めておくと、URLやレスポンスの仕様がぶれにくい。
Eloquentのリレーションは同じでも、周辺の設計は変わる
UUIDやULIDを使っても、hasManyやbelongsToといったEloquentのリレーションの考え方は変わらない。モデル間の関連を定義し、必要な外部キーを揃えればよい。
ただし、Eloquentがリレーションを抽象化してくれるからといって、データベース側のコストまで消えるわけではない。文字列型の外部キーを持つテーブルが増えれば、インデックスのサイズは大きくなる。JOINの条件、取得するカラム、N+1問題、複合インデックスの順序を確認する必要がある。
特に、一覧画面で関連モデルを大量に読み込む処理は、主キーの種類に関係なく重くなりやすい。UUIDに変更した後だけ遅くなったように見えても、実際にはインデックス設計やクエリの発行回数が原因かもしれない。主キー変更の前後で、SQLログやクエリプランを比較することが重要になる。
APIとセキュリティで見る主キーの役割
主キーを決めるとき、データベースの性能だけを見ていると判断を誤る。Webアプリケーションでは、IDがどこに出ていくかが重要だ。
URLに使うのか、JSONのレスポンスに含めるのか、メールのリンクに埋め込むのか、外部サービスとの連携キーにするのか。同じIDでも、利用される場所によって要求が変わる。
自動インクリメントIDを外部に出す場合は、次の対策が欠かせない。
- IDの推測だけで他人のリソースに到達できない認可処理を入れる
- 存在確認と権限確認で不要な情報を返さない
- 連番から件数を推測されることが問題になるかを検討する
- 管理画面と公開APIで識別子を使い分ける
- ログやエラーメッセージに内部情報を出しすぎない
UUIDやULIDに変えても、認可処理は必要だ。値が推測困難でも、漏洩したURLがそのまま使えるなら、トークンのように扱う設計は危険である。主キーは秘密情報ではない。アクセス権を表すものでもない。
また、UUIDやULIDを使えばURLが完全に匿名化されるわけでもない。ユーザー名やメールアドレス、作成日時、レスポンス内容など、別の情報から対象を推測できる場合がある。識別子だけを変更して、周辺の情報公開を放置するのは中途半端な対策になる。
ULIDの場合は、とくに時刻情報への注意が必要だ。作成時刻が推測されても問題のないコンテンツならメリットが勝つ。一方、申請、購入、投稿、採用など、タイミング自体が情報になるデータでは、ULIDの性質を公開してよいか考えたい。
分散システムとデータ統合で考えるUUID・ULID
自動インクリメントIDは、単一のデータベースから採番する限り、非常に分かりやすい。しかし、複数のデータベースで同時にレコードを作る構成では、IDの衝突を避けるための仕組みが必要になる。
たとえば、サービスを分割して別々のデータベースを持つ場合、各データベースが同じ整数のIDを発行する可能性がある。後からデータを統合するとき、単純なidだけではレコードを一意に扱えない。採番範囲を分ける、データベースごとに接頭辞を付ける、統合時にIDを変換するなど、追加の設計が必要になる。
UUIDやULIDなら、各アプリケーションが独立してIDを生成しやすい。キューで先に識別子を発行しておき、後から処理を完了させる構成にも向いている。データベースへのINSERTが一箇所に集中しないため、分散環境では設計上の自由度が上がる。
ただし、「将来マイクロサービス化するかもしれない」という理由だけで、すべての個人開発にUUIDを採用する必要はない。分散化の予定がない小規模な管理ツールなら、整数IDの単純さを捨てるコストのほうが大きいこともある。
将来の可能性を考えるときは、次の問いに分けると判断しやすい。
- データを複数のデータベースで生成する予定があるか
- オフライン環境や端末側でIDを先に発行する必要があるか
- 複数サービスのデータを統合する可能性が高いか
- 外部連携先に内部の採番規則を見せたくないか
- 主キーの変更を避けたいほど、リレーションが複雑になる予定か
この答えがほとんど「いいえ」なら、自動インクリメントを選んでも問題にならない可能性が高い。逆に、複数の場所で同時にデータを作る設計が見えているなら、初期段階でUUIDやULIDを採用する価値はある。
現場で迷わないための主キー選定の判断基準
最終的な判断では、IDが外部に見えるかどうかを最初に確認する。そのうえで、性能と運用の条件を重ねていく。
自動インクリメントIDを選びやすいケース
次のような条件なら、整数の自動採番は有力な選択肢になる。
- 社内ツールや管理画面が中心で、IDを外部に公開しない
- 単一データベースでデータを一元管理する
- リレーションやJOINが多く、外部キーをシンプルに保ちたい
- テーブルの規模や書き込み量が大きく、インデックスの効率を優先したい
- 開発者全員がLaravelの標準構成で保守できることを重視する
- 管理画面やログで、短い数字をすぐ確認できることが重要
この場合でも、APIの認可処理は必要だ。IDを公開しないことと、アクセス制御を実装することは別の問題である。
UUIDを選びやすいケース
UUIDは、ランダムな識別子を必要とする場面に向いている。
- URLやAPIレスポンスに主キーを出す
- 分散したアプリケーションからIDを生成する
- 作成順や時刻情報をIDに含めたくない
- 複数データベースの統合を想定している
- 公開用IDを推測されにくくしたい
- 既存の外部システムがUUIDを前提としている
UUIDv4を使う場合は、ランダム挿入によるインデックス負荷を確認する。データ量が増えたときに問題になりそうなら、保存形式やインデックス構成を早めに検討したい。
ULIDを選びやすいケース
ULIDは、公開しにくい識別子と時系列性の両方が欲しいときに候補になる。
- IDを外部に出すが、作成順に並べる処理も多い
- カーソルベースのページネーションを採用したい
- 分散環境でアプリケーション側からIDを生成したい
- UUIDv4ほど完全なランダム性を必要としない
- IDに含まれるおおまかな作成時刻が問題にならない
ただし、時刻情報を持つことが本当に許容されるかは確認する。ULIDを選んだ理由が「UUIDより新しそうだから」だけでは、設計判断として弱い。公開情報の性質と、クエリの作り方の両方にメリットがあるかを見るべきだ。
判断を数値化しすぎない
主キー選定では、条件を点数化して結論を出したくなる。しかし、実際の差はサービスの使われ方で変わる。
読み取りが中心の小規模サービスなら、UUIDのインデックス負荷が問題にならないこともある。逆に、書き込みが多く、複数のセカンダリインデックスを持つテーブルでは、主キーのサイズと挿入順が効いてくる。
迷ったら、次の順で考えるとよい。
1. 主キーをURLやAPIに公開する必要があるかを決める。
2. 公開するなら、内部主キーと公開用IDを分けるか、主キー自体をUUID・ULIDにするかを比較する。
3. データを複数の場所で生成する可能性を確認する。
4. 主キーを参照する外部キーとインデックスの数を見積もる。
5. 時系列性が必要なら、UUIDv4ではなくULIDや日時カラムとの組み合わせを検討する。
6. 実データに近い量で、挿入、JOIN、一覧取得、ページネーションを確認する。
この順番なら、「Laravelのデフォルトだから整数」「セキュリティが不安だからUUID」という短絡を避けられる。
変更コストを含めて決める
主キーは、あとから変更できないわけではない。ただし、変更コストはテーブル定義だけでは終わらない。
モデルのキャスト、外部キー、シーダー、ファクトリ、テストの期待値、URL、APIドキュメント、フロントエンドの型定義、ログ検索、管理者向けの運用手順まで影響する。すでに外部利用者へIDを公開しているなら、古いURLとの互換性も考えなければならない。
既存システムで連番IDが問題になっている場合、最初に実施する対策が主キーの全面変更とは限らない。公開用UUIDを追加する、認可処理を見直す、レスポンスのエラー差分を減らす、レート制限を導入するなど、問題の原因に直接効く対策を組み合わせるべきだ。
新規のLaravelアプリケーションなら、プロダクトの公開範囲と将来のデータ連携を見て、早い段階で決める。後から変更できるという理由で先送りすると、外部キーとAPI仕様が固まった後に、より大きな移行作業が待っている。
主キーの選定は、データベースだけの最適化ではない。ユーザーに見せる情報、攻撃される面、分散化の余地、そして将来の移行コストをまとめて決める設計だ。
結論:迷ったら「どこで、誰が、何のために使うIDか」を見る
自動インクリメントIDには、長年使われてきた理由がある。小さく、速く、Laravelの標準構成にそのまま乗り、外部キーやデバッグも扱いやすい。外部に公開しない内部データなら、今でも堅実な選択肢だ。
UUIDは、推測困難な識別子と分散環境での発行しやすさが強みになる。一方で、ランダムな挿入によるB-Treeの負荷、インデックスサイズ、長い値を扱う運用コストがある。
ULIDは、その中間にある。外部公開に向く性質を持ちながら、時系列で並べやすい。ただし、作成時刻の情報が含まれることと、連番のような厳密な順序を保証するものではない点は忘れてはいけない。
Laravel 9以降では、HasUuidsやHasUlidsによって導入のハードルは下がっている。だからこそ、トレイトを追加できることだけを理由に選ぶのではなく、データベースのインデックス、APIの認可、外部キー、移行手順まで含めて決めるべきだ。
個人開発で最初に確認するなら、公開APIのリソースを一つ見ればよい。そこに出ているIDが連番であることが、サービスにとって本当に問題なのか。問題なら、公開用IDを分けるのか、UUIDにするのか、ULIDにするのか。問題でないなら、整数IDの単純さを活かせるのか。
主キーの正解は、採用した技術の格好よさでは決まらない。サービスの公開範囲、データの増え方、クエリの形、チームが保守できる設計かどうか。その条件に一番素直に合う選択が、現場にとっての正解になる。
Related reading: Laravel SanctumとPassport:API認証における選定基準の最適解 and LaravelのAPI連携におけるRESTとGraphQLの長所と個人開発での最適解.