Spring Data JPAでEntityが更新されない原因を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアがトランザクションとUPDATEを学ぶイラスト。

Spring

Spring Data JPAでEntityを変更しても更新されない原因|@Transactionalとダーティチェック

AIによる要約

JPAでは、トランザクション内で取得した管理対象Entityを変更すると、コミット時のダーティチェックでUPDATEされます。更新されない場合は、Serviceに有効な@Transactionalがあるか、変更したEntityが管理対象か、readOnlyになっていないか、例外でロールバックしていないかを確認します。saveを追加して隠す前に、取得から変更、コミットまでが同じトランザクションかを追うことが重要です。

新人SE
新人SE
Entityのsetterは呼ばれ、デバッガー上の値も変わっています。それなのにDBを見ると更新されていません。saveを書き忘れたのでしょうか?
ポンコツSE
ポンコツSE
管理対象Entityなら、明示的なsaveがなくても更新されます。まずトランザクションとEntityの状態を確認しましょう。

Spring Data JPAを使い始めると、Repositoryで取得したEntityを書き換えただけでUPDATEされるコードに戸惑います。逆に、その仕組みを知らないまま動かない処理へsaveを足し続けると、根本原因が残ります。

JPAの更新を理解する鍵は「setterを呼んだか」ではなく、「そのEntityが管理対象で、トランザクションがコミットされたか」です。この記事では、注文ステータス更新を例に、現場での確認順を説明します。

この記事のポイント

  • 管理対象Entityの変更はダーティチェックでUPDATEされる
  • 取得・変更・コミットを同じトランザクションに入れる
  • detached Entityは変更しても自動更新されない
  • flushはコミットではなく、DBとの同期を早める操作
  • saveを追加する前にSQLとトランザクションを確認する

ダーティチェックで更新される基本形

@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へ分けます。自己注入で無理にプロキシを通すより、呼び出し構造を単純にする方が新人にも保守しやすいです。

原因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();
    }
}

クラス全体をreadOnly = trueにしている案件では、更新メソッド側で上書きし忘れることがあります。readOnlyの具体的な最適化や書き込み動作はJPA実装・DB構成にもよりますが、更新処理をreadOnlyのままにしないことが基本です。

原因4:例外でロールバックされている

UPDATE SQLがログへ出ていても、後続処理で例外が起きればトランザクション全体がロールバックされ、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へ同期する操作です。トランザクションを確定するcommitとは違います。flush後に例外が起きればロールバックできます。

@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のbulk updateは、管理対象Entityを1件ずつ変更せずDBを直接更新します。そのため、同じ永続化コンテキストに古いEntityが残ることがあります。clearAutomaticallyを使うか、更新後のEntityを再取得するなど、既存ルールへ揃えます。

大量更新には有効ですが、Entityの更新メソッドや監査項目処理を通らない可能性があります。件数が多いからという理由だけで置き換えず、影響する処理を確認します。

更新されない時の確認順

  1. UPDATE SQLが発行されているか確認する
  2. 更新ユースケースに有効な@Transactionalがあるか確認する
  3. 同じトランザクション内でEntityを取得・変更しているか確認する
  4. readOnlyになっていないか確認する
  5. 後続例外でロールバックしていないか確認する
  6. 画面からEntityを作り直していないか確認する
  7. bulk update後に古いEntityを見ていないか確認する
  8. テストがトランザクション終了前の値だけを見ていないか確認する

結合テストでコミット後の状態を確認する

@SpringBootTest
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void 出荷処理でDBの状態がSHIPPEDになる() {
        long orderId = prepareWaitingOrder();

        orderService.ship(orderId);

        Order reloaded = orderRepository.findById(orderId).orElseThrow();
        assertEquals(OrderStatus.SHIPPED, reloaded.getStatus());
    }
}

テストメソッド自体へ@Transactionalを付けると、同じ永続化コンテキストのキャッシュを見てDB再取得にならないことがあります。永続化の確認が目的なら、必要に応じてEntityManagerをflush・clearする、テスト側のトランザクション境界を分けるなど、何を検証しているかを明確にします。

現場レビューでよくある指摘

// レビューコメント例
更新されない対策として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版」の表紙
商品画像:Amazon.co.jp
PR Springを体系的に学ぶ若手向け

Spring徹底入門 第2版

株式会社NTTデータ (著), 株式会社NTTデータグループ (編集)

Spring Frameworkの主要機能を、実務レベルで整理する。

DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。ピンポイント検索から体系学習へ進みたい人向けです。

  • Springの全体像を整理したい
  • 現場で使う機能を体系的に学びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

JPAでは、トランザクション内で取得した管理対象Entityを変更すると、ダーティチェックによりコミット時にUPDATEされます。更新されないときは、save不足より先にトランザクションとEntityの状態を確認します。

UPDATE SQL、@Transactional、管理対象、readOnly、ロールバックの順で追い、画面からEntityを作り直していないかも確認してください。原因を説明できる形で直し、再取得したDB状態を結合テストで検証しましょう。

-Spring
-, , , , , ,