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
@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
}
}
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
가 이루어진 것.
@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() 는 어떻게 판단하는걸까?
@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
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));
}
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가지 방법이 있다.
- save 메서드를 수행하는 곳에서 강제로 em.persist() 메서드 실행
- Persistable 을 Implement 하여 isNew() 를 커스텀하게 구현
필자는 처음 이 문제를 맞닥뜨렸을 때, 찝찝한 마음으로 1 번과 같은 방식으로 했으나, PR 을 올렸을 때 왜 EntityManager 를 사용했냐는 질문을 받았고, 조금 더 elegance 한 방법을 찾아본 결과 Persistable 을 구현함으로써 해결할 수 있었다.
@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 이 발생했음을 알 수 있다.
@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
}