JPA를 사용할 때 @GeneratedValue 자동 생성 전략을 사용하지 않고 직접 식별키(PK)를 할당하여 INSERT를 하려고 하면 문제가 생긴다.

@Entity
class Member(
    @Id
    var id: Long = 0L,

    var name: String,
)

memberRepository.save(Member(id = 1L, name = "name2"))

다음과 같이 Member를 만들고 생성하려고 하면 select 쿼리가 날아간 다음에 insert 쿼리가 발생하는 것을 확인할 수 있다.

select
    m1_0.id,
    m1_0.name 
from
    member m1_0 
where
    m1_0.id=?
---
insert 
into
    member
    (name,id) 
values
    (?,?)

왜 그런지 디버깅을 해보자.

save 메서드 구현 부분을 보면 entityInformation.isNew 에서 해당 엔티티가 새로운 객체인지 판단한다.

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

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

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

여기서 현재 구현체를 보게 되면 새로운 객체로 판단하는 경우

  • primitive 타입일 경우에 pk가 null일 때
  • Number 타입일 경우 0일 때

위 두 경우 제외하고는 예외를 발생시킨다.

여기서 자동 생성 전략이 아닌 직접 식별키를 할당할 경우 새로운 객체로 판단하지 않기 때문에 merge를 하게 된다. 이렇게 될 경우 준영속 상태로 판단하고 select 쿼리를 발생시켰으나 값이 존재하지 않아 insert를 하게 되는 것이다.

// 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));
}

해결 방법은 엔티티에서 Persistable를 상속받아 isNew 함수를 오버라이딩하여 새로운 객체인지에 대해 판단하는 로직을 알맞게 변경하게 되면 계속해서 새로운 객체로 판단하여 불필요한 쿼리가 발생하는 것을 방지할 수 있다.

@Entity
class Member(
    @Id
    var id: Long = 0L,

    var name: String,
): Persistable<Long>
{
    override fun getId() = this.id

    override fun isNew() = this.id != -1L
}

Reference