들어가기 전

Soft Delete와 Hard Delete에 대해 설명하고 Kotlin을 이용하여 JPA에서 지원해 주는 Auditing과 JpaRepository를 이용하여 Soft Delete를 구현하는 예제에 대해 알아보겠습니다.

 

 

Hard Delete란?

DB에 저장되어 있는 데이터를 물리적으로 삭제하는 것을 의미합니다.

JPA에서 제공하는 메서드 deleteById, delete, deleteAll 등을 이용하거나  DML 중 하나인 delete 쿼리를 이용해서 물리적으로 데이터를 삭제할 수 있습니다.

 

 

Soft Delete란?

Hard Delete와 달리 DB에 저장되어 있는 데이터를 물리적이 아닌 논리적으로 삭제하는 것을 의미합니다.

논리적으로 삭제한다는 것은 테이블 컬럼 중 하나를 해당 데이터가 삭제된 데이터인지 아닌지 구분할 수 있는 컬럼을 만들어서 삭제될 때 컬럼의 값을 채워주고 DB상에 데이터는 남아있지만 삭제된 데이터로 구분이 되는 것을 의미합니다.

 

 

공통 코드

아래에서 Hard Delete와 Soft Delete 예제를 만들기 전에 사용되는 공통 코드를 먼저 작성해 보겠습니다.

 

@SpringBootApplication
@EnableJpaAuditing
class ApiApplication
fun main(args: Array<String>) {
    runApplication<ApiApplication>(*args)
}

 

  • Auditing을 사용하기 위해 @EnableJpaAuditing을 붙여줍니다.

 

@Entity
class AccountExample : BaseEntity()

 

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0

    @CreatedDate
    @Column(nullable = false, updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now()

    @LastModifiedDate
    @Column(nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.now()
}

 

 

 

Hard Delete

 

interface AccountExampleRepository : JpaRepository<AccountExample,Long>

 

 

@SpringBootTest
class AccountExampleRepositoryTest(
    accountExampleRepository: AccountExampleRepository,
) : FreeSpec(
    {
        "deleteById" - {
            "Hard Delete" - {
                // given
                val savedAccount1 = accountExampleRepository.save(AccountExample())
                val savedAccount2 = accountExampleRepository.save(AccountExample())

                // when
                accountExampleRepository.deleteById(savedAccount1.id)

                // then
                val findAllAccountExample = accountExampleRepository.findAll()
                findAllAccountExample.size shouldBe 1
            }
        }
    },
)
  • 2개의 데이터가 있어야 하는 데이터가 있어야 하는데 deleteById를 하여 Hard Delete가 일어나서 총데이터는 1개입니다.

 

발생하는 쿼리

 

 

 

 

Soft Delete

 

Soft Delete 같은 경우는 물리적 삭제가 아닌 논리적 삭제이기 때문에 JpaRepository를 바로 사용하지 않고 CustomRepository를 만들어서 JpaRepository에서 제공해 주는 메서드를 오버라이딩을  해줘야 합니다.

그리고 엔티티에도 프로퍼티를 하나 추가해줘야 합니다.

 

@Entity
class AccountExample(
    var deletedAt: LocalDateTime? = null,
) : BaseEntity()

 

interface AccountExampleRepository : JpaRepository<AccountExample, Long> {

    @Query("UPDATE AccountExample e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e.id = :id")
    @Modifying
    override fun deleteById(id: Long)
    

    @Query("UPDATE AccountExample e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e = :entity")
    @Modifying
    override fun delete(entity: AccountExample) 
    

    @Query("UPDATE AccountExample e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e IN :entities")
    @Modifying
    override fun deleteAll(entities: MutableIterable<AccountExample>)
    
}

 

  • 위와 같은 메서드를 오버라이딩 하여 Delete 쿼리문이 아닌 Update 쿼리를 발생시켜 논리적 삭제가 되도록 해줍니다.
  • 오버라이딩한 메서드를 호출하면 Entity에 정의한 프로퍼티 deletedAt에 엔티티가 삭제된 시간이 저장됩니다.
@SpringBootTest
class AccountExampleRepositoryTest(
    accountExampleRepository: AccountExampleRepository,
) : FreeSpec(
    {
        "deleteById" - {
        	"Soft Delete" - {
            	// given
            	val savedAccount1 = accountExampleRepository.save(AccountExample())
            	val savedAccount2 = accountExampleRepository.save(AccountExample())

            	// when
            	accountExampleRepository.deleteById(savedAccount1.id)

	            // then
    	        val findAllAccountExample = accountExampleRepository.findAll()
        	findAllAccountExample.size shouldBe 1
        	}
    	},
)

 

발생하는 쿼리

 

 

 

 

 

그런데 Soft Delete에 대한 테스트코드를 실행시켜 보면 실패를 하게 됩니다.

이유는 Soft Delete는 물리적 삭제가 아닌 논리적 삭제라서 데이터는 남아 있습니다.

 

그럼 논리적 삭제된 데이터는 조회를 해올 때 안 나오게 하는 방법 2가지에 대해 설명하겠습니다.

 

방법 1

쿼리 메서드를 이용

@Repository
interface AccountExampleRepository : JpaRepository<AccountExample, Long> {

    // 생략
    fun findAllByDeletedAtIsNull(): List<AccountExample> // 추가
}

 

@SpringBootTest
class AccountExampleRepositoryTest(
    accountExampleRepository: AccountExampleRepository,
) : FreeSpec(
    {
        "deleteById" - {
        	"Soft Delete" - {
            	// given
            	val savedAccount1 = accountExampleRepository.save(AccountExample())
            	val savedAccount2 = accountExampleRepository.save(AccountExample())

            	// when
            	accountExampleRepository.deleteById(savedAccount1.id)

	        // then
    	        val findAllAccountExample = accountExampleRepository.findAllByDeletedAtIsNull()
        	findAllAccountExample.size shouldBe 1
        	}
    	},
)

 

  • 이 테스트 코드는 성공을 하게 됩니다. 이유는 findAllByDeletedAtIsNull이라는 쿼리 메서드를 이용을 하게 되면 DeletedAt이 null인 데이터들만 조회를 해옵니다. 

발생하는 쿼리

 

 

그런데 이 방법에는 치명적인 단점이 있습니다.

 

단점

 

쿼리 메서드명을 쓸 때 xxxByDeletedAtIsNull을 붙여줘야 됩니다. 계속 붙여주다 보면 실수로 작성을 안 할 때도 있고 반복적인 작업을 한다는 치명적인 단점이 있습니다.

 

방법 2

엔티티에 @Where 이용하기

 

@Where(clause = "deleted_at is null")
@Entity
class AccountExample(
    var deletedAt: LocalDateTime? = null,
) : BaseEntity()
  • 위와 같이 엔티티에 @Where(clause = "deleted_at is null")만 붙여주면 삭제가 안된 데이터만 조회를 해옵니다.

 

@SpringBootTest
class AccountExampleRepositoryTest(
    accountExampleRepository: AccountExampleRepository,
) : FreeSpec(
    {
        "deleteById" - {
        	"Soft Delete" - {
            	// given
            	val savedAccount1 = accountExampleRepository.save(AccountExample())
            	val savedAccount2 = accountExampleRepository.save(AccountExample())

            	// when
            	accountExampleRepository.deleteById(savedAccount1.id)

	        // then
    	        val findAllAccountExample = accountExampleRepository.findAll()
                findAllAccountExample.size shouldBe 1
        	}
    	},
)

 

발생하는 쿼리

 

 

 

제 개인적인 생각은 방법 1보다는 방법 2가 더 좋은 방법이라고 생각을 합니다.

 

 

 

위에서 만든 Repository에 JpaRepository를
상속을 받아서 delete에 대한 메서드들을 재정의를 해주었습니다.

이 방법에 대한 단점은 새로 Repository를 만들 때마다

delete에 대한 메서드들을 재정의를 해줘야 하는 상황이 발생하게 됩니다.

그리고 엔티티에 deletedAt을 선언을 해주었는데 이것도 엔티티를 만들 때마다
deletedAt을 선언을 해줘야 한다는 단점이 있습니다.

그래서 이제부터 위에서 설명드린 단점들을 보완하는 방법에 대해서 알아보겠습니다.

 

엔티티에 deletedAt을 선언하는 방법에 대한 단점 보완방법

BaseEntity에 deletedAt 선언을 합니다.

 

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0

    @CreatedDate
    @Column(nullable = false, updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now()

    @LastModifiedDate
    @Column(nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.now()

    @Column(nullable = true)
    var deletedAt: LocalDateTime? = null
}

 

 

Repository를 생성할 때마다 JpaRepository에서 지원하는 delete에 대한 메서드 재정의하는 방법에 대한 단점 보완방법

 

공통 Repository 만들어서 공통 Repository에 JpaRepository 상속받아서 사용하는 방법입니다.

 

@NoRepositoryBean
interface BaseRepository<T : BaseEntity, ID : Serializable> : JpaRepository<T, ID> {
    @Query("UPDATE #{#entityName} e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e.id = :id")
    @Modifying
    override fun deleteById(@Param("id") id: ID)

    @Query("UPDATE #{#entityName} e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e IN :entities")
    @Modifying
    override fun deleteAll(@Param("entities") entities: MutableIterable<T>)
}

 

  • #{#entityName}은 BaseRepository를 상속받은 Respository를 사용하는 엔티티 값을 동적으로 바인딩해 줍니다.

 

사용 예시

 

@Repository
interface AccountExampleRepository : BaseRepository<AccountExample, Long>