내가 사용한 SQL Delete방식에서 Cascade속성을 함께 사용하려 했을 때 발생했던 아래의 문제에 대해 다시는 발생하지 않도록 개념을 다잡기 위해 정리한다! 이전에는 Cascade를 왜 사용하는지 잘 몰랐으나 SoftDelete와 함께 무턱대고 사용하려고 하니 충돌이 나서 각각의 방식과 전략을 확실히하고 어떤 방법으로 SoftDelete를 사용하며 Cascade는 왜 피해야 하는지 알아보자.
[발생한 예외]
org.hibernate.ObjectDeletedException: deleted instance passed to merge
[Soft Delete의 적합성]
Soft Delete는 데이터베이스에서 데이터를 물리적으로 삭제하지 않고, "삭제됨" 상태를 표시하기 위해 추가적인 컬럼(e.g., deleted
또는 deletedAt
)을 활용하는 방법이다. 이를 통해 다음과 같은 장점을 얻을 수 있다
- 삭제된 데이터를 복구 가능
- 데이터 삭제 이력 추적
- 삭제된 데이터를 제외한 결과만 조회 가능
하지만 완벽한 전략은 아니기에 단점 또한 존재한다.
- 삭제된 데이터를 제외하려면 추가적인 필터링 조건이 필요하여 쿼리 성능이 저하된다.
- 삭제된 데이터를 복구하거나 잘못된 상태로 관리할 경우 데이터의 정합성이 깨질 가능성이 있다.
- 물리적으로 삭제하지 않기 때문에 데이터베이스 용량이 불필요하게 증가할 수 있다.
[Soft Delete 구현]
1. 엔티티에 Soft Delete 필드 추가
Soft Delete를 구현하기 위해 엔티티에 @Column
어노테이션과 함께 deleted
또는 deletedAt
필드를 추가한다.
2. Soft Delete 필드를 활용한 삭제 메서드
JPA에서 데이터를 삭제하는 대신, 해당 필드 값을 변경하는 커스텀 메서드를 정의한다.
3. @Where
어노테이션으로 삭제된 데이터 제외
Hibernate에서 제공하는 @Where
어노테이션을 활용하면, 삭제된 데이터를 자동으로 제외한 결과를 반환할 수 있다.
내 코드에는 SoftDelete방식을 구현하였지만 Cascade의 Type을 ALL로 하였으며 orphanRemoval까지 적용되었다.
이 부분은 JPA의 속성들에 대해 무지했고 이제서야 수정을 한다는게 부끄러운 일이지만 하나하나 알아가며 수정하는 재미도 있기에 반성과 뿌듯함을 함께 느끼는 중이다...
@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;
... 필드들
@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()));
}
}
[문제점 - Cascade 연관관계와 Soft Delete의 상호작용]
JPA에서는 @OneToMany
, @ManyToOne
등의 연관관계에서 Cascade 옵션을 설정하여 관련 엔티티에 대한 작업을 자동으로 전파할 수 있다. 하지만 Soft Delete를 사용하는 경우, Cascade 옵션이 예상치 못한 결과를 초래할 수 있다. 내 코드의 두가지 문제점을 먼저 파악해보자.
1. CascadeType.ALL
은 연관 엔티티에 대한 모든 작업을 전파하므로, Soft Delete를 적용할 때 실제로 데이터가 물리적으로 삭제될 수 있는 문제를 초래할 수 있다.
1-1. @Where 어노테이션과 함께 설정되어 있고, 연관된 엔티티에 CascadeType.ALL
이 지정된 경우, 부모 엔티티를 삭제하려고 할 때 org.hibernate.ObjectDeletedException
과 같은 예외가 발생할 수 있다. 이는 Hibernate가 삭제된 상태로 간주된 엔티티를 Cascade 동작을 통해 다시 접근하려고 하기 때문이다.
2. orphanRemoval = true
는 부모 엔티티에서 연관 엔티티를 제거할 경우 물리적 삭제를 발생시키므로, Soft Delete와 충돌한다.
[해결 방안]
- CascadeType 및 orphanRemoval 제거: CascadeType을 제거하고 Soft Delete 메서드에서 연관된 엔티티를 명시적으로 처리한다.
public void softDelete() {
this.posts.forEach(참조 엔티티::softDelete);
}
delete()
대신 커스텀 쿼리 사용: 엔티티 삭제 시 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(참조 엔티티::softDelete);
}
}
- 연관 엔티티의 명시적 관리: 연관 엔티티를 직접 관리하여 Soft Delete와 Cascade 간의 충돌을 방지한다.
[적용 방안]
LifeCycle Callback을 활용해서 연관된 엔티티의 컬럼을 변경하기로 하였다.
각각의 Entity에는 SoftDelete가 명시되어 있고, 각 태그 엔티티들은 다른 엔티티를 참조하고 있지 않는 그래프의 마지막 엔티티이기 때문에 직접 삭제할 수 있다.
그래서 Callback 메서드를 활용하여 각 엔티티의 컬럼을 true로 변경하여 조회 시 반환되지 않도록 하였다.
@PreRemove
public void onPreRemove() {
deleteOwner();
deleteMate();
deleteDogs();
deleteBoards();
}
private void deleteOwner() {
if (owner != null) {
owner.setIsDeleted(true);
}
}
private void deleteMate() {
if (mate != null) {
mate.setDeleted(true);
}
}
private void deleteDogs() {
if (dog != null && !dog.isEmpty()) {
dog.forEach(d -> d.setDeleted(true));
}
}
private void deleteBoards() {
if (board != null && !board.isEmpty()) {
board.forEach(b -> b.setDeleted(true));
}
}
JPA의 다양한 속성들은 확실하게 알지 못하면 어디서 문제가 발생하는지 모르기 때문에 러닝커브가 지속적으로 발생할 수 있다. 이후 N+1 문제, 순환 참조, 영속성 컨텍스트와 1차 캐시 사이에서 발생하는 무결성 문제 등에 대해 추가로 다뤄보고 지속적으로 프로젝트를 개선해 나가고자 한다.
'Spring > 프로젝트 리팩토링 회고록' 카테고리의 다른 글
키워드 초성 검색 기능 개선 과정 (0) | 2024.12.16 |
---|---|
레이어 아키텍처 구조 개선하기 (1) | 2024.11.30 |
레이어드 아키텍처의 문제점과 해결방안 (0) | 2024.11.17 |