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

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

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

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

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

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

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

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

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

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

| BaseEntity.cs

```csharp
[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()` 메서드에서 호출할 다음의 메서드를 구현합니다.

```csharp
   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()` 메서드를 오버라이드 합니다.

```csharp
    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` 클래스를 상속 받아 구현한 후 인터셉터로 등록하는 방법도 있습니다. 다음은 예시 코드 입니다.

```csharp
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);
    }
}
```

```csharp
services.AddSingleton<SoftDeleteInterceptor>();

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

## 쿼리에서 `IsDeleted`가 `false`인 것만 필터링

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

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

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

```csharp
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));
        }
    }
}
```

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

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

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

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

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

## 정리

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

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