AIによる要約
書き込みトランザクションで管理対象Entityを変更すると、フラッシュで変更がDBへ送られ、コミットで確定します。更新されない場合は、Serviceに有効な@Transactionalがあるか、変更したEntityが管理対象か、readOnlyになっていないか、例外でロールバックしていないかを確認します。saveを追加して隠す前に、取得から変更、コミットまでが同じトランザクションかを追うことが重要です。


Spring Data JPAを使い始めると、Repositoryで取得したEntityを書き換えただけでUPDATEされるコードに戸惑います。逆に、その仕組みを知らないまま動かない処理へsaveを足し続けると、根本原因が残ります。
JPAの更新を理解する鍵は「setterを呼んだか」ではなく、「そのEntityが管理対象で、トランザクションがコミットされたか」です。この記事では、注文ステータス更新を例に、現場での確認順を説明します。
この記事のポイント
- 管理対象Entityの変更はダーティチェックでUPDATEされる
- 取得・変更・コミットを同じトランザクションに入れる
- detached Entityは変更しても自動更新されない
- flushはコミットではなく、DBとの同期を早める操作
- saveを追加する前にSQLとトランザクションを確認する
実行用サンプル
同じ注文に対し、Serviceのトランザクション有無とflush後の例外を切り替え、DBから取り直した状態を比較します。 JDK 17とMaven 3.9系を用意し、ZIPを展開したフォルダで mvn -Dtest=OrderServiceIntegrationTest test を実行すると、この記事の3ケースを確認できます。ZIPにはソース全文・テスト・pom.xml・READMEを収録しています。
ダーティチェックで更新される基本形
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
public void ship(long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.ship();
}
}この例ではsaveを呼んでいません。それでも、同じトランザクション内でRepositoryから取得したOrderは管理対象です。order.ship()で状態が変わると、コミット前にJPAが変更を検出し、UPDATE SQLを発行します。これがダーティチェックです。
update orders set status = ?, updated_at = ? where id = ?;
更新処理をRepositoryのsave呼び出し回数だけで理解せず、永続化コンテキストがEntityを追跡していると考えます。
原因1:@Transactionalが有効になっていない
Serviceにトランザクションがなく、Repositoryの取得処理が終わった時点でEntityが管理対象から外れると、その後の変更は自動更新されません。
@Service
public class OrderService {
public void ship(long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.ship(); // この時点で管理対象とは限らない
}
}
更新ユースケース全体をServiceのpublicメソッドで囲む形が分かりやすいです。Controllerやprivateメソッドへ場当たり的に付けず、どこからどこまでを一つの業務処理として成功・失敗させるかを考えます。
同じクラス内の呼び出しでは@Transactionalが効かないことがある
@Service
public class OrderService {
public void execute(long orderId) {
ship(orderId);
}
@Transactional
public void ship(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.ship();
}
}
Springの一般的なプロキシ方式では、外部のBeanからプロキシを経由して呼ばれたメソッドへトランザクション処理が適用されます。同じインスタンス内でshipを直接呼ぶ自己呼び出しでは、期待したトランザクションが始まらないことがあります。
対策として、外部から呼ぶpublicなユースケースメソッドへ@Transactionalを付ける、または責務を別Serviceへ分けます。自己注入で無理にプロキシを通すより、呼び出し構造を単純にする方が新人にも保守しやすいです。
配布ZIPの作業用コピーで、同じクラス内から呼ぶ場合を試します。 src/main/java/info/syachiku/audit/OrderService.javaに、@Transactionalを付けずにpublic void shipFromSameService(long orderId) { ship(orderId); }を追加します。元のshipのアノテーションは残します。次にsrc/test/java/info/syachiku/audit/OrderServiceIntegrationTest.javaで、service.ship(1)の呼び出しだけをservice.shipFromSameService(1)へ変えます。
pom.xmlのある場所でmvn -Dtest=OrderServiceIntegrationTest testを実行すると、コミット後の値を確認する1件がexpected: <SHIPPED> but was: <WAITING>で失敗します。テストから呼ぶ入口shipFromSameServiceにも@Transactionalを付け、同じコマンドを実行すると3件とも成功します。直接呼び出したshipでは有効だった設定が、同じクラス内から呼ぶ形では入口に必要になることを確かめられます。
原因2:Entityが管理対象ではない
次のようなEntityはdetached、つまり永続化コンテキストから外れた状態になり得ます。
- 別トランザクションで取得し、後から変更したEntity
- 画面やJSONからID付きで組み立て直したEntity
- EntityManagerをclearした後のEntity
- 長時間セッションやキャッシュへ保持していたEntity
// NGになりやすい例: 画面の値からEntityを新しく作り直す Order order = new Order(); order.setId(form.getId()); order.setStatus(form.getStatus()); // setterを呼んでも、このインスタンスは自動追跡されていない
更新処理ではIDから管理対象Entityを取得し、変更を許可する項目だけを反映する方が安全です。画面の全項目をEntityへ丸ごとコピーすると、更新不可項目や他ユーザーの値まで上書きする危険があります。
@Transactional
public void update(long orderId, OrderUpdateCommand command) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.changeDeliveryDate(command.deliveryDate());
order.changeNote(command.note());
}更新では「画面からEntityを作る」のではなく、「管理対象Entityを取得し、許可した変更を適用する」と考えると事故を減らせます。
原因3:readOnlyトランザクションになっている
@Service
@Transactional(readOnly = true)
public class OrderService {
// 更新メソッドだけreadOnlyを外す
@Transactional
public void ship(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.ship();
}
}
この例では、外側にトランザクションがない状態で、別のBeanからSpringのプロキシ経由でshipを呼びます。メソッド側の@Transactionalがクラスの指定に優先し、書き込みトランザクションを開始します。すでに外側のreadOnly = trueのトランザクションがあり、既定のREQUIREDで参加する場合は、内側の指定だけでは書き込みに切り替わりません。どの呼び出し元でトランザクションを開始したかを確認します。Spring Frameworkのトランザクション伝播を参照してください。
readOnlyは最適化のためのヒントです。Hibernateでは読み取り専用トランザクションでフラッシュモードがMANUALになり、ダーティチェックが省略されます。実際の書き込み可否はDBや設定にも依存します。Spring Data JPAのトランザクション設定を参照してください。
原因4:例外でロールバックされている
UPDATE SQLがログに出ていても、その後にRuntimeExceptionがトランザクションの境界を越えると、Springの既定設定ではロールバックされ、変更はDBに残りません。
@Transactional
public void ship(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.ship();
notificationClient.notifyShipped(orderId); // ここで例外
}
「SQLが出たから更新済み」と判断せず、同じリクエストの最後までログを読みます。例外をcatchしてログだけ出し、外側でロールバックされているケースもあります。トランザクション完了と実際のDB値を確認してください。
flushとcommitの違い
flushは永続化コンテキストの変更をDBへ同期する操作です。同期後もトランザクションは継続し、コミットで変更が確定します。フラッシュ後でも、ロールバックすればそのトランザクションで行った変更は残りません。
@Transactional
public void ship(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.ship();
orderRepository.flush(); // SQL発行を早める
// ここで例外なら、通常は更新もロールバックされる
}
flushは、DB制約違反を処理途中で検出したい場合などに使います。更新されない問題へ無条件で追加するものではありません。SQL発行のタイミングが変わるだけで、トランザクション設計の不備は直りません。
saveはいつ必要か
| 場面 | 考え方 |
|---|---|
| 新規Entityを登録 | saveで永続化する |
| 同じトランザクションで取得した管理対象Entityを変更 | ダーティチェックで更新できる |
| detached Entityを再び扱う | 設計を確認したうえでmerge相当の処理を検討 |
| 単に更新されないから追加 | 原因を隠すため避ける |
Spring Data JPAのsaveは、新規ならpersist、既存ならmerge相当の処理を選びます。mergeでは渡したインスタンスそのものではなく、状態を反映した管理対象インスタンスが返る点にも注意が必要です。新人のうちは、更新ユースケースを同一トランザクション内の取得と変更で組む方が追いやすいです。
JPQLの一括更新後はEntityの値に注意する
@Modifying(clearAutomatically = true)
@Query("""
update Order o
set o.status = :status
where o.id = :orderId
""")
int updateStatus(long orderId, OrderStatus status);このJPQLの一括更新メソッドは、書き込みトランザクション内で呼び出します。DBの行は更新されますが、すでに取得した管理対象Entityの値には反映されません。同じ永続化コンテキストでfindByIdを呼び直すだけでは、古い値が返ることがあります。更新後の値が必要なら、永続化コンテキストをクリアしたうえで取得し直します。この例のclearAutomatically = trueは更新直後にクリアする指定です。未フラッシュの変更があると失われるため、クリア前に同期が必要かも確認します。Spring Data JPAの更新クエリを参照してください。
大量更新には有効ですが、Entityの更新メソッドや監査項目処理を通らない可能性があります。件数が多いからという理由だけで置き換えず、影響する処理を確認します。
更新されない時の確認順
- UPDATE SQLが発行されているか確認する
- 更新ユースケースに有効な@Transactionalがあるか確認する
- 同じトランザクション内でEntityを取得・変更しているか確認する
- readOnlyになっていないか確認する
- 後続例外でロールバックしていないか確認する
- 画面からEntityを作り直していないか確認する
- bulk update後に古いEntityを見ていないか確認する
- テストがトランザクション終了前の値だけを見ていないか確認する
結合テストでコミット後の状態を確認する
OSIVを無効にし、テスト側のトランザクションも設定しません。Serviceのトランザクション外では戻り値が SHIPPED でも、別のRepository呼び出しで取り直した値は WAITING です。正常コミットでは SHIPPED になります。明示的にフラッシュした後、IllegalStateExceptionを投げるケースでは、HibernateのEntity更新件数が1件でも、再取得結果は WAITING です。
OrderService.java の全文
package info.syachiku.audit;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository repository;
OrderService(OrderRepository repository) {
this.repository = repository;
}
public OrderStatus shipWithoutTransaction(long orderId) {
Order order = repository.findById(orderId).orElseThrow();
order.ship();
return order.getStatus();
}
@Transactional
public void ship(long orderId) {
Order order = repository.findById(orderId).orElseThrow();
order.ship();
}
@Transactional
public void shipAndFailAfterFlush(long orderId) {
Order order = repository.findById(orderId).orElseThrow();
order.ship();
repository.flush();
throw new IllegalStateException("通知に失敗しました");
}
}OrderServiceIntegrationTest.java の全文
package info.syachiku.audit;
import jakarta.persistence.EntityManagerFactory;
import java.math.BigDecimal;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderRepository repository;
@Autowired
private OrderService service;
@Autowired
private EntityManagerFactory entityManagerFactory;
@BeforeEach
void prepareWaitingOrder() {
repository.deleteAll();
repository.saveAndFlush(new Order(1L, null, BigDecimal.ONE));
}
@Test
void トランザクション外ではJavaの値だけ変わる() {
assertEquals(OrderStatus.SHIPPED, service.shipWithoutTransaction(1));
assertEquals(OrderStatus.WAITING, reloadStatus());
}
@Test
void 同じトランザクション内で変更するとコミット後も残る() {
service.ship(1);
assertEquals(OrderStatus.SHIPPED, reloadStatus());
}
@Test
void flushでUPDATEした後も例外でロールバックされる() {
var stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.clear();
assertThrows(IllegalStateException.class, () -> service.shipAndFailAfterFlush(1));
assertEquals(1, stats.getEntityUpdateCount());
assertEquals(OrderStatus.WAITING, reloadStatus());
}
private OrderStatus reloadStatus() {
return repository.findById(1L).orElseThrow().getStatus();
}
}このテストが動かすのは、上に掲載したOrderServiceの3メソッドです。自己呼び出し、readOnly、一括更新のコードは、別の原因を切り分けるための説明例です。
現場レビューでよくある指摘
// レビューコメント例 更新されない対策としてsaveを追加していますが、 Serviceにトランザクションがなく取得後にdetachedになっています。 取得から変更までを同じ@Transactionalへ入れてください。 // レビューコメント例 画面から受け取ったEntityをそのままsaveしているため、 更新不可項目まで上書きできます。IDで取得して許可項目だけ変更してください。 // レビューコメント例 UPDATE SQLは出ていますが、その後の通知処理で例外になりロールバックしています。 同じリクエストの末尾までログを確認してください。
提出前のセルフチェック
- 更新ユースケースをServiceの@Transactionalで囲んだか
- 取得した管理対象Entityへ変更を適用しているか
- readOnlyの上書き漏れがないか
- 自己呼び出しで@Transactionalを迂回していないか
- 更新後に例外でロールバックしていないか
- 不要なsaveやflushで原因を隠していないか
- bulk update後の永続化コンテキストを整理したか
- 結合テストで再取得したDB状態を確認したか
SpringとJPAの更新処理を深く学ぶ参考書
トランザクション、永続化コンテキスト、Repositoryの関係を体系的に学ぶと、saveの有無だけに頼らず更新処理を説明できるようになります。
Spring徹底入門 第2版
アプリを作った後に、DIやDB更新の仕組みを調べる。
DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。
- Springで小さなWebアプリを作ったことがある
- DIやトランザクションの仕組みを確認したい
Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
書き込みトランザクション内で管理対象Entityを変更すると、ダーティチェックで変更が検出され、フラッシュ時にDBへ同期されます。更新されないときは、save不足より先にトランザクションとEntityの状態を確認します。
UPDATE SQL、@Transactional、管理対象、readOnly、ロールバックの順で追い、画面からEntityを作り直していないかも確認してください。原因を説明できる形で直し、再取得したDB状態を結合テストで検証しましょう。



