[Spring & JPA] Soft Delete를 구현해보자!

sinryuji·2025년 2월 25일
post-thumbnail

Soft Delete란?

Soft Delete란 데이터는 실제로 삭제하지 않고 논리적으로 삭제하는 것을 일컫는다. 삭제 상태를 표현하는 필드를 통해 삭제 여부를 구분하며, 실제로 데이터를 DB에서 삭제를 하지 않기 때문에 간편한 복구가 가능하다. 다만 삭제된 데이터를 여전히 가지고 있으므로 추가적인 저장 공간을 필요로 하고 쿼리에서 항상 삭제 여부를 고려하려야 한다.

반면 Hard delete는 물리적으로 삭제하는 것을 말하고 별도로 백업을 해두지 않은 이상 데이터 복구가 불가능하다.

JPA에서 Soft Delete 구현하기

@Column(nullable = false, columnDefinition = "boolean default false")
private boolean isDeleted;

private LocalDateTime deletedAt;

private String deletedBy;

우선 Soft Delete를 적용할 엔티티에 위 필드들을 추가해주자.

  • isDeleted: 삭제 여부를 저장할 필드. 기본 값을 false로 해준다.
  • deletedAt: 삭제 시점을 저장할 필드.
  • deletedBy: 삭제한 유저를 저장할 필드.

@SQLDelete

@SQLDelete를 사용하면 JpaRepository에서 delete()를 사용할 때 대신 작성해놓은 쿼리가 날아간다.

@Getter
@NoArgsConstructor
@Entity
@Table(name = "p_delivery_address")
@SQLDelete(sql = "UPDATE p_delivery_address SET isDeleted = true WHERE id = ?")
public class DeliveryAddress extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
}

필자의 경우 위와 같이 쿼리를 작성했다. DELETE 쿼리 대신 UPDATE 쿼리를 통해 isDeleted를 true로 만드는 쿼리이다.

setter + @PreUpdate

여기서 deletedAt이나 deletedBy는 어떻게 하지? 라는 생각이 들 것이다. 필자의 경우 isDeleted 하나만 있다면 @SQLDelete를 사용했을 것 같은데, 다른 필드들도 처리를 해야하다보니 @SQLDelete는 적합한 방식이 아니라는 판단이 들었다. 무엇보다 @SQLDeleteHard Delete와의 혼용을 아예 불가능하게 만드므로 벼롤 좋은 방식이 아니라는 생각이 들다.

그래서 setter와 @PreUpdate를 통한 방법을 채택하였다.

@PreUpdate
public void setDeleted() {
    if (isDeleted) {
        deletedAt = LocalDateTime.now();

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !authentication.isAuthenticated()) {
            deletedBy = "SYSTEM";
            return;
        }

        Object principal = authentication.getPrincipal();

        if (principal instanceof UserDetails) {
            deletedBy = ((UserDetails) principal).getUsername();
            return;
        }

        deletedBy = "UNKNOWN";
    }
}

BaseEntity의 함수이다. @PreDelete를 붙이면 엔티티가 update 되기전에 해당 함수를 호출해준다. 즉, isDeleted를 setter를 통해 true로 만들어주면 update 쿼리가 날아가기 직전에 deletedAtdeleteBy의 값을 모두 채워준다. deleteBy의 경우에는 SecurityContextHolder에서 유저 정보를 가져와 채워주었다.

물론 다른 개발자가 무지성으로 JpaRepository의 delete()를 호출해버림녀 난감한 상황이 되겠지만 팀 차원에서 Soft Delete를 채용하기로 결정되었으면 충분히 공유가 되었을 것이고 그런 상황을 잘 일어나지 않을 것이다. 그래도 불안하다면 @SQLDelete도 걸고 @PreUpdate 또한 이용하면 될 것이다.

@SQLRestriction

엔티티를 삭제할 떄 deleted 처리를 해왔다면 조회해 올 때 역시 deleted된 녀석들은 걸러주어야 할 것이다. 이에 JPA에는 자동으로 쿼리에 조건 절을 붙여주는 기능을 제공해준다. 찾아보면 @Where을 쓰는 방법이 많이 나올 텐데 이는 Spring 6.3부터 deprecated 되어서 그 이후 버전을 쓴다면 @SQLRestriction를 사용하면 된다. @SQLRestriction를 사용하면 조회 쿼리에 항상 특정 조건절을 붙여줄 수 있다.

@Getter
@NoArgsConstructor
@Entity
@Table(name = "p_delivery_address")
@SQLRestriction("is_deleted is false")
public class DeliveryAddress extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
}

위와 같이 작성을 해주면 JpaRepository를 이용하여 조회 쿼리를 날릴 때 자동으로 where 절에 해당 조건이 추가되므로 deleted 여부를 신경쓰지 않아도 된다.

다만 주의해야 할 점이 QueryDSL을 사용 할 때는 적용되지 않는다. 그리고 @SQLDelete와 마찬가지로 Hard Delete와의 혼용을 불편하게 만드는 단점이 있다. 그래서 상황에 맞게 사용 여부를 신중히 결정한 후 사용하는게 좋을 것이다!

profile
응애 개발자입니다.

0개의 댓글