業務システムの開発をしていると、ほぼ確実に「物理削除ではなく論理削除にしてほしい」という要件に出会いますよね。全クエリに WHERE deleted_at IS NULL を書き続けるのは現実的ではありません。
この記事では Spring Boot 3.x + Hibernate 6.4 + Lombok を前提に、@SQLDelete + @SQLRestriction と @FilterDef を使った透過的な論理削除の実装パターンを整理します。Hibernate 6.4 で追加された @SoftDelete との使い分けや、ユニーク制約との衝突、復元処理、削除者の記録など、実務でハマるところもあわせて見ていきましょう。
論理削除を選ぶ前に整理しておくこと
論理削除は「削除済みフラグ」を立てるだけで実データは残す方式です。誤削除からの復元、監査ログとの整合性、関連データ保護といった利点があります。
一方で、テーブルが肥大化しやすく、ユニーク制約と相性が悪く、インデックス設計を間違えるとパフォーマンスが落ちます。アクセスログのような追記専用テーブルは物理削除のままにする、といった使い分けが現実的です。
カラムは deleted boolean よりも deleted_at TIMESTAMP を推奨します。「いつ消したか」を後から追えるので、監査でも復元でも役に立ちます。PostgreSQL なら TIMESTAMP WITH TIME ZONE を選んでおくとタイムゾーン関連のトラブルを避けられます。
どのアノテーションを選ぶか
Hibernate 6 系では選択肢が増えました。先に整理しておきます。
@Whereは Hibernate 6.3 で deprecated になりました。後継は@SQLRestrictionで、機能はそのままアノテーション名が変わっただけです。@SoftDeleteは Hibernate 6.4 で導入された専用アノテーションで、boolean フラグ前提の振る舞いを自動生成します。新規プロジェクトで boolean 運用にできるなら最短ルートです。ただし削除日時を残せないため、監査要件がある場合は不向きです。- 「削除日時を残したい」「既存テーブル設計と合わせたい」現場では、
@SQLDelete + @SQLRestrictionの組み合わせがいまも堅実です。
新規で @SoftDelete を使うなら次のように書けます。
@Entity
@SoftDelete(columnName = "deleted", strategy = SoftDeleteType.DELETED)
public class Article { /* 省略 */ }
strategy を ACTIVE にすると真偽が逆転し、カラム名や値もカスタマイズできます。boolean 以外の運用にしたい場合は素直に @SQLDelete + @SQLRestriction を選んだほうが楽です。本記事は既存プロジェクトを想定して、以降は @SQLDelete + @SQLRestriction を中心に進めます。
エンティティの最小例
土台になる User エンティティです。Lombok の @Getter/@Setter を利用しています。
@Entity
@Table(name = "users")
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String name;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}
対応する DDL です(PostgreSQL 想定)。
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
deleted_at TIMESTAMP WITH TIME ZONE NULL
);
@SQLDelete + @SQLRestriction で透過化する
@SQLDelete(org.hibernate.annotations.SQLDelete)は Hibernate が発行する DELETE 文を、任意の SQL に置き換えるアノテーションです。@SQLRestriction(同パッケージ、旧 @Where)は SELECT 時に常に付与される WHERE 句を定義します。
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class User {
// 省略
}
ここで重要なのは、@SQLDelete の ? には Hibernate が 識別子(および @Version がある場合はバージョン)のみ を自動バインドする、という仕様です。deletedAt や deletedBy のような任意プロパティの値を ? に流し込むことはできません。SET 句にはリテラル(CURRENT_TIMESTAMP など)を直接書くか、後述する @Modifying UPDATE パターンに切り替えます。複合キーや楽観ロックを使うとバインド順や数が変わるので、spring.jpa.show-sql=true で実 SQL を確認してから定義してください。
userRepository.deleteById(1L) の実行ログは次のようになります。
update users set deleted_at = current_timestamp where id = ?
select u.id, u.email, u.name from users u where u.deleted_at is null and u.id = ?
@SQLRestriction は JPQL や Criteria API には適用される一方、ネイティブクエリには適用されません。これは復元用クエリを書くときに利用できる重要な性質です。
削除済みも見たいなら @FilterDef を併用する
@SQLRestriction は常時 ON で外せないため、管理画面で「削除済み一覧」を見たいときに困ります。動的に切り替えたいなら @FilterDef を併用します。
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@FilterDef(name = "activeOnly")
@Filter(name = "activeOnly", condition = "deleted_at IS NULL")
public class User { /* 省略 */ }
フィルタの有効化スコープは Hibernate Session 単位です(EntityManager は内部で Session を保持しています)。通常は同一トランザクション内で有効、OSIV を有効化していれば 1 リクエスト全体で有効、と捉えてください。
@Service
@RequiredArgsConstructor
public class UserQueryService {
private final EntityManager em;
private final UserRepository userRepository;
public List<User> findActiveUsers() {
em.unwrap(Session.class)
.enableFilter("activeOnly");
return userRepository.findAll();
}
public List<User> findAllIncludingDeleted() {
em.unwrap(Session.class).disableFilter("activeOnly");
return userRepository.findAll();
}
}
パラメータ付きの @FilterDef を使う場合は、enableFilter の戻り値に対して setParameter("name", value) でセットします。なお @Filter も @SQLRestriction と同様、JPQL/Criteria のみが対象で、ネイティブクエリには適用されません。復元や管理画面の要件がなければ @SQLRestriction だけで十分です。
ユニーク制約との衝突を回避する
論理削除で一番ハマるのがユニーク制約です。email に UNIQUE が付いていると、同じメールアドレスでの再登録ができません。
PostgreSQL なら部分インデックスが一番素直です。
CREATE UNIQUE INDEX users_email_active_idx
ON users (email)
WHERE deleted_at IS NULL;
PostgreSQL 15 以降であれば NULLS NOT DISTINCT も選べます。これを使うと email + deleted_at の複合 UNIQUE で NULL 同士も重複扱いにできるので、未削除レコードを 1 件に絞れます。
MySQL は部分インデックス非対応ですが、8 系であれば生成列 + UNIQUE で同等を実現できます。
ALTER TABLE users
ADD email_active VARCHAR(255)
GENERATED ALWAYS AS (IF(deleted_at IS NULL, email, NULL)) VIRTUAL,
ADD UNIQUE KEY uk_users_email_active (email_active);
MySQL は NULL 同士を UNIQUE 上「別物」として扱うため、削除済み行の email_active を NULL にすると重複扱いになりません。
削除済みデータの取得と復元
削除済みも見たいときは、@SQLRestriction が効かないネイティブクエリが最短です。
public interface UserRepository extends JpaRepository<User, Long> {
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
Optional<User> findByEmailAndDeletedAtIsNull(String email);
@Modifying
@Query(value = "UPDATE users SET deleted_at = NULL WHERE id = :id", nativeQuery = true)
int restore(@Param("id") Long id);
}
復元する前に、同じ email を持つアクティブなユーザーがいないか findByEmailAndDeletedAtIsNull で確認するロジックを入れておくと、ユニーク制約違反を未然に防げます。
関連エンティティでの注意点
OneToMany の関連先にも @SQLRestriction を付けておくと、親エンティティから子コレクションをフェッチした際に自動で削除済みが除外されます。一方で、これは collection.size() の値が物理レコード数と一致しなくなることを意味します。集計や履歴処理ではネイティブクエリで件数を取り直すのが安全です。
親に CascadeType.REMOVE を付けたうえで @SQLDelete を組み合わせる場合、子側にも @SQLDelete が必要です。子側が物理削除のままだと中途半端になるので、削除戦略はエンティティ単位で揃えておきましょう。関連の貼り方はJPAのエンティティ関連マッピング、フェッチまわりはSpring Data JPAのパフォーマンス最適化もあわせて読むと理解が深まります。
Auditing と組み合わせて削除者を記録する
さきほど触れたとおり、@SQLDelete の ? には識別子しかバインドされないので、deleted_by(削除者)を残したい場合は別の手段が必要になります。素直なのは、リポジトリに @Modifying の UPDATE メソッドを用意して、削除者を引数で受け取るパターンです。
public interface UserRepository extends JpaRepository<User, Long> {
@Modifying
@Query("UPDATE User u SET u.deletedAt = CURRENT_TIMESTAMP, u.deletedBy = :deletedBy WHERE u.id = :id")
int softDeleteById(@Param("id") Long id, @Param("deletedBy") String deletedBy);
}
削除者は AuditorAware から取り出します。SecurityContext の認証情報が無い/匿名のケースに備えて、フォールバックを SYSTEM などに固定しておくと安全です。
@Service
@RequiredArgsConstructor
public class UserDeletionService {
private final UserRepository userRepository;
private final AuditorAware<String> auditorAware;
@Transactional
public void delete(Long id) {
String deletedBy = auditorAware.getCurrentAuditor()
.orElse("SYSTEM");
userRepository.softDeleteById(id, deletedBy);
}
}
このパターンなら、エンティティに @SQLDelete を残しつつ、削除者を伴う論理削除はサービス経由で実行する、という二段構えにできます。AuditorAware の組み立て方や @LastModifiedBy との関係はSpring Data JPA Auditingで作成日時と更新日時を自動管理する方法を参考にしてください。
@DataJpaTest で論理削除を検証する
論理削除は「呼び出すと消える」のではなく「呼び出すと隠れる」挙動なので、テストで意図どおり動くかは確認する価値があります。@DataJpaTest は各テストをトランザクションで包み、終了時にロールバックするので、テスト間のデータ汚染を気にせず書けます。em.flush() と em.clear() で 1 次キャッシュをクリアしてから検証すると、実 SQL レベルの挙動を確認できます。
@DataJpaTest
class UserRepositorySoftDeleteTest {
@Autowired UserRepository userRepository;
@Autowired EntityManager em;
@Test
void deleteすると findByIdは空になる() {
User saved = userRepository.save(newUser("[email protected]"));
userRepository.deleteById(saved.getId());
em.flush();
em.clear();
assertThat(userRepository.findById(saved.getId())).isEmpty();
assertThat(userRepository.findByIdIncludingDeleted(saved.getId())).isPresent();
}
@Test
void restoreすると findByIdで再び取得できる() {
User saved = userRepository.save(newUser("[email protected]"));
userRepository.deleteById(saved.getId());
em.flush();
em.clear();
int updated = userRepository.restore(saved.getId());
em.flush();
em.clear();
assertThat(updated).isEqualTo(1);
assertThat(userRepository.findById(saved.getId())).isPresent();
}
}
まとめ
論理削除の選び方はそれほど複雑ではありません。既存プロジェクトなら @SQLDelete + @SQLRestriction で透過化し、復元や管理画面が必要なら @FilterDef を足す。新規で boolean カラム運用が許容できるなら @SoftDelete も選択肢に入ります。ユニーク制約は PostgreSQL なら部分インデックス(または NULLS NOT DISTINCT)、MySQL なら生成列 + UNIQUE が第一選択です。削除者を残したいときは @SQLDelete の ? に頼らず、@Modifying の UPDATE と AuditorAware で対応するのが現実的でした。最初の設計段階で削除戦略を揃えておきましょう。