JPA Entity ID를 외부에서 생성했을 때 생기는 일

dojinyou·2023년 6월 18일
1

저는 디프만에서 nalab이라는 서비스를 만들고 있습니다. 해당 서비스에서 헥사고날 아키텍처를 적용하고 도메인 클래스(domain class)와 엔티티 클래스(entity class)를 분리하고 있습니다. 이로 인해 겪은 문제 중 하나는 도메인 생성 및 엔티티 영속화 과정에서 불필요한 select query가 발생하는 것이었습니다. 이 문제에 대해서 원인은 무엇이고 어떤 방식으로 해결 가능한 지 알아보겠습니다.

JPA를 사용해보시고 도메인 클래스와 엔티티 클래스를 분리하고자 하는 분들이 읽으시면 좋을 것 같습니다.

현상

앞서 말씀드렸던 불필요한 select qeury가 발생하는 상황을 알려드리겠습니다.

  1. 새로운 도메인 객체를 생성합니다. 이때 식별자(id)도 생성됩니다.

    @Getter
    @RequiredArgsConstructor
    public class Sample {
    
        private final String id;
    
    }
  2. 도메인 객체를 엔티티 객체로 변환합니다.

    @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. 변환된 엔티티 객체를 영속화 합니다.

위와 같은 순서로 진행되며 새로 생성된 도메인 객체를 엔티티 객체로 변환하여 영속화하는 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의 구현체인 SimpleJpaRepositorysave 메서드에 breaking point를 추가하고 Debug로 실행해보았습니다.

SimpleJpaRepository save method

새로 생성되는 entity지만 기대와 다르게 entityInformation.isNew(entity) 의 결과가 false 였습니다.

Debug에서 step into를 통해서 실행되는 메서드를 확인해보면 JpaMetamodelEntityInformation.isNew 메서드였습니다.

JpaMetamodelEntityInformation isNew method

versionAtrribute.isEmpty() 조건이 true가 되어 super.isNew(entity) 를 호출하고 있습니다.

해당 메서드를 따라가보면 AbstractEntityInformation.isNew 메서드를 호출하고 있습니다.

AbstractEntityInformation isNew method

idType은 String이기 때문에, primitive 타입이 아니며 null인지 여부로 새로 생성된 entity인지를 판단하고 있습니다.

추가적으로 primitive type이라면 number 타입의 인스턴스일 경우에 longValue가 0인치 체크합니다. wrapper type일 경우에는 당연히 primitive type이 아니기 때문에 첫번째 if 분기에서 처리됩니다.

따라서 새로운 엔티티 객체가 아니라고 판단하고 merge 함수를 호출하게 됩니다.

이후 merge 과정은 간략화 하면 다음과 같습니다.

  1. 먼저 해당 entity의 상태를 판단하게 되고 해당 엔티티의 경우 Detached 상태로 판단되어 대상 객체를 id를 이용해 가져오게 됩니다. 이때 select query가 발생하게 됩니다.
  2. select query의 조회 결과가 없어 엔티티의 상태를 Transient 로 판단하고 다시 함수를 호출합니다. 이때 기존에 우리가 의도했던 persist와 동일하게 처리되어 insert query가 발생하게 됩니다.

즉, save 메서드에서 분기처리를 담당하는 entityInformation.isNew 함수가 해당 엔티티 객체가 새로운 객체인지 판단을 잘못하였고 그 결과 select query가 발생 하였다는 것을 알 수 있습니다.

해결 방안

이를 해결하기 위해서는 entityInformation.isNew 함수가 새로운 엔티티 객체인지 적절하게 판단할 수 있도록 도와주어야 합니다. entityInformation은 JpaEntityInformation 타입으로 구현체는 총 3가지가 있습니다.

  1. JpaEntityInformationSupport
  2. JpaMetamodelEntityInformation
  3. JpaPersistableEntityInformation

현재 위에서 설정된 타입은 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에 도메인이 꽤 강하게 결합되어 있었다는 생각을 하게 되었습니다.

profile
더 좋은 세상을 만드는 데 기술로 기여하고 싶습니다

0개의 댓글