[EF Core] 데이터 삭제 시 소프트 삭제 적용

DB에서 데이터를 삭제하면 일반적으로 복구할 수 없습니다.

또한 관계에 따라 영구 삭제 자체가 어려울 수도 있습니다.

그래서 데이터를 영구 삭제하는 대신 IsDeleted 속성을 true로 주고 IsDeleted 속성을 필터링해서 조회하는 방법을 사용하기도 합니다. 이를 소프트 삭제라고 합니다.

그런데 EF에서 알아서 데이터 삭제 시 소프트 삭제를 하고 쿼리시 IsDeleted 속성을 체크해서 삭제한 데이터를 제외한 데이터만 쿼리하게 하는 방법은 없을까요?

삭제 뿐만 아니라 생성 및 수정 시 그 시각을 기록하는 것도 코드에 포함됩니다.

이를 가능하게 하는 두가지 방법을 소개합니다.

1. DbContext의 SaveChanges()SaveChangesAsync()를 오버라이드 하는 방법

제가 사용한 방법입니다. DbContextSaveChanges() 메서드를 오버라이드 할 수 있는데 다음의 코드를 통해 생성/수정/삭제에 대한 처리를 해줄 수 있습니다.

다음처럼 모든 엔터티의 부모인 BaseEntity.cs를 정의합니다. 이 클래스를 상속한 엔터티는 생성/수정/삭제시 사용자ID와 그 시각을 기록하게 됩니다.

| BaseEntity.cs

[Index(nameof(CreateId))]
[Index(nameof(CreateAt), AllDescending = true)]
[Index(nameof(DeleteId))]
public class BaseEntity
{
    [Required]
    public Uid? CreateId { get; set; }
    [Required]
    public DateTime CreateAt { get; set; }
    public Uid? UpdateId { get; set; }
    public DateTime? UpdateAt { get; set; }
    public Uid? DeleteId { get; set; }
    public DateTime? DeleteAt { get; set; }

    public bool IsDeleted { get; set; }
}

SaveChanges() 메서드에서 호출할 다음의 메서드를 구현합니다.

   private static void ApplyCRUDMeta(ChangeTracker changeTracker, Uid userId)
   {
       if (userId == Uid.Empty)
           userId = Uid.Create("system");

       var changeSet = changeTracker.Entries<BaseEntity>();
       foreach (var entry in changeSet)
       {
           if (entry.State is EntityState.Added)
           {
               entry.Entity.CreateId = userId;
               entry.Entity.CreateAt = DateTime.Now;
           }
           else if (entry.State is EntityState.Modified)
           {
               entry.Property(x => x.CreateId).IsModified = false;
               entry.Property(x => x.CreateAt).IsModified = false;

               entry.Entity.UpdateId = userId;
               entry.Entity.UpdateAt = DateTime.Now;
               entry.Entity.IsDeleted = false;
           }
           else if (entry.State is EntityState.Deleted)
           {
               entry.Property(x => x.CreateId).IsModified = false;
               entry.Property(x => x.CreateAt).IsModified = false;
               entry.Property(x => x.UpdateId).IsModified = false;
               entry.Property(x => x.UpdateAt).IsModified = false;

               entry.State = EntityState.Modified;
               entry.Entity.DeleteId = userId;
               entry.Entity.DeleteAt = DateTime.Now;
               entry.Entity.IsDeleted = true;
           }
       }
   }

추적하고 있는 데이터 중 추가(Added)되거나 변경(Modified)되거나 삭제(Deleted)된 목록에 대한 관련 속성을 변경하고 삭제 시 IsDeleted 속성을 true로 하는 것으로 변경합니다.

그런 다음 SaveChanges()SaveChangesAsync() 메서드를 오버라이드 합니다.

    public override int SaveChanges()
    {
        var userId = Uid.Empty; // 테스트
        ApplyCRUDMeta(ChangeTracker, userId);

        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        var userId = Uid.Empty; // 테스트
        ApplyCRUDMeta(ChangeTracker, userId);

        return base.SaveChangesAsync(cancellationToken);
    }

이제 삭제 시 영구 삭제되지 않고 IsDeleted 속성이 true가 되게 됩니다.

2. SaveChangesInterceptor를 상속받아 구현 한 후 인터셉터 등록하는 방법

SaveChangesInterceptor 클래스를 상속받아 구현한 후 인터셉터로 등록하는 방법도 있습니다. 다음은 예시 코드 입니다.

public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null)
        {
            return base.SavingChangesAsync(
                eventData, result, cancellationToken);
        }

        IEnumerable<EntityEntry<ISoftDeletable>> entries =
            eventData
                .Context
                .ChangeTracker
                .Entries<ISoftDeletable>()
                .Where(e => e.State == EntityState.Deleted);

        foreach (EntityEntry<ISoftDeletable> softDeletable in entries)
        {
            softDeletable.State = EntityState.Modified;
            softDeletable.Entity.IsDeleted = true;
            softDeletable.Entity.DeletedOnUtc = DateTime.UtcNow;
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}
services.AddSingleton<SoftDeleteInterceptor>();

services.AddDbContext<ApplicationDbContext>(
    (sp, options) => options
        .UseSqlServer(connectionString)
        .AddInterceptors(
            sp.GetRequiredService<SoftDeleteInterceptor>()));

쿼리에서 IsDeletedfalse인 것만 필터링

이제 IsDeleted 속성을 일일이 확인하지 않고도 소프트 삭제한 것은 필터링 되도록 해야 합니다. 다음처럼 할 수 있습니다.

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Review>().HasQueryFilter(r => !r.IsDeleted);
    }

하지만 이 방법은 BaseEntity를 상속받아 구현한 모든 엔터티를 적어줘야 하므로 불편합니다. (실수할 수 도 있고요) 그래서 다음처럼 사용합니다.

public static class ModelBuilderExtensions
{
    public static void ApplyGlobalFilter<TInterface>(this ModelBuilder modelBuilder, Expression<Func<TInterface, bool>> expression)
    {
        var entities = modelBuilder.Model
            .GetEntityTypes()
            .Where(t => t.BaseType == null)
            .Select(t => t.ClrType)
            .Where(t => typeof(TInterface).IsAssignableFrom(t));
        foreach (var entity in entities)
        {
            var newParam = Expression.Parameter(entity);
            var newbody = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), newParam, expression.Body);
            modelBuilder.Entity(entity).HasQueryFilter(Expression.Lambda(newbody, newParam));
        }
    }
}

이제 다음처럼 등록할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyGlobalFilter<BaseEntity>(x => x.IsDeleted == false);
}

관리자 페이지 등에서 삭제된 데이터까지 보고자 할 때

관리자 페이지에서 삭제된 데이터를 다시 복구 시키는 기능이 필요하다고 칩시다. 그럴 때 쿼리를 다음처럼 해서 필터링 하지 않고 결과를 얻을 수 있습니다.

dbContext.Reviews
    .IgnoreQueryFilters()
    // ....
    .ToList();

정리

주의할 것은 BaseEntity를 상속받은 모든 엔터티에 필터가 적용되므로 IsDeleted 속성에 인덱스를 걸어주는 것이 좋습니다.

간략하게 소프트 삭제를 EF에 적용하는 방법에 대해서 살펴보았습니다.

12개의 좋아요

저는 생성일과 수정일 필드를 자동으로 처리하기 위해, ChangeTracker.StateChanged 와 ChangeTracker.Tracked 이벤트를 사용했는데, NoTracking 일 때 적용되지 않는 문제가 있어 항상 걱정이었습니다.

그런데, SaveChanges 를 재정의하는 방식이면 그런 우려를 할 필요가 없을 것 같네요.

다른 코드도 상당히 도움이 많이 됩니다.
감사합니다.

6개의 좋아요

잘 정리되었고 유용하네요. 감사합니다.

2개의 좋아요

개인적으로 EF Core 자체적으로 SoftDelete를 지원해줬으면 좋겠어요.
모델 클래스 키워드로 [SoftDelete(“Deleted_at”)] 이런식으로 추가만 해놓으면,
자동으로 컬럼이 추가되고 일련의 작업을 알아서 해준다던지 하는 방식…

2개의 좋아요

잘 보았습니다. 2번 방법으로 테스트 완료 했습니다.
ISoftDeletable 로 ForeignKey 해 놓은 하위 레코드도 Cascade 되어 같이 IsDelete = true 되는것을 확인 하니 너무 반갑네요.

SaveChangesInterceptor 기법이랑 ApplyGlobalFilter 도 잘 킵 해 놓고 사용하겠습니다. 좋은 코드 공유 감사합니다.