スピード重視で調査したため、誤記や解釈違いはすみません。
現象
java17 JPA Spring Boot で@Transactionalが付与されているサービスクラス内で、save()メソッドを実行していないにも関わらず、データが更新されてしまった。
DBアクセスをするJPAのリポジトリクラス内で定義されている@Tableが付与されたEntityクラスにset()が実行されているのみ。
まず結論
- 前提①:@Transactionalを付与
- 前提②:findBy(XXXX)とか、CRUD操作を行った戻り値の変数(Entitiy)がその後set(XXXX)をして変更を加える(そのEntityインスタンスが**永続化コンテキスト(Persistence Context)に管理されている状態)
※ただただnew した変数(Entitiy)にset(XXXX)したものは対象外(永続化コンテキストの監視対象外)
※変数が後続処理でメソッドに参照渡しをしたときは要注意。 - 前提の結果どうなる?:save()をしなくてもトランザクションが終了時にset(XXXX)したテーブルの変更がコミットされる。
- 対処は?:前提②での変数にset(XXXX)するな。型を保持したまま使いたいのなら別のデータクラス(DTO)を用意してmodelmapperで良しなに変換してくれ。
この結論に至った経緯は以降で記す。
なぜ
この現象は、@Transactional の伝播と、自動変更検知(Dirty Checking)の仕組みが関係している。
save()を呼び出していなくても、エンティティの状態が変更されるとコミット時に変更部分が反映されることがある
JPAの自動変更検知(Dirty Checking)は、トランザクション内でエンティティのフィールド値が変更された場合、それを検知して自動的にUPDATEクエリを発行します
詳細なポイント
エンティティ取得後の変更検知
例えば、エンティティをfind()やgetOne()で取得し、その後set()で値を変更しただけでも、トランザクションが終了してコミットされるタイミングで自動的に変更が反映されることがあります。
save()メソッドは明示的な保存のためのものだが、必須ではない
save()は新規作成や明示的な更新に使われますが、既にmanaged状態のエンティティに対してset()を行い、そのままトランザクションが終了すると、変更は自動的に保存されます。
@Transactionalの伝播
そのクラスやメソッドに付与された@Transactionalは、その範囲内のすべての操作を一つのトランザクションに包みます。その中でエンティティの状態を変更し、トランザクションの終了時に自動的に変更がコミットされる仕組みになっています。
どうすればコントロールできるか
必要に応じてEntityManager#clear()やdetach()を使って、エンティティの管理状態を解除すると良いです
あるいは、変更を反映させたくない場合は、変更を避けるか、エンティティのコピーを使うことを検討してください
例
@Transactional
public void updateEntity(Long id) {
MyEntity entity = repository.findById(id).orElseThrow();
entity.setSomeField("new value"); // 自動的にDirty CheckingでUPDATEに反映
// save()を書かなくてもコミット時に反映される
}
補足
もし、意図せずに変更が検知されてしまう場合は、エンティティを明示的にdetach()するか、トランザクション内での変更を追跡・記録しておく必要があります。
なぜ save() してないのに更新される?
JPA(Hibernate)は**エンティティの「状態管理」**を行っています。
つまり:
@Transactional スコープ内で取得されたエンティティは「永続化コンテキスト(Persistence Context)」に紐づけられ、フィールドに set() をすると dirty checking(変更検知) により、トランザクション終了時に flush() が自動実行され、SQL UPDATE が走ります。
つまり:
@Transactional
public void updateSomething() {
Entity entity = repository.findById(id).orElseThrow();
entity.setName("new name"); // これだけでUPDATEされる
// save() しなくてもOK
}
疑問点1.単純にEntitiyクラスをnew してset()するだけで更新される?
Entityクラスを単に new して set() しただけでは、JPA(Hibernate)は何も更新しません。
更新されるためには、そのEntityインスタンスが**永続化コンテキスト(Persistence Context)に管理されている状態(= managed)**である必要があります。
状態の違い(JPA Entityのライフサイクル)
状態 | 説明 |
---|---|
new(transient) | new で生成しただけ。JPAは何も知らない |
managed(永続) | find() や save() で得られたインスタンス。JPAが監視中 |
detached(分離) | 一度 managed だったが detach() などで切り離された |
removed | 削除予定としてマークされた状態(削除はトランザクション終了時) |
例:new して set() → 更新されるか?
@Transactional
public void updateUserName(Long id, String name) {
UserEntity entity = new UserEntity(); // ← これは new(transient)
entity.setId(id);
entity.setName(name);
// → ここではDB更新は発生しない
}
これでは Hibernate はこの entity を追跡していない(管理していない)ので、何も更新されません。
更新を反映させる方法
- 方法①: findById() で取得した managed entity に対して set()(変更検知)
@Transactional
public void updateUser(Long id, String name) {
UserEntity entity = userRepository.findById(id).orElseThrow(); // managed
entity.setName(name); // dirty checking → トランザクション終了時にUPDATE発行
}
- 方法②: new + save() で merge() を使う(上書き)
@Transactional
public void updateUser(Long id, String name) {
UserEntity entity = new UserEntity();
entity.setId(id);
entity.setName(name);
userRepository.save(entity); // ← merge: 存在するIDならUPDATE
}
ただし、既存のデータを「丸ごと上書き」するため、nullが入ってると元のカラムがnullになる点に注意。
結論
コード | DBに反映されるか |
---|---|
new Entity(); set(); | ❌更新されない |
repository.findById() → set() | ✅dirty checkingで更新される |
new Entity(); set(); repository.save() | ✅ mergeによる更新(上書き) |
new Entity(); set(); // saveなし | ❌何も起きない |
補足:意図しない save() 呼び出しがないか注意
時々、他の箇所で save() や flush() が自動で呼ばれてるケースもあるので、本当に save() されていないかを確認するために、SQLログ(show-sql: true)で確認すると確実です。
疑問点2.entityManager.clear() を最初に宣言しちゃう?
entityManager.clear(); // 永続化コンテキストのすべての管理対象をdetach(非管理化)
これを行うと、その後のset() はDBに反映されません(意図しない抑止になる)
他の @Transactional 内での変更にも影響を与える可能性があり、副作用が大きい
副作用について↓
ケース:@Transactional のメソッド内で複数のEntityに対して操作している
@Transactional
public void updateEntities() {
User user = userRepository.findById(1L).orElseThrow(); // managed
Order order = orderRepository.findById(10L).orElseThrow(); // managed
entityManager.clear(); // すべてdetachされる!
user.setName("新しい名前"); // ← もう反映されない(detachされた)
order.setStatus("SHIPPED"); // ← これも反映されない
}
→ このように、処理中のすべての Entity に対して「更新が効かなくなる」=副作用が大きい、という意味です。
❗ 意図しない副作用の具体例
パターン 問題点
clear() 後に set() を使っても DB更新されない バグになりやすい。開発者が「なぜ反映されないの?」と混乱
同一トランザクション内の他のロジックに影響 他のService・メソッドが使ってるEntityもdetachされてしまう
AOPやイベントで自動的に動いてる処理も影響 たとえば、@PreUpdate, @TransactionalEventListener などが発火しない
save() 時に別の新規INSERTが走ることも detachedされたEntityを save() すると、merge扱いになって思わぬINSERTが走ることがある(ID不一致時)
✅ 安全にしたいなら? clear() の代わりに detach(entity) を使う
entityManager.detach(specificEntity); // そのEntityだけ管理外に
これなら「そのEntityだけ反映されなくなる(副作用が小さい)」ので、限定的に使いたい場合に向いています。
✅ 結論まとめ
手段 | 影響範囲 | 注意点 |
---|---|---|
entityManager.clear() | 全てのmanaged Entityをdetach 非常に広範囲。 | 他の操作や自動イベントにも影響 |
entityManager.detach(entity) | 特定のEntityのみdetach | 副作用が小さく、安全に使いやすい |
💡補足:Spring + JPAで「副作用が目立たない」こともある
JPAのマジック(変更検知、遅延更新など)により、コード上はうまく動いてるように見えても、内部的に clear() によってバグの原因になっているケースが多いです。
そのため、Entityの状態遷移(managed/detached)を明示的に意識することが重要になります。
✅ 本質的な解決方法は?
- DTOを使って編集し、変換時にsave()を明示する
→ 意図しない更新を防げます
// DTOで一度受け取り
SomeDTO dto = service.fetchDto(id);
dto.setName("new name");
// 変換してsave
Entity entity = convert(dto);
repository.save(entity); // これで明示的に更新
- 読み取り専用の用途には @Transactional(readOnly = true) を使う
→ JPAはこの場合、変更検知をスキップするよう最適化されます
@Transactional(readOnly = true)
public Entity getEntity(Long id) {
return repository.findById(id).orElseThrow();
}
- 明示的に detach する(局所的)
Entity entity = repository.findById(id).orElseThrow();
entityManager.detach(entity); // このエンティティだけ非管理化
entity.setName("新しい名前"); // もうDBに影響なし
✅ 結論まとめ
方法 | メリット デメリット・注意点 |
---|---|
entityManager.clear() | 強制的に全てdetachできる 副作用が大きい、他の処理にも影響 |
DTOに分離してsave明示 | 意図しない更新防止 実装コストは少し増える |
@Transactional(readOnly=true | 読み取り専用に安全 書き込みが必要なケースには不向き |
entityManager.detach(obj) | 必要な部分だけ管理外に 管理がやや煩雑になることも |
💡補足:@Transactionalが付いていないとどうなる?
トランザクションがなければ flush() もされないため、変更はDBに反映されません。が、Spring Boot + JPAではほとんどの更新系メソッドに @Transactional が自動付与されるため、「トランザクションあり」は基本前提になります。
必要であれば、あなたのコードスニペットを見て具体的な対策を提案します!
疑問点3.modelmapperはどうよ
DTOに分離して意図的に save() する設計において、ModelMapper の利用は有力な選択肢ですが、メリット・デメリットのバランスを理解して使うのがポイントです。
ModelMapper とは何か
ModelMapper は Java のオブジェクトマッピングライブラリで、
DTO ⇔ Entity のプロパティ名が同じであれば自動マッピングしてくれる
UserDTO → UserEntity のような変換が一行で済む
UserEntity entity = modelMapper.map(userDto, UserEntity.class);
✅ ModelMapper のメリット
項目 | 2 |
---|---|
シンプル | ボイラープレートコード(getter/setter)を減らせる |
時短 | 複数のDTO/Entityで大量にマッピングがある場合は一括で済む |
双方向OK | DTO→Entity も Entity→DTO も簡単に変換できる |
Spring Bootとの相性 | Bean登録して DI すればすぐ使える |
⚠️ ModelMapper のデメリット・注意点
項目 | 内容 |
---|---|
自動マッピングの不透明性 | 内部でリフレクションしているため、バグが静かに起きやすい(誤ったプロパティ名、nullのマッピングなど) |
パフォーマンス | リフレクション使用のため、大量マッピング時は遅くなる可能性あり(特にループ内での使用に注意) |
複雑なマッピング対応 | ネスト構造やフィールド名が違う場合は カスタム設定が必要(自動マッピングの限界) |
テストしづらい | マッピングロジックが見えないため、不具合の原因が追いづらいことも |
ModelMapper を使うべきケース
✅ 向いている場面
- エンティティとDTOのフィールド名が一致していて、単純なデータ変換がほとんど
- マッピングクラスが多い(20〜30以上)など、開発効率を優先したい場面
- @Transactional 内部で安全に Entity を生成・更新したいが、ロジックはDTOに閉じたい
向いていない場面
- Entityにビジネスロジック(メソッド)が含まれている場合
- 異なるフィールド名やネスト構造が多く、カスタムマッピングが多発する場合
- デバッグしやすさ、可読性、明示性を重視したい場合
✅ 実運用でのベストプラクティス例
@Service
@RequiredArgsConstructor
public class UserService {
private final ModelMapper modelMapper;
private final UserRepository userRepository;
@Transactional
public void updateUser(UserDto dto) {
UserEntity entity = userRepository.findById(dto.getId())
.orElseThrow(() -> new RuntimeException("Not found"));
// DTOから既存Entityにマッピング(注意:これはmergeになる)
modelMapper.map(dto, entity); // dirty-checkingによりUPDATEされる
// repository.save(entity); ←これは不要
}
}
安全に使いたい場合
明示的にどのプロパティをマッピングするか指定する
カスタムマッピングの設定を ModelMapper に登録して使う
単純な変換は ModelMapper、複雑な変換は手書き or MapStruct に分離する
代替案: MapStruct(コンパイル時マッピング)
宣言的なコード生成(アノテーションでマッピング定義)
コンパイル時に型チェックできるため 安全性・パフォーマンスが高い
ただし、導入と学習コストはやや高い
✅ 結論
判断軸 | ModelMapperの適性 |
---|---|
開発スピード | ◎(爆速で開発可能) |
保守性・安全性 | △(ややブラックボックス) |
複雑な変換対応 | △(カスタムが面倒) |
パフォーマンス重視 | △〜×(大量データには不向き) |
→ 単純変換が多く、開発効率重視なら ModelMapper でOK。
ただし本番では「何が更新されているか」を明示したい場面では要注意。