はじめに#
Spring Data JPAを使った開発において、データの削除方式は重要な設計判断の一つです。特に「ソフトデリート(論理削除)」は、データの保全性や監査要件を満たすために多くのエンタープライズシステムで採用されています。
本記事では、Spring Data JPAにおけるソフトデリートの実装方法を詳しく解説します。Hibernateが提供する@SQLDeleteアノテーションによるDELETE文のUPDATEへの置き換え、@Where(Hibernate 6.3以降は@SQLRestriction)による自動フィルタリングなど、論理削除を実現するための具体的なテクニックを紹介します。
実行環境・前提条件#
本記事のコードは以下の環境で動作確認しています。
| 項目 |
バージョン |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Hibernate |
6.6.x |
| データベース |
PostgreSQL 15 / MySQL 8.0 |
依存関係(build.gradle)は以下の通りです。
1
2
3
4
5
|
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.postgresql:postgresql'
// または runtimeOnly 'com.mysql:mysql-connector-j'
}
|
物理削除と論理削除(ソフトデリート)の違い#
物理削除(Hard Delete)#
物理削除は、データベースからレコードを完全に削除する方式です。
1
|
DELETE FROM users WHERE id = 1;
|
特徴は以下の通りです。
- ストレージを即座に解放できる
- データは完全に消失し、復旧不可能
- 外部キー制約による整合性維持が必要
論理削除(Soft Delete / ソフトデリート)#
論理削除は、削除フラグや削除日時を更新することで、データを「削除済み」としてマークする方式です。
1
|
UPDATE users SET deleted_at = NOW() WHERE id = 1;
|
特徴は以下の通りです。
- データの復旧が可能
- 監査証跡や履歴の保持に有効
- クエリに削除フラグの条件が必要
- データ量が増加しやすい
選定基準#
以下の観点から削除方式を選定します。
| 観点 |
物理削除が適切 |
論理削除が適切 |
| 監査要件 |
監査が不要 |
削除履歴の保持が必要 |
| データ復旧 |
復旧の必要なし |
誤削除時の復旧が必要 |
| 法的要件 |
完全削除が必要(GDPR等) |
一定期間の保持義務あり |
| 参照整合性 |
厳密な整合性が必要 |
関連データへの影響を最小化したい |
| パフォーマンス |
大量データでのクエリ性能重視 |
データ量増加を許容できる |
deleted_atカラム設計と実装例#
カラム設計のパターン#
論理削除を実現するためのカラム設計には、主に3つのパターンがあります。
graph LR
A[論理削除カラム設計] --> B[booleanフラグ]
A --> C[削除日時]
A --> D[複合パターン]
B --> B1["deleted: boolean"]
C --> C1["deleted_at: timestamp"]
D --> D1["deleted: boolean + deleted_at + deleted_by"]パターン1: booleanフラグ#
1
|
ALTER TABLE users ADD COLUMN deleted BOOLEAN DEFAULT FALSE NOT NULL;
|
最もシンプルですが、削除日時や削除者の情報を持てません。
パターン2: 削除日時(推奨)#
1
|
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;
|
deleted_atがNULLでなければ削除済みと判定します。削除日時も記録でき、実用性が高い方式です。
パターン3: 複合パターン#
1
2
3
|
ALTER TABLE users ADD COLUMN deleted BOOLEAN DEFAULT FALSE NOT NULL;
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;
ALTER TABLE users ADD COLUMN deleted_by VARCHAR(100) NULL;
|
監査要件が厳しい場合に採用します。
エンティティの基底クラス設計#
再利用性を高めるため、論理削除機能を基底クラスとして実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
package com.example.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.Instant;
@MappedSuperclass
public abstract class SoftDeletableEntity {
@Column(name = "deleted_at")
private Instant deletedAt;
public Instant getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Instant deletedAt) {
this.deletedAt = deletedAt;
}
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now();
}
public void restore() {
this.deletedAt = null;
}
}
|
@SQLDeleteによるDELETE文のUPDATEへの置き換え#
@SQLDeleteの基本#
Hibernateの@SQLDeleteアノテーションを使用すると、EntityManager.remove()やSpring Data JPAのdeleteById()が呼び出されたときに、実際に発行されるSQL文をカスタマイズできます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
package com.example.domain.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.SQLDelete;
import java.time.Instant;
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
public class User extends SoftDeletableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = Instant.now();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Instant getCreatedAt() {
return createdAt;
}
}
|
動作確認#
以下のようにリポジトリから削除を実行します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void deleteUser(Long userId) {
// 内部的には DELETE ではなく UPDATE が発行される
userRepository.deleteById(userId);
}
}
|
実際に発行されるSQLは以下のようになります。
1
2
|
-- deleteById(1) 実行時
UPDATE users SET deleted_at = NOW() WHERE id = 1
|
複数テーブルの継承時の注意点#
@SQLDeleteは継承されないため、各エンティティクラスで個別に指定する必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Entity
@Table(name = "orders")
@SQLDelete(sql = "UPDATE orders SET deleted_at = NOW() WHERE id = ?")
public class Order extends SoftDeletableEntity {
// ...
}
@Entity
@Table(name = "products")
@SQLDelete(sql = "UPDATE products SET deleted_at = NOW() WHERE id = ?")
public class Product extends SoftDeletableEntity {
// ...
}
|
@Where/@SQLRestrictionによる自動フィルタリング#
@Whereアノテーション(Hibernate 6.3未満)#
@Whereアノテーションを使用すると、エンティティに対するすべてのクエリに自動的にWHERE条件が追加されます。
1
2
3
4
5
6
7
|
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class User extends SoftDeletableEntity {
// ...
}
|
@SQLRestrictionアノテーション(Hibernate 6.3以降、推奨)#
Hibernate 6.3以降では、@Whereは非推奨となり、@SQLRestrictionが推奨されています。機能は同等ですが、より明確な命名となっています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
package com.example.domain.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import java.time.Instant;
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class User extends SoftDeletableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "name", nullable = false)
private String name;
// ...
}
|
自動フィルタリングの効果#
@SQLRestrictionを設定すると、以下のようにすべてのクエリに条件が自動付与されます。
1
2
3
4
5
|
// Repository
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByName(String name);
Optional<User> findByEmail(String email);
}
|
1
2
3
4
5
6
7
8
|
-- findAll() 実行時
SELECT * FROM users WHERE deleted_at IS NULL
-- findByName("田中") 実行時
SELECT * FROM users WHERE name = '田中' AND deleted_at IS NULL
-- findById(1) 実行時
SELECT * FROM users WHERE id = 1 AND deleted_at IS NULL
|
関連エンティティへの適用#
関連エンティティにも@SQLRestrictionを適用することで、関連データの取得時にも自動フィルタリングが機能します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
@Entity
@Table(name = "posts")
@SQLDelete(sql = "UPDATE posts SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Post extends SoftDeletableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
// Getters and Setters
}
@Entity
@Table(name = "comments")
@SQLDelete(sql = "UPDATE comments SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Comment extends SoftDeletableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "content", nullable = false)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
// Getters and Setters
}
|
削除済みデータの取得方法#
@SQLRestrictionを使用すると通常のクエリでは削除済みデータを取得できません。管理画面での復元機能や監査目的で削除済みデータを取得する方法を紹介します。
方法1: ネイティブクエリを使用する#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public interface UserRepository extends JpaRepository<User, Long> {
// 削除済みを含むすべてのユーザーを取得
@Query(value = "SELECT * FROM users", nativeQuery = true)
List<User> findAllIncludingDeleted();
// 削除済みユーザーのみを取得
@Query(value = "SELECT * FROM users WHERE deleted_at IS NOT NULL", nativeQuery = true)
List<User> findAllDeleted();
// 特定IDの削除済みユーザーを取得
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
}
|
方法2: EntityManagerを直接使用する#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
@Repository
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<User> findAllIncludingDeleted() {
return entityManager
.createNativeQuery("SELECT * FROM users", User.class)
.getResultList();
}
@Override
public Optional<User> findDeletedById(Long id) {
List<User> results = entityManager
.createNativeQuery(
"SELECT * FROM users WHERE id = :id AND deleted_at IS NOT NULL",
User.class
)
.setParameter("id", id)
.getResultList();
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
}
|
方法3: Hibernate Filterを使用する(高度な制御が必要な場合)#
Hibernate Filterを使用すると、動的にフィルタリングのON/OFFを切り替えられます。
1
2
3
4
5
6
7
8
|
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
@FilterDef(name = "deletedFilter", parameters = @ParamDef(name = "isDeleted", type = Boolean.class))
@Filter(name = "deletedFilter", condition = "deleted_at IS NULL OR :isDeleted = true")
public class User extends SoftDeletableEntity {
// ...
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@Service
public class UserQueryService {
@PersistenceContext
private EntityManager entityManager;
public List<User> findAllWithDeletedOption(boolean includeDeleted) {
Session session = entityManager.unwrap(Session.class);
if (!includeDeleted) {
session.enableFilter("deletedFilter").setParameter("isDeleted", false);
}
return entityManager
.createQuery("SELECT u FROM User u", User.class)
.getResultList();
}
}
|
方法4: 別エンティティを定義する(推奨パターン)#
管理者向けに@SQLRestrictionを持たない別エンティティを定義する方法もあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// 通常のエンティティ(削除済みを除外)
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class User extends SoftDeletableEntity {
// ...
}
// 管理者用エンティティ(すべてのデータにアクセス可能)
@Entity
@Table(name = "users")
@Immutable // 読み取り専用
public class UserAdmin extends SoftDeletableEntity {
@Id
private Long id;
@Column(name = "email")
private String email;
@Column(name = "name")
private String name;
// Gettersのみ
}
|
1
2
3
|
public interface UserAdminRepository extends JpaRepository<UserAdmin, Long> {
List<UserAdmin> findByDeletedAtIsNotNull();
}
|
削除済みデータの復元#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
@Service
@Transactional
public class UserAdminService {
private final EntityManager entityManager;
public UserAdminService(EntityManager entityManager) {
this.entityManager = entityManager;
}
public void restoreUser(Long userId) {
int updated = entityManager
.createNativeQuery("UPDATE users SET deleted_at = NULL WHERE id = :id")
.setParameter("id", userId)
.executeUpdate();
if (updated == 0) {
throw new EntityNotFoundException("User not found: " + userId);
}
}
}
|
ソフトデリートの注意点#
UNIQUE制約の問題と解決策#
論理削除を使用すると、UNIQUE制約で問題が発生することがあります。
sequenceDiagram
participant App as アプリケーション
participant DB as データベース
Note over DB: users テーブル<br/>email UNIQUE制約
App->>DB: INSERT user@example.com (id=1)
DB-->>App: 成功
App->>DB: DELETE id=1 (論理削除)
Note over DB: deleted_at = NOW()
DB-->>App: 成功
App->>DB: INSERT user@example.com (id=2)
DB-->>App: UNIQUE制約違反!
Note over App,DB: 削除済みでも同じemailを<br/>再登録できない解決策1: 部分インデックス(PostgreSQL)#
1
2
3
4
|
-- 削除されていないレコードのみにUNIQUE制約を適用
CREATE UNIQUE INDEX users_email_unique_not_deleted
ON users (email)
WHERE deleted_at IS NULL;
|
解決策2: 複合UNIQUE制約#
1
2
3
4
|
-- emailとdeleted_atの組み合わせでUNIQUE
-- NULL同士は等しくないとみなされるDBでのみ有効
ALTER TABLE users ADD CONSTRAINT users_email_deleted_unique
UNIQUE (email, deleted_at);
|
解決策3: 削除時にユニーク値を変更#
1
2
3
4
5
6
7
8
9
10
11
12
|
@Entity
@Table(name = "users")
@SQLDelete(sql = """
UPDATE users
SET deleted_at = NOW(),
email = CONCAT(email, '_deleted_', id)
WHERE id = ?
""")
@SQLRestriction("deleted_at IS NULL")
public class User extends SoftDeletableEntity {
// ...
}
|
パフォーマンスとインデックス設計#
論理削除を採用すると、テーブルのデータ量が増加し続けるため、適切なインデックス設計が重要です。
推奨インデックス#
1
2
3
4
5
6
7
8
9
|
-- 削除フラグを含む複合インデックス
CREATE INDEX idx_users_email_deleted ON users (email, deleted_at);
-- 削除されていないレコードのみの部分インデックス
CREATE INDEX idx_users_active ON users (id) WHERE deleted_at IS NULL;
-- 削除日時での検索用(バッチ処理向け)
CREATE INDEX idx_users_deleted_at ON users (deleted_at)
WHERE deleted_at IS NOT NULL;
|
クエリプランの確認#
1
2
|
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'test@example.com' AND deleted_at IS NULL;
|
データアーカイブ戦略#
論理削除されたデータは定期的にアーカイブすることを検討します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
@Service
@Transactional
public class DataArchiveService {
private final EntityManager entityManager;
public DataArchiveService(EntityManager entityManager) {
this.entityManager = entityManager;
}
/**
* 90日以上前に削除されたデータをアーカイブテーブルに移動
*/
@Scheduled(cron = "0 0 2 * * *") // 毎日2時に実行
public void archiveOldDeletedData() {
Instant threshold = Instant.now().minus(90, ChronoUnit.DAYS);
// アーカイブテーブルにコピー
entityManager.createNativeQuery("""
INSERT INTO users_archive
SELECT * FROM users
WHERE deleted_at IS NOT NULL AND deleted_at < :threshold
""")
.setParameter("threshold", threshold)
.executeUpdate();
// 元テーブルから物理削除
entityManager.createNativeQuery("""
DELETE FROM users
WHERE deleted_at IS NOT NULL AND deleted_at < :threshold
""")
.setParameter("threshold", threshold)
.executeUpdate();
}
}
|
よくある誤解とアンチパターン#
アンチパターン1: すべてのテーブルに論理削除を適用する#
論理削除は万能ではありません。以下のようなテーブルには物理削除が適切です。
- 一時的なデータ(セッション、キャッシュ)
- 大量の時系列データ(ログ、メトリクス)
- 個人情報保護法により完全削除が必要なデータ
アンチパターン2: @SQLRestrictionを過信する#
@SQLRestrictionはHibernate経由のクエリにのみ適用されます。以下のケースでは効果がありません。
1
2
3
4
5
6
7
|
// NG: ネイティブクエリには適用されない
@Query(value = "SELECT * FROM users WHERE name = :name", nativeQuery = true)
List<User> findByNameNative(@Param("name") String name);
// OK: JPQLには適用される
@Query("SELECT u FROM User u WHERE u.name = :name")
List<User> findByNameJpql(@Param("name") String name);
|
アンチパターン3: 関連エンティティの削除を忘れる#
親エンティティを論理削除しても、子エンティティは自動的に論理削除されません。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PostRepository postRepository;
// 推奨: 関連エンティティも一緒に論理削除
public void deleteUserWithPosts(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
// 関連する投稿も論理削除
postRepository.softDeleteByUserId(userId);
// ユーザーを論理削除
userRepository.delete(user);
}
}
|
1
2
3
4
5
6
|
public interface PostRepository extends JpaRepository<Post, Long> {
@Modifying
@Query("UPDATE Post p SET p.deletedAt = CURRENT_TIMESTAMP WHERE p.user.id = :userId")
void softDeleteByUserId(@Param("userId") Long userId);
}
|
アンチパターン4: 削除済みデータのキャッシュ問題#
2次キャッシュを使用している場合、論理削除後もキャッシュにデータが残る可能性があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final EntityManager entityManager;
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
// キャッシュをクリア
entityManager.getEntityManagerFactory()
.getCache()
.evict(User.class, userId);
}
}
|
まとめと実践Tips#
まとめ#
Spring Data JPAでソフトデリート(論理削除)を実装する際の主要なポイントは以下の通りです。
@SQLDeleteでDELETE文をUPDATE文に置き換える
@SQLRestriction(Hibernate 6.3以降)で自動フィルタリングを適用する
- 削除済みデータの取得にはネイティブクエリや専用エンティティを使用する
- UNIQUE制約は部分インデックスで対応する
- 適切なインデックス設計とデータアーカイブ戦略を検討する
実践Tips#
Tip 1: 共通基底クラスの活用#
プロジェクト全体で一貫した論理削除を実現するため、基底クラスを活用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@MappedSuperclass
public abstract class SoftDeletableEntity {
@Column(name = "deleted_at")
private Instant deletedAt;
@Column(name = "deleted_by")
private String deletedBy;
public void markAsDeleted(String deletedBy) {
this.deletedAt = Instant.now();
this.deletedBy = deletedBy;
}
// Getters...
}
|
Tip 2: テスト時の注意点#
テストでは削除済みデータも検証できるようにします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void softDeleteShouldSetDeletedAt() {
// Given
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
user = userRepository.save(user);
Long userId = user.getId();
// When
userRepository.deleteById(userId);
entityManager.flush();
entityManager.clear();
// Then: 通常のfindByIdでは取得できない
assertThat(userRepository.findById(userId)).isEmpty();
// Then: ネイティブクエリで削除済みを確認
User deletedUser = (User) entityManager.getEntityManager()
.createNativeQuery("SELECT * FROM users WHERE id = :id", User.class)
.setParameter("id", userId)
.getSingleResult();
assertThat(deletedUser.getDeletedAt()).isNotNull();
}
}
|
Tip 3: 監査機能との統合#
Spring Data JPAのAuditing機能と組み合わせることで、より詳細な監査情報を記録できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableSoftDeletableEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private Instant updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false)
private String createdBy;
@LastModifiedBy
@Column(name = "updated_by")
private String updatedBy;
@Column(name = "deleted_at")
private Instant deletedAt;
@Column(name = "deleted_by")
private String deletedBy;
// ...
}
|
参考リンク#