[Jpa] jpaRepository.save() 에서 DataIntegrityViolationException 이 나지 않는 경우 (kotlin)

최대한·2022년 4월 30일
0

서론


Spring Data Jpa 를 사용할 때, unique index 가 달려있는 column 에 이미 저장되어있는 value 로 JpaRepository.save() 할 경우 DataIntegrityViolationException 이 발생함으로써 중복을 방지할 수 있다.
하지만 해당 칼럼이 Pk 일 경우 Unique index 임에도 불구하고 DataIntegrityViolationException 은 발생하지 않는데 이를 해결하는 방안을 공유하고자 한다.

본론


Tech stack

  • Kotlin 1.6.21
  • Spring boot 2.6.7
  • kotest 5.2.3

1. 현상


@Entity
class PaymentTransaction (@Id var id: UUID, var amount: Int)

interface PaymentTransactionRepo: JpaRepository<PaymentTransaction, UUID>

@DataJpaTest
internal class PaymentTransactionTest {

    @Autowired lateinit var repo: PaymentTransactionRepo

    @Test
    fun `save dataIntegrityViolationException test`() {
        val id = UUID.randomUUID()
        val original = PaymentTransaction(id = id, amount = 3)
        val duplicated = PaymentTransaction(id = id, amount = 5000)

        repo.save(original)
        repo.save(duplicated)

        val paymentTransaction = repo.findByIdOrNull(id)

        paymentTransaction shouldNotBe null; paymentTransaction!!
        
        paymentTransaction.amount shouldBe original.amount
    }
}

2. 결과


expected:<3> but was:<5000>
Expected :3
Actual   :5000
<Click to see difference>

io.kotest.assertions.AssertionFailedError: expected:<3> but was:<5000>
	at app//io.vitamaxdh.springweb.PaymentTransactionTest.save dataIntegrityViolationException test(PaymentTransactionTest.kt:36)

결과에서 볼 수 있다시피, DataIntegrityViolationException 이 발생해야함에도 불구하고 익셉션이 발생하지도 않았고, db 에 들어간 amount 조차 중복된 데이터로 update 된 것을 확인할 수 있다. 즉 insert 가 아닌 upsert 가 이루어진 것.

3. 원인


3.1 SimpleJpaRepository.java

@Transactional
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}

JpaRepository 의 구현체의 save() 를 살펴보면, 해당 객체가 isNew 인지 판단하고 새로운 객체일 경우에만 em.persist 를 통하여 insert 를 수행하고, 이미 db 에 저장되어있는 객체일 경우 merge 로 update 를 수행하는 것.

그럼 isNew() 는 어떻게 판단하는걸까?

3.2 JpaMetamodelEntityInformation.java

@Override
	public boolean isNew(T entity) {

		if (!versionAttribute.isPresent()
				|| versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
			return super.isNew(entity);
		}

		BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);

		return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
	}
  • Metamodel 은 Jpa Entity 모델의 각 property 변경 대응 및 object 생성 을 방지하려고 static 하게 접근하게 해주는 클래스
  • SingularAttribute 는 각 Entity property (column) 에 대응되는 클래스.
  • 여기서는 @Version 을 사용하지 않으니 해당사항이 없어서 super.isNew(entity) 로 넘어간다

https://stackoverflow.com/questions/19704407/what-is-staticmetamodel-and-singularattributeobj-obj
https://www.baeldung.com/hibernate-criteria-queries-metamodel

3.3 AbstractEntityInformation

	public boolean isNew(T entity) {

		ID id = getId(entity);
		Class<ID> idType = getIdType();

		if (!idType.isPrimitive()) {
			return id == null;
		}

		if (id instanceof Number) {
			return ((Number) id).longValue() == 0L;
		}

		throw new IllegalArgumentException(String.format("Unsupported primitive id type %s!", idType));
	}
  • id 가 primitive 가 아니고, id 에 값이 null 인 경우에만 isNew() 로 판단

즉, save 하려는 Entity 의 id 타입기본형이 아니고, id 미리 설정하고 save 하려했기 때문에 isNew() 가 false 가 되었고, EntityManger.merge() 메소드를 호출했던 것

그래서 해결은..?

해결 방안 (결론)


https://stackoverflow.com/questions/40605834/spring-jparepositroy-save-does-not-appear-to-throw-exception-on-duplicate-save 를 참조해보면, 크게 2가지 방법이 있다.

  1. save 메서드를 수행하는 곳에서 강제로 em.persist() 메서드 실행
  2. Persistable 을 Implement 하여 isNew() 를 커스텀하게 구현

필자는 처음 이 문제를 맞닥뜨렸을 때, 찝찝한 마음으로 1 번과 같은 방식으로 했으나, PR 을 올렸을 때 왜 EntityManager 를 사용했냐는 질문을 받았고, 조금 더 elegance 한 방법을 찾아본 결과 Persistable 을 구현함으로써 해결할 수 있었다.

1. DataIntegrityViolationException

@Entity
class PaymentTransaction (
    @Id
    @JvmField 	// Platform declaration clash: 방지 (중복 getId()) 
    var id: UUID,
    
    var amount: Int,
    
    @Transient 
    var update: Boolean = false
): Persistable<UUID> {
    override fun getId(): UUID = id

    override fun isNew(): Boolean = !update
}

...

	@Test
    fun `save dataIntegrityViolationException test`() {
        val id = UUID.randomUUID()
        val original = PaymentTransaction(id = id, amount = 3)
        val duplicated = PaymentTransaction(id = id, amount = 5000)

        repo.save(original)
        val exception = shouldThrow<DataIntegrityViolationException> {
            repo.save(duplicated)
        }
        println("exception message: ${exception.message}")
    }
    

exception message: A different object with the same identifier value was already associated with the session : [io.vitamaxdh.springweb.PaymentTransaction#c7d95667-2937-47e2-949c-f064b02fcb29]; nested exception is javax.persistence.EntityExistsException: A different object with the same identifier value was already associated with the session : [io.vitamaxdh.springweb.PaymentTransaction#c7d95667-2937-47e2-949c-f064b02fcb29]

성공적으로 Exception 이 발생했음을 알 수 있다.

2. Entity Update

    @Test
    fun `update test`() {
        val id = UUID.randomUUID()
        val original = PaymentTransaction(id = id, amount = 3)
        val updated = PaymentTransaction(id = id, amount = 5000, update = true)

        repo.save(original)
        repo.save(updated)
        val transaction = repo.findByIdOrNull(id)

        transaction shouldNotBe null; transaction!!
        transaction.amount shouldBe 5000
    }
  • update 를 할 경우 간단히 update = true 값만 설정해주면 된다.
profile
Awesome Dev!

0개의 댓글