최근에 사내에 코드 아키텍처를 새로 도입하였다.
해당 아키텍쳐에서는 도메인과 영속성 계층 클래스를 분리해서 사용하게끔 되어있다.
이 구조의 특징 중 하나는 분산 시스템과 코드 간의 의존성을 분리할 수 있다는 것이다.
이 덕분에 분산 시스템과 로직을 분리할 수 있게된다.
이것이 장점이자 단점이 될 수 있다는 것인데, 바로 계층 간의 컨텍스트 분리가 일어난다는 것이다.
특히 JPA 에서는 영속성 컨텍스트가 분리될 수 있는 거 아니야?
라는 합리적인 의심을 할 수 있게 된다.
이러한 의심을 해소하고자 아래 내용들을 공부해보았다.
Jpa 의 save() 는 새로운 객체인지 아닌지를 판별하여 — isNew() == true 인지 — 영속화를 처리한다.
만약 새로운 객체라면 persist 를 통해 비영속 상태를 영속 상태로 만들고
만약 기존 준영속 객체라면 merge 를 통해 준영속 상태를 영속 상태로 만든다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
@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);
}
}
}
그렇다면 isNew() 는 어떤 흐름으로 처리될까?
☝
TL;DR;
요컨데 JPA 에서는 새로운 객체 판별을 아래와 같이 처리할 수 있다.
본인의 상황에 따라 (엔티티 생성/수정 전략) 알맞게 사용하자
- Version-Property와 Id-Property 검사 (디폴트값):
- Version-property가 있는지 검사
- 만약 Version-property가 있고 그 값이 null이라면, 해당 엔티티는 새로운 것으로 간주
- 만약 Version-property가 없다면, 엔티티의 식별자(Id-property)를 검사하여 그 값이 null이면 새로운 엔티티로 간주하고, 그렇지 않으면 기존 엔티티로 간주
- Persistable 인터페이스 구현:
- 엔티티가
Persistable
인터페이스를 구현- Spring Data JPA는 엔티티가 새로운지 아닌지 감지하는 작업을
isNew(…)
메서드에 위임- EntityInformation 구현:
EntityInformation
추상화를 사용자 정의JpaRepositoryFactory
의 서브 클래스를 생성하고getEntityInformation(…)
메서드를 오버라이드하는 방식으로 구현 가능- 커스텀한
JpaRepositoryFactory
를 Spring 빈으로 등록- 이 방법은 일반적으로 거의 사용되지 않음
먼저 entityInformation.isNew(entity)
에서의 entityInformation
가 무슨 클래스인지를 살펴보아야한다.
EntityInformation 는 SimpleJpaRepository 가 생성될 때 주입되는데, 이 때 도메인 엔티티의 상태에 따라 다른 구현체가 주입된다.
아래 두 가지 케이스로 나뉘어 처리되는 것을 알 수 있다.
if (Persistable.class.isAssignableFrom(domainClass)) {
return new JpaPersistableEntityInformation(domainClass, metamodel, persistenceUnitUtil);
} else {
return new JpaMetamodelEntityInformation(domainClass, metamodel, persistenceUnitUtil);
}
Persistable 는 무엇이고 그에 대한 구현여부를 왜 따지는 걸까?
Persistable 은 새로운 객체인지를 판별하는 isNew() 를 override 할 수 있도록 도와주는 Wrapper Interface 이다.
따라서 아래와 같이 경우의 수가 나뉘어지는 것이다.
그렇다면 각각의 클래스는 isNew() 를 어떻게 구현하고 있을까?
/**
* Extension of {@link JpaMetamodelEntityInformation} that consideres methods of {@link Persistable} to lookup the id.
*
* @author Oliver Gierke
* @author Christoph Strobl
* @author Mark Paluch
*/
public class JpaPersistableEntityInformation<T extends Persistable<ID>, ID>
extends JpaMetamodelEntityInformation<T, ID> {
,,,
@Override
public boolean isNew(T entity) {
return entity.isNew();
}
}
/**
* Abstract base class for entities. Allows parameterization of id type, chooses auto-generation and implements
* {@link #equals(Object)} and {@link #hashCode()} based on that id.
*
* @author Oliver Gierke
* @author Thomas Darimont
* @author Mark Paluch
* @author Greg Turnquist
* @author Ngoc Nhan
* @param <PK> the type of the identifier.
*/
@MappedSuperclass
public abstract class AbstractPersistable<PK extends Serializable> implements Persistable<PK> {
@Id @GeneratedValue private @Nullable PK id;
@Nullable
@Override
public PK getId() {
return id;
}
/**
* Sets the id of the entity.
*
* @param id the id to set
*/
protected void setId(@Nullable PK id) {
this.id = id;
}
/**
* Must be {@link Transient} in order to ensure that no JPA provider complains because of a missing setter.
*
* @see org.springframework.data.domain.Persistable#isNew()
*/
@Transient // DATAJPA-622
@Override
public boolean isNew() {
return null == getId();
}
}
버저닝 필드에 대한 검증을 시도한다.
만약 버저닝에서 검증이 성공했다면 super.isNew() 를 호출하여 AbstractEntityInformation
클래스의 isNew() 를 호출한다.
AbstractEntityInformation.isNew()
는 아래와 같은 케이스에 따라 새로운 객체인지를 판단한다.
Id
의 타입이 Object
타입이고 null
→ 새로운 객체 !Id
의 타입이 Primitive
타입이고 0
→ 새로운 객체 !// Id
의 타입이 Primitive
가 아니라면 예외 발생.
/**
* Implementation of {@link org.springframework.data.repository.core.EntityInformation} that uses JPA {@link Metamodel}
* to find the domain class' id field.
*
* @author Oliver Gierke
* @author Thomas Darimont
* @author Christoph Strobl
* @author Mark Paluch
* @author Jens Schauder
* @author Greg Turnquist
*/
public class JpaMetamodelEntityInformation<T, ID> extends JpaEntityInformationSupport<T, ID> {
private final Optional<SingularAttribute<? super T, ?>> versionAttribute;
,,,
@Override
public boolean isNew(T entity) {
if (versionAttribute.isEmpty()
|| versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
return super.isNew(entity);
}
BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
}
}
/**
* Base class for implementations of {@link EntityInformation}. Considers an entity to be new whenever
* {@link #getId(Object)} returns {@literal null} or the identifier is a {@link Class#isPrimitive() Java primitive} and
* {@link #getId(Object)} returns zero.
*
* @author Oliver Gierke
* @author Nick Williams
* @author Mark Paluch
* @author Johannes Englmeier
*/
public abstract class AbstractEntityInformation<T, ID> implements EntityInformation<T, ID> {
,,,
@Override
public boolean isNew(T entity) {
ID id = getId(entity);
Class<ID> idType = getIdType();
if (!idType.isPrimitive()) {
return id == null;
}
if (id instanceof Number n) {
return n.longValue() == 0L;
}
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
}
☝
TL;DR;
EntityManager
의merge()
는 Event-Driven 구조를 통해 아래와 같이 처리된다.
- 세션 검증 및 MergeEvent 발행
- 중복 병합 방지
- 식별자를 통해 영속성 컨텍스트 내 존재 여부 검증
- 영속상태에 따라 영속화 진행
새로운 객체가 아니라면 비영속, 준영속 상태의 객체를 영속 상태로 처리하기 위해
EntityManager 의 merge() 를 호출한다.
이 때 EntityManager 의 자식 인터페이스인 Session 을 호출하게 되고,
그에 대한 구현체 SessionImpl 이 호출된다.
public interface Session extends SharedSessionContract, EntityManager {
/**
* Copy the state of the given object onto the persistent object with the same
* identifier. If there is no persistent instance currently associated with
* the session, it will be loaded. Return the persistent instance. If the
* given instance is unsaved, save a copy and return it as a newly persistent
* instance. The given instance does not become associated with the session.
* This operation cascades to associated instances if the association is mapped
* with {@link jakarta.persistence.CascadeType#MERGE}.
*
* @param object a detached instance with state to be copied
*
* @return an updated persistent instance
*/
<T> T merge(T object);
}
public class SessionImpl
extends AbstractSharedSessionContract
implements Serializable, SharedSessionContractImplementor, JdbcSessionOwner, SessionImplementor, EventSource,
TransactionCoordinatorBuilder.Options, WrapperOptions, LoadAccessContext {
,,,
@Override @SuppressWarnings("unchecked")
public <T> T merge(T object) throws HibernateException {
checkOpen();
return (T) fireMerge( new MergeEvent( null, object, this ));
}
}
SessionImpl 클래스에서는 세션이 열려있는지 확인하고, MergeEvent 를 발행한다.
@Override @SuppressWarnings("unchecked")
public <T> T merge(T object) throws HibernateException {
checkOpen();
return (T) fireMerge( new MergeEvent( null, object, this ));
}
private Object fireMerge(MergeEvent event) {
try {
checkTransactionSynchStatus();
checkNoUnresolvedActionsBeforeOperation();
fastSessionServices.eventListenerGroup_MERGE
.fireEventOnEachListener( event, MergeEventListener::onMerge );
checkNoUnresolvedActionsAfterOperation();
}
catch ( ObjectDeletedException sse ) {
throw getExceptionConverter().convert( new IllegalArgumentException( sse ) );
}
catch ( MappingException e ) {
throw getExceptionConverter().convert( new IllegalArgumentException( e.getMessage(), e ) );
}
catch ( RuntimeException e ) {
//including HibernateException
throw getExceptionConverter().convert( e );
}
return event.getResult();
}
프록시 체크 (onMerge 단계)
session.load()
로 실제 엔티티를 로드해서 반환doMerge()
로 넘김 /**
* Handle the given merge event.
*
* @param event The merge event to be handled.
*
*/
@Override
public void onMerge(MergeEvent event, MergeContext copiedAlready) throws HibernateException {
final Object original = event.getOriginal();
// NOTE : `original` is the value being merged
if ( original != null ) {
final EventSource source = event.getSession();
final LazyInitializer lazyInitializer = HibernateProxy.extractLazyInitializer( original );
if ( lazyInitializer != null ) {
if ( lazyInitializer.isUninitialized() ) {
LOG.trace( "Ignoring uninitialized proxy" );
event.setResult( source.load( lazyInitializer.getEntityName(), lazyInitializer.getInternalIdentifier() ) );
}
else {
doMerge( event, copiedAlready, lazyInitializer.getImplementation() );
}
}
else if ( isPersistentAttributeInterceptable( original ) ) {
final PersistentAttributeInterceptor interceptor = asPersistentAttributeInterceptable( original ).$$_hibernate_getInterceptor();
if ( interceptor instanceof EnhancementAsProxyLazinessInterceptor ) {
final EnhancementAsProxyLazinessInterceptor proxyInterceptor = (EnhancementAsProxyLazinessInterceptor) interceptor;
LOG.trace( "Ignoring uninitialized enhanced-proxy" );
event.setResult( source.load( proxyInterceptor.getEntityName(), proxyInterceptor.getIdentifier() ) );
}
else {
doMerge( event, copiedAlready, original );
}
}
else {
doMerge( event, copiedAlready, original );
}
}
}
event.setEntity()
후 실제 병합 로직(merge()
) 호출 private void doMerge(MergeEvent event, MergeContext copiedAlready, Object entity) {
if ( copiedAlready.containsKey( entity ) && copiedAlready.isOperatedOn( entity ) ) {
LOG.trace( "Already in merge process" );
event.setResult( entity );
}
else {
if ( copiedAlready.containsKey( entity ) ) {
LOG.trace( "Already in copyCache; setting in merge process" );
copiedAlready.setOperatedOn( entity, true );
}
event.setEntity( entity );
merge( event, copiedAlready, entity );
}
}
EntityEntry
를 가져옴PERSISTENT
상태로 판정getEntityState(...)
결과(DETACHED, TRANSIENT, PERSISTENT, DELETED)에 따라 각기 다른 처리 메서드로 분기DETACHED
→ 기존 DB 레코드와 비교해 “값 복사” 후 새로운 관리 엔티티로 등록TRANSIENT
→ 단순히 persist()
호출PERSISTENT
→ 이미 세션에 있으므로 특별한 동기화 없이 반환DELETED
→ 삭제 예약 해제 및 DETACHED 로직 혹은 예외 처리 private void merge(MergeEvent event, MergeContext copiedAlready, Object entity) {
final EventSource source = event.getSession();
// Check the persistence context for an entry relating to this
// entity to be merged...
final PersistenceContext persistenceContext = source.getPersistenceContextInternal();
EntityEntry entry = persistenceContext.getEntry( entity );
final EntityState entityState;
final Object copiedId;
final Object originalId;
if ( entry == null ) {
final EntityPersister persister = source.getEntityPersister( event.getEntityName(), entity );
originalId = persister.getIdentifier( entity, copiedAlready );
if ( originalId != null ) {
final EntityKey entityKey;
if ( persister.getIdentifierType() instanceof ComponentType ) {
/*
this is needed in case of composite id containing an association with a generated identifier, in such a case
generating the EntityKey will cause a NPE when trying to get the hashcode of the null id
*/
copiedId = copyCompositeTypeId(
originalId,
(ComponentType) persister.getIdentifierType(),
source,
copiedAlready
);
entityKey = source.generateEntityKey( copiedId, persister );
}
else {
copiedId = null;
entityKey = source.generateEntityKey( originalId, persister );
}
final Object managedEntity = persistenceContext.getEntity( entityKey );
entry = persistenceContext.getEntry( managedEntity );
if ( entry != null ) {
// we have a special case of a detached entity from the
// perspective of the merge operation. Specifically, we have
// an incoming entity instance which has a corresponding
// entry in the current persistence context, but registered
// under a different entity instance
entityState = EntityState.DETACHED;
}
else {
entityState = getEntityState( entity, event.getEntityName(), entry, source, false );
}
}
else {
copiedId = null;
entityState = getEntityState( entity, event.getEntityName(), entry, source, false );
}
}
else {
copiedId = null;
originalId = null;
entityState = getEntityState( entity, event.getEntityName(), entry, source, false );
}
switch ( entityState ) {
case DETACHED:
entityIsDetached( event, copiedId, originalId, copiedAlready );
break;
case TRANSIENT:
entityIsTransient( event, copiedId != null ? copiedId : originalId, copiedAlready );
break;
case PERSISTENT:
entityIsPersistent( event, copiedAlready );
break;
default: //DELETED
if ( persistenceContext.getEntry( entity ) == null ) {
assert persistenceContext.containsDeletedUnloadedEntityKey(
source.generateEntityKey(
source.getEntityPersister( event.getEntityName(), entity )
.getIdentifier( entity, event.getSession() ),
source.getEntityPersister( event.getEntityName(), entity )
)
);
source.getActionQueue().unScheduleUnloadedDeletion( entity );
entityIsDetached(event, copiedId, originalId, copiedAlready);
break;
}
throw new ObjectDeletedException(
"deleted instance passed to merge",
null,
EventUtil.getLoggableName( event.getEntityName(), entity)
);
}
}
여기까지 save() 호출 시 발생하는 과정에 대해 다뤄보았다.
이제 실제로 어떤 점이 내 코드 구조에 있어서 문제가 되는지를 살펴보자.
준영속 상태인 친구는 영속 컨텍스트에 캐싱되어있는 객체를 가져온다.
하지만 비영속 상태, DETACHED 되었을 때는 어떻게 할까?
아래와 같이 처리하게 된다.
실제로 아래와 같이 처리된다.
originalId
와 copiedId
(필요 시)를 구해서, DB 조회에 사용할 수 있는 “복사된 식별자(clonedIdentifier)”를 준비source.get(…)
)isTransient
)를 검사StaleObjectStateException
예외entityIsTransient
호출하여 새로 저장copyCache
에 기록event.setResult(result)
로 병합 결과 반환switch ( entityState ) {
case DETACHED:
entityIsDetached( event, copiedId, originalId, copiedAlready );
break;
,,,
}
protected void entityIsDetached(MergeEvent event, Object copiedId, Object originalId, MergeContext copyCache) {
LOG.trace( "Merging detached instance" );
final Object entity = event.getEntity();
final EventSource source = event.getSession();
final EntityPersister persister = source.getEntityPersister( event.getEntityName(), entity );
final String entityName = persister.getEntityName();
if ( originalId == null ) {
originalId = persister.getIdentifier( entity, source );
}
final Object clonedIdentifier;
if ( copiedId == null ) {
clonedIdentifier = persister.getIdentifierType().deepCopy( originalId, event.getFactory() );
}
else {
clonedIdentifier = copiedId;
}
final Object id = getDetachedEntityId( event, originalId, persister );
// we must clone embedded composite identifiers, or we will get back the same instance that we pass in
// apply the special MERGE fetch profile and perform the resolution (Session#get)
final Object result = source.getLoadQueryInfluencers().fromInternalFetchProfile(
CascadingFetchProfile.MERGE,
() -> source.get( entityName, clonedIdentifier )
);
if ( result == null ) {
LOG.trace( "Detached instance not found in database" );
// we got here because we assumed that an instance
// with an assigned id and no version was detached,
// when it was really transient (or deleted)
final Boolean knownTransient = persister.isTransient( entity, source );
if ( knownTransient == Boolean.FALSE ) {
// we know for sure it's detached (generated id
// or a version property), and so the instance
// must have been deleted by another transaction
throw new StaleObjectStateException( entityName, id );
}
else {
// we know for sure it's transient, or we just
// don't have information (assigned id and no
// version property) so keep assuming transient
entityIsTransient( event, clonedIdentifier, copyCache );
}
}
else {
// before cascade!
copyCache.put( entity, result, true );
final Object target = targetEntity( event, entity, persister, id, result );
// cascade first, so that all unsaved objects get their
// copy created before we actually copy
cascadeOnMerge( source, persister, entity, copyCache );
copyValues( persister, entity, target, source, copyCache );
//copyValues works by reflection, so explicitly mark the entity instance dirty
markInterceptorDirty( entity, target );
event.setResult( result );
}
}
필자가 구성한 코드 아키텍쳐에서는 아래와 같이 처리된다.
이 과정 중 데이터 수정을 하려면 조회 과정과 저장 과정은 아래와 같이 처리되어야 한다.
조회 : UserDao → User
값 변환 : User
저장 : User → UserDao
이 각각의 과정 중에 User, UserDao 변환에 따라 객체가 생성된다.
이 때 id 가 이미 있으므로 isNew() 는 통과된다.
하지만 merge() 를 호출하게 되고, 영속성 컨텍스트에 데이터가 없으므로
결과적으로 SELECT 쿼리가 부가적으로 호출되게 된다.
실제로 아래와 같이 데이터 변경 유스케이스에 대한 테스트코드를 실행하게 되면
UPDATE 쿼리를 수행하기 위해 SELECT → UPDATE 가 처리되는 것을 볼 수 있다.
@SpringBootTest
class UserInfraImplTest {
@BeforeEach
void setUp() {
userInfra.save(
User.builder()
.balance(new Money(BigDecimal.valueOf(100L)))
.build()
);
}
@Autowired
private UserInfraImpl userInfra;
@Test
@DisplayName("merge")
void merge() {
// GIVEN
List<User> users = userInfra.findUsers();
// WHEN
User user = users.get(0);
user.changeName("changedName");
// THEN
userInfra.save(user);
}
}
~~미안하지만 해당 구조에서는 피할 수 없다.
DETACHED 상태인데 Dirty Checking 을 쓸 수가 없고, 무조건 save()/merge() 를 통해 영속화를 해주어야 한다.
따라서 SELECT 가 호출되는 건 아키텍처 트레이드오프로 받아들이기로 하였다.
만약 피할 수 있는 방법을 알게 된다면 해당 포스트에 더 이어서 적도록 하겠다.
엔티티 별로 interface 를 통해 isUpdated flag 를 구현하도록하고, 만약 isUpdated 가 true 인 경우에는 UPDATE 쿼리가 나가도록 하면 되지 않을까?
DynamicUpdate 를 활용하던, 모든 컬럼에 대한 UPDATE 를 그대로 처리하던지 둘 중 선택해서 말이다
https://docs.spring.io/spring-data/jpa/reference/jpa/entity-persistence.html
https://velog.io/@yglee8048/JPA-Persistable
https://ttl-blog.tistory.com/852