저는 디프만에서 nalab이라는 서비스를 만들고 있습니다. 해당 서비스에서 헥사고날 아키텍처를 적용하고 도메인 클래스(domain class)와 엔티티 클래스(entity class)를 분리하고 있습니다. 이로 인해 겪은 문제 중 하나는 도메인 생성 및 엔티티 영속화 과정에서 불필요한 select query가 발생하는 것이었습니다. 이 문제에 대해서 원인은 무엇이고 어떤 방식으로 해결 가능한 지 알아보겠습니다.
JPA를 사용해보시고 도메인 클래스와 엔티티 클래스를 분리하고자 하는 분들이 읽으시면 좋을 것 같습니다.
앞서 말씀드렸던 불필요한 select qeury가 발생하는 상황을 알려드리겠습니다.
새로운 도메인 객체를 생성합니다. 이때 식별자(id)도 생성됩니다.
@Getter
@RequiredArgsConstructor
public class Sample {
private final String id;
}
도메인 객체를 엔티티 객체로 변환합니다.
@Entity(name = "sample")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SampleEntity {
@Id
private String id;
public static SampleEntity from(Sample domain) {
return new SampleEntity(domain.getId());
}
}
변환된 엔티티 객체를 영속화 합니다.
위와 같은 순서로 진행되며 새로 생성된 도메인 객체를 엔티티 객체로 변환하여 영속화하는 3번 과정에서 select query와 insert query가 발생하는 걸 볼 수 있습니다. (이해를 돕기위해 아주 간단하게 코드를 작성했습니다.)
@RestController
@RequiredArgsConstructor
public class SampleController {
private final SampleRepository sampleRepository;
@Transactional
@GetMapping("/create/{id}")
public Sample create(@PathVariable String id) {
// create domain
var domain = new Sample(id);
// convert entity
var entity = SampleEntity.from(domain);
// persist
sampleRepository.save(entity);
return domain;
}
}
Hibernate:
select
s1_0.id
from
sample s1_0
where
s1_0.id=?
Hibernate:
insert
into
sample
(id)
values
(?)
발생 원인을 찾기 위해서 JPARepository
의 구현체인 SimpleJpaRepository
의 save
메서드에 breaking point를 추가하고 Debug로 실행해보았습니다.
새로 생성되는 entity지만 기대와 다르게 entityInformation.isNew(entity)
의 결과가 false 였습니다.
Debug에서 step into를 통해서 실행되는 메서드를 확인해보면 JpaMetamodelEntityInformation.isNew
메서드였습니다.
versionAtrribute.isEmpty()
조건이 true가 되어 super.isNew(entity)
를 호출하고 있습니다.
해당 메서드를 따라가보면 AbstractEntityInformation.isNew
메서드를 호출하고 있습니다.
idType은 String이기 때문에, primitive 타입이 아니며 null인지 여부로 새로 생성된 entity인지를 판단하고 있습니다.
추가적으로 primitive type이라면 number 타입의 인스턴스일 경우에 longValue가 0인치 체크합니다. wrapper type일 경우에는 당연히 primitive type이 아니기 때문에 첫번째 if 분기에서 처리됩니다.
따라서 새로운 엔티티 객체가 아니라고 판단하고 merge
함수를 호출하게 됩니다.
이후 merge 과정은 간략화 하면 다음과 같습니다.
Detached
상태로 판단되어 대상 객체를 id를 이용해 가져오게 됩니다. 이때 select query
가 발생하게 됩니다. Transient
로 판단하고 다시 함수를 호출합니다. 이때 기존에 우리가 의도했던 persist
와 동일하게 처리되어 insert query
가 발생하게 됩니다.즉, save 메서드에서 분기처리를 담당하는
entityInformation.isNew
함수가 해당 엔티티 객체가 새로운 객체인지 판단을 잘못하였고 그 결과select query
가 발생 하였다는 것을 알 수 있습니다.
이를 해결하기 위해서는 entityInformation.isNew
함수가 새로운 엔티티 객체인지 적절하게 판단할 수 있도록 도와주어야 합니다. entityInformation
은 JpaEntityInformation 타입으로 구현체는 총 3가지가 있습니다.
현재 위에서 설정된 타입은 2번의 JpaMetamodelEntityInformation
으로 우선 해당 구현체가 어떻게 선택되는 지 먼저 알아보겠습니다.
SimpleJpaRepository
의 생성자에서 힌트를 얻을 수 있습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
// ...
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager em;
private final PersistenceProvider provider;
// ...
public SimpleJpaRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
Assert.notNull(entityInformation, "JpaEntityInformation must not be null");
Assert.notNull(entityManager, "EntityManager must not be null");
this.entityInformation = entityInformation;
this.em = entityManager;
this.provider = PersistenceProvider.fromEntityManager(entityManager);
}
public SimpleJpaRepository(Class<T> domainClass, EntityManager em) {
this(JpaEntityInformationSupport.getEntityInformation(domainClass, em), em);
}
// ...
}
2개의 생성자 중 아래에 위치한 생성자를 보면 JpaEntityInformationSupport.getEntityInformation
메서드를 통해서 구현체를 가져오는 걸 확인할 수 있습니다.
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);
}
}
위 코드를 보면 Persistable.class.isAssignableFrom(domainClass)
여부에 따라 구현체를 선택하고 있습니다. isAssignableFrom
메서드를 native method로 메서드의 주석을 번역해보면 다음과 같습니다.
Determines if the class or interface represented by this Class object is either the same as, or is a superclass or superinterface of, the class or interface represented by the specified Class parameter. It returns true if so; otherwise it returns false.
클래스 오브젝트가 파라미터로 주어진 특정 클래스와 같거나 혹은 그의 superclass 또는 superintefacce인지를 결정합니다. 이것이 맞다면 true 아니라면 false를 반환합니다.
도메인 클래스(JPA 엔티티 클래스를 의미)가 Persistable
클래스와 같거나 그를 상속하고 있는 클래스 혹은 인터페이스인지를 확인합니다. 따라서 우리는 Persistable
클래스를 엔티티 클래스가 상속하도록 만들어 JpaEntityInformation
의 구현체를 다르게 선택하도록 할 수 있습니다.
public interface Persistable<ID> {
@Nullable
ID getId();
boolean isNew();
}
Persistable
인터페이스는 위 코드에서 볼 수 있듯이 2개의 메서드를 선언하고 있습니다.
public class JpaPersistableEntityInformation<T extends Persistable<ID>, ID>
extends JpaMetamodelEntityInformation<T, ID> {
public JpaPersistableEntityInformation(Class<T> domainClass, Metamodel metamodel,
PersistenceUnitUtil persistenceUnitUtil) {
super(domainClass, metamodel, persistenceUnitUtil);
}
@Override
public boolean isNew(T entity) {
return entity.isNew();
}
@Nullable
@Override
public ID getId(T entity) {
return entity.getId();
}
}
JpaPersistableEntityInformation
은 엔티티에 구현된 메서드를 호출하여 isNew
메서드를 구현하고 있습니다.
@Entity(name = "sample")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SampleEntity implements Persistable<String> {
@Id
private String id;
public static SampleEntity from(Sample domain) {
return new SampleEntity(domain.getId());
}
@Override
public boolean isNew() {
return true;
}
}
막상 적용하자니 isNew()
메서드를 적절한 값을 넣어주는 것이 어려웠습니다. 이를 해결하기 위해서 [spoqa 기술블로그]스포카에서 Kotlin으로 JPA Entity를 정의하는 방법를 참고하였습니다.
public class SampleEntity implements Persistable<String> {
// ...
@Transient
private boolean isNew = true;
@Override
public boolean isNew() {
return isNew;
}
@PostLoad
@PostPersist
protected void loadOrPersist() {
this.isNew = false;
}
}
@Transient를 이용해 isNew
라는 필드를 만든 뒤에 해당 값을 객체 생성시에는 true 이후 persist 되거나 load된 뒤에는 false로 변경되도록 하였습니다. 물론 이 경우 이미 영속화된 id를 가진 entity를 생성자를 통해 생성할 경우 예외를 발생시킬 수 있어, 기존 entity를 변경하는 경우엔 조회된 entity에 값을 업데이트해 주어야 합니다.
위와 같이 변경한 뒤에 다시 엔티티 객체를 영속화하면 아래와 같이 insert query만 발생하는 것을 확인할 수 있습니다.
불필요한 쿼리를 제거해가는 과정에서 JPA의 내부 구현을 알 수 있었고 이를 통해서 정확한 해결 방안을 찾아낼 수 있었던 것이 기쁩니다. 또한 jpa 엔티티 클래스를 도메인 클래스로 이용하면서 어쩌면 굉장히 편하게 작업을 하고 있었고 또한 jpa에 도메인이 꽤 강하게 결합되어 있었다는 생각을 하게 되었습니다.