Spring Data JPA Insert 할 때 Select 가 호출되는 이유

박지찬·2023년 11월 21일

상황

  • 설계한 Entity의 id 가 생성된 (Generated Value) 값이 아니다
  • 같은 id 값을 가진 엔티티가 저장되어 있는지 확인 후, 없는 경우 저장하려 한다
@Test
@DisplayName("회원 등록 테스트")
void insertMemberTest() {
    // given
    Member member = Member.builder().id("id1").username("name1").age(20).build();

    // when
    Member saveMember = memberRepository.save(member);

    // then
    assertEquals(saveMember.getUsername(), "초원");
}

쿼리 로그를 찾아보니 insert 쿼리 전에 select 쿼리가 실행되고 있었다.

Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.age as age2_0_0_,
        member0_.name as name3_0_0_ 
    from
        member member0_ 
    where
        member0_.id=?

Hibernate: 
    insert 
        into
            member
            (age, name, id) 
        values
            (?, ?, ?)

나는 insert 를 하기 전에 중복되는 값이 있는지 확인하고 넣을 것이기 때문에 이 select 문이 필요없다. 그래서 이 insert 문이 왜 발생하는 것이고, 어떻게 하면 생략하도록 할 수 있는지 알아보기로 했다.

문제의 원인

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
	private final JpaEntityInformation<T, ?> entityInformation;
	private final EntityManager em;
	// ...
    
	@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의 구현체인 SimpleJpaRepository 클래스에서 save() 메서드의 동작 방식에서 확인할 수 있다.
entityInformation.isNew()를 호출하여 전달 받은 객체가 new 상태라면 persist() (신규 저장) 하고, 새로운 객체가 아니면 merge() (병합) 한다.

isNew() 메서드가 호출되면 위의 JpaMetamodelEntityInformationAbstractEntityInformation 클래스의 isNew() 메서드가 호출된다.

JpaMetamodelEntityInformation

public class JpaMetamodelEntityInformation<T, ID> extends JpaEntityInformationSupport<T, ID> {
	private final Optional<SingularAttribute<? super T, ?>> versionAttribute;
	
    public boolean isNew(T entity) {
        if (!this.versionAttribute.isEmpty() && 
        		!(Boolean) this.versionAttribute.map(Attribute::getJavaType)
        								   		.map(Class::isPrimitive)
                                           		.orElse(false)) {
                                    
            BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
            
            return (Boolean)this.versionAttribute.map((it) -> {
                return wrapper.getPropertyValue(it.getName()) == null;
            }).orElse(true);
        } else {
            return super.isNew(entity);
        }
    }
}

먼저 JpaMetamodelEntityInformationisNew() 메서드의 동작 과정을 살펴보자.

  1. Entity 에 @Version 어노테이션을 사용하고 있는 필드가 있는지 확인

  2. 관련 필드가 없으면 versionAttribute.isEmtpy() 가 true 를 리턴하고 super.isNew(entity) 가 호출된다.

  3. 관련 필드가 있는 경우에도 primitiveType 이라면 super.isNew(entity) 가 호출된다.

2, 3 의 경우가 아니면 @Version 어노테이션을 사용하는 필드가 있고, primitive type 이 아닌 경우이다. 이 경우에는 해당 필드 값의 null 여부를 리턴한다.

AbstractEntityInformation

public abstract class AbstractEntityInformation<T, ID> implements EntityInformation<T, ID> {
	// ...
    
	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));
	}
}

다음으로 super.isNew() 에 해당하는 AbstractEntityInformation 의 isNew() 메서드를 보자.

먼저 @Id 어노테이션이 달려 있는 필드를 확인한다.
만약 이 필드가 primitive type 이 아니라면 null 여부를 리턴한다. (나의 경우에는 id 값이 String 타입이여서 이곳에서 false 가 리턴되면서 새로운 엔티티가 아닌 것으로 인식되었다)

만약 primitive type 중 Number 의 하위 타입이면 0인지 확인한다.

정리

@Version 필드가 없고 @Id 필드에 값이 있는 상태에서 저장할 경우 (Number 타입이면 0이 아닐 경우) isNew() 의 반환 값이 false 를 반환한다.
그래서 isNew() 가 false 를 반환할 경우 새로운 엔티티가 아닌 것으로 인식되어 save() 메서드가 em.merge() 를 호출한다.

merge() 메서드는 Detached 상태의 엔티티를 Managed 상태로 만들기 위해 식별자 값으로 조회 (select 문 발생) 한 뒤, 조회된 객체가 있으면 Update 하고, 없으면 새로 Insert 를 하여 병합한다.

save() 시 select문이 호출되지 않도록 하는 법

Entity 클래스에서 Persistable 인터페이스를 구현하면 된다.

package org.springframework.data.domain; 

public interface Persistable<ID> { 
@Nullable
ID getId();

@Override
boolean isNew();
}

Persistable 인터페이스를 구현한 엔티티

@Entity
public class User implements Persistable<String> {     
  @Id
  private String id; 
  
  @Override    
  public String getId() {       
  	return id;    
  }     
  
  @Override    
  public boolean isNew() {       
  	return true;    //하지만 이렇게 하면 update가
    				//필요한 경우에도 insert 문이 나간다.
  }
}

여기서 isNew() 를 무조건 true 를 리턴하게 되면 select 하지 않고 insert 하긴 하지, update 을 할 때에도 insert 를 수행하게 된다.

그러면 어떻게 해야될까?

@Entity
public class User implements Persistable<String> {     
  @Id
  private String id; 
  
  /* save 시 Select 날리는 것을 방지 */
  @Transient
  private boolean isNew = true;

  @Override    
  public String getId() {       
  	return id;    
  }  
  
  @Override
  public boolean isNew() {
    return isNew;
  }
    
  @PrePersist
  @PostLoad
  void markNotNew() {
      this.isNew = false;
  }
}

isNew 가 true 가 되는 기준을 id 존재 여부가 아닌

  • 영속성 콘텍스트에서 조회되지 않았을 때 (@PostLoad)
  • 영속 상태가 되기 전 (@PrePersist)

으로 변경해준다.

@Transient 어노테이션을 붙여 해당 데이터를 테이블의 컬럼과 매핑 시키지 않는다

이렇게 하면 전에 처럼 JpaMetamodelEntityInformationisNew() 메서드가 아닌 JpaPersistableEntityInformationisNew() 가 호출된다.

public class JpaPersistableEntityInformation<T extends Persistable<ID>, ID> extends JpaMetamodelEntityInformation<T, ID> {
    public boolean isNew(T entity) {
        return entity.isNew();
    }
}

그리고 이 메서드에서는 앞서 우리가 구현했던 isNew() 메서드가 호출된다.

참고자료

https://gmoon92.github.io/jpa/2019/09/29/what-is-the-transient-annotation-used-for-in-jpa.html
https://ttl-blog.tistory.com/191
https://programmer-chocho.tistory.com/80

0개의 댓글