서론
프로젝트 초기에는 JDBC Template을 사용해 데이터베이스와의 연동 작업을 진행했다. 하지만 객체지향적 언어로 도메인을 구성하며 프로그래밍을 하던 중, 객체와 데이터베이스 간의 연결 방법에 대한 고민이 생겼다. 객체지향 설계와 관계형 데이터베이스 사이의 패러다임 불일치로 인해 코드가 복잡해지는 문제를 겪었다.
이러한 문제를 해결하기 위해 ORM(Object-Relational Mapping) 기술인 JPA를 도입하게 되었고, 이를 통해 객체와 데이터베이스 간의 매핑을 더 자연스럽게 처리하고자 했다.
JPA는 Java Persistence API의 약자로, 자바 ORM 기술에 대한 표준 API 명세를 의미한다. JPA는 여러 인터페이스로 구성되어 있으며, 이 인터페이스들을 구현한 대표적인 프레임워크가 하이버네이트(Hibernate)이다.
JPA를 사용하면 데이터베이스와의 매핑이 자동으로 처리되기 때문에 개발자가 직접 SQL을 작성할 필요가 없다. 그런데, 코드 한 줄만으로 데이터베이스에 객체를 저장하고 조회할 수 있다는 점에서 JPA 내부에서 어떤 동작이 이루어지는지 궁금해졌다. 이러한 궁금증을 해결하기 위해, 하이버네이트의 내부 동작을 살펴보기로 했다.
JpaRepository
스프링 데이터 JPA는 데이터베이스 작업을 간편하게 수행할 수 있는 여러 인터페이스를 제공한다. 그중 JpaRepository는 CRUD(Create, Read, Update, Delete) 작업을 손쉽게 처리할 수 있도록 도와주는 핵심적인 인터페이스다.
JpaRepository의 save() 메서드는 엔티티를 데이터베이스에 저장하거나 업데이트하는 기능을 수행한다.
개발자는 이 메서드를 통해 간단하게 데이터를 처리할 수 있지만, 내부적으로 이 메서드가 하이버네이트(Hibernate)와 같은 JPA 구현체를 통해 어떻게 동작하는지에 대한 이해를 해보고자 한다.
JpaRepository.save() 메서드는 스프링 데이터 JPA가 기본적으로 제공하는 SimpleJpaRepository 클래스에서 구현된다.
SimpleJpaRepository클래스는 엔티티의 저장, 수정, 삭제와 같은 다양한 데이터베이스 작업을 처리한다.
SimpleJpaRepository
@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);
}
}
save() 메서드가 호출되면, SimpleJpaRepository는 내부적으로 다음과 같은 과정을 수행한다.
1. 새 엔티티 저장 : 엔티티가 새로 추가된 것이라면 EntityManager의 persist() 메서드를 호출하여 엔티티를 저장한다.
2. 기존 엔티티 업데이트 : 만약 엔티티가 이미 존재하는 것이면 EntityManager의 merge() 메서드를 호출하여 해당 엔티티를 업데이트한다.
먼저 새로운 엔티티를 추가하는 persist() 메서드의 동작 과정에 대해서 자세히 살펴보자.
EntityManager.persist() 동작 과정
하이버네이트(Hibernate)의 내부 동작을 자세히 살펴보기 전에, 하이버네이트는 이벤트(Event)를 기반으로 동작한다는 것을 기억하고 동작 흐름을 따라가면 좋다.
1. SessionImpl : 이벤트 생성 및 발행
*EntityManager의 구현체인 SessionImpl 기준으로 설명
📌 persist 메서드 : 이벤트 생성
SessionImpl의 persist() 메서드를 통해 엔티티를 저장하는 이벤트를 생성하고 발행한다.
public void persist(Object object) throws HibernateException {
checkOpen();
firePersist( new PersistEvent( null, object, this ) );
}
PersistEvent라는 이벤트가 생성되며, 해당 이벤트는 실제 엔티티 객체와 SessionImpl의 참조를 가지고 있다.
📌 firePersist 메소드 : 이벤트 발행
생성된 PersistEvent를 인자로 한 firePersist 메소드를 호출하게 된다.
firePersist 메서드는 엔티티의 저장 이벤트(PersistEvent)를 발행하는 역할을 한다.
다양한 예외 사항에 대한 처리를 포함하여 코드가 복잡해 보이지만, 이벤트를 발사하는 빨간 사각형으로 표시된 핵심 부분만 살펴보자.
fastSessionServices.eventListenerGroup_PERSIST.fireEventOnEachListener( event, PersistEventListener::onPersist );
PERSIST, DELETE, CLEAR, MERGE 등 각 이벤트에는 이벤트 리스너 그룹(EventListenerGroup)이 존재하고 EventListener의 구현체들이 등록되어 있다.
▶️ eventListenerGroup_PERSIST : 저장 이벤트를 처리하는 이벤트 리스너 그룹을 나타낸다.
▶️ fireEventOnEachListener 메서드 : 생성한 PersistEvent를 리스너 그룹의 각 리스너에 전달하여 이벤트를 처리하도록 위임한다.
▶️ PersistEventListener::onPersist : 이벤트 리스너의 onPersist 메서드가 호출되어, 엔티티 저장 시 필요한 작업을 수행하도록 한다.
PersistEventListener의 구현체인 DefaultPersistEventListener의 onPersist() 메서드가 호출되어 엔티티 저장이 본격적으로 시작된다.
2. DefaultPersistEventListener : 엔티티 영속화 작업
이제 본격적으로 엔티티를 영속화하는 작업을 수행한다.
📌 onPersist 메서드 : 엔티티 상태에 따른 처리 로직 호출
public void onPersist(PersistEvent event) throws HibernateException {
onPersist( event, new IdentityHashMap( 10 ) );
}
코드 내용이 상당하니 대다수 생략하고 영속화 작업과 관련된 주요 부분만 살펴보자.
엔티티의 상태에 따라 적절한 처리 로직을 호출한다. 새롭게 저장하는 엔티티는 TRANSIENT 상태로 분류되고, entityIsTransient() 메서드를 호출하게 된다.
📌 entityIsTransient 메소드
protected void entityIsTransient(PersistEvent event, Map createCache) {
LOG.trace( "Saving transient instance" );
final EventSource source = event.getSession();
final Object entity = source.getPersistenceContextInternal().unproxy( event.getObject() );
if ( createCache.put( entity, entity ) == null ) {
saveWithGeneratedId( entity, event.getEntityName(), createCache, source, false );
}
}
Map createCache에 엔티티를 저장한다. 해당 과정에서 createCache.put 메서드가 null을 반환한다면
→ 즉, 기존에 존재하지 않은 새로운 엔티티를 저장하는 상황임을 의미한다.
이러한 경우엔 새로운 엔티티를 저장하기 위해 saveWithGeneratedId() 메서드를 호출한다.
📌 saveWithGeneratedId 메소드 : 식별자 생성 및 저장
saveWithGeneratedId() 메서드는 식별자를 생성하고, 그 결과에 따라 적절한 작업을 수행한다.
새로운 엔티티는 performSave() 메서드를 호출하여 저장된다.
▶️ persister.getIdentifierGenerator().generate( source, entity ) : 식별자 생성기를 이용해 고유 식별자를 생성한다.
▶️ SHORT_CIRCUIT_INDICATOR : 식별자가 이미 존재하는 경우를 나타내며, 이 경우 기존 식별자를 반환한다.
▶️ POST_INSERT_INDICATOR : 식별자가 데이터베이스에 삽입된 후에만 결정될 수 있음을 나타낸다. 이 경우 performSave 메서드를 호출하여 데이터베이스 삽입을 처리한다.
▶️ 그 외의 경우 식별자를 로그로 기록하고, performSave를 호출하여 엔티티를 저장한다.
📌 performSaveOrReplicate 메서드 : 엔티티 저장 및 Insert 작업 추가
(*DefaultPersistEventListener 가 상속받는 AbstractEventListener 클래스에 구현되어 있다.)
performSaveOrReplicate() 메서드는 performSave()에서 호출되며,
해당 메서드에서는 이제 본격적으로 엔티티를 실제로 저장하고 엔티티를 데이터베이스에 삽입하기 위한 Insert 작업을 추가한다.
아래 스크린샷은 performSaveOrReplicate 메서드의 주요 부분만 캡쳐하였다.
▶️ 엔티티 등록 :
❇️ EntityEntry original = persistenceContext.addEntry(…);
❇️ 엔티티를 PersistContext에 등록한다.
❇️ placeholder(표식)을 넣어 동일한 객체를 다시 저장하려고 재귀적으로 호출되는 것을 방지한다.
❇️ 무한 루프에 빠질 가능성이 있는 대표적인 경우는 양방향 관계를 가진 엔티티가 있는 상황이다. 두 엔티티가 서로를 참조하고 있을 때, 한쪽 엔티티를 저장하면서 다른 쪽 엔티티도 저장하려고 하다가 무한루프에 빠질 수 있다.
▶️ INSERT 작업 추가 :
❇️ AbstractEntityInsertAction insert = addInsertAction(…);
❇️ addInsertAction() 메서드를 통해 INSERT 작업을 ActionQueue에 추가한다. ActionQueue는 쓰기 지연(Write-behind) 전략을 가능하게 해 준다. 이 큐에 쌓인 작업들은 트랜잭션이 커밋될 때 한꺼번에 실행된다.
❇️ ActionQueue 내에는 INSERT 외에도 EntityAction의 구현체들이 저장된다.

persist() 정리
JpaRepository.save() 메서드는 스프링 데이터 JPA와 Hibernate가 복합적으롣 동작하여 엔티티를 데이터베이스에 저장하는 과정을 간단하게 처리한다.
그러나 내부적으로는 여러 단계의 이벤트 생성, 발행, 엔티티 상태 관리, 식별자 생성, 그리고 데이터베이스 작업 큐에 추가하는 등 복잡한 작업이 수행된다.
주요 포인트
✔️ 이벤트 기반 처리 : 이벤트 시스템을 통해 엔티티 관리 및 작업 수행
✔️ 엔티티 상태 관리 : 엔티티의 상태에 따라 적절한 작업 수행
✔️ ActionQueue를 이용한 쓰기 지연 : 데이터베이스 작업을 Action으로 큐에 저장하여 트랜잭션 커밋 시점에 한꺼번에 실행
EntityManager.merge() 동작 과정
다시 SimpleJpaRepository로 돌아가보자.
@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);
}
}
엔티티를 영속성 콘텍스트에 최초로 저장하는 persist() 메서드에 대해서 살펴봤다.
그렇다면 save하려는 엔티티가 이미 영속성 컨텍스트에 존재한다면 어떤 일이 벌어질까? entityInformation.isNew(entity)가 false이기 때문에 em.merge(entity)가 호출된다.
merge 메소드 내부 동작도 자세히 살펴보자.
1. SessionImpl : 이벤트 생성 및 발행
*EntityManager의 구현체인 SessionImpl 기준으로 설명
📌 merge, fireMerge : 이벤트 생성 및 발행
@Override
public Object merge(Object object) throws HibernateException {
checkOpen();
return fireMerge( new MergeEvent( null, object, this ));
}
이전에 살펴보았듯이 Hibernate는 이벤트를 기반으로 동작한다. merge메서드가 호출되면 MergeEvent를 생성하여 발행한다.
2. DefaultMergeEventListener : 엔티티 병합 작업
📌 onMerge 메소드 : 병합 작업 처리
public void onMerge(MergeEvent event) throws HibernateException {
final EntityCopyObserver entityCopyObserver = createEntityCopyObserver( event.getSession().getFactory() );
final MergeContext mergeContext = new MergeContext( event.getSession(), entityCopyObserver );
try {
onMerge( event, mergeContext );
entityCopyObserver.topLevelMergeComplete( event.getSession() );
}
finally {
entityCopyObserver.clear();
mergeContext.clear();
}
}
▶️ EntityCopyObserver : 엔티티 복사 과정을 추적하고 관리하는 역할
▶️ MergeContext : 병합 작업에 필요한 콘텍스트 정보를 관리한다.
📌 엔티티 상태에 따른 처리 로직 호출
병합 처리와 관련된 주요 부분만 살펴보자. 엔티티의 상태에 따라 적절한 처리 로직을 호출한다.
병합되는 엔티티는 PERSISTENT 상태로 분류되고, entityIsPersistent 메서드를 호출하게 된다.
📌 entityIsPersistent 메소드 : 영속성 콘텍스트에 존재하는 엔티티에 대해 병합 작업을 수행
protected void entityIsPersistent(MergeEvent event, Map copyCache) {
LOG.trace( "Ignoring persistent instance" );
//TODO: check that entry.getIdentifier().equals(requestedId)
final Object entity = event.getEntity();
final EventSource source = event.getSession();
final EntityPersister persister = source.getEntityPersister( event.getEntityName(), entity );
( (MergeContext) copyCache ).put( entity, entity, true ); //before cascade!
cascadeOnMerge( source, persister, entity, copyCache );
copyValues( persister, entity, entity, source, copyCache );
event.setResult( entity );
}
copyCache에 엔티티를 추가하여 이 엔티티가 현재 병합 과정에서 처리 중임을 나타낸다. 그리고 copyValues 메서드를 통해 속성값을 복사한다.
MergeContext.put() 자세히 살펴보기
MergeContext 클래스 살펴보기
🏷️ 필드
public class MergeContext implements Map {
private static final Logger LOG = Logger.getLogger( MergeContext.class );
private final EventSource session;
private final EntityCopyObserver entityCopyObserver;
private Map<Object,Object> mergeToManagedEntityXref = new IdentityHashMap<Object,Object>(10)
private Map<Object,Object> managedToMergeEntityXref = new IdentityHashMap<Object,Object>( 10 );
private Map<Object,Boolean> mergeEntityToOperatedOnFlagMap = new IdentityHashMap<Object,Boolean>( 10 );
// 메소드
}
- mergeToManagedEntityXref : 병합되는 엔티티(mergeEntity)와 이미 관리되고 있는 엔티티(managedEntity)를 매핑
- managedToMergeEntityXref : mergeToManageEntityXref의 역방향 매핑 유지
- 관리된 엔티티(managedEntity)를 키로 이에 대응되는 병합되는 엔티티(mergeEntity)를 값으로 갖는다.
- 성능 최적화를 위해 존재한다. 병합 과정에서 되는 엔티티에 대응되는 병합 엔티티를 빠르게 찾기 위해 사용된다.
- mergeEntityToOperatedOnFlagMap : mergeEntity가 병합 과정에서 현재 작업 중인지 여부를 추적한다. 병합 작업이 중복되지 않도록 하며, 동일한 병합 엔티티가 여러 번 처리되는것을 방지하는 데 사용된다.
🏷️ MergeContext.put()
public Object put(Object mergeEntity, Object managedEntity, boolean isOperatedOn) {
if ( mergeEntity == null || managedEntity == null ) {
throw new NullPointerException( "null merge and managed entities are not supported by " + getClass().getName() );
}
Object oldManagedEntity = mergeToManagedEntityXref.put( mergeEntity, managedEntity );
Boolean oldOperatedOn = mergeEntityToOperatedOnFlagMap.put( mergeEntity, isOperatedOn );
Object oldMergeEntity = managedToMergeEntityXref.put( managedEntity, mergeEntity );
if ( oldManagedEntity == null ) {
// this is a new mapping for mergeEntity in mergeToManagedEntityXref
if ( oldMergeEntity != null ) {
// oldMergeEntity was a different merge entity with the same corresponding managed entity;
entityCopyObserver.entityCopyDetected(
managedEntity,
mergeEntity,
oldMergeEntity,
session
);
}
if ( oldOperatedOn != null ) {
throw new IllegalStateException(
"MergeContext#mergeEntityToOperatedOnFlagMap contains a merge entity " + printEntity( mergeEntity )
+ ", but MergeContext#mergeToManagedEntityXref does not."
);
}
}
else {
// mergeEntity was already mapped in mergeToManagedEntityXref
if ( oldManagedEntity != managedEntity ) {
throw new IllegalArgumentException(
"Error occurred while storing a merge Entity " + printEntity( mergeEntity )
+ ". It was previously associated with managed entity " + printEntity( oldManagedEntity )
+ ". Attempted to replace managed entity with " + printEntity( managedEntity )
);
}
if ( oldOperatedOn == null ) {
throw new IllegalStateException(
"MergeContext#mergeToManagedEntityXref contained a merge entity " + printEntity( mergeEntity )
+ ", but MergeContext#mergeEntityToOperatedOnFlagMap did not."
);
}
}
return oldManagedEntity;
}
- 만약 mergeEntity가 처음 매핑되는 경우(oldManagedEntity == null), 역방향 매핑(managedToMergeEntityXref)에 다른 병합 엔티티(oldMergeEntity)가 이미 존재하는지 확인한다. 만약 존재하면,entityCopyObserver.entityCopyDetected를 통해 복제된 엔티티가 감지되었음을 알린다.
- 만약 mergeEntity가 이미 매핑된 경우, 매핑된 엔티티가 변경되었는지 확인한다. 만약 변경되었다면, 예외를 던진다.
entityIsPersistent 메서드는 이미 영속성 콘텍스트에 존재하는 엔티티에 대해 병합 작업을 수행하며, 이때 캐시에 엔티티를 추가하고 관련된 연쇄 작업 및 값 복사를 처리한다. put 메서드는 병합 엔티티와 관리된 엔티티 간의 매핑을 처리하며, 중복 병합을 감지하고 처리한다.
JpaRepository.save() 안티패턴에 대한 고찰
엔티티 수정 후 save 호출 시 유의할 점
JPA는 엔티티의 수정이 발생하면 DirtyChecking을 수행하여 트랜잭션 커밋 시점에 자동으로 update 쿼리를 날린다.
엔티티 수정 후 save()를 호출하는 것이 데이터 반영에 있어서 문제가 되는 것은 아니지만 성능상 피해야 할 이유가 존재한다.
마지막에 살펴본 copyValues 메서드는 entity 객체에서 target 객체로 속성 값을 복사하는 기능을 수행한다.
protected void copyValues(
final EntityPersister persister,
final Object entity,
final Object target,
final SessionImplementor source,
final Map copyCache) {
final Object[] copiedValues = TypeHelper.replace(
persister.getPropertyValues( entity ),
persister.getPropertyValues( target ),
persister.getPropertyTypes(),
source,
target,
copyCache
);
persister.setPropertyValues( target, copiedValues );
}
이 과정에서 배열을 생성하게 되는데, 엔티티가 이미 영속성 콘텍스트에 존재하지만 불필요한 배열을 생성하는 것이다.
만약 최상위 엔티티에 다른 하위 엔티티가 포함되어 있다면 계단식으로 복사가 진행되어 오버헤드가 커지게 된다.
서비스 로직에서 Entity 수정 후 save를 호출하지 말아야 하는 것일까?
JPA를 사용하면 엔티티 수정 시 Dirty Checking이 발생하여 자동으로 UPDATE 쿼리를 실행한다. 따라서 서비스 로직에서 엔티티를 수정한 후 save 호출은 불필요하며, 바로 위에서 살펴보았듯이 성능상 권장되지 않는다.
그러나 이것은 JPA를 사용할 때의 이야기이다. 만약 영속화 기술이 JPA에서 변경된다면? 그렇다면 엔티티 수정 후 저장이 가능하도록 서비스 로직에 save를 추가해야 하는 것일까?
우선 기술 관점은 잠시 미루고, 설계적인 관점에서 이 문제를 살펴보자. Application Service 레이어에서 엔티티 수정 후 save 메서드를 호출하는 것은 필요할까?
이에 관하여 의견이 분분할 수 있지만 필자는 save 메소드 호출이 자연스럽다고 생각한다.
서비스 레이어는 영속화 기술에 관심을 두지 않으며, 엔티티의 수정 내용을 저장하는 것이 자연스러운 흐름이라고 생각하기 때문이다.
그렇다면 영속화 기술로 JPA를 사용할 때, 이를 어떻게 해결할 수 있을까?
서비스 로직은 특정 기술에 종속되지 않는 것이 좋다. 즉, 기술이 변경되더라도 서비스 로직 코드에 변경이 있어서는 안 된다.
그러기 위해 우리는 SOLID 5원칙 중 하나인 DIP(Dependency Inversion Principal)를 사용하여 서비스 레이어가 Repository 인터페이스를 의존하도록 설계한다.
그렇다면 Repository 구현체 내부에서 이에 관하여 세밀하게 조정할 수 있을 것 같다.
public interface MemberRepository {
Member save(MemberV2 member);
// ...
}
public interface MemberJpaRepository extends JpaRepository<Member, Long> {
// ...
}
@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepository{
@PersistenceContext
private EntityManager entityManager;
private final MemberJpaRepository memberJpaRepository;
@Override
public Member save(Member member) {
if(!entityManager.contains(member)) {
memberJpaRepository.save(member);
return member;
}
return member;
}
위 코드에서, Repository 구현체에서 Persistence Context가 현재 엔티티를 갖고 있지 않을 시, 즉 새롭게 저장하는 엔티티이면 JpaRepository의 save를 호출한다. 그렇지 않다면 별다른 로직 없이 엔티티만 반환하여 JPA에서 자동 update문을 생성하도록 한다.
그러나 이것은 어디까지나 접근 방법 중 하나에 불과하다. Hibrernate 내부에서 새로운 엔티티 여부를 판단하는 메서드는 entityInformation.isNew(entity) 이다.
isNew 메서드는 식별자를 기준으로 새롭게 저장되는 엔티티인지 판단한다. 식별자가 Primitive 타입인 경우 0이거나 Primitive 타입이 아니면 null일 때 새로운 엔티티로 간주한다.
반면, entityManager.contains(entity) 메소드는 내부적으로 엔티티의 상태를 통해 영속성 콘텍스트에 존재하는 엔티티인지 아닌지를 판단한다.
isNew()가 true면 contains()는 false가 분명히 나올 것이라고 현재까지는 판단되나 이와 관련해서는 훨씬 깊게 살펴봐야 할 내용들이 분명 있을 것이다.
기존에 존재하는 프레임워크의 내부 동작을 임의적으로 변경하면 추후에 어디서 어떤 영향을 미칠지 쉽게 예측하기 어렵기 때문이다. 해당 내용에 대해서는 추후에 더 깊게 살펴보겠다.
현실적으로는, 복잡한 연관관계를 가진 엔티티가 아니라면 조금의 성능 저하를 감수하더라도 엔티티 수정 후 JpaRepository.save()를 호출하고, 복잡한 연관관계를 가진 엔티티를 수정할 땐 JPA를 사용하지 않는 방법도 고려할 수 있을 것이다.
'Programming > Spring,JPA' 카테고리의 다른 글
[Spring] 예외 처리에만 국한되지 않는 @ControllerAdvice (0) | 2024.10.26 |
---|---|
[Spring] 단일 행이 반환될때는 queryForObject가 항상 정답일까? (0) | 2024.10.26 |
[Spring] 스프링에서 사용되는 디자인 패턴들 (0) | 2024.10.25 |
[Spring] 스프링 삼각형(IoC/DI, AOP, PSA) (1) | 2024.10.25 |
[JPA] 영속성 컨텍스트(Persistence Context) 내부 구조 살펴보기 (1) | 2024.10.25 |