Snowflake ID 도입 후의 삽질_2 (Jpa)
아이디를 직접 넣은 경우, JPA에서 save 메서드만 호출했는데도 select 쿼리가 발생하는 이유와 그 해결 방법.

이전 글에서 이어지는 내용이에요.
Intro
끝난줄 알았는데, 이번엔 JPA에서 또 한번 문제가 생겼어요.
분명히 repository.save()
만 호출했는데, SELECT
문이 왜 생기는거지?
--repository.save(entity)
select .... from table where id = ? --?????
insert into table....
why?
이번엔 Spring data JPA의 문제였어요.
Save,Persist,Merge
사실, 이건⭐⭐킹영한⭐⭐님의 강의를 열심히 들었다면 바로 알 수 있어요.
순수 JPA는em.persist()
, em.merge()
를 개발자가 직접 구분해서 호출하지만, Spring data JPA는 save()
하나로 처리해, id가 null인지 여부로 신규 엔티티를 판단하게 되요.
우리는 새로 만든 엔티티들도 @GeneratedValue
사용 대신 아이디를 명시적으로 넣어주고 있어서 JPA가 무조건 merge
를 선택한 거에요.
그럼 어떻게 해야 할까요?
Persistable<ID>
해결책: 엔티티에 Persistable<ID>
인터페이스를 구현해서 해결할 수 있어요.
public interface Persistable<ID> {
@Nullable
ID getId(); //엔티티의 아이디를 반환
boolean isNew(); //엔티티가 새로 생성됬는지를 반환
}
Spring Data JPA는 엔티티가 Persistable<ID>
를 구현하고 있으면, id를 통하지 않고, isNew()
로 새로운 엔티티인지 판단해요.
다시 말해 ID를 직접 생성해서 넣는 경우에도, isNew()
가 true를 반환하면 persist()
가 호출되어 불필요한 merge()
를 피할 수 있어요.
Why???? 각오 후 클릭하세요.
우리가 xxxRepository implement JpaRepositor<T,ID>
를 선언하는 순간, 스프링은 이를 런타임에 해석해 실제 repository 구현체를 만들어준다.
이는 JpaRepositoryFactory
의 역활인데, JpaEntityInformationSupport
를 받아 서 엔티티 정보를 내부에 가지고 있게 된다.
// JpaRepositoryFactory.java
public <T, ID> JpaEntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
return (JpaEntityInformation<T, ID>) JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
}
이를 받기 위해 내부에서 JpaEntityInformationSupport
를 사용하는데,
이를 유심히 보면, Persistable을 구현하고 있는지에 따라서, JpaPersistableEntityInformation
, JpaMetamodelEntityInformation
로 나뉘는 걸 볼 수 있다.
//JpaEntityInformationSupport
@SuppressWarnings({ "rawtypes", "unchecked" })
public static <T> JpaEntityInformation<T, ?> getEntityInformation(Class<T> domainClass, EntityManager em) {
Assert.notNull(domainClass, "Domain class must not be null");
Assert.notNull(em, "EntityManager must not be null");
Metamodel metamodel = em.getMetamodel();
PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil();
if (Persistable.class.isAssignableFrom(domainClass)) { --여기!
return new JpaPersistableEntityInformation(domainClass, metamodel, persistenceUnitUtil);
} else {
return new JpaMetamodelEntityInformation(domainClass, metamodel, persistenceUnitUtil);
}
}
중간의 if문 주목

두 구현체의 isNew() 호출에서 차이가 나게 된다.
//JpaPersistableEntityInformation.java
@Override
public boolean isNew(T entity) {
return entity.isNew(); //entity의 isNew()를 호출한다
}
//JpaMetamodelEntityInformation.java
@Override
public boolean isNew(T entity) {
if (versionAttribute.isEmpty()
|| versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
return super.isNew(entity); // <== 여기
}
// 여기서부터는 @Version 즉 낙관적락시 사용됨
BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
}
super.isNew()를 호출
// AbstractEntityInformation.java
@Override
public boolean isNew(T entity) {
ID id = getId(entity);
Class<ID> idType = getIdType();
if (!idType.isPrimitive()) { //wrapper 타입일 경우
return id == null; //null인지로 판별
}
if (id instanceof Number n) {
return n.longValue() == 0L;
}
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
드디어 찾았다!
정리하면, JpaMetamodelEntityInformation은 id가 null인지로, JpaPersistableEntityInformation는 엔티티의 isNew()
로 새 객체 여부를 판단한다.
결국, repository 구현체는 이걸 토대로 새 객체를 판별하게 된다.
와 쓰는거 개힘드네
이제Persistable
을 구현하면 SimpleJpaRepository
에서, isNew()
가 true를 반환하고, 불필요한 조회가 일어나지 않아요
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private final JpaEntityInformation<T, ?> entityInformation; // 여기 들어간다!!
@Override
@Transactional // 트랜잭션이 걸려있음
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);
}
}
}
사실, 이것만 알아도 될 거 같아요.
Implement
그럼 이제, Persistable
은 어떻게 구현해야 될까요?
지금부터 제가 직접 고민하고 실험해본 여러 방법들을 소개할게요.
1.무조건 새거
public void isNew(){
return true;
}
모든 엔티티를 무조건 신규로 간주하는 가장 간단한 방법이에요.
물론 데이터베이스에 존재하는 객체가 persist()
를 호출하게 되면 오류가 발생하겠지만, JPA는 애초에 merge()
사용을 권장하지 않아요.
하지만 merge()
도 엄연한 기능이고, 이건 너무 극단적인 것 같아서...
2. @Transient
엔티티의 필드에는 @Transient
어노테이션을 붙여 해당 필드를 JPA의 영속성 관리 대상에서 제외시켜줄 수 있어요. 이걸 활용해서 다음과 같이 구현했어요.
@Entity
public class MyEntity implements Persistable<Long> {
...
@Transient
private boolean isNew; // 실제 저장되지 않음
@Override
public boolean isNew() {
return isNew;
}
public static void of(...) {
var myEntity = new MyEntity()...;
myEntity.isNew = true;
}
}
JPA는 DB에서 엔티티를 로딩할 때 리플렉션으로 객체를 생성하기 때문에 false가 되고, 직접 생성할 때만 isNew = true로 마킹해주는거에요.
만약 생성된 객체를 트랜잭션 종료 후에 merge()
를 호출해야 한다면, 문제가 생길 수 있으니 @PostLoad
등이 필요해요.
3. Auditing 사용
저희는 다음과 같은 공통 BaseEntity를 사용하고 있어요.
@Getter
@MappedSuperclass
public abstract class BaseEntity {
private ZonedDateTime createdAt; //null
@PrePersist
public void prePersist() {
this.createdAt = ZonedDateTime.now();
}
...
}
실제 사용중인 BaseEntity
@Prepersist는 단순히 "엔티티 저장 전" 이라고 많이들 알고 있지만, 정확히는 save()
호출 중 Hibernate의 EntityCallbac.performCallback()
에서 실행되요.
즉, isNew()
판별이 끝난 후에 실행되는 거에요.
@Override
public boolean isNew() {
return getCreatedAt() == null; //다만, createdAt 수동추가시 조심!
}
다음과 같이 하면 되겠죠?
그래서 선택한 방법은?
저는 createdAt==null
방법을 사용하고, 이걸 BaseEntity에서 구현하기로 했어요.
엔티티의 생명주기 흐름만으로 신규 여부를 정확히 판단해 가장 깔끔하다는 판단이였죠.
그리고 잊어버린게 있는데...
사실, Persistable<Id>를 구현하려면,isNew()
말고도getID(ID id)
메서드가 필요해요.
그런데 이게 재미있게도…
Lombok이 이미 만들어준 getter가 자동으로 getId() 역할을 해주기 때문에,
구현한 줄도 몰랐는데 이미 구현되어 있어요 ㅎㅎ.
물론, 필드명이 id가 아닌경우 반드시 구현해줘야 해요
만약 엔티티마다 ID타입이 다르다면?
다음과 같이 제네릭 타입 특화(generic type specialization)를 사용해 Id 타입이 다른 엔티티들도 공통 베이스로 묶을 수 있어요.
@Getter
@MappedSuperclass
public abstract class BaseEntity<ID extends Serializable> implements Persistable<ID>
@Override
public boolean isNew()
// 실제 entity
public class User extends BaseEntity<Long>
Long getId() //제너릭에 맞는 getId()
사실 제너릭은 ..
BaseEntity implements Persistable<Object>
로 해놓고, 구현체에서 공변성을 활용해 Long getId()
를 사용해도, 전혀 문제없이 동작해요.
테스트해봤을때 save(),findById(), 변경감지, merge()등도 정상 했어요.
제너릭은 런타임에 정보가 모두 날라가니깐, 다른 문제도 일어나지 않겠죠.
하지만, 저는 결국 명시적으로 제너릭을 사용하기로 했습니다.
왜냐고요?
문서에 나와있지도 않고, 구글에도 비슷한 구현 사례가 없는데, 굳이 똑같은 기능을 커스텀해서 쓸 이유가 없어요.
Final
사실, SnowFlake를 제안한건 저였는데,
계속해서 이런저런 문제가 터져서 팀원들에게 좀 미안했어요.
심지어 어려운 내용도 아니였고, 전부 구글에 있는 거였죠.
그래도, 새로운 경험치를 쌓았다는 것에 만족할려고 해요.
3-POINT
- JS의 Number는 Java의 Long을 감당 못한다.
- JPA에서 ID를 직접 넣으면
save()
시에merge()
로 가므로 , Persistable을 구현해 막자. - 처음으로 뭔가 기술 블로그다운 내용을 써봤다.