はじめに
Spring BootでログインAPIを作っている中で、DB上の users テーブルを使った認証処理に進もうとしていました。
それまでは、AuthService#login で仮の dummy-token を返していました。次の段階として、DBに登録されたユーザーを loginId で検索し、入力されたパスワードとDB上のハッシュ化済みパスワードを照合する処理に進みたいと考えました。
そのために必要になったのが、users テーブルに対応する User Entityです。
ただ、実際に User.java を書こうとすると、思った以上に分からないことが出てきました。@Entity や @Column のようなアノテーションは見たことがあるものの、最終的に何が必要で、どうなれば完成なのかが分かっていませんでした。
JPA、Hibernate、UUID、OffsetDateTime、引数なしコンストラクタ、getter/setter、Lombokなど、周辺の概念の理解も曖昧で混乱しました。
この記事では、ログイン認証に使う User Entityを書きながら、自分がどこで詰まり、どう整理したのかを振り返ります。
今回やろうとしていたこと
今回の目的は、ログイン処理を少しだけ本物に近づけることでした。
最終的にはJWTを発行したり、ログイン後の認可やテナント切り替えまで扱う予定ですが、今回はそこまでは進みません。まずは、DB上の users テーブルからユーザーを検索し、パスワードを照合するところまでを目指しました。
流れとしては、次のようなイメージです。
loginIdでusersテーブルを検索する
↓
該当するuserが存在しなければ401を返す
↓
userが存在すれば、入力passwordとhashed_passwordを照合する
↓
一致しなければ401を返す
↓
一致すれば、今はまだdummy-tokenを返す
この処理を進めるには、まず UserRepository で users テーブルを検索できる必要があります。Spring Data JPAでRepositoryを使うには、DBテーブルに対応するEntityが必要になります。ここで User.java を作ることになりました。
最初に書いたUser.java
最初に書いた User.java は、だいたい次のような内容でした。
package com.yoshida.orgflow.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String uuid;
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
@Column(name = "login_id", nullable = false, length = 100)
private String loginId;
@Column(name = "hashed_password", nullable = false)
private String hashedPassword;
@Column(name = "mail_address", nullable = false)
private String mailAddress;
@Column(name = "created_at", nullable = false, length = 100)
private String createdAt;
}
ネットで調べながら書いたので、@Entity、@Id、@Column などを付ければそれっぽくなる、という程度の理解でした。
しかし、この時点では次のことが分かっていませんでした。
- Entityの完成条件は何か
- DBの型とJavaの型をどう対応させるのか
-
@Tableは必要なのか -
@GeneratedValueを付けるべきなのか -
created_atをStringで持ってよいのか - getter/setterはどこまで必要なのか
コードは書き始めたものの、何を満たせばEntityとして正しいのか が見えていませんでした。
Entityの完成条件を整理する
まず整理したのは、Entityは何のためにあるのかです。
今回の User Entityの目的は、users テーブルの1行をJavaオブジェクトとして扱えるようにすること です。
ログイン認証では、少なくとも次の値が必要になります。
-
login_id:入力されたログインIDでユーザーを検索するために使う -
hashed_password:入力されたパスワードと照合するために使う
そのため、今回のEntityでは、DBの users テーブルのカラムと、Java側のフィールドを正しく対応させる必要があります。
たとえば、DB側が次のようなカラムを持っているとします。
id
display_name
login_id
hashed_password
mail_address
created_at
Java側では、これらを次のようなフィールドとして扱います。
id
displayName
loginId
hashedPassword
mailAddress
createdAt
ここで大事なのは、EntityはAPIの入力や出力そのものではない ということです。
ログインAPIの入力には LoginRequest というDTOを使っています。これは、外部から送られてくるリクエスト用のデータです。一方で、User EntityはDBの users テーブルに対応するデータです。
DTOはAPIの入力・出力に使うデータで、EntityはDBテーブルの1行をJavaで扱うためのデータです。今回のログインAPIでは、LoginRequest はAPI入力用、User EntityはDB上のユーザー情報を扱うためのものです。
自分の中では、次のように整理しました。
DTO
APIの入力・出力に使うもの
Entity
DBテーブルの1行をJavaで扱うためのもの
Repository
Entityを使ってDBへアクセスする窓口
DTOはAPIや画面に近い層のデータであり、EntityはDBに近い層のデータです。この違いを意識しないと、ログインAPI用、ユーザー作成API用、ユーザー更新API用にEntityを分けるのか、という方向に混乱しやすくなります。
今回は DTOは用途ごとに分けるが、Entityは基本的にテーブルごとに対応させる という整理にしました。
Entityを書きながら詰まった論点
Entityの完成条件を整理したあとも、実際に書き直そうとすると個別の論点で詰まりました。ここからは詰まった論点ごとに振り返ります。
JPAとHibernateの関係
最初は、JPAとHibernateを「どちらもDBとJavaをつなぐ何か」くらいの理解でした。特に分からなかったのは、両者の責務の違いです。
整理すると、次のようになります。
JPA
JavaでDBとEntityを扱うための仕様・ルール・API
Hibernate
JPAの仕様に従って、実際にSQL発行やEntity生成を行う実装ライブラリ
Spring Data JPA
Repositoryを使いやすくするためのSpringの仕組み
たとえるなら、JPAはルールブックで、Hibernateはそのルールに従って実際に動く実行役です。
自分がEntityに書いている次のようなアノテーションは、JPA側のルールです。
@Entity
@Table
@Id
@Column
これらは「このクラスはDBテーブルに対応するEntityです」「このフィールドはこのカラムに対応します」という情報を表します。一方で、実際にSQLを作ったり、DBから取得した結果をJavaオブジェクトに詰めたりするのは、Hibernateの役割です。
Spring Bootのアプリケーションで見ると、ざっくり次のような流れになります。
AuthService
↓
UserRepository
↓
Spring Data JPA
↓
JPA
↓
Hibernate
↓
PostgreSQL
UserRepository#findByLoginId のようなメソッドを呼ぶと、その裏側でSpring Data JPAやHibernateが動き、SQLを発行してDBからデータを取得してくれます。
この流れを理解すると、Entityに書くアノテーションは「ただのおまじない」ではなく、HibernateがDBとJavaオブジェクトを対応させるための情報 だと分かってきました。
@Table(name = "users") が必要な理由
最初のコードでは、@Entity は付けていましたが、@Table(name = "users") は付けていませんでした。
@Entity
public class User {
// ...
}
これでも一見動きそうに見えます。しかし、テーブル名を明示しない場合、JPA/Hibernateはクラス名からテーブル名を推測します。
今回のクラス名は User ですが、実際のDBテーブル名は users です。もしHibernateが user のようなテーブル名を推測してSQLを発行すると、DBにはそのテーブルが存在しないため、エラーになります。
そのため、今回のように実テーブル名が明確に users である場合は、次のように明示した方が安全です。
@Entity
@Table(name = "users")
public class User {
// ...
}
この指定により、Hibernateは User Entityを users テーブルに対応するものとして扱えます。@Table(name = "users") は、Hibernateが正しいテーブルに対してSQLを発行するための情報 です。
idをUUID型にした理由
最初は、IDを String で持っていました。
@Id
private String uuid;
しかし、DB側の id カラムはPostgreSQLの uuid 型です。Java側も String ではなく、UUID 型にした方が自然です。
import java.util.UUID;
@Id
@Column(name = "id")
private UUID id;
UUID はJava標準の型で、たとえば次のような形式の値を扱います。
550e8400-e29b-41d4-a716-446655440000
String でも文字列としては持てますが、String にしてしまうと、Java上ではただの文字列です。極端に言えば、次のような値も代入できてしまいます。
hello
abc
not-uuid
一方で、UUID 型にすると、「これはUUID形式の識別子である」という意味が型に出ます。
DBがuuid型
↓
Java側もUUID型にする
↓
ただの文字列ではなく、UUID形式の識別子として扱える
型は、単にデータを入れる箱ではなく、その値が何を意味するかをコード上に表すもの だと理解しました。
created_atをOffsetDateTimeにした理由
最初のコードでは、created_at を String で持っていました。
@Column(name = "created_at", nullable = false, length = 100)
private String createdAt;
しかし、created_at は文字列ではなく日時です。DB側では TIMESTAMPTZ を使っていました。これは、タイムゾーンを意識する日時型です。Java側では OffsetDateTime を使う方が自然です。
import java.time.OffsetDateTime;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
OffsetDateTime は、日時に加えてUTCからの時差を持てる型です。たとえば、次のような値を表せます。
2026-05-05T10:30:00+09:00
これは「2026年5月5日 10:30、日本時間」という意味です。String で持つと、日時っぽい文字列として扱うだけになります。OffsetDateTime にすると、Java上でも日時として扱えます。
DBのcreated_atはTIMESTAMPTZ
↓
Java側ではOffsetDateTimeが自然
↓
日時としての意味を型で表せる
また、最初に付けていた length = 100 も不要でした。length は主に文字列カラムの長さを指定するもので、日時型の created_at に対して付けるものではありません。
@GeneratedValueを外した理由
最初のコードでは、IDに次のような指定をしていました。
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String uuid;
しかし、今回のDB定義では、IDはPostgreSQL側で生成する方針にしていました。
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
このようにDB側の gen_random_uuid() によってUUIDを生成します。
この状態でJava側にも @GeneratedValue(strategy = GenerationType.UUID) を付けると、Hibernateが INSERT 文に UUID を含めるため、DB 側の DEFAULT gen_random_uuid() は使われなくなります(DEFAULT は INSERT で値が省略されたときに発火する仕組みのため)。結果として、ID生成の責任がJava側とDB側で曖昧になります。
この責任がブレると、後でユーザー作成処理などを実装するときに、どちらを正とするのかが分かりにくくなります。
今回のログイン認証では、そもそも新しいユーザーを作成しません。すでにDBに存在するseed userを読むだけです。今回のEntityでは @GeneratedValue は外し、DBにある id を UUID として読むだけにしました。
@Id
@Column(name = "id")
private UUID id;
ID生成には、Java側で生成する方法もDB側で生成する方法もあります。どちらが絶対に正しいというより、プロジェクト内で「どこがIDを生成するのか」を揃えることが大事だと理解しました。
引数なしコンストラクタが必要な理由
Entityには、基本的に引数なしコンストラクタが必要です。最初はここもよく分かりませんでした。
Javaはコンストラクタを書かなければデフォルトコンストラクタを作ってくれるはずでは?
HibernateがDBの値を入れるなら、引数ありコンストラクタを使えばよいのでは?
このあたりで混乱していました。
整理すると、HibernateはDBから取得した1行をもとに、まずEntityの空のオブジェクトを作り、その後フィールドに値を詰めていきます。
そのため、Hibernateが呼び出せる引数なしコンストラクタが必要になります。JPAの仕様では、引数なしコンストラクタは public または protected で宣言する必要があります(private だと使えません)。
public User() {
}
ここで注意が必要なのは、Javaのデフォルトコンストラクタとの関係です。
自分でコンストラクタを1つも書いていない場合、Javaは引数なしコンストラクタを自動で作ってくれます。しかし、自分で引数ありコンストラクタを書くと、Javaは引数なしコンストラクタを自動生成しません。
public class User {
public User(UUID id) {
this.id = id;
}
}
このコード自体はコンパイルできます。ただし、引数なしコンストラクタが自動生成されなくなってしまうので、Hibernateが new User() できなくなり、Entityとして扱うときに問題になります。
そのため、引数なしコンストラクタは、Hibernateが new User() できるよう、安全のために明示しておくものと理解しました。
getter/setterとLombokで悩んだ
Entityを書いていてもう一つ悩んだのが、getter/setterをどこまで書くかです。今まで見てきた教材やハンズオンでは、getter/setterを手書きしているものもあれば、Lombokを使っているものもありました。
たとえば、Lombokを使うと、次のように書けます。
@Getter
@Entity
@Table(name = "users")
public class User {
// ...
}
これにより、getterを自動生成できます。
ただ、今回はLombokを使わず、必要なgetterだけ手書きする方針にしました。理由は、今の段階ではEntityに何が必要なのかを理解することを優先したかったからです。
特に、Entityに対して何も考えずに @Data を付けるのは避けたいと感じました。@Data は便利ですが、getter、setter、toString、equals、hashCode などをまとめて生成します。
今回のログイン認証では、hashedPassword を読み取る必要はありますが、外部から自由に変更する必要はありません。少なくとも今は setHashedPassword() のようなsetterは不要です。
ログイン認証ではEntityを更新しない
↓
setterはまだ不要
↓
AuthServiceで使う値だけgetterを用意する
Lombok自体が悪いわけではありません。ただ、Entityに対して @Data を雑に付けると、不要なsetterまで公開してしまう可能性があります。今回は学習段階でもあるため、必要なgetterだけを手書きする方針にしました。
今回は最低限、次のようなgetterだけを用意する方針にしました。
public UUID getId() {
return id;
}
public String getHashedPassword() {
return hashedPassword;
}
getLoginId() については、Repositoryが findByLoginId で検索するときに必ず必要になるわけではありません。Spring Data JPAは、メソッド名に含まれるフィールド名(findByLoginId → loginId)をもとに JPQL を組み立てる仕組みのためです。ただし、デバッグや今後の処理で使う可能性があれば、追加してもよいと考えています。
修正後のUser.javaで整理できたこと
最終的に、今回の User Entityは次のような方向で整理しました。
package com.yoshida.orgflow.entity;
import java.time.OffsetDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "users")
public class User {
public User() {
}
@Id
@Column(name = "id")
private UUID id;
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
@Column(name = "login_id", nullable = false, length = 100)
private String loginId;
@Column(name = "hashed_password", nullable = false)
private String hashedPassword;
@Column(name = "mail_address", nullable = false)
private String mailAddress;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public String getHashedPassword() {
return hashedPassword;
}
}
このコードで意識したのは、次の点です。
-
@Table(name = "users")で実テーブル名を明示する - DBの
uuid型に合わせて、Java側はUUID型にする - DBの
TIMESTAMPTZに合わせて、Java側はOffsetDateTime型にする - ID生成はDB側に任せる方針なので、
@GeneratedValueは付けない - HibernateがEntityを作れるように、引数なしコンストラクタを用意する
- ログイン認証で必要な値だけgetterを用意する
- setterはまだ作らない
これで、users テーブルの1行をJavaの User オブジェクトとして扱うための土台ができました。
次は、このEntityを使って UserRepository を作り、findByLoginId(String loginId) でユーザーを検索できるようにする段階です。
今回理解したこと
今回一番大きかったのは、Entityを「なんとなくアノテーションを付けたJavaクラス」ではなく、DBテーブルの1行をJavaで扱うための対応表 として理解できたことです。
最初は @Entity や @Column を付ければよいと思っていました。しかし実際には、DBのカラム名、DBの型、ID生成の責任、Hibernateがオブジェクトを作る仕組み、DTOとの役割分担まで考える必要がありました。
特に、次の理解が整理できました。
JPAは仕様
HibernateはJPAを実際に動かす実装
Spring Data JPAはRepositoryを扱いやすくする仕組み
また、型の選び方も重要だと分かりました。id を String ではなく UUID にすることで、ただの文字列ではなくUUID形式の識別子として扱えます。created_at を String ではなく OffsetDateTime にすることで、日時としての意味をJava側にも表せます。
さらに、getter/setterやLombokについても、便利だから全部自動生成するのではなく、今回の用途で何が必要かを考えるべきだと分かりました。今回のログイン認証では、Entityを更新する必要はありません。まずは必要なgetterだけを用意し、setterは作らない方針にしました。