JPA를 사용하다 보면 데이터 삭제 시 완전 삭제(Hard Delete)가 아닌 Soft Delete를 고려해야 하는 경우가 있습니다. Soft Delete는 데이터를 물리적으로 삭제하지 않고 특정 컬럼 값을 변경하여 삭제 상태를 표시하는 방식으로, 데이터를 보존하면서 삭제 여부를 관리할 수 있습니다. 이 글에서는 Soft Delete를 구현하는 방법과 Soft Delete 적용 시 Cascade 연관관계에서 발생할 수 있는 문제 및 해결 방법을 살펴보겠습니다.
Soft Delete란?
Soft Delete는 데이터베이스에서 데이터를 물리적으로 삭제하지 않고, "삭제됨" 상태를 표시하기 위해 추가적인 컬럼(e.g., deleted
또는 deletedAt
)을 활용하는 방법입니다. 이를 통해 다음과 같은 장점을 얻을 수 있습니다:
- 삭제된 데이터를 복구 가능
- 데이터 삭제 이력 추적
- 삭제된 데이터를 제외한 결과만 조회 가능
하지만 Soft Delete에는 단점도 존재합니다:
- 성능 문제: 삭제된 데이터를 제외하려면 추가적인 필터링 조건이 필요하여 쿼리 성능이 저하될 수 있습니다.
- 데이터 정합성 이슈: 삭제된 데이터를 복구하거나 잘못된 상태로 관리할 경우 데이터의 정합성이 깨질 가능성이 있습니다.
- 데이터 유지 비용 증가: 물리적으로 삭제하지 않기 때문에 데이터베이스 용량이 불필요하게 증가할 수 있습니다.
Soft Delete를 사용하지 않을 경우
Soft Delete를 사용하지 않을 경우, 다음과 같은 방법으로 데이터를 관리할 수 있습니다:
- 물리적 삭제 (Hard Delete): 데이터를 완전히 삭제하여 데이터베이스 용량을 확보하지만, 삭제 이력 추적이나 복구는 불가능합니다.
- 아카이빙: 삭제된 데이터를 별도의 테이블로 옮겨 저장하여 주요 테이블의 성능을 유지하고 이력을 보관합니다.
- 버전 관리: 데이터 변경 및 삭제를 버전으로 관리하여 변경 사항을 추적할 수 있습니다.
각 방법은 시스템의 요구사항에 따라 적합성이 다를 수 있으므로, 사용 사례에 따라 적절한 방식을 선택해야 합니다.
Soft Delete 구현 방법
Spring Boot와 JPA를 사용하여 Soft Delete를 구현하는 방법을 단계적으로 살펴보겠습니다.
1. 엔티티에 Soft Delete 필드 추가
Soft Delete를 구현하기 위해 엔티티에 @Column
어노테이션과 함께 deleted
또는 deletedAt
필드를 추가합니다.
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
private Boolean deleted = false; // 삭제 여부 플래그
// Getter, Setter, Constructor 생략
}
2. Soft Delete 필드를 활용한 삭제 메서드
JPA에서 데이터를 삭제하는 대신, 해당 필드 값을 변경하는 커스텀 메서드를 정의합니다.
public void softDelete() {
this.deleted = true;
}
3. @Where
어노테이션으로 삭제된 데이터 제외
Hibernate에서 제공하는 @Where
어노테이션을 활용하면, 삭제된 데이터를 자동으로 제외한 결과를 반환할 수 있습니다.
@Entity
@Where(clause = "deleted = false")
public class Post {
// 필드와 메서드 생략
}
4. Spring Data JPA의 쿼리 메서드 활용
JpaRepository
의 쿼리 메서드는 Soft Delete 필드를 고려하지 않으므로, 별도로 쿼리를 정의해야 합니다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByDeletedFalse();
}
Cascade 연관관계와 Soft Delete의 상호작용
JPA에서는 @OneToMany
, @ManyToOne
등의 연관관계에서 Cascade 옵션을 설정하여 관련 엔티티에 대한 작업을 자동으로 전파할 수 있습니다. Soft Delete를 사용하는 경우, CascadeType.ALL과 같은 옵션이 예상치 못한 결과를 초래할 수 있습니다. 예를 들어, Soft Delete가 적용된 엔티티가 @Where
어노테이션으로 필터링되고 CascadeType.ALL을 사용하여 연관 엔티티가 물리적으로 삭제되면, Hibernate는 삭제된 상태로 간주된 엔티티를 다시 처리하려고 시도하며 ObjectDeletedException
과 같은 예외를 발생시킬 수 있습니다. 이를 방지하기 위해, CascadeType 설정을 조정하거나 연관된 엔티티의 Soft Delete를 명시적으로 처리하는 전략이 필요합니다.
문제 상황
다음과 같은 예제를 가정해 보겠습니다:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
public void softDelete() {
this.posts.forEach(Post::softDelete);
}
}
- `CascadeType.ALL`은 연관 엔티티에 대한 모든 작업을 전파하므로, Soft Delete를 적용할 때 실제로 데이터가 물리적으로 삭제될 수 있는 문제를 초래할 수 있습니다.
-package pupket.togedogserver.domain.user.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import pupket.togedogserver.domain.board.entity.Board;
import pupket.togedogserver.domain.dog.entity.Dog;
import pupket.togedogserver.domain.user.constant.AccountStatus;
import pupket.togedogserver.domain.user.constant.RoleType;
import pupket.togedogserver.domain.user.constant.UserGender;
import pupket.togedogserver.domain.user.entity.mate.Mate;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static jakarta.persistence.GenerationType.IDENTITY;
@Entity(name = "users")
@Getter
@Setter
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@SQLDelete(sql = "UPDATE users SET account_status = 'DELETED' WHERE uuid = ?")
@SQLRestriction("account_status = 'ACTIVE'")
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long uuid;
@Column(nullable = false)
private String email;
private String password;
private String nickname;
private String profileImage;
private String name;
@Enumerated(EnumType.STRING)
private UserGender userGender;
private int birthyear;
private int birthday;
private String phoneNumber;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RoleType role;
private String address1;
private String address2;
private double mapX;
private double mapY;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@ColumnDefault("'ACTIVE'")
@Builder.Default
public AccountStatus accountStatus = AccountStatus.ACTIVE;
@OneToOne(fetch = FetchType.LAZY,cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "owner_uuid")
private Owner owner;
@OneToOne(fetch = FetchType.LAZY,cascade = CascadeType.ALL, orphanRemoval = true)
private Mate mate;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Dog> dog;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Board> board;
private String fcmToken;
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(this.role.name()));
}
}
### 개선 방안
1. **CascadeType 수정:** `CascadeType.PERSIST`나 `CascadeType.MERGE`와 같이 필요한 작업만 전파하도록 제한합니다.
```java
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts = new ArrayList<>();
- orphanRemoval 제거: 고아 객체 삭제를 방지하고 Soft Delete 메서드에서 연관 엔티티를 명시적으로 처리합니다.
public void softDelete() {
this.posts.forEach(Post::softDelete);
}
- Custom Query 활용: 연관 엔티티의 Soft Delete를 명시적으로 처리하기 위해 커스텀 쿼리를 사용합니다.
@Modifying
@Query("UPDATE Post p SET p.deleted = true WHERE p.user.id = :userId")
void softDeletePostsByUserId(@Param("userId") Long userId);
이러한 변경을 통해 Soft Delete와 Cascade 설정 간의 상충 문제를 효과적으로 해결할 수 있습니다.
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
public void softDelete() {
this.posts.forEach(Post::softDelete);
}
}
- `User` 엔티티가 Soft Delete될 때 관련된 `Post` 엔티티도 Soft Delete를 적용합니다.
- 하지만 CascadeType.ALL을 사용하면 `Post` 엔티티가 물리적으로 삭제될 수 있습니다. 이는 Soft Delete의 목적과 어긋납니다.
### 예외 발생 상황
Soft Delete를 적용한 엔티티가 `@Where` 어노테이션과 함께 설정되어 있고, 연관된 엔티티에 CascadeType.ALL이 지정된 경우, 부모 엔티티를 삭제하려고 할 때 `org.hibernate.ObjectDeletedException`과 같은 예외가 발생할 수 있습니다. 이는 Hibernate가 삭제된 상태로 간주된 엔티티를 Cascade 동작을 통해 다시 접근하려고 하기 때문입니다.
#### 예제 코드
```java
User user = userRepository.findById(userId).orElseThrow();
user.softDelete();
userRepository.delete(user);
위 코드는 @Where
어노테이션이 활성화된 상태에서 CascadeType.ALL
을 설정한 경우, 연관된 엔티티를 물리적으로 삭제하려고 시도하며 예외를 발생시킬 수 있습니다.
예외 메시지 예시
org.hibernate.ObjectDeletedException: deleted instance passed to merge
해결 방안
- CascadeType 제거: CascadeType을 제거하고 Soft Delete 메서드에서 연관된 엔티티를 명시적으로 처리합니다.
public void softDelete() {
this.posts.forEach(Post::softDelete);
}
- ``** 대신 커스텀 쿼리 사용:** 엔티티 삭제 시 Soft Delete 상태를 업데이트하도록 커스텀 쿼리를 사용합니다.
@Modifying
@Query("UPDATE User u SET u.deleted = true WHERE u.id = :userId")
void softDeleteUser(@Param("userId") Long userId);
- Lifecycle Callback 활용:
@PreRemove
를 사용하여 삭제 전에 연관된 엔티티를 Soft Delete 처리합니다.
@Entity
public class User {
@PreRemove
public void onPreRemove() {
this.posts.forEach(Post::softDelete);
}
}
- 연관 엔티티의 명시적 관리: 연관 엔티티를 직접 관리하여 Soft Delete와 Cascade 간의 충돌을 방지합니다.
결론
JPA에서 Soft Delete를 구현할 때는 데이터 삭제 방식과 연관된 Cascade 동작을 주의 깊게 설계해야 합니다. 특히 연관된 엔티티에 CascadeType.ALL을 사용하면 Soft Delete의 장점이 무력화되거나 예외가 발생할 수 있으므로, 아래 사항을 고려해야 합니다:
- Soft Delete 필드를 활용해 데이터를 물리적으로 삭제하지 않고 상태를 관리합니다.
- CascadeType을 제거하거나 적절히 수정하여 Soft Delete의 의도를 유지합니다.
- 연관 엔티티의 Soft Delete는 커스텀 메서드나 쿼리로 명시적으로 처리합니다.
@Where
어노테이션과 Cascade 설정 간의 충돌을 방지하기 위해 예외 처리와 전략을 명확히 정의합니다.
이를 통해 JPA의 연관관계를 유지하면서도 데이터의 무결성과 복구 가능성을 확보할 수 있습니다.
'Spring > JPA' 카테고리의 다른 글
save the transient instance before flushing (0) | 2024.11.20 |
---|---|
@Where Deprecated되고 새로 쓰이는 @SQLRestriction (1) | 2024.11.20 |
JPA - DB 연결 예외 : 'url' attribute is not specified and no embedded datasource could be configured. (0) | 2024.06.14 |
JPA - 지연로딩과 즉시로딩 (1) | 2023.12.10 |
JPA - Proxy (2) | 2023.12.10 |