コンテンツにスキップ

10-6. JPA/Hibernateでデータを永続化する

  • メモリ上のデータと 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 で設定

10-6 は、設定を書く → サーバーを再起動する → curl と SQL ログで確認する という流れで進めると理解しやすい。

このセクションでは、各実装段階の末尾に「ここで確認する」を置く。毎回、次の順で進めよう。

  1. 指定された設定や Java ファイルを追加・修正する
  2. 保存する
  3. ./gradlew bootRun を実行中ならいったん止め、再起動する
  4. curl -i とサーバーログを見比べる
  5. 必要なら再起動後もデータが残るかを確認する

見た目が変わらない段階もある。
その場合は、Explorer・Problems・サーバーログで「永続化の土台ができたか」を確認できればよい。


メモリ上の List<Book> は、アプリが動いている間だけ有効である。

サーバー起動中 -> データあり
サーバー再起動 -> 消える

書籍管理アプリとしては、登録した本が再起動後も残っていてほしい。
そのためには、メモリではなく DB に保存する必要がある。


学習用としては、H2 を使うと始めやすい。
application.properties には次のような設定を置ける。

追加する場所: src/main/resources/application.properties に書く(新規ファイル)。

application.properties
spring.application.name=bookmanager
spring.datasource.url=jdbc:h2:file:./data/bookapp
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
  • セットアップが軽い
  • Spring Boot と相性が良い
  • 学習段階で JPA の流れに集中しやすい

ここで大切なのは、DB 製品名そのものよりも、Java オブジェクトとテーブルの橋渡しがどう行われるか を理解することである。

  1. src/main/resources/application.properties を保存する
  2. ターミナルで ./gradlew bootRun を実行する
  3. Tomcat started on port 8080 が出ることを確認する

まだ画面変化はなくてよい。ここでは DB へ接続する設定が読み込まれたか を確認する段階である。


JPA では、テーブルへ保存する対象を Entity として表す。

BookEntity.java
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 は DB 保存の都合を持つ。
一方 DTO は API の入出力の都合を持つ。
この2つは似て見えても責務が違うため、分けたほうが変更に強い。

  1. BookEntity.java を保存したら、サーバーを再起動する
  2. 起動ログにテーブル作成や更新に関する SQL が出ていないかを見る
  3. もし既に同じ DB ファイルを使っていて create table が目立たなくても、エラーなく起動できればよい

ここで見たいのは、Entity 定義が JPA に認識されているか である。


Repository は、Entity の読み書きを担当する部品である。

BookRepository.java
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> {
}

Spring Data JPA は、JpaRepository を継承した interface から基本的な CRUD 処理を用意してくれる。
そのため、一覧取得・ID 検索・保存・削除の多くを自分で最初から書かなくてよい。

  • 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 セクションに以下を追加する。

BookService.java
import com.example.bookmanager.entity.BookEntity;
import com.example.bookmanager.repository.BookRepository;

変更する場所: コンストラクタを BookRepository を受け取る形へ置き換える。bookssequence は、この時点ではまだ残してよい。

BookService.java
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;
}
  1. BookService.java を保存したら、サーバーを再起動する
  2. 起動時にエラーが出ていないことを確認する
  3. まだ API の見た目が変わらなくてもよい

ここでは、Service が Repository を受け取れる形になったか を確認する。

5-2. findAll()findById() を Repository 版へ切り替える

Section titled “5-2. findAll() と findById() を Repository 版へ切り替える”

変更する場所: findAll()findById() を赤い行から緑の行へ置き換え、toResponse()getBookEntityOrThrow() を追加する。import に BookEntity も追加する。

BookService.java
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));
}
  1. BookService.java を保存したら、1 本目のターミナルで Ctrl+C を押してサーバーを止め、./gradlew bootRun で再起動する
  2. 起動ログに Started BookManagerApplication が出たことを確認する
  3. 2 本目のターミナルで次を実行する
Terminal window
curl -i http://localhost:8080/api/books
  1. HTTP/1.1 200 が返ることを確認する
  2. サーバーログに select ... from books ... が出ていることを確認する
  3. DB がまだ空なら、レスポンス本文が [] でもよい

ここで、読む処理だけが先に DB へ切り替わった ことを確認できる。


6. 登録 (POST) を DB ベースへ切り替える

Section titled “6. 登録 (POST) を DB ベースへ切り替える”

次に、書籍追加も DB へ保存するようにする。

変更する場所: create() を赤い行から緑の行へ置き換える。

BookService.java
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);
}

通常は ./gradlew bootRun を実行している VS Code のターミナルへ表示される。
「API を叩いた直後にどの SQL が出たか」を時系列で追うのがコツである。

  • insert into ... が出た → save() による登録が走った
  • select ... が出た → 一覧取得や詳細取得が走った
  • update ... が出た → 状態更新が DB へ反映された

SQL の ? はプレースホルダで、実際の値は別扱いで安全にバインドされる。
最初は値そのものよりも、「今どの種類の SQL が出たか」を読むだけで十分である。

  1. BookService.java を保存したら、サーバーを再起動する(Ctrl+C./gradlew bootRun
  2. 起動ログに Started BookManagerApplication が出たことを確認する
  3. 次の POST を実行する
Terminal window
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"JPA実践入門","author":"研修 太郎","category":"バックエンド","price":3200,"status":"未読","memo":"SQLログ確認用"}'
  1. HTTP/1.1 201 が返ることを確認する
  2. サーバーログに insert into books ... が出ていることを確認する
  3. 続けて curl -i http://localhost:8080/api/books を実行し、追加した本が一覧へ含まれることを確認する
  4. そのときサーバーログに select ... from books ... が出ていることも確認する

ここでは、登録も DB を経由しているか を見る。


7. 状態更新 (PATCH) を DB ベースへ切り替える

Section titled “7. 状態更新 (PATCH) を DB ベースへ切り替える”

状態更新では、対象 Entity を取得し、status を書き換えて保存する。

変更する場所: 既存の update() を削除し、緑の update() へ置き換える。

BookService.java
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 に対する存在確認や永続化タイミング を意識する必要がある点である。

  1. BookService.java を保存したら、サーバーを再起動する(Ctrl+C./gradlew bootRun
  2. 起動ログに Started BookManagerApplication が出たことを確認する
  3. 次を実行する
Terminal window
curl -i -X PATCH http://localhost:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"status":"読了"}'
  1. HTTP/1.1 200 と更新後データが返ることを確認する
  2. サーバーログに update books ... が出ていることを確認する
  3. 続けて 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() へ置き換える。

BookService.java
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);
}
  1. BookService.java を保存したら、サーバーを再起動する(Ctrl+C./gradlew bootRun
  2. 起動ログに Started BookManagerApplication が出たことを確認する
  3. 次を実行する
Terminal window
curl -i -X DELETE http://localhost:8080/api/books/1
  1. HTTP/1.1 204 が返ることを確認する
  2. サーバーログに delete from books ... が出ていることを確認する
  3. 最後に一覧取得を行い、削除した本が含まれていないことを確認する

8-2. 使わなくなったメモリ保存用コードを片付ける

Section titled “8-2. 使わなくなったメモリ保存用コードを片付ける”

ここまで来ると、bookssequencegetBookOrThrow()、それに対応する Book / Map / LinkedHashMap / AtomicLong の import は不要になる。赤い行を削除する。

BookService.java
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;
}
  1. BookService.java を保存してサーバーを再起動する
  2. 起動エラーがないことを確認する
  3. GET / POST / PATCH / DELETE を 1 回ずつ軽く試し、すべて DB ベースで動くことを確認する

これで、登録・取得・更新・削除のすべてが DB ベースで動いている状態 になる。


9. ブラウザ画面で通しで動かす

Section titled “9. ブラウザ画面で通しで動かす”

curl での確認が終わったら、フロントエンドを開いて画面越しに操作し、サーバーログと Network タブを照らし合わせながら DB との連携を体感する。

  1. バックエンド(./gradlew bootRun)が起動していることを確認する
  2. 別のターミナルで book-frontend/ へ移動し、フロントエンドサーバーを起動する
Terminal window
cd book-frontend
npx --yes serve . --listen 4173
  1. ブラウザで http://localhost:4173 を開く
  2. DevTools の Network タブConsole タブ を開いておく
  1. ブラウザを更新する
  2. Network タブに GET /api/books200 で出ていることを確認する
  3. サーバーログに select ... from books ... が出ていることを確認する
  4. 画面の書籍テーブルに DB の内容が表示されることを確認する
  1. フォームにタイトル・著者・カテゴリ・価格・状態・メモを入力して「書籍を追加」を押す
  2. Network タブに POST /api/books201 で出ていることを確認する
  3. サーバーログに insert into books ... が出ていることを確認する
  4. テーブルに新しい行が追加されることを確認する
  5. ブラウザを更新しても、追加した本が残っている ことを確認する(メモリではなく DB に保存されている証拠)
  1. 一覧の状態欄の select を変更する
  2. Network タブに PATCH /api/books/{id}200 で出ていることを確認する
  3. サーバーログに update books ... が出ていることを確認する
  4. ブラウザを更新しても変更した状態が保持されることを確認する
  1. 任意の行の削除ボタンを押す
  2. Network タブに DELETE /api/books/{id}204 で出ていることを確認する
  3. サーバーログに delete from books ... が出ていることを確認する
  4. ブラウザを更新しても削除した本が戻ってこないことを確認する

9-5. サーバー再起動してもデータが残ることを確認する

Section titled “9-5. サーバー再起動してもデータが残ることを確認する”

これが永続化の本質である。

  1. バックエンドを Ctrl+C で止める
  2. ./gradlew bootRun で再起動する
  3. ブラウザを更新する
  4. 再起動前に登録した書籍がそのまま表示される ことを確認する
再起動前 → データあり
↓ Ctrl+C → 再起動
再起動後 → データあり ← 永続化できている証拠

失敗原因確認ポイント
テーブルが作られない@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主キー値を自動採番する設定
RepositoryEntity の CRUD を担当する部品
JPA / HibernateJava オブジェクトと DB を橋渡しする仕組み
SQL ログJPA が実際に発行した SQL を確認する手段

演習問題 に取り組んで、Entity・Repository・SQL ログの見方を整理しよう。

その後は 10-7. 第10章 総括 へ進もう。