[Django] Django 에서 Soft Delete 구현하기

오제욱·2024년 10월 8일
0
post-thumbnail

1. Soft Delete가 필요한 이유

Soft Delete는 데이터 삭제를 관리하는 중요한 방식 중 하나로, 많은 곳에서 사용된다. 완전 삭제(hard delete)와 달리, Soft Delete는 데이터를 삭제된 것처럼 표시하지만 실제로 데이터베이스에서는 해당 데이터를 삭제하지 않는다.
다음과 같은 이유에서 Soft Delete 를 사용한다.

데이터 복구

종종 사용자가 실수로 데이터를 삭제하는 상황이 발생할 수 있는데 완전 삭제를 하면 복구가 어렵거나 불가능할 수 있다. 하지만 Soft Delete를 사용하면 데이터를 완전히 삭제하지 않고 ‘삭제된’ 상태로 유지하므로, 나중에 언제든지 해당 데이터를 복구할 수 있다. 특히 민감한 데이터나 중요한 데이터를 다룰 때는 Soft Delete가 유리할 수 있다.

참조된 데이터의 무결성 유지

데이터베이스의 테이블 간에는 종종 외래키 관계가 존재한다. 이런 제약이 걸린 데이터를 완전히 삭제할 경우, 관련된 데이터의 무결성이 깨질 수 있다. Soft Delete를 사용하면 데이터의 논리적 삭제만 이루어지므로, 참조된 데이터를 유지하면서도 삭제된 데이터처럼 취급할 수 있다.

비즈니스 로직 관리

비즈니스 요구사항에 따라 데이터를 물리적으로 삭제하지 않고 관리하는 것이 필요할 수 있다. 예를 들어, 쇼핑몰의 주문 내역, 사용자의 구매 기록, 리뷰와 같은 데이터를 실제로 삭제하지 않고 보관해야 하는 경우가 있다. Soft Delete는 이런 데이터를 “비활성화”된 상태로 표시할 수 있어 유연한 관리가 가능하다.

2. Soft Delete 구현 개념

Soft Delete는 데이터베이스에서 데이터를 삭제하지 않고, 해당 레코드가 삭제된 것처럼 보이게 만드는 것이다. 이를 위해 주로 Boolean 필드를 사용하여, 데이터의 상태를 deleted 또는 active로 구분한다.
Hard Delete 에서 삭제는 데이터베이스에서 delete 를 의미하고 Soft Delete 에서 삭제는 Update 를 의미한다.

그렇기 때문에 외래키 관계에서 몇가지 주의할 점이 있다.

Unique 처리

Soft Delete를 적용하는 모델이 특정 필드에 대해 unique 제약 조건을 가지고 있다면 레코드가 삭제되었을 때도 해당 필드의 값이 고유하게 유지되어야 할까? 이 문제를 해결하려면 삭제된 데이터를 unique 제약에서 제외시키는 방법을 사용해야 한다.

예를 들어, name 필드에 대한 unique 제약이 걸려 있는 경우, Soft Delete된 레코드가 새로운 레코드 추가에 방해가 되어서는 안 된다. 즉, deleted=True 상태인 레코드가 존재하더라도, 동일한 name 값을 가진 새로운 레코드를 생성할 수 있어야 한다.

이를 해결하기 위해 partial index 등 추가적인 작업이 필요하다.

CASCADE 처리

Soft Delete를 구현할 때, CASCADE 동작에 대해서도 신경을 써야 한다. CASCADE는 외래키 관계가 있는 경우 부모 모델이 삭제될 때 자식 모델도 함께 삭제되도록 하는 옵션이다. 하지만 Soft Delete에서는 완전히 삭제되지 않고 deleted 필드를 True로 설정하므로 별도의 처리가 필요하다.
CASCADE 삭제를 처리하기 위해서는 애플리케이션 또는 데이터베이스 레벨에서 트리거로 직접 구현해야한다.

3. Django에서의 구현

Django에서 Soft Delete를 실제로 구현하는 코드는 다음과 같다

1) SoftDeleteQuerySet 클래스

delete() 메소드를 오버라이드하여 실제 삭제 대신 deleted 플래그를 True로 설정하고, deleted_at을 설정한다. hard_delete()는 데이터를 실제로 삭제하는 메소드이다.

class SoftDeleteQuerySet(models.QuerySet):
   def delete(self):
       return super(SoftDeleteQuerySet, self).update(
           deleted=True, deleted_at=timezone.now()
       )

   def hard_delete(self):
       return super(SoftDeleteQuerySet, self).delete()

2) SoftDeleteManager 클래스

Soft Delete를 지원하는 모델의 기본 QuerySet을 설정한다. 여기서 deleted=False 조건을 걸어 삭제되지 않은 레코드만 가져오도록 한다.

class SoftDeleteManager(models.Manager):
    def get_queryset(self):
        return SoftDeleteQuerySet(self.model, using=self._db).filter(deleted=False)

3) SoftDelete 모델

Soft Delete 기능을 적용할 모델이다. delete()와 hard_delete() 메소드를 오버라이드하여 Soft Delete와 실제 삭제를 처리한다.

class SoftDelete(models.Model):
    deleted = models.BooleanField(default=False)
    name = models.CharField(max_length=255)

    objects = SoftDeleteManager()
    all_objects = AllObjectsManager()

    def delete(self, using=None, keep_parents=False):
        self.deleted = True
        self.save()

    def hard_delete(self, using=None, keep_parents=False):
        super(SoftDelete, self).delete(using, keep_parents)

4) unique 속성 처리 방법

unique 제약 조건을 Soft Delete에 적용하기 위해 Meta 클래스에 제약조건을 추가한다. deleted=False인 경우에만 name 필드가 고유해야 한다는 제약 조건을 추가할 수 있다. 이를 위해 models.UniqueConstraint와 condition을 사용한다.

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["name"],
            condition=models.Q(deleted=False),
            name="unique_name_partial_index",
        )
    ]

5) CASCADE 처리 방법

Soft Delete가 적용된 모델이 외래키로 다른 모델과 연결되어 CASCADE 옵션으로 설정 되었을 경우 연결된 레코드도 함께 Soft Delete 처리를 해줘야 한다.

class Foo(models.Model):
    deleted = models.BooleanField(default=False)
    deleted_at = models.DateTimeField(null=True, blank=True)
    name = models.CharField(max_length=255)

    def delete(self, using=None, keep_parents=False):
        self.deleted = True
        Bar.objects.filter(foo=self).delete()
        self.save()

4. Testing

Soft Delete 구현 되었는지 확인하는 Test Code 이다.

from django.test import TestCase
from soft_delete.models import SoftDelete, Foo, Bar


class SoftDeleteTestCase(TestCase):
    def test_create_and_soft_delete(self):
        """
        1. 객체를 생성하고 soft delete 시킨 이후에 같은 이름으로 생성이 잘 되는지 테스트
        """
        # 객체 생성
        instance = SoftDelete.objects.create(name="test")
        self.assertEqual(instance.name, "test")
        print(f"Created instance: {instance}")

        # Soft delete
        instance.delete()
        self.assertEqual(instance.deleted, True)
        print(f"Soft deleted instance: {instance}")

        # Soft delete 후, 같은 이름으로 새 객체 생성
        new_instance = SoftDelete.objects.create(name="test")
        self.assertEqual(SoftDelete.objects.count(), 1)  # deleted=False인 객체는 1개
        print(f"New instance after soft delete: {new_instance}")

    def test_create_twice_after_soft_delete(self):
        """
        2. 객체를 생성하고 soft delete 시킨 후, 같은 이름으로 두 번 생성 시 unique 제약 조건 확인
        """
        # 객체 생성 및 soft delete
        instance = SoftDelete.objects.create(name="test")
        instance.delete()
        self.assertEqual(instance.deleted, True)

        # 같은 이름으로 객체 첫 번째 생성 (정상)
        new_instance_1 = SoftDelete.objects.create(name="test")
        self.assertEqual(new_instance_1.name, "test")
        print(f"First creation after soft delete: {new_instance_1}")

        # 같은 이름으로 객체 두 번째 생성 (unique 제약 조건으로 인해 실패해야 함)
        with self.assertRaises(Exception):
            SoftDelete.objects.create(name="test")
        print("Second creation with the same name raised an exception as expected.")

    def test_soft_delete_after_creation(self):
        """
        3. 객체를 생성하고 soft delete 시킨 후, 같은 이름으로 다시 생성한 객체를 soft delete 했을 때 unique 제약 조건에 방해받지 않고 삭제되는지 확인
        """
        # 객체 생성 및 soft delete
        instance = SoftDelete.objects.create(name="test")
        instance.delete()
        self.assertEqual(instance.deleted, True)

        # 같은 이름으로 객체 다시 생성
        new_instance = SoftDelete.objects.create(name="test")
        self.assertEqual(new_instance.name, "test")
        print(f"Re-created instance: {new_instance}")

        # 다시 생성한 객체 soft delete
        new_instance.delete()
        self.assertEqual(new_instance.deleted, True)
        print(f"Soft deleted re-created instance: {new_instance}")

        # unique 제약 조건에 방해받지 않고 잘 삭제되는지 확인
        self.assertEqual(SoftDelete.objects.count(), 0)  # deleted=False인 객체는 0개
        print("Soft deleted successfully without unique constraint issues.")

    def test_soft_delete_CASCADE(self):
        """Soft Delete CASCADE 테스트"""
        # 객체 생성
        foo = Foo.objects.create(name="foo")
        bar = Bar.objects.create(name="bar", foo=foo)
        self.assertEqual(Foo.objects.count(), 1)
        self.assertEqual(Bar.objects.count(), 1)

        # foo 객체 soft delete
        foo.delete()
        bar.refresh_from_db()
        self.assertEqual(foo.deleted, True)
        self.assertEqual(bar.deleted, True)
        self.assertEqual(Foo.objects.count(), 0)
        self.assertEqual(Bar.objects.count(), 0)
        print("Soft deleted foo and bar objects successfully with CASCADE.")
profile
Django Python 개발자

0개의 댓글