[EF Core] 데이터 삭제 시 소프트 삭제 적용
DB에서 데이터를 삭제하면 일반적으로 복구할 수 없습니다.
또한 관계에 따라 영구 삭제 자체가 어려울 수도 있습니다.
그래서 데이터를 영구 삭제하는 대신 IsDeleted
속성을 true
로 주고 IsDeleted
속성을 필터링해서 조회하는 방법을 사용하기도 합니다. 이를 소프트 삭제라고 합니다.
그런데 EF에서 알아서 데이터 삭제 시 소프트 삭제를 하고 쿼리시 IsDeleted
속성을 체크해서 삭제한 데이터를 제외한 데이터만 쿼리하게 하는 방법은 없을까요?
삭제 뿐만 아니라 생성 및 수정 시 그 시각을 기록하는 것도 코드에 포함됩니다.
이를 가능하게 하는 두 가지 방법을 소개합니다.
1. DbContext의 SaveChanges()
및 SaveChangesAsync()
를 오버라이드 하는 방법
제가 사용한 방법입니다. DbContext
는 SaveChanges()
메서드를 오버라이드 할 수 있는데 다음의 코드를 통해 생성/수정/삭제
에 대한 처리를 해줄 수 있습니다.
다음처럼 모든 엔터티의 부모인 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>()));
쿼리에서 IsDeleted
가 false
인 것만 필터링
이제 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에 적용하는 방법에 대해서 살펴보았습니다.