Skip to main content

Command Palette

Search for a command to run...

엔티티 프레임워크 코어로 소프트 삭제 전략을 구현하는 방법 | Khalid Abuhakmeh

Updated
10 min read

Khalid Abuhakmeh님의 How to Implement a Soft Delete Strategy with Entity Framework Core를 번역하였습니다.


개발 경력을 쌓는 동안 '삭제'의 정의에 대해 혼란스러웠던 경험이 있을 것입니다. 그렇다면 사용자가 "내 데이터 삭제"라고 말하는 것은 무엇을 의미할까요? 저와 같은 사람이라면 사용자가 사용자 인터페이스를 어지럽히는 정보를 논리적으로 삭제하고 싶다는 뜻이지, 존재하지 않는 기록을 영구적으로 삭제하고 싶다는 뜻이 아니라는 것을 금방 알 수 있을 것입니다... 웁스 😬.

많은 개발자는 뼈아픈 교훈을 얻은 후 실수로 삭제한 데이터를 되돌리고 데이터 무결성을 유지하며 일반적인 관리 감독을 수행할 수 있는 소프트 삭제 전략으로 전환합니다. 또한 법에 따라 일정 기간 동안 데이터를 보존해야 할 수도 있는데, 이 전략은 이러한 요건을 충족하는 데 도움이 될 수 있습니다.

이 게시물에서는 엔티티 프레임워크 코어로 소프트 삭제 전략을 구현하는 방법과 선택한 데이터베이스 엔진에 쓰고 읽는 동안 이를 사용하는 방법을 살펴봅니다.

소프트 삭제란 무엇인가요?

소개에서 언급했듯이 애플리케이션 개발에는 물리적 삭제와 논리적 삭제의 두 가지 유형이 있습니다.

물리적 삭제는 데이터 저장소에서 레코드를 제거하며 매우 파괴적입니다. 물리적 삭제를 통해 삭제된 데이터는 손실되며, 시스템 관리자만 극단적인 조치나 백업을 사용하여 데이터를 복구할 수 있습니다. 물리적 삭제는 일반적으로 데이터 저장소 엔진의 메커니즘을 사용하여 되돌릴 수 없는 명령을 실행합니다. 예를 들어, SQL 기반 데이터베이스는 DELETE 문을 실행하여 테이블에서 레코드를 제거할 수 있습니다(실수로 WHERE 절을 잊어버린 적이 있다면 손 들어 보세요).

DELETE FROM dbo.Movies
WHERE Movies.Id = '1'

반대로, 소프트 삭제는 개발 팀이 쿼리 중에 무시할 레코드를 표시하기 위해 내린 논리적 결정입니다. 데이터 모델의 루트 요소에는 삭제 시간을 나타내는 부울 플래그 또는 타임스탬프와 같은 일종의 플래그가 있습니다. 루트 요소에 적용되는 쿼리는 삭제 표시기를 결과 집합을 생성하는 요소로 사용할지 여부를 명시적으로 지정해야 합니다.

예를 들어, 다음은 레코드를 반환하는 SQL 쿼리이지만 IsDeleted 비트 열이 "false"에 대해 0으로 설정된 경우에만 레코드를 반환하는 쿼리입니다.

SELECT * FROM dbo.Movies
WHERE Movies.Id = '1' AND Movies.IsDeleted = 0

당신 또는 당신의 고객이 데이터를 복구하려는 경우 삭제 표시기의 값을 변경하는 것만큼이나 간단하게 삭제된 데이터를 복구할 수 있습니다.

UPDATE Movies
SET Movies.IsDeleted = 0
WHERE Id = '1';

소프트 삭제 마커는 삭제 표시기를 적용할 시기와 위치를 고려해야 하므로 기존 시스템에 구현하기가 더 어렵습니다. 또한 추가 인덱스와 레코드 수 증가로 인해 약간의 오버헤드가 발생할 수 있습니다. 디스크 공간이나 I/O 제한이 있는 경우 이러한 단점을 고려해야 합니다.

이제 소프트 삭제 전략의 구성 요소에 대한 일반적인 아이디어를 얻었으므로, Entity Framework Core를 사용하여 이를 구현해 보겠습니다.

인터셉터를 사용한 엔티티 프레임워크 소프트 삭제

엔티티 프레임워크 코어에는 실행 파이프라인을 확장하는 접근 방식인 인터셉터라는 개념이 포함되어 있습니다. 인터셉터에는 여러 가지 유형이 있으며, 표준 구현을 통해 SQL 명령을 수정하고, 변경 사항을 저장하기 전에 엔티티를 변경하고, 감사 기술을 사용할 수 있습니다.

이 예제에서는 인터셉터를 사용하여 애플리케이션의 작성 단계에서 엔티티를 수정합니다. 먼저 소프트 삭제를 지원하도록 조정할 Movie 엔티티를 정의해 보겠습니다.

public class Movie 
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Writer { get; set; } = "";
    public string Director { get; set; } = "";
    public int ReleaseYear { get; set; }

    public override string ToString()
        => $"{Id}: {Title} ({ReleaseYear})";
}

재사용을 늘리기 위해 ISoftDelete 인터페이스를 만들어 공유 프로퍼티와 삭제 취소를 위한 구현을 제공하겠습니다. 소프트 삭제 전략에는 IsDeleted 또는 DeletedAt 속성 중 하나만 사용해도 충분하지만, 이 예제에서는 최대한 상세하게 설명하기 위해 두 가지 속성을 모두 추가했습니다. 이 소프트 삭제 접근 방식을 채택하려는 경우 이러한 속성 중 하나만 사용하면 됩니다.

public interface ISoftDelete
{
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
    public void Undo()
    {
        IsDeleted = false;
        DeletedAt = null;
    }
}

인터페이스를 Movie 엔티티에 적용하고 최종 엔티티 정의를 확인합니다.

public class Movie : ISoftDelete
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Writer { get; set; } = "";
    public string Director { get; set; } = "";
    public int ReleaseYear { get; set; }

    public override string ToString()
        => $"{Id}: {Title} ({ReleaseYear})";
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
}

삭제하려는 모든 엔티티에 삭제 플래그를 설정할 수도 있지만, 그렇게 하면 지루할 뿐 아니라 오류가 발생하기 쉽습니다. 따라서 EF 코어 인프라를 활용하여 SoftDeleteInterceptor를 작성해 보겠습니다.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, 
        InterceptionResult<int> result)
    {
        if (eventData.Context is null) return result;

        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            if (entry is not { State: EntityState.Deleted, Entity: ISoftDelete delete }) continue;
            entry.State = EntityState.Modified;
            delete.IsDeleted = true;
            delete.DeletedAt = DateTimeOffset.UtcNow;
        }
        return result;
    }
}

DbContext 인스턴스에서 SaveChanges를 호출하면 이 인터셉터는 변경 추적기의 항목이 ISoftDelete를 구현하는지 확인합니다. 그렇다면 인터셉터는 엔티티 상태를 Deleted(삭제됨)에서 Modified(수정됨)로 변경하고 모든 소프트 삭제 속성을 설정합니다.

구현에서 볼 수 있듯이 이 인터셉터는 데이터베이스별 기능을 호출하기 전에 EF 코어 구조체와 함께 작동합니다. 이 인터셉터는 SQL Server, PostgreSQL, SQLite 및 MySQL을 포함하되 이에 국한되지 않는 EF Core에서 지원하는 모든 데이터베이스 공급자와 함께 작동합니다. 이 샘플에서는 Microsoft.EntityFrameworkCore.InMemory 패키지를 사용했지만 원하는 공급자로 자유롭게 대체할 수 있습니다.

쓰기 단계 수정을 완료하는 마지막 단계는 초기화의 OnConfiguring 단계에서 AddInterceptors 호출을 사용하여 인터셉터를 DbContext 정의로 등록하는 것입니다.

using Microsoft.EntityFrameworkCore;
public class Database : DbContext
{
    public DbSet<Movie> Movies => Set<Movie>();
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseInMemoryDatabase("test")
            .AddInterceptors(new SoftDeleteInterceptor());
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
    }
}

EF Core DbContext를 사용하여 데이터베이스에서 엔티티를 제거하려고 시도하면 삭제 문에서 업데이트 문으로 전환됩니다. 이제 실제로 작동하는 모습을 살펴보겠습니다.

var db = new Database();
var firstMovie = db.Movies.First();

Console.WriteLine($"{firstMovie.Title} ({firstMovie.ReleaseYear})");

// delete operation (actually an update)
db.Movies.Remove(firstMovie);
db.SaveChanges();
Console.WriteLine($"Deleted \"{firstMovie}\"");

이미 눈치채셨겠지만, 코드는 기존의 EF 코어와 비슷해 보입니다. 데이터 읽기는 어떨까요? 삭제된 레코드를 어떻게 필터링할까요? 다음 섹션에서 그 방법을 살펴보겠습니다.

자동으로 소프트 삭제된 레코드 필터링

삭제할 레코드를 표시하는 것은 절반의 이야기일 뿐입니다. 단일 구성으로 쿼리를 실행할 때 소프트 삭제된 레코드를 무시하도록 EF Core에 지시할 수 있으며, 엔티티 정의에 쿼리 필터를 사용하여 이를 수행할 수 있습니다. 예를 들어, Movies 컬렉션에 쿼리 필터를 사용하여 수정된 DbContext 정의는 여기에 있습니다.

public class Database : DbContext
{
    public DbSet<Movie> Movies => Set<Movie>();
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseInMemoryDatabase("test")
            .AddInterceptors(new SoftDeleteInterceptor());
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Automatically adding query filter to 
        // all LINQ queries that use Movie
        modelBuilder.Entity<Movie>()
            .HasQueryFilter(x => x.IsDeleted == false);
    }
}

쿼리 필터는 원하는 만큼 적용할 수 있지만, 쿼리 필터는 일반적으로 LINQ 쿼리에서 보이지 않으므로 필요한 만큼만 제한하는 것이 좋습니다. 쿼리 필터의 '보이지 않는' 특성은 팀의 개발자가 이러한 개념을 이해하고 관리해야 한다는 것을 의미합니다. 필터가 너무 많으면 혼란스러워지고 예기치 않은 버그가 발생할 수 있습니다.

읽기 및 쓰기로 모든 것을 통합하기

아래에 간단한 샘플 애플리케이션을 만들었는데, 이전 섹션의 DbContext를 고려할 때 예상할 수 있는 것입니다.

using Microsoft.EntityFrameworkCore;
using SoftDeletes.Models;

// save test data of movies
Movies.Initialize();

var db = new Database();
var firstMovie = db.Movies.First();

Console.WriteLine($"{firstMovie.Title} ({firstMovie.ReleaseYear})");

// delete operation
db.Movies.Remove(firstMovie);
db.SaveChanges();
Console.WriteLine($"Deleted \"{firstMovie}\"");

Console.WriteLine($"Total Movies: {db.Movies.Count()}");
Console.WriteLine($"Total Movies (including deleted): {db.Movies.IgnoreQueryFilters().Count()}");
Console.WriteLine($"Total Deleted: {db.Movies.IgnoreQueryFilters().Count(x => x.IsDeleted)}");

public static class Movies
{
    public static readonly IReadOnlyList<Movie> All = new List<Movie> {
        new() { Id = 1, Title = "Glass Onion", Director = "Rian Johnson", Writer = "Rian Johnson", ReleaseYear = 2022 },
        new() { Id = 2, Title = "Avatar: The Way of Water", Director ="James Cameron", Writer = "James Cameron", ReleaseYear = 2022 },
        new() { Id = 3, Title = "The Shawshank Redemption", Writer = "Stephen King", Director = "Frank Darabont", ReleaseYear = 1994 },
        new() { Id = 4, Title = "Pulp Fiction", Writer = "Quentin Tarantino", Director = "Quentin Tarantino", ReleaseYear = 1994 },
        new() { Id = 5, Title = "Seven Samurai", Writer = "Akira Kurosawa", Director = "Akira Kurosawa", ReleaseYear = 1954 },
        new() { Id = 6, Title = "Gladiator", Writer = "David Franzoni", Director = "Ridley Scott", ReleaseYear = 2000 },
        new() { Id = 7, Title = "Old Boy", Writer = "Garon Tsuchiya", Director = "Park Chan-wook", ReleaseYear = 2003 },
        new() { Id = 8, Title = "A Clockwork Orange", Director = "Stanley Kubrick", Writer = "Stanley Kubrick", ReleaseYear = 1971 },
        new() { Id = 9, Title = "Metroplis", Director = "Fritz Lang", Writer = "Thea von Harbou", ReleaseYear = 1927 },
        new() { Id = 10, Title = "The Thing", Director = "John Carpenter", Writer = "Bill Lancaster", ReleaseYear = 1982 }
    };

    public static void Initialize()
    {
        var db = new Database();
        db.Movies.AddRange(All);
        db.SaveChanges();
    }
}

코드 어디에서도 IsDeleted 플래그에 대한 언급을 찾을 수 없습니다. 읽기/쓰기 사용에서 ISoftDelete 속성이 없는 것은 EF Core 인터셉터와 쿼리 필터가 속성을 투명하게 사용하기 때문입니다.

또한 쿼리 필터를 무효화하려면 모든 LINQ 쿼리에 IgnoreQueryFilters를 사용하면 완전한 액세스 권한을 얻어 LINQ 쿼리를 작성할 수 있습니다.

// after a delete
db.Movies.Count(); // 9
db.Movies.IgnoreQueryFilters().Count(); // 10

전체 애플리케이션을 하나의 파일로 살펴보겠습니다.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

// save test data of movies
Movies.Initialize();

var db = new Database();
var firstMovie = db.Movies.First();

Console.WriteLine($"{firstMovie.Title} ({firstMovie.ReleaseYear})");

// delete operation
db.Movies.Remove(firstMovie);
db.SaveChanges();
Console.WriteLine($"Deleted \"{firstMovie}\"");

Console.WriteLine($"Total Movies: {db.Movies.Count()}");
Console.WriteLine($"Total Movies (including deleted): {db.Movies.IgnoreQueryFilters().Count()}");
Console.WriteLine($"Total Deleted: {db.Movies.IgnoreQueryFilters().Count(x => x.IsDeleted)}");

public static class Movies
{
    public static readonly IReadOnlyList<Movie> All = new List<Movie> {
        new() { Id = 1, Title = "Glass Onion", Director = "Rian Johnson", Writer = "Rian Johnson", ReleaseYear = 2022 },
        new() { Id = 2, Title = "Avatar: The Way of Water", Director ="James Cameron", Writer = "James Cameron", ReleaseYear = 2022 },
        new() { Id = 3, Title = "The Shawshank Redemption", Writer = "Stephen King", Director = "Frank Darabont", ReleaseYear = 1994 },
        new() { Id = 4, Title = "Pulp Fiction", Writer = "Quentin Tarantino", Director = "Quentin Tarantino", ReleaseYear = 1994 },
        new() { Id = 5, Title = "Seven Samurai", Writer = "Akira Kurosawa", Director = "Akira Kurosawa", ReleaseYear = 1954 },
        new() { Id = 6, Title = "Gladiator", Writer = "David Franzoni", Director = "Ridley Scott", ReleaseYear = 2000 },
        new() { Id = 7, Title = "Old Boy", Writer = "Garon Tsuchiya", Director = "Park Chan-wook", ReleaseYear = 2003 },
        new() { Id = 8, Title = "A Clockwork Orange", Director = "Stanley Kubrick", Writer = "Stanley Kubrick", ReleaseYear = 1971 },
        new() { Id = 9, Title = "Metroplis", Director = "Fritz Lang", Writer = "Thea von Harbou", ReleaseYear = 1927 },
        new() { Id = 10, Title = "The Thing", Director = "John Carpenter", Writer = "Bill Lancaster", ReleaseYear = 1982 }
    };

    public static void Initialize()
    {
        var db = new Database();
        db.Movies.AddRange(All);
        db.SaveChanges();
    }
}

public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, 
        InterceptionResult<int> result)
    {
        if (eventData.Context is null) return result;

        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            if (entry is not { State: EntityState.Deleted, Entity: ISoftDelete delete }) continue;

            entry.State = EntityState.Modified;
            delete.IsDeleted = true;
            delete.DeletedAt = DateTimeOffset.UtcNow;
        }

        return result;
    }
}

public class Database : DbContext
{
    public DbSet<Movie> Movies => Set<Movie>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseInMemoryDatabase("test")
            .AddInterceptors(new SoftDeleteInterceptor());

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Movie>()
            .HasQueryFilter(x => x.IsDeleted == false);
    }
}

public class Movie : ISoftDelete
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Writer { get; set; } = "";
    public string Director { get; set; } = "";
    public int ReleaseYear { get; set; }

    public override string ToString()
        => $"{Id}: {Title} ({ReleaseYear})";

    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
}

public interface ISoftDelete
{
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }

    public void Undo()
    {
        IsDeleted = false;
        DeletedAt = null;
    }
}

위의 프로그램을 실행하면 다음과 같은 출력이 표시됩니다.

Glass Onion (2022)
Deleted "1: Glass Onion (2022)"
Total Movies: 9
Total Movies (including deleted): 10
Total Deleted: 1

이전 버전에 비해 EF Core를 사용하면 훨씬 더 간단하고 쉽게 작업을 수행할 수 있습니다. 데이터는 물리적으로 삭제되지 않고 논리적으로만 '삭제'된다는 점을 다시 한 번 상기시켜 드립니다. 엔티티를 삽입/삭제하는 코드는 동일하게 유지되며 쿼리 또한 일상적인 EF 쿼리와 크게 다르지 않습니다. 이 모든 것이 EF 인터셉터의 힘 덕분입니다.

결론

소프트 삭제 전략을 사용하면 데이터를 치명적으로 파괴할 위험 없이 의도한 사용자 경험을 제공할 수 있습니다. 이 기법에는 약간의 오버헤드가 있지만, EF Core 인프라와 간단한 인터페이스를 사용하면 이러한 문제를 극복할 수 있습니다. 쿼리 속도를 높이려면 테이블에 삭제 플래그에 대한 인덱스를 추가하는 것이 좋습니다. 또한 쿼리 필터와 인터셉터를 다른 문제에도 적용할 수 있습니다. 복잡한 비즈니스 작업을 해결하기 위한 다양한 접근 방식을 살펴볼 때 좋은 지식입니다.

읽어주셔서 감사드리며, 의견이나 질문이 있으시면 언제든지 댓글 섹션에 남겨주시기 바랍니다.


More from this blog

개발, 테스트, 운영에서의 도커 활용

핵심 원칙: "한 번 빌드하고, 어디서든 실행한다 (Build once, run anywhere)" 도커의 가장 큰 장점은 환경 일관성입니다. 동일한 도커 이미지를 사용하여 개발, 테스트, 운영 환경을 구성함으로써 "제 PC에서는 됐는데..." 하는 문제를 최소화할 수 있습니다. 1. 개발 단계 (Development) 목표: 빠른 코드 변경 반영, 쉬운 디버깅, 실제 운영 환경과 유사한 환경 구성. Docker 사용 방안: Dockerf...

May 9, 20256 min read15

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

DB에서 데이터를 삭제하면 일반적으로 복구할 수 없습니다. 또한 관계에 따라 영구 삭제 자체가 어려울 수도 있습니다. 그래서 데이터를 영구 삭제하는 대신 IsDeleted 속성을 true로 주고 IsDeleted 속성을 필터링해서 조회하는 방법을 사용하기도 합니다. 이를 소프트 삭제라고 합니다. 그런데 EF에서 알아서 데이터 삭제 시 소프트 삭제를 하고 쿼리시 IsDeleted 속성을 체크해서 삭제한 데이터를 제외한 데이터만 쿼리하게 하는 ...

Mar 18, 20243 min read19

[EF Core] ValueConverter를 이용해서 엔터티 속성의 도메인 관리

EF Core를 사용하면서 문자열 길이 등의 특성을 일일이 지정하는 것은 번거롭습니다. ... [MaxLength(32)] public string? 제목 { get; set; } 엔터티가 한 개일 때는 상관이 없으나 제목 유형이 여러 엔터티에 사용될 경우 유형을 지정하기 번거롭습니다. 속성 유형을 도메인으로 관리하면 참 편할텐데요, ValueConverter를 이용할 수 있습니다. 그런데 이것을 인터페이스 정적 추상를 사용해서 다음처럼 ...

Mar 16, 20242 min read8

디모이 블로그

154 posts

.NET 관련 기술을 선호하고 새로운 언어를 배우는데 관심이 있습니다.

엔티티 프레임워크 코어로 소프트 삭제 전략을 구현하는 방법 | Khalid Abuhakmeh