10-6. JPA/Hibernateでデータを永続化する
このセクションで学ぶこと
Section titled “このセクションで学ぶこと”- メモリ上のデータと DB 上のデータの違い
@Entity,@Id,@GeneratedValueの役割- Repository を使って CRUD を行う流れ
- JPA/Hibernate と SQL の関係
- SQL ログを見ながら保存処理を調べる方法
10-4 までで、URL・DTO・CRUD を持つ API はそろった。
10-5 では、その API をフロントエンドから呼び出せるようにした。
ただし保存先はまだメモリなので、サーバーを再起動するとデータが消えてしまう。
10-6 では、書籍データをデータベースへ保存し、本当に永続化された API へ進化させる。
このセクションの終わりでは、次の状態を目指す。
BookEntityを定義できるBookRepositoryを使って CRUD できる- API が DB を通してデータを保存・取得できる
- SQL ログを見て、保存や取得の動きを追える
Controller ↓Service ↓Repository ↓JPA / Hibernate ↓Databaseこのセクション完了後の bookmanager/ Java ソースの構成は次のとおりになる。
entity/ と repository/ が新たに加わり、model/Book.java(メモリ用)は不要になるため削除する。
bookmanager/src/main/├── java/com/example/bookmanager/│ ├── BookManagerApplication.java│ ├── controller/│ │ └── BookController.java│ ├── dto/│ │ ├── BookResponse.java│ │ ├── CreateBookRequest.java│ │ └── UpdateBookRequest.java│ ├── entity/│ │ └── BookEntity.java ← 10-6 で追加│ ├── exception/│ │ ├── BookNotFoundException.java│ │ └── GlobalExceptionHandler.java│ ├── repository/│ │ └── BookRepository.java ← 10-6 で追加│ └── service/│ └── BookService.java└── resources/ └── application.properties ← 10-6 で設定このセクションの進め方
Section titled “このセクションの進め方”10-6 は、設定を書く → サーバーを再起動する → curl と SQL ログで確認する という流れで進めると理解しやすい。
このセクションでは、各実装段階の末尾に「ここで確認する」を置く。毎回、次の順で進めよう。
- 指定された設定や Java ファイルを追加・修正する
- 保存する
./gradlew bootRunを実行中ならいったん止め、再起動するcurl -iとサーバーログを見比べる- 必要なら再起動後もデータが残るかを確認する
見た目が変わらない段階もある。
その場合は、Explorer・Problems・サーバーログで「永続化の土台ができたか」を確認できればよい。
1. なぜ永続化が必要なのか
Section titled “1. なぜ永続化が必要なのか”メモリ上の List<Book> は、アプリが動いている間だけ有効である。
サーバー起動中 -> データありサーバー再起動 -> 消える書籍管理アプリとしては、登録した本が再起動後も残っていてほしい。
そのためには、メモリではなく DB に保存する必要がある。
2. まずは接続先を用意する
Section titled “2. まずは接続先を用意する”学習用としては、H2 を使うと始めやすい。
application.properties には次のような設定を置ける。
追加する場所: src/main/resources/application.properties に書く(新規ファイル)。
spring.application.name=bookmanagerspring.datasource.url=jdbc:h2:file:./data/bookappspring.datasource.driverClassName=org.h2.Driverspring.datasource.username=saspring.datasource.password=spring.jpa.hibernate.ddl-auto=updatespring.jpa.show-sql=truespring.h2.console.enabled=trueなぜ H2 を使うのか
Section titled “なぜ H2 を使うのか”- セットアップが軽い
- Spring Boot と相性が良い
- 学習段階で JPA の流れに集中しやすい
ここで大切なのは、DB 製品名そのものよりも、Java オブジェクトとテーブルの橋渡しがどう行われるか を理解することである。
ここで確認する
Section titled “ここで確認する”src/main/resources/application.propertiesを保存する- ターミナルで
./gradlew bootRunを実行する Tomcat started on port 8080が出ることを確認する
まだ画面変化はなくてよい。ここでは DB へ接続する設定が読み込まれたか を確認する段階である。
3. BookEntity を作る
Section titled “3. BookEntity を作る”JPA では、テーブルへ保存する対象を Entity として表す。
package com.example.bookmanager.entity;
import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.Table;
@Entity@Table(name = "books")public class BookEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String title; private String author; private String category; private int price; private String status; private String memo;
protected BookEntity() { }
public BookEntity(String title, String author, String category, int price, String status, String memo) { this.title = title; this.author = author; this.category = category; this.price = price; this.status = status; this.memo = memo; }
public void changeStatus(String status) { this.status = status; }
public Long getId() { return id; } public String getTitle() { return title; } public String getAuthor() { return author; } public String getCategory() { return category; } public int getPrice() { return price; } public String getStatus() { return status; } public String getMemo() { return memo; }}なぜ Entity と DTO を分けるのか
Section titled “なぜ Entity と DTO を分けるのか”Entity は DB 保存の都合を持つ。
一方 DTO は API の入出力の都合を持つ。
この2つは似て見えても責務が違うため、分けたほうが変更に強い。
ここで確認する
Section titled “ここで確認する”BookEntity.javaを保存したら、サーバーを再起動する- 起動ログにテーブル作成や更新に関する SQL が出ていないかを見る
- もし既に同じ DB ファイルを使っていて
create tableが目立たなくても、エラーなく起動できればよい
ここで見たいのは、Entity 定義が JPA に認識されているか である。
4. BookRepository を作る
Section titled “4. BookRepository を作る”Repository は、Entity の読み書きを担当する部品である。
package com.example.bookmanager.repository;
import org.springframework.data.jpa.repository.JpaRepository;import com.example.bookmanager.entity.BookEntity;
public interface BookRepository extends JpaRepository<BookEntity, Long> {}なぜ interface だけでよいのか
Section titled “なぜ interface だけでよいのか”Spring Data JPA は、JpaRepository を継承した interface から基本的な CRUD 処理を用意してくれる。
そのため、一覧取得・ID 検索・保存・削除の多くを自分で最初から書かなくてよい。
ここで確認する
Section titled “ここで確認する”BookRepository.javaが保存され、Problems に import エラーが出ていないことを確認する- サーバーを再起動し、起動時にエラーが出ていないことを確認する
- まだ API の見た目は変わらなくてよい。今は DB へ触る入口だけを追加した段階 である
5. 取得系 (GET) を DB ベースへ切り替える
Section titled “5. 取得系 (GET) を DB ベースへ切り替える”最初に、一覧取得と ID 指定取得から DB ベースへ移す。
この段階では、まだ登録・更新・削除は次の節で順に切り替える。
5-1. Repository を Service へ注入する
Section titled “5-1. Repository を Service へ注入する”変更する場所: BookService.java の import セクションに以下を追加する。
import com.example.bookmanager.entity.BookEntity;import com.example.bookmanager.repository.BookRepository;変更する場所: コンストラクタを BookRepository を受け取る形へ置き換える。books と sequence は、この時点ではまだ残してよい。
public BookService() { books.put(1L, new Book(1L, "リーダブルコード", "Dustin Boswell", "バックエンド", 3200, "読書中", "第3章まで読了")); books.put(2L, new Book(2L, "ドメイン駆動設計入門", "Eric Evans", "設計", 5400, "未読", "週末に読み始める"));}
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) { this.bookRepository = bookRepository;}ここで確認する
Section titled “ここで確認する”BookService.javaを保存したら、サーバーを再起動する- 起動時にエラーが出ていないことを確認する
- まだ API の見た目が変わらなくてもよい
ここでは、Service が Repository を受け取れる形になったか を確認する。
5-2. findAll() と findById() を Repository 版へ切り替える
Section titled “5-2. findAll() と findById() を Repository 版へ切り替える”変更する場所: findAll() と findById() を赤い行から緑の行へ置き換え、toResponse() と getBookEntityOrThrow() を追加する。import に BookEntity も追加する。
public List<BookResponse> findAll() { return books.values().stream() .map(BookResponse::from) .toList();}
public BookResponse findById(Long id) { return BookResponse.from(getBookOrThrow(id));}
public List<BookResponse> findAll() { return bookRepository.findAll().stream() .map(this::toResponse) .toList();}
public BookResponse findById(Long id) { return toResponse(getBookEntityOrThrow(id));}
private BookResponse toResponse(BookEntity entity) { return new BookResponse( entity.getId(), entity.getTitle(), entity.getAuthor(), entity.getCategory(), entity.getPrice(), entity.getStatus(), entity.getMemo());}
private BookEntity getBookEntityOrThrow(Long id) { return bookRepository.findById(id) .orElseThrow(() -> new BookNotFoundException(id));}ここで確認する
Section titled “ここで確認する”BookService.javaを保存したら、1 本目のターミナルでCtrl+Cを押してサーバーを止め、./gradlew bootRunで再起動する- 起動ログに
Started BookManagerApplicationが出たことを確認する - 2 本目のターミナルで次を実行する
curl -i http://localhost:8080/api/booksHTTP/1.1 200が返ることを確認する- サーバーログに
select ... from books ...が出ていることを確認する - DB がまだ空なら、レスポンス本文が
[]でもよい
ここで、読む処理だけが先に DB へ切り替わった ことを確認できる。
6. 登録 (POST) を DB ベースへ切り替える
Section titled “6. 登録 (POST) を DB ベースへ切り替える”次に、書籍追加も DB へ保存するようにする。
変更する場所: create() を赤い行から緑の行へ置き換える。
public BookResponse create(CreateBookRequest request) { Long id = sequence.incrementAndGet(); Book book = new Book(id, request.title(), request.author(), request.category(), request.price(), request.status(), request.memo()); books.put(id, book); return BookResponse.from(book);}
public BookResponse create(CreateBookRequest request) { BookEntity entity = new BookEntity( request.title(), request.author(), request.category(), request.price(), request.status(), request.memo());
BookEntity saved = bookRepository.save(entity); return toResponse(saved);}SQL ログはどこで見るのか
Section titled “SQL ログはどこで見るのか”通常は ./gradlew bootRun を実行している VS Code のターミナルへ表示される。
「API を叩いた直後にどの SQL が出たか」を時系列で追うのがコツである。
insert into ...が出た →save()による登録が走ったselect ...が出た → 一覧取得や詳細取得が走ったupdate ...が出た → 状態更新が DB へ反映された
SQL の ? はプレースホルダで、実際の値は別扱いで安全にバインドされる。
最初は値そのものよりも、「今どの種類の SQL が出たか」を読むだけで十分である。
ここで確認する
Section titled “ここで確認する”BookService.javaを保存したら、サーバーを再起動する(Ctrl+C→./gradlew bootRun)- 起動ログに
Started BookManagerApplicationが出たことを確認する - 次の
POSTを実行する
curl -i -X POST http://localhost:8080/api/books \ -H "Content-Type: application/json" \ -d '{"title":"JPA実践入門","author":"研修 太郎","category":"バックエンド","price":3200,"status":"未読","memo":"SQLログ確認用"}'HTTP/1.1 201が返ることを確認する- サーバーログに
insert into books ...が出ていることを確認する - 続けて
curl -i http://localhost:8080/api/booksを実行し、追加した本が一覧へ含まれることを確認する - そのときサーバーログに
select ... from books ...が出ていることも確認する
ここでは、登録も DB を経由しているか を見る。
7. 状態更新 (PATCH) を DB ベースへ切り替える
Section titled “7. 状態更新 (PATCH) を DB ベースへ切り替える”状態更新では、対象 Entity を取得し、status を書き換えて保存する。
変更する場所: 既存の update() を削除し、緑の update() へ置き換える。
public BookResponse update(Long id, UpdateBookRequest request) { Book book = getBookOrThrow(id); book.updateStatus(request.status()); return BookResponse.from(book);}
public BookResponse update(Long id, UpdateBookRequest request) { BookEntity entity = getBookEntityOrThrow(id); entity.changeStatus(request.status()); BookEntity saved = bookRepository.save(entity); return toResponse(saved);}ここで重要なのは、メモリ上の配列操作と違って、DB に対する存在確認や永続化タイミング を意識する必要がある点である。
ここで確認する
Section titled “ここで確認する”BookService.javaを保存したら、サーバーを再起動する(Ctrl+C→./gradlew bootRun)- 起動ログに
Started BookManagerApplicationが出たことを確認する - 次を実行する
curl -i -X PATCH http://localhost:8080/api/books/1 \ -H "Content-Type: application/json" \ -d '{"status":"読了"}'HTTP/1.1 200と更新後データが返ることを確認する- サーバーログに
update books ...が出ていることを確認する - 続けて
curl -i http://localhost:8080/api/booksを実行し、対象の状態が変わっていることを確認する
これで、更新も DB ベースへ切り替わった と分かる。
8. 削除 (DELETE) を DB ベースへ切り替える
Section titled “8. 削除 (DELETE) を DB ベースへ切り替える”最後に削除も DB を通るようにする。
8-1. delete() を Repository 版へ切り替える
Section titled “8-1. delete() を Repository 版へ切り替える”変更する場所: 既存の delete() を削除し、緑の delete() へ置き換える。
public void delete(Long id) { Book removed = books.remove(id); if (removed == null) { throw new BookNotFoundException(id); }}
public void delete(Long id) { if (!bookRepository.existsById(id)) { throw new BookNotFoundException(id); } bookRepository.deleteById(id);}ここで確認する
Section titled “ここで確認する”BookService.javaを保存したら、サーバーを再起動する(Ctrl+C→./gradlew bootRun)- 起動ログに
Started BookManagerApplicationが出たことを確認する - 次を実行する
curl -i -X DELETE http://localhost:8080/api/books/1HTTP/1.1 204が返ることを確認する- サーバーログに
delete from books ...が出ていることを確認する - 最後に一覧取得を行い、削除した本が含まれていないことを確認する
8-2. 使わなくなったメモリ保存用コードを片付ける
Section titled “8-2. 使わなくなったメモリ保存用コードを片付ける”ここまで来ると、books、sequence、getBookOrThrow()、それに対応する Book / Map / LinkedHashMap / AtomicLong の import は不要になる。赤い行を削除する。
import java.util.LinkedHashMap;import java.util.Map;import java.util.concurrent.atomic.AtomicLong;import com.example.bookmanager.model.Book;
private final Map<Long, Book> books = new LinkedHashMap<>();private final AtomicLong sequence = new AtomicLong(2L);
private Book getBookOrThrow(Long id) { Book book = books.get(id); if (book == null) { throw new BookNotFoundException(id); } return book;}ここで確認する
Section titled “ここで確認する”BookService.javaを保存してサーバーを再起動する- 起動エラーがないことを確認する
GET/POST/PATCH/DELETEを 1 回ずつ軽く試し、すべて DB ベースで動くことを確認する
これで、登録・取得・更新・削除のすべてが DB ベースで動いている状態 になる。
9. ブラウザ画面で通しで動かす
Section titled “9. ブラウザ画面で通しで動かす”curl での確認が終わったら、フロントエンドを開いて画面越しに操作し、サーバーログと Network タブを照らし合わせながら DB との連携を体感する。
- バックエンド(
./gradlew bootRun)が起動していることを確認する - 別のターミナルで
book-frontend/へ移動し、フロントエンドサーバーを起動する
cd book-frontendnpx --yes serve . --listen 4173- ブラウザで
http://localhost:4173を開く - DevTools の Network タブ と Console タブ を開いておく
9-1. 一覧取得を確認する
Section titled “9-1. 一覧取得を確認する”- ブラウザを更新する
- Network タブに
GET /api/booksが200で出ていることを確認する - サーバーログに
select ... from books ...が出ていることを確認する - 画面の書籍テーブルに DB の内容が表示されることを確認する
9-2. 書籍登録を確認する
Section titled “9-2. 書籍登録を確認する”- フォームにタイトル・著者・カテゴリ・価格・状態・メモを入力して「書籍を追加」を押す
- Network タブに
POST /api/booksが201で出ていることを確認する - サーバーログに
insert into books ...が出ていることを確認する - テーブルに新しい行が追加されることを確認する
- ブラウザを更新しても、追加した本が残っている ことを確認する(メモリではなく DB に保存されている証拠)
9-3. 状態変更を確認する
Section titled “9-3. 状態変更を確認する”- 一覧の状態欄の
selectを変更する - Network タブに
PATCH /api/books/{id}が200で出ていることを確認する - サーバーログに
update books ...が出ていることを確認する - ブラウザを更新しても変更した状態が保持されることを確認する
9-4. 削除を確認する
Section titled “9-4. 削除を確認する”- 任意の行の削除ボタンを押す
- Network タブに
DELETE /api/books/{id}が204で出ていることを確認する - サーバーログに
delete from books ...が出ていることを確認する - ブラウザを更新しても削除した本が戻ってこないことを確認する
9-5. サーバー再起動してもデータが残ることを確認する
Section titled “9-5. サーバー再起動してもデータが残ることを確認する”これが永続化の本質である。
- バックエンドを
Ctrl+Cで止める ./gradlew bootRunで再起動する- ブラウザを更新する
- 再起動前に登録した書籍がそのまま表示される ことを確認する
再起動前 → データあり ↓ Ctrl+C → 再起動再起動後 → データあり ← 永続化できている証拠よくある失敗
Section titled “よくある失敗”| 失敗 | 原因 | 確認ポイント |
|---|---|---|
| テーブルが作られない | @Entity 漏れ、設定不足 | Entity と ddl-auto を確認する |
| 保存できない | 制約違反、接続設定ミス | 例外メッセージと SQL ログを見る |
id が入らない | @Id / @GeneratedValue の設定不備 | Entity の主キー設定を見る |
404 ではなく 500 になる | 見つからないケースを例外変換していない | Service と例外処理を見る |
| 更新しても反映されない | 取得対象が違う、保存していない | 対象 ID と save 呼び出しを確認する |
1. サーバーログと SQL ログを両方見る
Section titled “1. サーバーログと SQL ログを両方見る”- 例外名は何か
- INSERT / SELECT / UPDATE / DELETE は出ているか
- 想定したタイミングで SQL が発行されているか
2. curl と DB の両面から確認する
Section titled “2. curl と DB の両面から確認する”API は成功しているか? ↓DB に実際に保存されているか?この2つを分けて見ると、Controller 側の問題か DB 側の問題かを整理しやすい。
3. JPA が隠している処理を意識する
Section titled “3. JPA が隠している処理を意識する”JPA は便利だが、裏で SQL が出ている。
その意識を持つと、第5章で学んだ DB 知識とつながる。
| キーワード | 説明 |
|---|---|
| 永続化 | アプリ再起動後もデータを残すこと |
@Entity | テーブルへ保存する対象を表すクラス |
@Id | 主キーを表す注釈 |
@GeneratedValue | 主キー値を自動採番する設定 |
| Repository | Entity の CRUD を担当する部品 |
| JPA / Hibernate | Java オブジェクトと DB を橋渡しする仕組み |
| SQL ログ | JPA が実際に発行した SQL を確認する手段 |
次のステップ
Section titled “次のステップ”演習問題 に取り組んで、Entity・Repository・SQL ログの見方を整理しよう。
その後は 10-7. 第10章 総括 へ進もう。