[Spring JPA] @PersistenceContext 어노테이션의 동작
[Spring JPA] @PersistenceContext 어노테이션의 동작
한 새벽에 소스를 계속 파고든 배경
SNS 개발을 하던 중 도메인 엔티티, 영속성 엔티티를 분리하면서 여러 문제점에 직면했고, 그러다가 도메인 엔티티를 대상으로 더티 체킹을 직접 구현하면 어떨까? 라는 생각이 들었다.
Spring data jpa 를 사용할 경우 엔티티의 isNew 라는 필드를 (자동으로 생성된다) 판단하게 되는데, 숫자 원시 타입의 경우 0으로 지정되어 있으면 신규 엔티티로 인식하고 그 외의 경우엔 이미 존재하는 엔티티로 인식한다.
원시 타입이 아닌 경우, null이면 신규 객체, 그렇지 않다면 존재하는 객체로 인식한다.
이 부분에서 Kotlin에서 처음 JPA를 사용할 경우 많은 어려움에 봉착하게 된다. 1년 넘게 Kotlin을 사용하면서 JPA 를 이용하면 Entity의 @Id 필드를 어떻게 정의할 것인지에 대한 고민을 하게 되는 필연의 수순을 거쳤고 현재는 아래와 같은 방식을 사용한다.
@MappedSuperclass
abstract class BaseEntity<UUID>(id: T?): Persistable<UUID> {
private val id: T? = id
val identifier: T
get() = T!!
}
생각해보면 JPA만 사용할 때 어떤 엔티티의 id 필드를 접근하는 일은 이미 영속화된 엔티티를 조회하는 경우 밖에는 없다. 신규 생성의 경우엔 Java에서 JPA를 사용하면 애초에 id 필드를 null로 두고 Repository의 save 메서드를 호출한다. 그래서 딱히 문제점을 못 느끼다가 Kotlin에서 사용하게되면 위에 설명한 조건들과 맞물려 온갖 고민을 하게 된다.
최근 Dev Log에 올린 글 중 도메인 엔티티와 영속성 엔티티를 분리할 때 겪은 글을 적었는데, 잘못된 이해에서 비롯된 결론을 냈고, 실제로는 코드 상의 잘못 구현된 부분이 있어 문제가 발생했던 거였다. 이 부분은 따로 포스팅을 올릴 계획이다. 아무튼 그 때문에 JPA의 동작을 좀 살펴보다가 @Persistence Context 라는 어노테이션으로 주입되는 EntityManager 인터페이스의 필드에 대한 동작이 궁금해져서 이 글을 쓰게 되었다.
최초의 의문점
스프링을 사용할 때, @Component 등을 비롯한 빈을 등록해주는 어노테이션을 이용할 경우, 기본 동작은 싱글턴이다. Spring MVC 를 기준으로 모든 요청은 단일 스레드에 의해 독점적으로 처리된다. 하지만 싱글턴으로 생성된 Bean은 기본적으로 모든 스레드들에 의해 참조되기 때문에 당연하게도 모두 동일한 객체를 참조하게 된다.
여기서부터 의문점이 증폭되기 시작한다.
EntityManager
JPA를 처음 공부하면 무조건 배우는 것이 EntityManager 인스턴스이다. persist, merge, detach 등의 메서드들은 당연하고, 한 요청, 정확히는 트랜잭션에 대해 하나의 EntityManager가 생성된다는 것을 먼저 숙지하고 사용하게 된다. 그런데 Repository 구현체를 직접 작성하기 위해 EntityManager 를 의존성 주입을 받았다고 가정해보자. 당연히 해당 Repository의 생성 시점에 고정된 EntityManager 인스턴스를 사용하게 될 것이다. 당연히 JPA에서 EntityManager의 동작과 맞지 않다는 걸 알 수 밖에 없다. 프록시 객체를 구성하여 실시간으로 현재 트랜잭션의 EntityManager 오는 것 말고는 딱히 답이 없다라는 생각이 들기 시작했다.
PersistenceAnnotationBeanPostProcessor
역시나 @PersistenceContext 를 통해 주입되는 EntityManager 인스턴스는 프록시 객체이다. 이 어노테이션이 붙은 필드가 존재하는 Bean은 스프링 어플리케이션 초기화 시점에 PersistenceAnnotationBeanPostProcessor 에 의해 해당 필드가 EntityManagerProxy 인터페이스로 감싸져 반환된다. 따라서 실제로 Repository 레이어에서 EntityManager 인스턴스를 조작하면, EntityManagerProxy가 실제 EntityManager 객체에게 실행을 위임하는 방식으로 실행된다.
SharedEntityManagerCreator
위 프록시 객체를 생성하는 것은 해당 프로세서에서 SharedEntityManagerCreator 를 이용하여 수행되게 된다. 이 클래스 내부에는 SharedEntityManagerInvocationHandler가 정의되어있다. Creator 클래스를 이용하여 InvocationHandler를 생성하고, 이를 EntityManagerProxy로 리턴하게 되는 것이다.
실제 EntityManager 인스턴스의 생성 시점과 @Transactional 어노테이션
Spring에서 Transaction 이 사용되는 스코프를 지정하는 이 어노테이션은 TransactionInterceptor 에 의해 AOP가 수행된다. 결국 PlatformTransactionManager 의 구현체(JPA를 사용한다면 JpaTransactionManager가 될 것이다.)에 의해 실제 트랜잭션의 시작과 끝이 정의되고 트랜잭션 과정에 필요한 전처리, 후처리 과정을 수행하게 된다.
EntityManager는 이 시점에서 생성된다. JpaTransactionManager는 내부적으로 TransactionSynchronizationManager 라는 녀석을 사용하는데 이 클래스의 내부를 살펴보면 ThreadLocal 을 이용하여 현재 트랜잭션 스코프에서 사용되는 EntityManager를 관리하고 있다. 재미있게도 여기서 ThreadLocal 에 EntityManager를 저장하는 로직을 살펴보면 EntityManagerFactory 인스턴스를 키로 entityManager를 저장하고 관리하는 것을 알 수 있다. 뭔가 Thread 관련 값을 가지고 저장해둘줄 알았는데 ThreadLocal이니 사실 그건 필요가 없고, 중첩되는 Transaction을 관리하기 위한 수단으로 추정된다.
ThreadLocal에 저장된 EntityManager는 어떻게 참조되는가?
결국 @PersistenceContext에 의해 주입된 EntityManager 필드는 EntityManagerProxy(SharedEntityManagerInvocationHandler) 이고, 이 클래스는 내부적으로 EntityManagerFactory를 필드로 가지고있다. SharedEntityManagerInvocationHandler 의 invoke 메소드를 살펴보면 다음과 같은 부분이 존재한다.
class SharedEntityManagerInvocationHandler {
...
@Nullable
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
...
EntityManager target = EntityManagerFactoryUtils.doGetTransactionalEntityManager(this.targetFactory, this.properties, this.synchronizedWithTransaction);
}
}
이 부분이 실제로 현재 ThreadLocal에 저장되어있는 EntityManager 객체를 가져오는 부분이다. EntityManagerFactoryUtils의 해당 메소드를 살펴보면 다음과 같은 부분이 존재한다.
class EntityManagerFactoryUtils ... {
...
@Nullable
public static EntityManager doGetTransactionalEntityManager(EntityManagerFactory emf, @Nullable Map<?, ?> properties, boolean synchronizedWithTransaction) throws PersistenceException {
...
EntityManagerHolder emHolder = (EntityManagerHolder)TransactionSynchronizationManager.getResource(emf);
if (emHolder != null) {
if (synchronizedWithTransaction) {
...
return emHolder.getEntityManager();
}
}
}
}
여기서 TransactionSynchronizationManager 의 ThreadLocal 필드에서 entityManagerFactory 를 키로 entityManger를 조회하고 이를 EntityManagerHolder를 생성하는데 사용하고 있음을 알 수 있다. 그리고 emHolder가 null이 아닌경우, 그러니까 entityManager가 이미 존재하는 경우 해당 EntityManager 인스턴스를 반환하도록 하고있다. 최종적으로 1차 캐시를 관리하는 EntityManager 객체가 프록시 객체를 통해 Repository에서 사용되게 되는 것이다.
결국 공부를 한 이유는
위에서 언급했듯 도메인 엔티티와 영속성 엔티티를 분리하면서 영속성 엔티티 쪽을 JPA를 이용했는데, 연관관계도 모두 제거하고 말그대로 영속성 레이어로만 사용하려고 시도하고 있다. 연관관계를 지정하면 오히려 문제가 생기는 점이 훨씬 많았다. 문제는 도메인 엔티티를 Mapper를 통해 영속성 엔티티로 변환할 때, 기존에 로드한 엔티티가 신규 생성된 엔티티인지 또는 이미 로드된 엔티티면 수정이 된 필드가 존재할 경우에만 update 쿼리가 나가도록 해야한다는 점이었다. JPARepository의 구현체의 save 부분을 보면 다음과 같다.
class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return this.entityManager.merge(entity);
}
}
}
결국 상단에 언급한 id 의 null 여부로(래퍼 타입인 경우) new 여부를 판단할 수 밖에 없는데, 현재 도메인 엔티티는 UUID V7 를 사용중이고, 생성자의 id 기본값이 지정되어 있기 BaseEntity 쪽에서 isNew를 판별하는 또는 세팅하는 메서드를 작성해야하는 상황이었다. 현재는 Naive하게 다음과 같은 도메인 엔티티 추상 클래스를 선언하여 사용하고 있었다.
abstract DomainEntity<T>(id: T) {
private val _isNew = true
var EntityStatus status = EntityStatus.NEW
protected set
fun loaded() {
...
}
fun created() {
status = EntityStatus.NEW
}
fun updated() {
...
}
fun removed() {
...
}
val isNew: Boolean
get() {
if(status == EntityStatus.NEW) {
...
} else if(...)
}
}
이런 도메인 엔티티 최상위 클래스를 하위 클래스에서 상속받아 사용하고 있는데, Mapper를 이용하여 도메인 엔티티로 변환하거나 하는 과정에서 이러한 상태를 일일히 참조하여 변환을 해야한다는 문제도 있지만, 도메인 엔티티의 필드를 수정할 때 일일히 updated() 등을 호출해줘야 한다는 문제점이 있다. 거기에 단순히 JPA 레이어로 모든 영속성 쿼리를 맡겨버리면, Mapper를 통해 매번 엔티티를 새로 생성하여 반환하면 불필요한 update 문이 나가는 등의 문제가 있었다. 그래서 직접 DirtyChecking 을 구현하고, PersistenceAdapter 쪽에서 불필요한 쿼리를 줄이는 최적화를 수행할 수 있지 않을까 싶어 시도해보려고 새벽에 JPA 소스코드를 뜯어보았다