본문 바로가기
320x100
320x100

서론

지난 1편

https://code-boki.tistory.com/266

 

FlushEvent와 Action Queue의 동작 방식으로 알아보는 Hibernate(JPA) 1편

일단 문제를 함께 풀어보자ㅎㅎUser엔티티부터!(사실 서적에서는 엔터티라고 부르던데..난 엔티티가 편하다)QuizGivenJPA를 공부하셨고 구현체인 Hibernate의 ActionQueue에 대해서 들어보셨다면.. 다음

code-boki.tistory.com

에 이은 2편이다

이번에는 save() 메서드를 깊이 파보면서 JPA의 동작방식을 이해해보자

ㅋㅋ 진짜 삽질 엄청 많이했다...

참고로 이 글은 불친절하게 작성되어 있다

나는.. Internet Explorer의 호환성은 싫어했지만, Explorer라는 뜻은 좋아한다

탐험가라는 뜻이다. 네트워크의 등장으로 전 세계가 인터넷이라는 망 위에서 각자 탐험가가 되어서 탐험할 수 있게 된 것이다

이 글도 탐험하는 느낌으로 작성했다

답만 최대한 빠르게 도출해서 이쁜 그림으로, 표로 정리하는 그런것도 나쁘진 않지만... 이 글에서는 내부를 탐험하면서 중간중간 삼천포로 빠지기도 하지만, 그 과정에서 얻을 수 있는 크고작은 지식들이 있다. 그런것들을 함께 얻어가신다면 좋겠다

 

Version History

먼저 이 글은 2024년 12월에 작성된 글이며, Springboot 3.3.5버전을 다루고 있고, 그에 따른 Spring은 6.1.14 버전을 사용한다

boot이기때문에 version정보 없이 의존성을 참조할 수 있었으며 starter data jpa를 사용했고 버전은 똑같이 3.3.5다

구현체인 hibernate의 버전정보는 사진으로 첨부한다

 

본론

몇가지 방법으로 실험해봤다

첫번째: 디버그모드로 break point를 걸어서 내부로 들어가기(step into)

두번째: 실제 메서드 구현부를 찾아 들어가기(cmd + b / opt + cmd + b)

 

테스테에 사용될 객체들을 알아보자

엔티티: User

Entity

Persistent Layer(Repository): UserRepository

Repository

Persistent Layer(Repository): OrderRepository

Repository

Business Layer(Service): UserService

Service

Test Dependencies

테스트 클래스

테스트 클래스에서는 주 생성자를 통해 주입받은 다음의 프로퍼티들을 사용한다

때에 따라 @SpringBootTest도 사용할 예정이다

@DataJpaTest는 내부적으로 @Transactional 어노테이션을 갖고 있다

 

Test Code 1

테스트코드

테스트 메서드명을 기깔나게 잘 지은것같다. 사실이든 아니든!

뭔가 이름만 보면 좋은 테스트의 속성인 FIRST를 잘 지킬 것 같다!!(너 블로그라고 너무 막하는거 아니냐...ㅠ)

Fast, Isolated, Repeatable, Self-validating, Timely

 

이제 위에서 말한 첫번째 방식으로 실험해보자

1. 디버그모드로 break point를 걸어서 내부로 들어가기(step into)

break point

Data Jpa Repository가 Entity를 영속화를하는 save()메서드에 중지 포인트를 만들었다

사용되는 구현체

현재 프린트된 라인을 보면 SimpleJpaRepository라는 것을 볼 수 있다

인터페이스끼리는 상속

잠깐 다른이야기를 하자면 JpaRepository또한 Interface이다. 우리가 interface UserRepository를 만들고 extends(:)를 하는 이유가 인터페이스끼리는 상속이라고 표현하기 때문이다

JpaRepository의 구현체 목록

어쨌든, JpaRepository로 들어가서 구현체를 확인해보면 다음과 같다

구현체들

QuerydslJpaRepository는 Deprecated가 된 것을 볼 수 있고, 아래의 SimpleJpaRepository가 구현체로 사용중이다

step into

다시 원래의 코드로 돌아와서 Step Into로 들어가보자(내부를 하나하나 뜯어보고 싶어서 Step Over가 아닌 Into를 선택했다)

JdkDynamicAopProxy의 invoke()

계속 이어서 Step Into를 해보면 JdkDynamicAopProxy.class의 invoke메서드쪽으로 들어왔다

JdkDynamicAopProxy

Spring에 대해서 공부를 해본사람이라면 Spring에서의 AOP의 기본 전략이 objenesis의 등장으로 CGLIB의 문제점(기본 생성자 필요, 생성자 이중호출)들이 해결되어서 JDK Dynamic Proxy에서 CGLIB을 기본 AOP Proxy전략으로 사용하게 되었다는 걸 알 것이다(여전히 final 키워드로 상속이 불가한 경우는 안됨)

하지만 Spring이 기본 전략을 CGLIB으로 가져간다는 것이지, JDK Dynamic Proxy를 사용하지 않는다는 뜻은 아니다

구체클래스 없이 interface만 있는 경우는 JDK Dynamic AOP Proxy를 사용한다(라이브러리 내부에는 구체클래스가 존재함)

위의 경우가 바로 JPA Repository를 사용하는 경우다. 정확히는 Hibernate가 JDK Dynamic AOP Proxy를 통해 엔티티 정보를 수집하고, 그 과정에서 Reflection을 사용한다

-> Reflection을 사용하기 위해서는 기본생성자가 필요하기때문에 @Entity가 붙은 클래스는 기본 생성자가 항상 있어야된다

그럼 이제 proceed()를 하기 전까지 쭉쭉 내려가면서 수집되는 정보를 살펴보자

복잡해보일 수 있지만, target.proceed(); 또는 methodInvocation.proceed(); 가 있다면 target의 메서드를 호출하는 부분이라 생각하면 된다

많은 정보를 얻을 수 있다

- proxy: SimpleJpaReposity

- targetSoruce: SingletonTargetSource

- cache: JdkDynamicAopProxy

- advised: ProxyFactory

- invocation: 안보여 좀 더 옆으로 밀어서 보자

- invocation: ReflectiveMethodInvocation(CrudRepository.save, SimpleJpaRepository)

오홍.. 우리는 SimpleJpaRepository의 존재만 확인했는데 요기에서 CrudRepository를 처음 만났다

사실.. JpaRepsository를 잘 보셔다면 여러 인터페이스를 상속받은 모습을 볼 수 있었다

그 중 ListCrudRepository를 보면 CrudRepository를 상속받고 있다

그리고 이 CrudRepository의 구현체가 SimpleJpaRepository인 것도 볼 수 있다

다시 정리해서 말해보면 내가 작성한 (interface) UserRepository extends (interface)JpaRepository에서 구현했던 save메서드는 CrudRepository 인터페이스의 추상메서드를 구현한 것과 똑같은 것이고, 그 구현체는 SimpleJpaRepository인 것이다

계속 step into를 하다가 중간에 디버그 메뉴의 Console 탭을 보면

2024-12-03T14:12:46.838+09:00  WARN 83084 --- [l-1 housekeeper] com.zaxxer.hikari.pool.HikariPool        
: HikariPool-1 - Thread starvation or clock leap detected (housekeeper delta=8m35s798ms).

이와 같은 메세지를 볼 수 있다

운영체제를 공부했거나 스레드, 멀티스레드, 코루틴 등에 대해서 공부를 하셨다면 눈치챌만한 부분이 있다

먼저, HikariPool에서 메세지가 출력되는 것을 볼 수 있다

스레드를 생성하는 비용이 큰 것처럼, 데이터베이스 서버와 스프링 서버와의 TCP 연결로 만들어진 DB Connection 객체도 비싼 객체이기 때문에 미리 Pool을 만들어두는데 이 공간에서는 여러가지 역할을 도맡아 한다

현재의 메세지는 경고문으로 스레드 기아(Thread Stravation) 또는 시계점프(Clock Leap)이 발생한 것을 볼 수 있는데, 주기적인 작업 도중 참조한 시스템 클럭이 큰 폭으로 변경되거나 스레드 기아가 발생했을 수도 있다는 뜻이다

스레드 기아 현상이 발생하는 원인은 여러가지가 있겠지만, 현재의 원인으로는 긴 작업 처리로 인해 발생한 것 같다

계속 step into를 사용하고 적절히 step over를 사용한 결과...

JdkDynamicAopProxy.invoke()
ReflectiveMethodInvocation.proceed() -> ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke()
MethodInvocationValidator.invoke() -> MethodInvocation.proceed()
ExposeInvocationInterceptor.invoke()
CrudMethodMetadataPostProcessor.invoke()
PersistenceExceptionTranslationInterceptor.invoke()
TransactionInterceptor.invoke() -> AopUtils.getTargetClass()
TransactionAspectSupport.invokeWithinTransaction()
DefaultMethodInvokingMethodInterceptor.invoke()
QueryExecutorMethodInterceptor.invoke() -> this.doInvoke() 
RepositoryFactorySupport.invoke()
RepositoryComposition.invoke()
..
..
..
SimpleJpaRepository.save()

 

이렇게 된다

되게 많은 것들을 알아냈다..!! 스크린샷도 자세히 살펴보면 좋은정보가 많다

1. JPA Repository는 JDK Dynamic Proxy로 동작하고..

2. RelfectiveMethodInvocation 내부를 보니 Reflection API를 사용해서 정보를 수집하는 것 같아보인다

3. Validation이 호출되서 유효한 메서드인지 체크를 하고

4. Expose..? 뭔가 내보내고 있고...

5. CrudMethodMetadataPostProcessor 클래스의 코드를 보면 TransactionSynchronizationManager가 메서드로부터 자원을 읽어와서 Crud에 필요한 메타데이터를 만들고

이 메타데이터의 생성자에서는 LockMode, QueryHint, Comment, EntityGraph, Method에 대한 정보가 만들어지는 것을 확인했고

이후에 TransactionSynchronizationManager가 위에서 수집된 metadata와 method를 bindResource라는 메서드로 뭔가 묶고

6. PersistenceExceptionTranslationInterceptor에서는 뭔가 데이터베이스 예외에 대한 추상화를 위한 작업을 하는 부분으로 추측되고

7. TransactionInterceptor에서는 AopUtil에서 타겟(구체클래스)를 얻어와서 트랜잭션과 함께 invoke(함수 호출)을 발생시키고

8. TransactionAspectSupport에서는 if구문에서 Reactive한 요청인지 아닌지 판단 후 아니라면 PlatformTransactionManager가 트랜잭션과 함께 코드 처리한다

9. QueryExecutorMethodInterceptor가 호출을 가로채거나 아님 위임받아서 쿼리를 호출하는 책임을 맡는 놈인것 같다

 

휴......일단 여기까지만 알아보자..ㅠㅠ

여기까지만 해도 진짜 오래걸렸다..!!

대신 엄청 많은 정보들을 알 수 있었다

 

이렇게 깊이 파보면 많은 정보들을 얻을 수 있다. 하지만 실제 쿼리를 발생시키는 SimpleJpaRepository.save()까지 몇개를 파고들어야 할지 알 수 없다..!!

내 행동을 멍청하다고는 하고싶지 않다. 하지만 이렇게 열심히 안으로 타고 들어가는게 현명한 걸까..?

내 생각은 현명하지는 않다라는 쪽이다. 하지만 좋은 경험이라고 말하고 싶다!!

 

현명한 방법으로는 IntelliJ를 좀 더 잘 사용하는 방법이 있다

1. 테스트코드의 UserRepository.save() 부분에 break point 추가

2. SimpleRepository의 save()의 부분에 break point 추가

3. Step Over() 또는 Resume Program 버튼을 눌러서 SimpleRepository의 코드부분까지 이동

4. Hide Frames from Libraries 체크박스 해제

 

 

 

콜스택은 아래부터 위로 쌓인다. 예외 로그가 출력될때랑 똑같다. SimpleRepository.save()가 호출되기까지 총 34개의 프레임이 생긴다는 것을 알 수 있다. 그리고 필요하다면 보고싶은 부분만 클릭해서 볼 수도 있었다

영한님의 강의에서 V0 -> V1 -> V2 -> V3.. 이렇게 석기시대에서 현대시대로 점진적으로 발전하는 것처럼, 일단 힘든 과정을 한번 겪고 내부를 파악한 다음에 쉬운 방법을 알아봣다 쿠쿡

 

2.  실제 메서드 구현부를 찾아 들어가기(cmd + b / opt + cmd + b)

자, 여기서부터는 2번 방법이다. 사실 디버그모드와 break point를 사용하지 않은 채로 save()를 탐구하려면 이전 과정은 하나도 모르는 것이었다. 힘든 과정이었지만 많은 것들을 얻었다고 생각한다

아까 SimpleRepository의 save() 메서드 맨 윗부분에서 콘솔을 보면 아직까지 insert into 쿼리가 나가지 않은 것으로 보아, 여기부터 내부의 동작중에서 쿼리를 발생하는 부분이 있는걸로 예측할 수 있다

여기서부터는 debug 모드는 필요시 최소한으로 사용하고, 최대한 단축키로 구현체의 동작을 따라가보면서 알아봤다

save() 메서드 쪽에 커서를 둔 채로 cmd + b(윈도우: Ctrl + B)를 눌러보자

save(S entity);

그럼 CrudRepository 인터페이스 내부로 들어오게 된다

이번에는 save()쪽에 커서를 둔 채로 opt + cmd + b를 눌러보자

그럼 SimpleJpaRepository 클래스의 save 메서드로 이동했다 

 

코드를 분석해보자

this.entityInformation.isNew() 메서드로 신규/기존 엔티티인지 판단 후 persist(insert)를 할지 merge(update)를 할지 결정한다

 

그럼 신규 엔티티인지 판별하는 entityInformation은..?

이미 SimpleJpaRepository가 인스턴스화 된 순간 JpaEntityInformation으로부터 정보를 받아서 내부 필드로 갖고 있게 된다

그래서 this.entityInformation.isNew() 메서드를 호출할 수 있던 것이다

그럼 isNew 메서드를 알아보자. 이번에도 cmd + b로 들어가봤다

EntityInformation 이라는 인터페이스가 나왔다

isNew() 메서드에 커서를 가져간 후 opt + cmd + b를 눌러보자

이런......!!

겁나 많은 구체클래스들이 이 EntityInformation의 인터페이스를 구현화하고 있었다

어떤 구현체가 내 코드에서 실행되는지 모를때는 2가지 방법이 있다ㅋ

1. 눈치껏 영어이름으로 파악하기

2. 디버그 모드 활용

난 여기에서 2번을 사용해봣다

isNew 부분에 break point를 걸어보니 JpaMetamodelEntityInformation이라는 구현체의 isNew() 메서드를 사용하는 것을 확인했다

isNew(T entity);

내부로 들어가봤더니 else구문으로 빠져서 super맨을 호출해 상속받은 부모의 메서드한테 help치는 걸 확인했다

AbstractEntityInformation의 isNew() 메서드를 분석해봤다

ID는 제네릭으로 넘어온 타입을 받는다(ex: JpaRepository<User, Long/Int/UUID>

만약 Primitive 타입이 아니라면 id를 null로 초기화한다

Java의 경우 Long타입은 WrapperType이기때문에 맨 위의 if구문을 타서 id가 null이 된다

Kotlin의 경우 id는 ?를 붙여서 null로 초기화를 해놓을 수도 있고, Long을 붙여서 0L로 할 수도 있다

그렇기때문에 Kotlin Long타입의 경우 Number 타입을 상속받기 때문에 else if 구문으로 들어온 것을 볼 수 있다

 

- id가 null일때

id가 nullable타입으로 선언된 경우 java.lang.Long타입으로 JVM내부에서 변환되어 첫번째 if문으로 타서 id가 null이 되는 모습을 볼 수 있다

다시말해 val id: Long? = null을 하면 java.lang.Long id(nullable);이 되기때문에 첫번째 if 구문을 타고

val id: Long = 0L을 하면 kotlin.Long(not null)이 돼서 두번째 else if 구문을 타게 된다

 

다시 save메서드로 돌아오면 당연히 isNew 내부로 진입한 것을 볼 수 있다 

이제 디버그 모드를 off하고 entityManager의 persist로 커서를 이동해서 cmd + b로 구현부로 이동해보자

persist(Object var1);

그럼 또 킹받게도 EntityManager 인터페이스로 이동한다

프로그램 세상은 인터페이스가 아니라 구현체에 의해 동작하기 때문에.... 그럼 또 어떤 구현체의 persist() 메서드가 실행되는지 파악해야 한다

이번엔 유추해볼까? SessionImpl아니면 SessionDelegatorBaseImpl일것 같은데.....ㅎㅎ

답은 SessionImpl이다

이번에도 코드를 분석해보자

checkOpen을 타고 들어가보니 session이 열린 것을 확인하는 코드이다

여기에서 session이란 hibernate의 Session을 뜻하며 데이터베이스의 Connection 추상화, 영속성, 쿼리 관련 메서드를 제공하는 인터페이스이다

간단하게 Session이 닫힌 경우 롤백처리하는 옵션이다

다시 persist()로 돌아와서 분석을 이어나가면

firePersist( new PersistEvent( entityName, object, this ) );

위 메서드를 통해 PersistEvent를 fire(트리거/발행)하는 것을 알 수 있었다

이 이벤트를 살펴보잣...!!

firePersist( new PersistEvent ( .. ) );

Gavin King이라는 왕아저씨가 만드셨나보다ㅋ

엇.. 뭔가 PersistEvent 말고 다른 이벤트도 있을 것 같은데?? 싶으면 맛집 잘 찾아온거 맞다ㅋ

수많은 이벤트가 존재한다

스프링에서는 이 이벤트가 트리거(발행)되면 처리하는 리스너 친구들이 깜찍하게 기다리고 있다

 

생성자를 살펴보자

(힙메모리에 적재할때 == 인스턴스화 할때) 인자를 3개를 넘겼으므로....

19-22번째의 생성자에서 24-31번째 생성자를 호출하면서 24-31번째에서 부모클래스의 생성자를 또 호출한다

이번에는 Steve Ebersole 스티브 앱솔 형님께서 만드셨다

AbstractEvent는 이벤트소스를 받아서 내부 필드로 갖는 이벤트객체다

아.... 더 들어가고 싶지 않은데... EventSource도 들어가보자!!

오.. Gavin King 형님 다시 만났다

아닛....!! JPA의 쿼리순서를 책임지는 ActionQueue가 저기 있는 모습을 볼 수 있다

ActionQueue의 내부는 추후에 알아보기로 하자.. 너무들어왔어!!!! To deeeeep...

다시 뒤로 빽해서 PersistEvent를 트리거하는 firePersist 메서드의 구현부로 가보자

 

솔직히 여기서 catch부분은 예외처리니까 스무쓰하게 넘어가고 try 부분만 살펴보자

뭐 굳이 예외까지 분석해보면 어떤 예외가 발생할지 몰라서 null로 선언하고 초기화를 하면서, 그것도 추상화된 공통 예외를 던지기 위해서 컨버터를 통해서 변환한다

그럼 try 부분을 간단하게 분석해보면...

- validation 췤~!

  • checkTransactionSyncStatus(): 트랜잭션이 동기화된 상태인지 확인
  • checkNoUnresolvedActionsBeforeOperation(): 현재 메서드가 작동하기 전에 해결되지 않은 액션이 있나 확인

  • finally부분에서도 작업이 이루어진 후에 해결되지 않은 액션이 있나 checkNoUnresolvedActionsAfterOperation() 메서드를 통해 후처리

checkNoUnresolvedActionsBeforeOperation() 메서드에서는 ActionQueue의 풀리지않은 엔티티 Insert 액션이 있나 확인하는 작업이 또 이루어진다

 

fireEventOnEachListener(event, PersistEventListener::onPersist );

- 이벤트 처리

fastSessionServices.eventListenerGroup_PERSIST
       .fireEventOnEachListener( event, PersistEventListener::onPersist );

 

코드는 영어 그대로 해석해도 읽을 수 있을 정도면 잘 작성되어있다고 보면된다

fasetSessionServices 라는 빠른 하이버네이트 세션(작업)을 처리할 수 있는 서비스가 있나보다

그리고 그 중 PERSIST를 담당하는 리스너 그룹이 있고..!?

확인해보자

와우.. 수많은 이벤트리스너 그룹을 갖고 있는 객체다

당연히 다양한 이벤트가 있기 때문에 그 이벤트만을 주로 담당하는 리스너그룹이 존재하는 것을 알 수 있따

 

fireEventOnEachListner() 메서드로 들어가봤다

인터페이스가 반겨줬다

오.. 메소드 시그니처가 함수형 인터페이스네~~

 

@Incubating이라는 어노테이션이 궁금해서 잠깐 찾아봤다

Hibernate 진영 내부에서 개발중이라는 메타정보를 나타내기 위한 어노테이션으로 추후에 변경될 수도 있는 API를 나타내는 것 같다

 

이어서 구현부를 따라가봤다

내부의 리스너가 돌면서 첫번째 인자의 이벤트를 계속 처리하는 코드를 확인할 수 있다

 

PersistEventListener::onPersist

다시 돌아와서

fireEventOnEachListner() 메서드의 두번째 함수형 아규먼트인 PersistEventListener::onPersist 구현부로 이동해봤다

이런.. 이번에도 Gavin King 형님이 반겨줬다

 

DefaultPersistEventListener.onPersist(..);

onPersist의 구현체를 찾아 opt + cmd + b를 누르니 DefaultPersistEventListener 클래스가 나왔다

 

이 클래스의 구현부를 보면 몇몇 특징을 파악할 수 있다

1. onPersist에서 내부의 onPersist를 호출하면서 PersistContext.create()를 호출한다. 영속성 컨텍스트에 1차캐시를 등록하는 부분으로 추측할 수 있다

2. 이벤트에 포함된 오브젝트에서 Proxy객체여부를 확인하면서 추출. 프록시 객체가 아닌 경우 not null

프록시 객체의 초기화 여부를 확인하고 초기화가 된 경우에는 초기화된 객체에서 실제 구현체를 가져와서 영속화 진행

일반 객체인 경우는 직접 영속화

 

엔티티의 영속상태 여부에 따라 분기처리하는 코드

현재는 영속성 컨텍스트에 없는 객체를 새로 저장하니깐 TRANSIENT(비영속) case로 빠질 예정

 

entityIsTransient(..);

break point를 걸어서 디버그 모드로 확인해도 예상대로 동작한다

내부의 entityIsTransient 메서드로 들어가보자

DefaultPersistEventListener 내부의 메서드를 호출한다

1. 세션 획득

2. 프록시객체에서 실제 엔티티로 변환 - createCache.add(entity)

3. ID 생성 전략을 사용하여 엔티티 저장 - saveWithGeneratedId(...)

=> if구문을 통해 동일한 엔티티가 중복저장되지 않도록 방지한다

 

위에서 가장 중요한건 3번이다. 내부로 진입해보자

AbstractSaveEventListener.saveWithGeneratedId(..);

AbstractSaveEventListener 추상클래스의 saveWithGeneratedId() 메서드를 호출한다

분석해보자

1. final EntityPersister persister = source.getEntityPersister( entityName, entity );

-> 엔티티 정보 추출

2. final Generator generator = persister.getGenerator();

-> ID 생성전략에 따른 객체 가져옴(IDENTITY, SEQUENCE, TABLE)

3. final boolean generatedOnExecution = generator.generatedOnExecution( entity, source );

-> ID가 실행시점에 생성되는지 여부(IDENTITY: true / 그 외: false)

 

요기까지만 하고 2, 3번을 ID 생성 전략을 다르게 해서 break point를 걸어 확인해보자

< DB의존: IDENTITY 전략인 경우 >

Generator: IdentityGenerator가 되고 그에 따라 generatedOnExecution: true

 

< DB의존: SEQUENCE 전략인 경우 >

Generator: SequenceStyleGenerator가 되고 그에 따라 generatedOnExecution: false

 

< DB의존: TABLE 전략인 경우 >

Generator: TableGenerator가 되고 그에 따라 generatedOnExecution: false

 

< Application의존: UUID 전략인 경우 >

Generator: UuidGenerator가 되고 그에 따라 generatedOnExecution: false

 

결국 3번 코드에서 IDENTITY 전략을 제외하고는 모두 generatedOnExecution가 false 되는 것을 확인했다

 

다시 돌아와서 코드분석을 이어나가보자

4.if~else if~else 구문은 스크린샷의 설명으로 대체한다

 

5. final boolean delayIdentityInserts = !source.isTransactionInProgress() && !requiresImmediateIdAccess && generatedOnExecution;

-> 트랜잭션이 진행중이 아니고, 즉시 ID접근이 필요하지 않고, ID가 DB실행시점에 생성되는 경우

이 delayIdentityInserts는 insert 최적화를 위해 사용되는 flag이다

사실 대부분의 상황에서 이 delayIdentityInserts는 false이다. 왜냐하면 애초에 save()메서드부터 @Transactional이 붙어있기때문에 첫번째 조건인 !source.isTransactionInProgress()은 무조건 false이기 때문이다

generatedOnExecution은 IDENTITY 전략일 때만 true이다

만약 이 3개의 AND 조건을 true로 만족시키려면 트랜잭션을 사용하지 않고, ID가 즉시 사용되지 않으며, IDENTITY 생성 전략을 사용하는 경우일 것이다!

그래서 대부분의 경우 delayIdentityInserts는 false라는 것을 알아도 무방할 것 같다

 

performSave메서드를 호출하게 되는데, 이 코드도 구현부를 따라가보자

AbstractSaveEventListener.performSave(..);

동일 클래스 내부에서 호출되는 코드이다(AbstractSaveEventListener)

 

또 하나하나 뜯어보자 line by line!!

1. callbackRegistry.preCreate(..);

-> 엔티티에 @PrePersist 콜백함수를 호출한다. 실제로 확인해봤다

< 아무것도 없는 경우 >

-> EmptyReadOnlyMap. 아무것도 작동하지 않는다

 

< @PrePersist를 엔티티에 추가한 경우 >

-> 실제로 호출되는 코드, 비어있지 않은 callbacks을 확인할 수 있었다

 

2. processIfSelfDirtinessTracker( entity, SelfDirtinessTracker::$$_hibernate_clearDirtyAttributes );

-> 엔티티가 hibernate의 enhancement를 사용한 경우 또는 SelfDirtinessTracker를 직접 구현한 경우 dirty tracking을 위한 변경 상태를 초기화한다. Hibernate의 기본 스냅샷 방식 대신, 엔티티 수준에서 변경 사항을 직접 추적함

 

나는 직접 SelfDirtinessTracker 인터페이스를 구현해봤다(이 방식은 추천되는 방식은 아니다)

class User : SelfDirtinessTracker {
    private val dirtyAttributes = mutableSetOf<String>()

    override fun `$$_hibernate_trackChange`(attribute: String) {
        dirtyAttributes.add(attribute)
    }
    
    ...
}

실제로는 이렇게 변경감지를 담을 컨테이너(컬렉션)가 필요하다

 

실제로 clearDirtyAttributes 로직이 실행되는 모습을 볼 수 있다

또한 실제로 디버깅을 해보면 if문 브랜치를 타는 모습을 볼 수 있었다

실제로 Hibernate에서 제공하는 Gradle Plugin을 사용하면 위의 코드를 자동으로 만들어준다

 

이번에는 인터페이스를 구현하지 않는 단순한 User 엔티티의 경우는 어떻게 되는지 디버그를 걸어서 들어가봤다

 

-> if구문에 들어가지도 않는 모습을 볼 수 있다

 

결론적으로, 다시 정리해보면

processIfSelfDirtinessTracker( entity, SelfDirtinessTracker::$$_hibernate_clearDirtyAttributes );

코드는 bytecode enhancement를 사용해 DirtyTracker가 활성화된 경우 clearDirtyAttributes를 트리거함으로써 변경감지된 필드들을 비운다(collection clear)

 

3. processIfManagedEntity( entity, (managedEntity) -> managedEntity.$$_hibernate_setUseTracker( true ) );

-> Entity가 Hibernate에 의해서 Managed되는 ManagedEntity가 되기 위해서는 2번처럼 ManagedEntity 인터페이스를 구현화하는 방법도 있지만, 이번에는 정석적인 방법으로 Hibernate에서 제공하는 Gradle Plugin을 통해 hibernate의 enhancement를 사용해서 bytecode에서 내부 메서드가 생성되게 해보자

 

< Hibernate에서 제공하는 Gradle Plugin을 통해 bytecode를 조작(메서드 추가 생성) >

https://plugins.gradle.org/plugin/org.hibernate.orm

 

Gradle - Plugin: org.hibernate.orm

 

plugins.gradle.org

plugins {
    kotlin("plugin.jpa") version "1.9.25"
    id("org.hibernate.orm") version "6.5.3.Final" // 현재 Hibernate의 버전
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

hibernate {
    enhancement { }
}

build.gradle.kts 파일 내부

 

결과는... 다음과 같다

if(entity instanceof PrimeAmongSecondarySupertypes) .. 내부의 if (e != null) 내부 로직까지 타게 되서 두번째 action($$_hibernate_setUseTracker)가 활성화(true)가 된다

 

 

사실 두번째 속성은

hibernate {
    enhancement {
        enableDirtyTracking.set(true)
    }
}

간단하게 이렇게 설정할 수도 있다

그럼 이 설정이 적용된 경우와 아닌 경우의 Entity 내부를 살펴보자

< enhancement가 적용되지 않은 경우 >

< enhancement가 적용되고 dirtyTracking이 true인 경우 >

엔티티 내부에 $$라는 필드가 생기고 age만 변경되었다고 추적되었다!!

오..? 그럼 update 쿼리가 다르게 만들어지지 않을까!??(기대기대)

update users set age=?,username=? where id=? 

update users set age=? where id=? { 요거 예상 }

결과는..?

Dog 같이 실패... 왜 그럴까?

일단 엔티티 내부에 변경된 필드를 갖고 있는 Attribute 컬렉션이 생성되는 것은 맞지만, 이게 update쿼리에 영향을 미치지는 않는다

save()를 할때 persist() 또는 merge()가 호출되는데, 특히 merge()가 호출될때는 영속성 컨텍스트(PersistContext) 내부에 있는 엔티티의 스냅샷(초기 버전)과 비교를 한다

이 스냅샷이란 건 사실 엔티티의 모든 필드를 갖고 있다고 봐도 된다. 만약 User의 필드가 100개라면 merge()가 호출될때 엔티티의 스냅샷하고 비교를 하기 위해 100개의 필드를 모두 비교한다. 하지만 hibernate의 enhancement 기능으로 인해 dirtyTracking이 활성화된 경우에는 이 merge가 좀 더 빠르게 작동하게 된다

결국 update는 전체 쿼리가 나간다. 부분 업데이트를 바란다면 @DynamicUpdate 또는 nativeQuery밖에 방법이 없다는 사실을 알아두자..!!

누가 DirtyTrack이 활성화되면 변경감지된 필드를 내부적으로 알고 있기때문에 update가 변경된 필드만 발생한다고 말한다면 그것은 틀린 말이다. 단지 스냅샷 비교를 최적화하여(하지 않고) 변경 감지 비용을 줄이는 역할을 하는 것이다

 

다른 enhancement 옵션들은 다음과 같다

hibernate {
    enhancement {
        enableDirtyTracking.set(true)
        enableLazyInitialization.set(true)
        enableAssociationManagement.set(true)
        enableExtendedEnhancement.set(true)
    }
}

 

각각 어떤 역할을 하는지 궁금하면 찾아보자. 하이버네이트를 공부하는 데 손가락 몇개는 움직여봐야지!!!

난 이제 다음줄 코드 분석을 이어서...ㅠㅠ 힘들군

 

4. final Generator generator = persister.getGenerator();

-> Id의 생성 전략을 얻어온다

 

5. if ( generator instanceof Assigned || generator instanceof CompositeNestedGeneratedValueGenerator ) { ... }

@Id
val id: Long = 0L,

@Test
val newUser = User.of(id = 10, username = "Boki", age = 20)

-> @Id만 사용하거나 복합 ID 생성기를 사용하는 경우 내부로직을 탄다

즉, @GeneratedValue 또는 @UuidGenerator 를 사용하지 않은 id 직접 할당의 경우이다

예외처리는 당연한 로직이다. ID를 직접할당하는 전략으로 갔는데, ID가 null이라면 예외를 뿜어야지~~

 

6.  final EntityKey key = useIdentityColumn ? null : entityKey( id, persister, source );

-> IDENTITY 전략인경우 데이터베이스가 ID를 생성하기때문에 EntityKey는 null이고 그 외에는 entityKey() 메서드를 통해 EntityKey를 생성한다

 

7.  if ( invokeSaveLifecycle( entity, persister, source ) ) { return id; }

-> 위 코드도 참 재미있는 코드다....!!!

코드를 잘 살펴보면 if구문을 제외하면 return false인 것을 볼 수 있다

이로써 추측할 수 있는 것은 if구문을 타지 않는다면 아래 8번의 else 구문으로 바로 넘어가도록 설계가 되었다는 것을 유추해볼 수 있다

이제 내부 if구문을 언제 타는지 알아보자

 

아무런 구현도 하지 않은 순수 Entity는 if (persister.implementsLifecycle()) 구문을 타지도 않고 곧바로 return false로 이동하면서 false를 반환하고 메서드는 끝나버린다

@Entity
class User(
 var username: String,
 ...
 @Id
 @GeneratedValue(strategey = GenerationType.IDENTITY)
 val id: Long = 0L,
)

이와 같은 엔티티 말이다

그럼 if (persister.implementsLifecycle())구문을 타기 위해서는 어떻게 해야 할까? 영어 그대로 Lifecycle을 implements한 후 구현화하면 된다

먼저 Lifecycle 내부를 탐험해보자

많이 봐온 익숙한 이름의 Gavin King 형님 이름이 보인다

그리고 두번째 사진의 맨 아래 breadcrumb indicator를 통해서 내가 보고있는 소스가 hibernate-core(6.5.3) 내부의 org.hibernate.classic.Lifecycle 내부라는 것을 알 수 있다

이 Lifecycle 인터페이스는 내부적으로 VETO, NO_VETO라는 필드를 갖고 있고 메서드들은 default로 선언되어있다

(default가 붙은 메서드는 원하면 커스텀 구현을 하거나 하지 않아도 된다. body를 갖고있기 때문에!)

 

VETO라는 단어를 처음 들어봐서 찾아봤다

VETO는 거부/금지라는 뜻을 갖고 있다. 그럼 NO_VETO는 허용/승인 이라는 뜻을 가질것이라고 생각할 수 있다

그리고 이 부정어구와는 반대로 VETO는 true, NO_VETO는 false를 갖고 있다는 사실을 기억해야 한다!!

 

고럼 이제 if문 내부로 들어가도록 Lifecycle 인터페이스를 구현한 엔티티로 변경해보자

Lifecycle을 구현하고 있음에도 빨간줄이 쳐져있지 않는 것을 확인할 수 있다

이 말은, Lifecycle 인터페이스의 모든 메서드가 body가 있는 default 메서드로 구현되어있고 추가로 커스텀하고 싶은 부분만 override해서 재정의를 하면 된다는 뜻이다

 

이런식으로 작성해봤다

그리고 디버그 모드로 아까부분까지 진입해봤다

if 구문 내부로 진입 성공

다음 포인트인 onSave 메서드까지 온 것을 볼 수 있다

예상컨대 VETO를 만나서 true가 되고. 이 뜻은 저장이 안될 것이라고 추측해볼 수 있다

오...!! 예상대로 동작했다

onSave() 메서드에서 VETO(true)가 되어서 저장이 되지 않았기 때문에 invokeSaveLifecycle() 메서드 또한 true가 되어서 else구문이 실행되지 않을 거라고 회색으로 처리된 모습과 insert into 쿼리가 보이지 않는 모습을 볼 수 있었다

 

그럼 여기에 관해 추가로 나올 수 있는 질문들을 생각해봤다

Q. 어...!? 저는 보통 @PrePesist 콜백메서드에서 예외를 발생시켜서 insert 쿼리를 방지했는데요?? Lifecycle을 implement하면서 onSave()를 재정의하는 것과의 차이점은 뭔가요?

A. 일단.... @PrePersist 메서드에서 어떤 예외를 발생시키느냐가 중요할 것 같다. 왜냐하면 현재 내부는 트랜잭션으로 관리되고 있는 상태이기 때문이다. @Transactional로 AOP처리를 하는 경우 또는 save메서드 호출시 트랜잭션이 붙어있기 때문에..!!

그리고 CheckedException이면 commit, RuntimeException이면 rollback을 할 것이다

결론만 말하자면 @PerPersist에서 예외를 발생시키는건 트랜잭션을 종료시키는 것이고, Lifecycle을 구현한 onSave() 메서드에서 처리하는 것은 insert쿼리만 작동하지 않게 만드는 것이다. 트랜잭션과 영속성 컨텍스트는 유지한 채로 말이다

결과를 테스트해보자

onSave()때와 같은 코드이지만, 예외를 발생시킨다

 

테스트 결과는 위와 같다

userRepository.save() 를 하는 부분에서 예외가 발생해 테스트가 실패한다

예외를 캐치하는 테스트였다면 통과했을 수도 있겠지만.. 여기선 그게 중요한게 아니니..!!

그리고 IllegalArgumentException은 런타임 예외니까 롤백이 수행됐을 것이다

여기서 꿀팁 하나! 만약에 내가 날리려는 예외가 런타임예외인지 헷갈릴 경우...?

이렇게 항상 참을 반환한다는 IDE의 알림메시지로 실행해보지 않고 판단할 수 있다

자바에서는 instanceof 키워드를 사용하면 될 것이다

 

이번엔 perPersist의 예외 던지는 부분을 주석처리하고, onSave() 부분에서 VETO를 리턴해보자

테스트가 깨지지 않았다. 다만 저장 작업 중단이라는 콘솔 출력과 insert into 쿼리를 찾아볼 수 없다는 차이가 있다

 

Q. 오..!! 그럼 PerPersist에서 필드값을 검증하면서 예외를 던지는 것과 Lifecycle의 onSave에서 검증하면서 true/false를 하는 방식 중 뭐가 더 좋은건가요?

A. 뭐가 더 좋다고 말할 수 없다. 일단 프론트엔드(웹or앱)에서 1차 검증 후 Backend의 Persentation Layer인 Controller에서 2차 입력 검증, Business Layer인 Service에서 애플리케이션 도메인 특성에 기반한 예외 3차 검증, 이후에 뭐 엔티티의 생성자 부분이나 필드에서 4차 검증....

내가 볼때는 4차검증까지는 과다하고 생각한다. 3차까지는 할 수 있지만, 데이터가 들어가기 직전에 DB단에 찔러서 스키마 제약을 확인해서 예외를 터트리거나 저장하지 않는 건 좀 아니지않은가..!?

그리고 Lifecycle은 Hibernate꺼다..!! 만약에 EclipseLink같은 다른 구현체를 사용할 경우는 다른 방법이 존재할지도 모르겠다

그리고 Lifecycle에서는 session 객체를 인자로 받을 수 있다. 그 안에 트랜잭션 정보라던지, 엔티티매니저 등.. 풍부한 데이터가 있는 반면 PrePersist는 아무런 인자도, 리턴값도 없다!

하지만 잘못된 입력이 들어왔을때는 커밋or롤백처리를 하는게 맞다고 생각한다. 트랜잭션을 살린채로 insert쿼리만 하지 않는 경우는... 돈 많은 클라이언트가 특정한 기능을 만들어주세요~~ 하는 경우가 아니거나 아트를 좋아하는 시니어개발자가 라이프사이클 하나하나 다 커스텀하는 경우 아니고서는 없을 것 같다고 생각한다

 

휴... 드디어 else 구문으로 넘어갈 수 있게됐다!! 아직 ActionQueue까지는 멀었다 ㅋㅋㅋㅋㅋㅋㅋ

다시 엔티티를 PrePersist 콜백메서드도, Lifecycle도 구현하지 않는 순수한 친구로 돌려놓고 디버깅해보자

휴 드디어 performSaveOrReplace() 메서드를 들어가볼 수 있게 됏다

다시 정리하면, Hibernate에 종속적인 Lifecycle 인터페이스를 implements하지 않는다면 저 if구문으로 들어가지 않는다

내부적으로는 재정의한 메서드 혹은 기본 전략에서 VETO, NO_VETO를 리턴하느냐에 따라 true/else로 타게 돼서 결론적으로 performSaveOrReplace()까지 도달할 수 있다

 

8.  else { return performSaveOrReplicate( entity, key, persister, useIdentityColumn, context, source, delayIdentityInserts );

드디어 내부로 들어왔다!!

 

AbstractSaveEventListener.performSaveOrReplicate(..);

위 코드도 사실 매우매우 길다

하지만 난 지지않아!! 분석에 들어가보자

 

일단 순수 Entity로 테스트했을 때 넘어가는 인자가 어떻게 되는지 알아야 내부 로직 파악이 쉽다

- ID 자동 생성 전략: IDENTITY

- Hibernate bytecode enhancement 사용 X

- PreXXX, PostXXX 콜백 구현 X

- Hibernate Lifecycle 인터페이스 구현 X

위 조건의 경우 이 메서드를 호출한 곳에서 넘긴 인자를 살펴보면

key: null / useIdentityColumn: true / source: SessionImpl / delayIdentityInserts: false

다음과 같다

 

잘게 짤라먹어보자

final Object id = key == null ? null : key.getIdentifier();

IDENTITY의 전략의 경우 null, 아니라면 ID 추출

final PersistenceContext persistenceContext = source.getPersistenceContextInternal();

 

source(SessionImpl)에서 영속성 컨텍스트 가져오기

// Put a placeholder in entries, so we don't recurse back and try to save() the
// same object again. QUESTION: should this be done before onSave() is called?
// likewise, should it be done before onUpdate()?
final EntityEntry original = persistenceContext.addEntry(
       entity,
       Status.SAVING,
       null,
       null,
       id,
       null,
       LockMode.WRITE,
       useIdentityColumn,
       persister,
       false
);

 

엔티티의 Entry(내부 상태정보를 관리하기 위한 객체)를 만들어 영속성 컨텍스트에 저장

  • 상태: SAVING
  • id: null
  • 잠금: WRITE
  • useIdentityColumn: true(IDENTITY 전략인 경우)
cascadeBeforeSave( source, persister, entity, context );

 

저장 전, 연관된 엔티티 cascade 처리

final AbstractEntityInsertAction insert = addInsertAction(
       cloneAndSubstituteValues( entity, persister, context, source, id ),
       id,
       entity,
       persister,
       useIdentityColumn,
       source,
       delayIdentityInserts
);

 

Insert 작업을 ActionQueue에 추가

// postpone initializing id in case the insert has non-nullable transient dependencies
// that are not resolved until cascadeAfterSave() is executed
cascadeAfterSave( source, persister, entity, context );

저장 후, 연관된 엔티티 cascade 처리

final Object finalId = handleGeneratedId( useIdentityColumn, id, insert );

최종 생성된 ID

 

final EntityEntry newEntry = persistenceContext.getEntry( entity );
if ( newEntry != original ) {
    final EntityEntryExtraState extraState = newEntry.getExtraState( EntityEntryExtraState.class );
    if ( extraState == null ) {
       newEntry.addExtraState( original.getExtraState( EntityEntryExtraState.class ) );
    }
}

 

영속성 컨텍스트의 엔티티 상태정보 업데이트

return finalId;

생성된 최종 id 반환

 

디버그 모드로 찍힌 값들을 확인해보며 위에서 분석한 코드들에 어떤 인자들이 넘어갈 것인지 예상해보자

끝이 보인다..!!

 

위의 한줄한줄 분석에서 중요하다고 생각하거나 내부로 들어가봄직한 코드들을 살펴보자

 

persistenceContext.addEntry(..);

// Put a placeholder in entries, so we don't recurse back and try to save() the
// same object again. QUESTION: should this be done before onSave() is called?
// likewise, should it be done before onUpdate()?
final EntityEntry original = persistenceContext.addEntry(
       entity,
       Status.SAVING,
       null,
       null,
       id,
       null,
       LockMode.WRITE,
       useIdentityColumn,
       persister,
       false
);

이 코드의 내부로 들어가보자 addEntry로..!!

위의 코드는 PersistenceContext 인터페이스 내부의 코드이다

JavaDoc을 해석해보면 적절한 엔티티엔트리 인스턴스를 생성하고 이벤트소스의 내부에 캐시를 더한다고 나와있다

보통 이벤트소스는 SessionImpl을 뜻하면서 내부에 EntityManager, PersistenceContext 등을 갖고 있다

그럼 구현체를 찾아가보자!

좌: 인터페이스 / 우: 구현체

PersistentceContext를 구현하는 StatefulPersistenceContext 구현체 클래스

@Override
public EntityEntry addEntry(
       final Object entity,
       final Status status,
       final Object[] loadedState,
       final Object rowId,
       final Object id,
       final Object version,
       final LockMode lockMode,
       final boolean existsInDatabase,
       final EntityPersister persister,
       final boolean disableVersionIncrement) {
    assert lockMode != null;

    final EntityEntry e;

    /*
       IMPORTANT!!!

       The following instanceof checks and castings are intentional.

       DO NOT REFACTOR to make calls through the EntityEntryFactory interface, which would result
       in polymorphic call sites which will severely impact performance.

       When a virtual method is called via an interface the JVM needs to resolve which concrete
       implementation to call.  This takes CPU cycles and is a performance penalty.  It also prevents method
       inlining which further degrades performance.  Casting to an implementation and making a direct method call
       removes the virtual call, and allows the methods to be inlined.  In this critical code path, it has a very
       large impact on performance to make virtual method calls.
    */
    if (persister.getEntityEntryFactory() instanceof MutableEntityEntryFactory) {
       //noinspection RedundantCast
       e = ( (MutableEntityEntryFactory) persister.getEntityEntryFactory() ).createEntityEntry(
             status,
             loadedState,
             rowId,
             id,
             version,
             lockMode,
             existsInDatabase,
             persister,
             disableVersionIncrement,
             this
       );
    }
    else {
       //noinspection RedundantCast
       e = ( (ImmutableEntityEntryFactory) persister.getEntityEntryFactory() ).createEntityEntry(
             status,
             loadedState,
             rowId,
             id,
             version,
             lockMode,
             existsInDatabase,
             persister,
             disableVersionIncrement,
             this
       );
    }

    entityEntryContext.addEntityEntry( entity, e );

    setHasNonReadOnlyEnties( status );
    return e;
}

이번에도 코드가 길어서 사진 대신 코드블럭으로 첨부했다

 

assert lockMode != null;

먼저 맨 처음에 lockMode가 null이 아니어야 한다는 assert 검증이 있다. LockMode에는 어떤 것들이 있는지 들어가보자~

enum으로 구성되어 있고, 값 하나마다 level과 외부에 나타낼 형태 2개의 내부 정보를 갖고 있다

NONE, READ, OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT, WRITE, ... 등 총 10개의 LockMode가 있고 관련 메서드들이 여러개 존재 했다 

Structure 탭으로 살펴보니 문자열 to LockMode를 변환 static 메서드, LockOptions으로 만드는 메서드도 존재하는 것을 볼 수 있었다

 

다시 다음줄 코드를 살펴보자

final EntityEntry e;

두번째로 EntityEntry가 final 키워드로 선언되어있는데, 무조건 초기화 블록이 하위에 있어야 한다

그렇지 않으면...

이런 오류를 볼 수 있다

어쨌든 final을 한줄에 초기화를 안했다면 아래의 if-else구문 등에서 꼭 초기화를 하는 부분이 있을 것이다! 살펴보자

 

에...? 주석이 되게 길다

해석해보자~!! 잘 못하는 영어 실력으로...

/*

중요!!!!

다음의 instanceof 검사와 캐스팅은 의도적이다

EntityEntryFactory 인터페이스를 통해 메서드를 호출하도록 리팩토링 하지 마라. 그것은 다형적 호출이 발생하며, 이는 성능에 심각한 영향을 미친다

인터페이스를 통해 가상메서드가 호출되면 JVM은 호출할 구현체를 결정하기 위해 탐색과정을 거쳐야 한다

이는 CPU 사이클을 소모하며, 이는 하나의 성능 저하이다. 또한 메서드 인라이닝을 막기때문에 성능을 더욱 악화시킨다

구현체로 캐스팅하여 직접 메서드를 호출하면 이러한 가상 호출을 제거할 수 있고, 메서드 인라이닝도 허용된다

중요한 코드 경로에서, 가상 메서드를 호출하는 것은 성능에 매우 큰 영향을 미친다

*/

 

여기에서 중요한 키워드를 2개정도 추출해 낼 수 있는데 다형적 호출, 메서드 인라이닝이다

- 다형적 호출은 JVM이 인터페이스나 추상클래스의 구체적인 구현체를 찾기 위해 가상 메서드 테이블인 vtable을 참조하는 프로세스를 거친다. 위의 설명은 instanceof를 사용하지 않는다면, 이 과정에서 CPU 자원을 소모하고 성능에 영향을 줄 수 있다는 뜻

- 메서드 인라이닝은 JVM의 최적화 기법의 일종이다. 메서드 호출을 제거하고 호출된 메서드의 코드를 호출 지점에 직접 삽입하여 오버헤드를 제거하는 과정. 위의 설명은 다형적 호출을 사용하면 메서드 인라이닝이 불가능하기 때문에 성능을 최적화할 수 없다는 뜻

 

보통 instanceof를 사용하면 객체지향 원칙을 위배하고, 코드 가독성을 해치며 테스트와 리팩토링이 어렵다고 하여 사용을 지양하라고만 했던 것 같은데... 이런 장점이 있는지 몰랐다...!!

instanceof의 사용 시 언급한 단점들은 fact이다!! 하지만 새로운 구현체를 추가하거나 코드 확장성을 고려치 않고 최대한 성능저하 없이 기능이 동작되게 하려면 이렇게 해야하는구나를 배웠다

 

그럼 이제 if-else를 살펴보자

맨 앞의 e = ... 를 보면 차이점을 알 수 있다

MutableEntityEntryFactory / ImmutableEntityEntryFactory

차이점이 뭘까? 일단 영어로만 해석하면 가변/불변 엔티티팩토리이다

아무것도 상속받고 구현화하지 않고 Entity, Table 어노테이션만 붙여놓은 경우에는 어느쪽 branch를 타는지 디버그 모드로 확인해봤다

오..? 왜 가변엔티티쪽으로 빠질까?? 내가 기존에 작성했던 기존 엔티티 말고 다른 동작을 보고싶다!!

그럼 else쪽으로 빠지는 방법은..? 엔티티에 org.hibernate.annotations.Immutable -> @Immutable 어노테이션을 붙여주면 된다

다시 돌려보자

 

오 else쪽으로 들어왔다

그럼.. MutableEntity와 ImmutableEntity는 어떻게 다른걸까? 추측을 해본다면 ImmutableEntity는 불변이니까 Update 동작이 뭔가 다를 것 같다. 테스트해보자!

테스트코드는 다음과 같다

< @Immutable Entity >

테스트 결과는 다음과 같다...ㅋㅋ 이번에는 Immutable 어노테이션을 붙이지 않았던 기존 엔티티로 테스트해봤다

 

< no @Immutable Entity >

 

=> 차이점은 @Immutable이 붙은 엔티티에서는 update쿼리가 발생하지 않는다는 것이다. 그 외의 insert, delete 쿼리는 정상 작동했다

기존 DB에서 조회하는 부분은 없지만 조회쿼리는 잘 동작할것이라고 예상할 수 있다

 

이처럼 엔티티에 어노테이션 하나만 붙여서 좀 더 안정적인 프로그래밍을 할 수 있는 기능을 제공한다. 어디서? 하이버네이트에서..

annotation의 출처를 보면 org.hibernate... 인 것을 볼 수 있다

 

남은 3줄의 코드를 살펴보자

entityEntryContext.addEntityEntry( entity, e );

setHasNonReadOnlyEnties( status );
return e;

맨 처음 entityEntryContext.addEntityEntry() 메서드를 살펴보려 했눈데.. 무려 메서드가 80줄이나 돼서 간단하게 정리하고 넘어간다

// 생성된 EntityEntry를 entityEntryContext에 추가하여 관리

 

두번째 setHasNonReadOnlyEnties(status)는 현재 같은 객체의 StatuefulPersistenceContext에서 호출된다

Status를 보면 현재 SAVING인 것을 볼 수 있는데.. 현재 insert 쿼리를 실행하는 중간 상태를 나타낸다!!

 

다른 Status는 이렇게 존재한다! 차례로 영속상태, 읽기전용(변경감지 비활성화), 삭제될 상태, 삭제되지 않는 상태, 초기화되지 않은 엔티티를 로드하는 중.. 이라고 한닷

 

마지막으로 아까 초기화를 했던 EntityEntry를 리턴하고 메서드를 빠져나간다

 

다시!!!! performSaveOrReplicate 메서드로 돌아가서 이후의 코드를 살펴보자

먼저 위 코드를 보고 조금의 유추를 해보자

예상하고 그 예상이 맞는 경우 코드를 읽는 능력이 조금 상승한다고 개인적으로 생각한다

 

cascadeBeforeSave(...);

final AbstractEntityInsertAction insert = addInsertAction(...);

cascadeAfterSave(...);

 

DB나 JPA에 대해서 공부하셨다면 참조 무결성과 관련된 cascade옵션을 알 것이다

흐름을 보면 cascadeBeforeSave -> addInsertAction(저장..?) -> cascadeAfterSave

addInsertAction이 무엇인지 모르지만, 앞뒤로 저장 전/후에 cascade를 처리하는 코드가 있다고 보면 addInsertAction에서 insert쿼리가 나가려나?하고 예측해볼 수 있다

cascadeAfterSave(..);까지 디버그 모드로 이동시켜서 쿼리가 나가는지 확인해서 우리가 한 예측에 대해 검증해보자

before.. 부분에서 콘솔을 보면 아무 흔적도 없다

하지만 addInsertAction을 거친 후

after... 부분을 보면 insert into users (..) values (...) 쿼리가 나간 것을 볼 수 있었다

=> 예측한대로 동작한다. 그럼 insert into쿼리를 발생시키는 addInsertAction 메서드를 집중탐구하기 위해 내부로 들어가보자

 

addInsertAction(..);

참고로 이 메서드를 보면 static키워드가 없고 private인 것으로 보면 알 수 있듯 이 메서드를 호출한 상위메서드인 performSaveOrReplicate와 같은 AbstractSaveEventListner 추상클래스 내부의 메서드라는 것을 알 수 잇다

먼저, 내부 로직을 보면 크게 if와 else로 나뉘는데 현재는 else브랜치를 타지 않는다고 회색처리된 것을 통해 확인했다

넘어오는 인자를 보면 Object[] values, Object id, Object entity, EntityPersister persister, boolean useIdentityColumn, Eventsource source, boolean delayIdentityInserts가 넘어오며 주의깊게 봐야할 인자는 id(null), useIdentityColumn(true)이다

변수명에서 알 수 있듯 useIdentityColumn은 ID가 IDENTITY 전략으로 생성된 경우에 true이고 그 외는 false이다

최초로 이 값은 상위, 상위 메서드인 saveWithGeneratedId() 메서드에서 만들어진 generatedOnExecution에서부터 넘어왔다

현재 우리는 performSave 메서드가 호출한 performSaveOrReplicate 메서드를 살피고 있는데.... performSave 메서드에서는 useIdentityColumn이라는 변수명으로 바뀌어서 계속 넘어오고 있는 것을 볼 수 있다

헷갈릴수도 있는 이유는 generatedOnExecution -> useIdentityColumn이라는 변수명으로 바뀌면서 같은 값이 넘어오기 때문이었다

 

다시 addInsertAction을 보면 현재의 IDENTITY 생성 전략의 경우 if문 내부로 타는 것을 볼 수 있다

또 잘 보면 if와 else구문에서 만들어지는 객체가 다르다

하지만 이후의 source.getActionQueue().addAction(insert); 코드를 보면 메서드가 같다

메서드가 같다는 것은.... EntityIdentityInsertAction()과 EntityInsertAction()이 추상클래스를 상속한 자식객체이거나 메서드 오버로딩을 해놓지 않았을까 예상해볼 수 있다

드.디.어! JPA를 공부하는 사람들이 한번쯤은 들어봤을 ActionQueue라는 네이밍을 눈으로 봤다(두근두근)

이젠 addAction 메서드 내부로 들어가보자!! if와 else 둘다 일단 들어가서 차이를 보자

 

드디어 ActionQueue 소스로 들어왔다!

어랏!? 일단 두 함수의 파라미터를 보면 EntityIdentityInsertAction / EntityInsertAction 으로 다르다

하지만 addInsertAction이라는 내부 메서드를 또 동시에 호출한다

다른 점이 있다면 trace LEVEL로 로깅을 찍는 부분이 다르다

 

ActionQueue.addInsertAction(AbstractEntityInsertAction insert);

addInsertAction(action); 코드를 살펴보자

드디어 추상클래스를 인자로 받는 메서드가 반겨준다

분석해보자~!!

큰 가지로 보면 if 1개, insert로부터 어떤 값을 가져와서 null이면 if, not null이면 else 구문으로 빠진다

 

1. if (insert.isEarylyInsert()) 

ID생성 전략이 IDENTITY의 경우 해당 구문으로 들어온다

하지만... executeInsert(); 구문이 실행되도 어떤 쿼리도 실행되지 않는다!

if를 거쳐 executeInserts() 실행 후 final NonNullable..까지 넘어와도 insert into 쿼리의 실행로그가 안보인다

왜냐하면 ActionQueue의 insertions이 비어있기때문이다

 

< ID 생성 전략: SEQUENCE의 경우 >

if 구문을 타지 않고 바로 final쪽으로 넘어온다(Trace 로그가 찍혀있지 않은 모습 확인)

 

다시 기존의 IDENTITY 전략을 사용하는 엔티티 코드로 돌아와서....

final NonNullableTransientDependencies nonNullableTransientDependencies = 

    insert.findNonNullableTransientEntities();

위 코드는 무슨 역할을 하는 걸까?

메서드명을 잘 살펴보자. find(찾다) NonNullable(널이 아닐 수 있는), Transient(비영속의) Entities(엔티티들)

그리고 어디에서 호출했을까? insert(AbstractEntityInsertAction)에서!

관련된 비영속 엔티티들을 외래키를 기준으로 찾는다

https://code-boki.tistory.com/254

 

RDB라고 해서 FK가 꼭 필요할까?

퇴사를 하면서 Off-Boarding 당시 받은 백엔드 피드백들이 있다오늘은 그 중 하나인 외래키에 대해서 작성해보려고 한다 나는 그 당시 1인 백엔드 - 코틀린 스프링부트 개발자로 스키마설계까지도

code-boki.tistory.com

이전의 포스팅중 요즘에는 외래키를 걸지 않는 추세라고 했는데.. 만약에 연관된 테이블이 많으면 여기에서 오류 또는 부하가 걸릴 수도 있을 것 같다. 물론 테스트코드를 작성 시 주요 관심사가 아닌 엔티티도 생성되어야 한다는 것을 강제하기 때문에 외래키를 안쓰는 경우가 더 많다

 

addResolvedEntityInsertAction(..);

연관관계가 없거나 혹은 nullable한 연관관계를 가지는 엔티티이기때문에 null로 처리되서 if / else 구문 중 else구문을 타지 않고 if구문만 타게 된다. TRACE 레벨의 Adding insert ... 로그가 찍힌 콘솔을 확인할 수 있다

아직 insert into 쿼리가 발생하지 않았으므로 addResolvedEntityInsertAction( insert ); 메서드가 그 역할을 할 것이라고 추측해볼 수 있다

 

addResolvedEntityInsertAction( insert ); 메서드 또한 ActionQueue 클래스 내부의 private 메서드로 구성되어 있는 모습을 볼 수 있었다

 

IDENTITY 전략을 사용하기 때문에 맨 위의 if구문을 탈 것으로 예상할 수 있고, Lifecycle 인터페이스를 구현하지 않으면 기본적으로 NO_VETO를 onSave() 메서드에서 리턴하기 때문에 맨 마지막 if(!insert.isVeto()) 구문도 탈 것이라고 예상해볼 수 있다

(isVeTo가 아닌 경우 -> NO_VETO인 경우)

 

마찬가지로 Lifecycle 인터페이스를 구현하고 onSave() 메서드를 커스텀해서 VETO(true)를 리턴해서 저장로직만 거절시킨다고 하면 else 구문으로 빠져서 예외가 발생되어서 저장로직이 수행되지 않을 것 같다

(흠........ CheckedException 또는 그 외의 RuntimeException일텐데 어떻게 트랜잭션을 커밋/롤백 시키지 않는지는 잘 몰겠다..)

HibernateException은 뭔가 다른가?

 

예측한 결과를 눈으로 직접 보기 위해 디버깅 모드로 확인해봤다

왼쪽부분에 ✅ 표시된 부분은 로직을 탔다고(조건 맞음) 보면 되고, ❌ 표시된 부분은 해당 구문을 타지 않았다고 보면 된다

3번은 현재 if문 내부로 들어간 것을 보여주고 있고, 4번을 보면 회색으로 나타나서 앞으로 저기로는 들어가지 않을것이라고 알려준다

 

먼저 insert쿼리가 어느 부분에서 실행됐는지 1번을 다시 내부분석해봤다

debug 레벨까지 찍혔고...

299번째 줄의 Veto상태인지 아닌지 판단하는 쪽에 와서 insert into users 쿼리가 실행된 것을 보면 execute(insert); 구문이 쿼리를 실행한 코드라고 판단된다

내부로 들어가봤다

일단 실행시키고 finally쪽에서 후처리(자원정리 등)을 해주는 것 같다. 아까 이와 비슷한게 있었는데?

맞다. 바로 executeInserts() 메서드이다

두 메서드의 차이점은 execute는 이경영 선생님의 일단 진행시켜!! 가 떠오르고....

executeInserts는 일단 insertions이 비어있지 않으면 뭔가 실행시키고, 예외로 HibernateException이 발생할 수 있다는 것을 알 수 있다

 

그럼 이제 if(!insert.isVeto()) 부분을 보자

JPA에서 Managed라는 건 영속성 컨텍스트에 의해 관리되는 영속 상태라는 것을 뜻한다

실제로 저장 했으니 이제 엔티티를 영속성 상태로 만들고 트랜잭션이 끝나기 전까지 1차캐시에 보관하기 위한 작업으로 보인다

또한 화살표로 이은 곳을 따라가보면 unresolvedInsertions가 null이므로 이후의 if구문을 타지 않을 것이라는 것을 예측할 수 있다

풀리지않은 insertions이 있다면 해결하기 위해 2중으로 후처리 validation 코드를 넣어둔 모양새다

 

우리가 어디를 보고 있었을까..?ㅎㅎ

AbstractSaveEventListner의 performSaveOrReplicate의 addInsertAction(...) 코드를 살펴보고 있었다

(엄청 깊숙히 들어와있었다!!)

이제 우리가 insert into 쿼리를 만든다고 판단했었던 addInsertAction()을 지나 cascadeBefore -> save -> cascadeAfter까지 왔다

엔티티의 상태가 SAVING에서 MANAGED로 변경 후

생성된 id인 1을 반환한다

 

거의 다 끝나간다!! 홧팅!!

 

ActionQueue

요번에는 ActionQueue 내부를 살펴보자

현재 보이는 화면은 jar에 압축된 바이너리 파일인 .class파일을 IntelliJ이 내부적으로 디컴파일을 시도해서 최대한 개발자가 이해할 수 있게 만든 것이다

힌트: @UnknownKeyFor

-> 컴파일러가 Checker Framework를 통해 타입 정보를 정확히 추론하지 못했을 때, 관련 Annotation이 누락되거나, 컴파일러 버전이나 특정 기능을 지원하지 않을 때 보이는 어노테이션

상단의 Download Sources를 클릭하자

소스 다운로드 중

JavaDoc부터 해서 전부 잘 보인다~~

일단 소스파일이 위치한 곳은 org.hibernate.engine.spi.ActionQueue이다

공개/내부 메서드까지 포함하면 약 1400줄(총 1393줄)이 되므로 일단 클래스가 갖고 있는 멤버변수만 살펴보자

private static final CoreMessageLogger LOG = CoreLogging.messageLogger( ActionQueue.class );

현재 클래스의 로그를 나타낼 로거

 

private final SessionImplementor session;

내부에 ActionQueue의 Getter를 가지고 있기도 하며 EntityGraph를 만들고 얻어올수도 있고... forceFlush, lock, merge, persist를 하는 등 다양한 작업을 하는 친구다

 

private UnresolvedEntityInsertActions unresolvedInsertions;

해결되지 않은 Entity에 대한 insertAction들을 갖고 있는 객체

 

private ExecutableList<AbstractEntityInsertAction> insertions;
private ExecutableList<EntityDeleteAction> deletions;
private ExecutableList<EntityUpdateAction> updates;

private ExecutableList<CollectionRecreateAction> collectionCreations;
private ExecutableList<CollectionUpdateAction> collectionUpdates;
private ExecutableList<QueuedOperationCollectionAction> collectionQueuedOps;
private ExecutableList<CollectionRemoveAction> collectionRemovals;
private ExecutableList<CollectionRemoveAction> orphanCollectionRemovals;

private ExecutableList<OrphanRemovalAction> orphanRemovals;

ExecutableList<T> 형태로 쿼리에 대한 action들을 보관하고 있는 List!!

변수명을 보면 insert, update, delete.. 그리고 연관관계가 끊어진 고아객체들을 처리하기 위한 Collection 등으로 구성되어있다

내부를 더 살펴보자

초기 사이즈를 5로 갖고 있는 값 객체이다

내부적으로 ArrayList를 갖고 있고 ComparableExecutable에 한정(extends)된 E만 담을 수 있게 만들어져있다

또한 ComparableExecutable 인터페이스는 Executable이란 인터페이스를 상속받고 있는데 이곳 내부에는 리스트에 담긴 객체의 내부를 execute 하는 공통 메서드를 가지고 있다

결국 ActionQueue내부는 이 ExecutableList를 여러개 갖고 있는 Action의 이름이 붙은 Queue 즉 ActionQueue일뿐

내부 구현체가 LinkedList라던지, PriorityQueue라던지 ArrayDeque라던지 그런게 아니란 뜻이다

이어서 다른 멤버 변수도 살펴보자

 

private transient boolean isTransactionCoordinatorShared;

직렬화 작업에서 제외하기 위한 transient 키워드가 붙어있는 값. 무슨 flag인지는 다른 메서드의 javadoc을 참고해서 알아냈다 Transaction Context를 공유하는지 아닌지를 가리키는 flag값이라고 한다!

 

private AfterTransactionCompletionProcessQueue afterTransactionProcesses;
private BeforeTransactionCompletionProcessQueue beforeTransactionProcesses;

내부를 보지 않고... 변수명(객체명)만 보고 책임(행동)을 유추해보면 트랜잭션이 완료되기 전후로 큐에 쌓인 작업들을 처리하는 큐인것 같다

살펴봤더니 재밌는 걸 봤다. 이 둘다 AbstractTransactionCompletionProcessQueue를 상속받고 있는 것을 확인했는데, 내부를 살펴봤더니..

ConcurrentLinkedQueue<@NonNull T> .. 동시성 제어를 위해 Concurrent 패키지에 있는 Collection을 사용하고, 제네릭을 담는 대신 @NonNull 키워드로 null인 T타입의 객체가 들어오지 않도록 막은 모습이다

 

private enum OrderedActions {
	OrphanCollectionRemoveAction {
    	@Override
        public ExecutableList<?> getActions(ActionQueue instance) {
            return instance.orphanCollectionRemovals;
        }
        @Override
        public void ensureInitialized(ActionQueue instance) {
            if ( instance.orphanCollectionRemovals == null ) {
                instance.orphanCollectionRemovals = new ExecutableList<>( instance.isOrderUpdatesEnabled() );
            }
        }
    },
	OrphanRemovalAction { ... },
	EntityInsertAction { 
        @Override
        public ExecutableList<?> getActions(ActionQueue instance) {
            return instance.insertions;
        }
        @Override
        public void ensureInitialized(final ActionQueue instance) {
            if ( instance.insertions == null ) {
                //Special case of initialization
                instance.insertions = instance.isOrderInsertsEnabled()
                        ? new ExecutableList<>( InsertActionSorter.INSTANCE )
                        : new ExecutableList<>( false );
        }
    },
	EntityUpdateAction { ... },
	QueuedOperationCollectionAction { ... },
	CollectionRemoveAction { ... },
	CollectionUpdateAction { ... },
	CollectionRecreateAction { ... },
	EntityDeleteAction { ... },
    
    public abstract ExecutableList<?> getActions(ActionQueue instance);
    public abstract void ensureInitialized(ActionQueue instance);
}

그 다음으로 중요한 것은 이 enum을 들 수 있다!!

내부적으로 추상메서드인 getActions(), ensure...() 메서드를 통해 구현을 강제화하고 있는 모습이다

그리고 getActions()는 위에서 살펴본 ExecutableList를 담고 있는 ActionQueue의 멤버변수들을 리턴한다

이중에서 한놈만 특이한 성질을 갖고 있는데 바로 EntityInsertAction의 ensureInitialized(...) 메서드이다

이 속성들이 활성화되어 있으면 JPA를 사용해서 insert가 발생할 경우 bacth insert를 가능하게 해준다고 한다!( + batch_size도 함께 지정해줘야 함) + 추가로 id 생성 전략이 IDENTITY인 경우는 batch insert 불가!!

(이 글은 JPA에서의 batch 처리를 알아보기 위한 글은 아니므로 테스트는 하지 않는다)

insertAction을 제외한 다른 OrderActions들은 동작이 동일하다

 

지금까지 살펴본 ActionQueue는 JPA가 Transactional Write-behind(쓰기 지연)을 구현하기 위해 사용하는 객체(방법/클래스)였다!!

쓰기지연은 Hibernate가 제공하는 성능 최적화 기법 중 하나로, 엔티티 상태 변경 작업을 즉시 Database에 반영하지 않고 Hibernate의 내부 큐(ActionQueue)에 저장해놨다가 트랜잭션 commit 시점 또는 flush 시점에 ActionQueue에 저장된 작업을 한꺼번에 실행하는 것이다

 


 

마지막으로 Flush 메서드 호출부 찾기, FlushEvent 종류, ID 생성 전략에 따른 ActionQueue 내부상태 확인을 해보고 정리를 해보자

 

트랜잭션의 최종 상태는 commit() or rollback()이다

그리고 JPA에 종속적인 flush()가 일어날 때는 commit()이 일어날 때이다

(commit() -> flush() 순서로 일어난다는 뜻이지만 commit이 다 끝난 이후가 아니라 commit() 메서드 내부에서 flush()를 호출하고 후처리를 하고 return한다)

 

Flush 메서드 호출부 찾기

코드로 한번 flush() 메서드가 어디서 호출되는건지 살펴보자

public class JdbcResourceLocalTransactionCoordinatorImpl implements TransactionCoordinator

 

commit() 메서드에서 살펴보면 jdbcResourceTransaction.commit(); 이 호출되기 직전 JdbcResourceLocalTransactionCoordinatorImpl.this.beforeCompletionCallback(); 이 호출된다

이 부분이 flush()를 호출하는 부분이다

rollback() 메서드를 살펴보면 rollback이 일어나기 전에 일어나는 특별한 작업은 없고, afterCompletionCallback();의 인자만 다른 것을 볼 수 있다

rollbackOnly는 트랜잭션 전파 섹션의 논리 트랜잭션으로 묶인 내부 트랜잭션에서 UncheckException이 발생했을 때 마크를 표시하는 걸로 기억한다

 

어쨌던, 다시 commit() 메서드의 flush()를 호출하는 before... 메서드로 들어가보자

동일 클래스의 private 메서드가 호출되고, try 구문의 맨 처음 transactionCoordinatorOwner.beforeTransactionCompletion(); 메서드가 flush()를 처리하는 부분이다. 또 고고 딥딥

 

public interface TransactionCoordinatorOwner

인터페이스가 나왔으니 메서드로 가서 cmd + opt + b로 구현체 메서드를 찾아봤다

 

public class JdbcCoordinatorImpl implements JdbcCoordinator

구체클래스가 나왔지만... 이놈이 super. 으로 부모메서드를 호출하는 것도 아니고 owner의 이름이 똑같은 메서드를 호출한다

이번에는 cmd + b로 저 메서드의 내부 호출부로 이동해봤다

 

public interface JdbcSessionOwner

이런;; 또 인터페이스가 나왔지만.. 이젠 눈에 익엇다.....ㅋ

구현체로 이동해보자!

이중에서 뭘까? 대충 SessionImpl이라고 봐도 된다. 상황에 따라 다르겠지만, Hibernate의 중추는 요놈으로 판단내렸다 땅땅

 

public class SessionImpl extends AbstractSharedSessionContract  
    implements Serializable, SharedSessionContractImplementor, JdbcSessionOwner, SessionImplementor, EventSource, ransactionCoordinatorBuilder.Options, WrapperOptions, LoadAccessContext

위에서 찾은 JdbcSessionOwner를 implements하고 있는 SessionImpl ^^

드디어 flush라고 하는 명시적 행동을 하는 메서드를 찾을 수 있었다

끝? ㄴㄴ 더 들어가야지!!

위의 메서드들은 모두 SessionImpl 내부의 호출이었다

드디어..! (내가 원했던) FLUSH 이벤트리스너 그룹에서의 onFlush 이벤트를 트리거하는 메서드를 확인할 수 있었다

최종적으로 이놈이 flush()를 일으키는 주체이다

flush()가 일어나면 ActionQueue의 내용이 비워지며 트랜잭션 로그에 반영된다

(1차캐시는 비워지지 않음!)

 

고럼 이제 이벤트리스너와 onFlush라는 메서드(이벤트)를 확인했으니, 이 Flush를 일으키는 코드는 무엇인지, 그때 무슨 리스너가 활약하는지 알아보자

FlushEvent를 처리하는 리스너에 대해 알아보기 전에... SessionFactoryImpl에 대해 보고 갔으면 좋겠다

먼저 SessionFactoryImpl이라는 Session, SessionImpl, SessionImplementor 등을 생성하는 Thread-safe한 최상위객체가 있다

여기에서는 내부적으로 EventEngine도 생성해서 갖고 있게 된다

이 EventEngine은 생성자가 되게 긴데, 이벤트리스너레지스트리구현체의 Builder를 호출한다

 

EventListnerRegistry구현체 클래스의 Builder static class를 보면 Builder가 만들어지면서 applyStandardListeners(); 메서드도 호출한다. 코틀린에서는 named arguments와 default parameter의 힘으로 빌더를 굳이 쓰진 않지만, 자바에서 롬복의 빌더패턴을 쓰던 직접 구현하던.. 이렇게 객체 내부에 static 클래스를 들고있게 된다

잠깐 대학생때 C# 윈폼으로 윈도우 프로그램을 개발할때 프로그램이 종료되도 재실행됐을 때 창 크기 등을 기억하기 위해 시스템 레지스트리를 만진 것처럼.. 요기에서도 레지스트리란 네이밍을 썼다

어쨌든, 이 리스너들을 보면 발행되는 이벤트와 리스너에 대해 알 수 있는데.. 너무 길기때문에 사진이 아닌 코드로 첨부한다

private void applyStandardListeners() {
    // auto-flush listeners
    prepareListeners( AUTO_FLUSH, new DefaultAutoFlushEventListener() );

    // create listeners
    prepareListeners( PERSIST, new DefaultPersistEventListener() );

    // create-onflush listeners
    prepareListeners( PERSIST_ONFLUSH, new DefaultPersistOnFlushEventListener() );

    // delete listeners
    prepareListeners( DELETE, new DefaultDeleteEventListener() );

    // dirty-check listeners
    prepareListeners( DIRTY_CHECK, new DefaultDirtyCheckEventListener() );

    // evict listeners
    prepareListeners( EVICT, new DefaultEvictEventListener() );

    prepareListeners( CLEAR );

    // flush listeners
    prepareListeners( FLUSH, new DefaultFlushEventListener() );

    // flush-entity listeners
    prepareListeners( FLUSH_ENTITY, new DefaultFlushEntityEventListener() );

    // load listeners
    prepareListeners( LOAD, new DefaultLoadEventListener() );

    // resolve natural-id listeners
    prepareListeners( RESOLVE_NATURAL_ID, new DefaultResolveNaturalIdEventListener() );

    // load-collection listeners
    prepareListeners( INIT_COLLECTION, new DefaultInitializeCollectionEventListener() );

    // lock listeners
    prepareListeners( LOCK, new DefaultLockEventListener() );

    // merge listeners
    prepareListeners( MERGE, new DefaultMergeEventListener() );

    // pre-collection-recreate listeners
    prepareListeners( PRE_COLLECTION_RECREATE );

    // pre-collection-remove listeners
    prepareListeners( PRE_COLLECTION_REMOVE );

    // pre-collection-update listeners
    prepareListeners( PRE_COLLECTION_UPDATE );

    // pre-delete listeners
    prepareListeners( PRE_DELETE );

    // pre-insert listeners
    prepareListeners( PRE_INSERT );

    // pre-load listeners
    prepareListeners( PRE_LOAD, new DefaultPreLoadEventListener() );

    // pre-update listeners
    prepareListeners( PRE_UPDATE );

    // post-collection-recreate listeners
    prepareListeners( POST_COLLECTION_RECREATE );

    // post-collection-remove listeners
    prepareListeners( POST_COLLECTION_REMOVE );

    // post-collection-update listeners
    prepareListeners( POST_COLLECTION_UPDATE );

    // post-commit-delete listeners
    prepareListeners( POST_COMMIT_DELETE );

    // post-commit-insert listeners
    prepareListeners( POST_COMMIT_INSERT );

    // post-commit-update listeners
    prepareListeners( POST_COMMIT_UPDATE );

    // post-delete listeners
    prepareListeners( POST_DELETE, new PostDeleteEventListenerStandardImpl() );

    // post-insert listeners
    prepareListeners( POST_INSERT, new PostInsertEventListenerStandardImpl() );

    // post-load listeners
    prepareListeners( POST_LOAD, new DefaultPostLoadEventListener() );

    // post-update listeners
    prepareListeners( POST_UPDATE, new PostUpdateEventListenerStandardImpl() );

    // update listeners
    prepareListeners( UPDATE, new DefaultUpdateEventListener() );

    // refresh listeners
    prepareListeners( REFRESH, new DefaultRefreshEventListener() );

    // replicate listeners
    prepareListeners( REPLICATE, new DefaultReplicateEventListener() );

    // save listeners
    prepareListeners( SAVE, new DefaultSaveEventListener() );

    // save-update listeners
    prepareListeners( SAVE_UPDATE, new DefaultSaveOrUpdateEventListener() );
}

진짜 수많은 이벤트와 리스너들이 있다는 것을 알 수 있고, 이건 fireEvent를 하는 부분이 아니라 단순히 초기화를 하는 부분이다. 아마 생성자에서 if구문으로 특정 이벤트 타입만 처리한다는 구문이 있을 것이고 이걸 메모리에 올려놓는 과정일 것이다..! 그래야 이벤트가 들어왓을때 처리를 하기 때문

 

너무 이곳저곳 돌아왔지만...(결국 이걸 찾기 위함)

우리가 지금 찾고 싶은 FLUSH 이벤트를 처리하는 리스너를 찾아가보자

AbstractFlushingEventListener를 상속받고, FlushEventListner를 구현하는 DefaultFlushEventListener의 모습이다

추상클래스의 구현체로 또 다른 어떤 것들이 있을까?

 

FlushEvent 종류

이렇게 3개가 존재한다

 

이 중에서 선택과 집중으로 DefaultAutoFlushEventListener, DefaultFlushEventListener 두개만 살펴보자

JPA를 공부하신 분들이라면 flush에 대해서 알테고, JPQL을 실행하거나 명시적 flush를 호출하거나 commit으로 트랜잭션 상태가 끝나면 flush가 발생한다고 알 것이다!

 

DefaultAutoFlushEventListener vs DefaultFlushEventListener 어떻게 다른걸까?

좌: DefaultAutoFlushEventListener

우: DefaultFlushEventListener

 

서로 같은 부분은

flushEverythingToExecutions();

이 메서드이다. 물론 넘겨지는 인자가 달라서 오버로딩이 되어있는 것을 알 수 있다

L은 왼쪽 Auto쪽에서 호출되는 부분, R은 우측 Default에서 호출되는 부분인데 웃기게도 R에서 내부적으로 다시 아래의 메서드를 호출하기 때문에 결론적으로 event로 넘어온 session에 대해서 전처리를 하는 부분을 제외하면 flushEverythingToExecution(A, B, C) 메서드를 호출하긴 한다

 

아래의 메서드의 로직을 조금 더 살펴보자

flush를 해야 할 Entity들(복수)에 대한 갯수를 집계하고, flush를 해야 할 Collection들(복수)에 대한 집계를 하고

그 값을 넘겨서 event로 넘겨서 처리하나보다

flushEntities, flushCollections 메서드를 살펴보자

flushEntities

두 메서드 동작이 조금은 다르지만, 공통적으로 actionQueue 내부에서 실행될 쿼리 순서를 정렬하는 메서드를 호출하고 있다

이렇게 내부적으로 ExecutableList를 sort() 하는 모습을 볼 수 있었다

외래키에 대한 처리, 쿼리 순서 최적화 등이 요기 부분에서 일어날 것이라고 추측할 수 있다!!

(todo: ..) 부분에 적힌 고민의 흔적도 엿볼 수 있다. 오픈소스 개발자도 사람이다!!

 

다시 원래의 Listener를 구분했던 곳으로 돌아와서 이번엔 부분을 찾아보면

if ( flushMightBeNeeded( source ) ) {
	...
    if ( flushIsReallyNeeded( event, source ) ) {
    	...
    	performExecutions( source );
    }
}

좌측 부분(DefaultAutoFlushEventListener)은 if구문 2번을 타야지 아래의 코드가 실행된다

performExecutions( source );

미리 스포를 하자면 이 부분이 실제 쿼리를 동작시키는 부분이다

 

performExecutions를 보기에 앞서 몇가지 살펴볼 게 있다

일단 if 구문을 들어가게 하는 2개 메서드를 보자

메서드 이름이 독특하다

1. flush가 아마도 필요하다면?

2. flush가 정말로 필요하다면?

디버깅 하면서 볼거긴한데 2번으로 들어가기 위해서는 FlushMode가 ALWAYS이거나 ActionQueue의 QuerySpace가 업데이트도되어야 할 테이블이라면 ..?이다

 

performExecutions( source );

performExecutions이 동작하기 전에 내부적으로 ActionQueue에서 sort()를 통해 실행순서를 최적화하는 것을 알았다

전편에서 살펴보지 않았던 actionQueue.executeActions(); 메서드를 들어가서 살펴보자

 

ActionQueue 내부의 메서드

각각 sort(); 를 통해 실행순서가 최적화(정렬)되었다는 것을 flushEverythingToExecution() 메서드를 통해 아셨을 테고, 내부적으로 그래서 Ordered라는 이름이 붙은 것이다

결국 EmptyList가 아닌 이상 이 enum에 정의된 순서 + 내부 정렬(최적화)된 상태로 for-each구문을 통해 내부반복을 하게 된다!!

 

이정도면 어느정도 내부동작에 대해서 파악한 것 같다! 다음으로 테스트코드를 통해서 어떤 경우에 어떤 리스너가 트리거가 되는지 알아보자

먼저 탭을 분할해서 break point를 두곳 걸어놨다. 이러면 debug mode로 테스트를 실행 시 어디로 들어올지 바로 파악하기 좋으니깐!

 

테스트코드에 사용한 환경은 다음과 같다

1. AOP를 활용한 Transaction 처리

2. PlatformTransactionManager(구현체: JpaTransactionManager)를 활용한 수동 Transaction 처리

두 방법으로 리스너의 동작 방식을 알아보기로 했다

두 테스트는 스프링 환경과 최대한 비슷하게 만들기 위해 @SpringBootTest를 활용했다

또한 BeforeXXX, AfterXXX 등의 테스트케이스의 라이프사이클에 관여하는 메서드도 제거했다

 

1번: AOP Transaction 처리

내부적으로 6개의 테스트를 진행했다

a. save() -> rollback()

b. save() -> flush() -> rollback()

c. save() -> commit[flush()]

d. query: findAll() -> rollback()

e. query: deleteAll() -> rollback()

f. query: findByXX() -> save() -> commit()

 

a번 테스트(Debug mode): save() -> rollback()

디버그 모드로 실행

DefaultAutoFlushEventListener / DefaultFlushEventListener 둘 다 호출되지 않은 채로 테스트가 끝났다

=> X (only rollback)

 

b번 테스트(Debug mode): save() -> flush() -> rollback()

flush()를 강제로 호출해봤더니 Auto가 아닌 DefaultFlush쪽으로 빠지고 테스트가 종료됐다

=> DefaultFlushEventListener(flush -> rollback)

 

c번 테스트(Debug mode): save() -> commit[flush()]

save()후에 트랜잭션 마지막 상태를 commit()으로 마무리 해봤다

=> DefaultFlushEventListener(commit -> flush)

 

d번 테스트(Debug mode): findAll() -> rollback()

쿼리가 실행되면서(JQPL) 자동으로 flush를 한다. 순서는 flush() -> query실행

생각해보자. 쿼리가 실행되기 전에 영속성 컨텍스트에 아직 반영되지 않은 변경점들을 트랜잭션 로그로 날려 반영시킨 후 쿼리가 실행돼야  한 트랜잭션 내에서 일관성이 유지되는 것이다

예를 들어

주석으로 다 설명해놓긴 했는데, 실제로 DB로 쿼리를 날려야 하는 경우 기존의 영속성 컨텍스트에 있는 변경점들을 적용시키고 나서 진행하는게 맞는 순서라는 것이다

=> DefaultAutoFlushEventListener(auto flush -> rollback)

 

e번 테스트(Debug mode): deleteAll() -> rollback()

=> 생략(d번과 동일)

 

f번 테스트(Debug mode): findByXX() -> save() -> commit()

이번에는 예측해보자. findUserBy...에서 JPQL이 작동하므로 flush(auto)

그리고 트랜잭션이 commit상태로 끝나기 때문에 commit() -> flush(default)

결론적으로 DefaultAutoFlushEventListener -> DefaultFlushEventListener 순서로 호출되지 않을까 예상할 수 있다

=> DefaultAutoFlushEventListener(jqpl) -> DefaultFlushEventListener(commit)

 

 

- 소결론

우리가 알고 있던 flush를 일으키는 JPQL, Commit은 사실 내부적으로는 다른 Flush Event Listener가 호출되고 있었다

다만 똑같은 flush 이벤트이기에 그렇게 외우고 있던 것이라는 사실..!!

또한 명시적으로 flush를 호출하는 것은 DefaultFlush가 호출된다

 

 

2번: Manual Transaction 처리(PlatformTransactionManager -> 구현체: JpaTransactionManager)

이번에는 명령형or프로프래밍 트랜잭션이라고 불리는 방식으로 테스트해보자

PlatformTransactionManager를 통해서 트랜잭션을 처리하도록 일을 줬다

결론적으로 앞서 AOP로 처리하는 것과 동일한 이벤트리스너가 트리거 되는 것을 볼 수 있었다

 

- 대결론

선언형/명령형 트랜잭션 처리에 상관없이 Flush 동작이 동일하게 처리된다

그리고 Flush라는 동작은 내부적으로 DefaultAutoFlushEventListener, DefaultFlushEventListener, DefaultDirtyCheckEventListener 3개의 추상클래스 리스너에 의해 동작한다

(사실.... dirty check 부분은 break point를 걸어도 확인이 안됐다 댓글로 제보를..)

 

 

ID 생성 전략에 따른 ActionQueue 내부상태 확인

이번에는 ID 생성전략에 따른 ActionQueue의 내부를 코드로 직접 확인해보자

< 테스트코드 >

Spring 관련 빈들을 굳이 띄울 필요가 없기때문에 다시 DataJpaTest로 돌아왔고, 실제 DB를 사용하기 위한 설정을 해줬다

추가로 ActionQueue의 내부를 보기 위해 entityManager.unwrap(SessionImpl::class.java)를 사용했다

save(insert into) -> delete(delete from) -> save(insert into)

 

- IDENTITY

<테스트 결과>

== ActionQueue 내부 출력 시작==
ActionQueue[
	insertions=ExecutableList{size=0} 
    updates=ExecutableList{size=0} 
    deletions=ExecutableList{size=1} 
    orphanRemovals=ExecutableList{size=0} 
    collectionCreations=ExecutableList{size=0} 
    collectionRemovals=ExecutableList{size=0} 
    collectionUpdates=ExecutableList{size=0} 
    collectionQueuedOps=ExecutableList{size=0} 
    unresolvedInsertDependencies=null
]
== ActionQueue 내부 출력 끝==

엥...왜? insertions의 size가 0일까? deletions는 1인데!?

그건... IDENTITY 전략을 공부하신 분 + 위에서 쭈욱 같이 코드를 살펴보신 분이라면

private void addResolvedEntityInsertAction(AbstractEntityInsertAction insert)

해당 메서드에서 IDENTITY 전략인 경우는 즉각 쿼리를 실행하는 것을 알 수 있다

 

 

- Non IDENTITY(SEQUENCE, TABLE, UUID, Assigned)

그럼 IDENTITY가 아닌 SEQUENCE의 테스트 결과는 어떨까?

< 테스트 결과 >

== ActionQueue 내부 출력 시작==
ActionQueue[
	insertions=ExecutableList{size=2} 
    updates=ExecutableList{size=0} 
    deletions=ExecutableList{size=1} 
    orphanRemovals=ExecutableList{size=0} 
    collectionCreations=ExecutableList{size=0} 
    collectionRemovals=ExecutableList{size=0} 
    collectionUpdates=ExecutableList{size=0} 
    collectionQueuedOps=ExecutableList{size=0} unresolvedInsertDependencies=null
]
== ActionQueue 내부 출력 끝==

아까와 다른 로그. 그리고 flush()가 일어나기 전 ActionQueue 내부의 insertions 크기가 2인 것을 확인할 수 있었다

 

SEQUENCE, TABLE id 생성 전략은

allocationSize 등을 조절해서 DB에 왔다갔다 하는 횟수를 최소화할 수 있다

 

하지만.. 뭐 ID 생성 쿼리가 DB에 직접 날아간다고 해서 엄청 큰 부하가 있는것도 아니라고 알고있다

IDENTITY전략으로도 충분하고, Batch Processing을 굳이 해야한다고 하면 SEQUENCE나 TABLE 전략을 활용할 수도 있을 것 같다

 

결론

하이버네이트 소스 내부로 들어가서 내부 동작을 살펴봤다

깊이 들어가서 특정 부분에 break point를 걸고 디버그 모드로 실행 후 멈춘다음 "하....이거 어떻게 해야 if문 내부로 들어가지?"를 고민해가며 메서드 하나하나 동작과정을 볼 수 있었다

대단한 오픈소스 개발자들의 성함도 알게 되었고, // TODO에서 하이버네이트 개발자의 고민도 엿볼 수 있었고, 그들의 코드 스타일 method( othermethod( A, B) );도 볼 수 있고 함수형 인터페이스, 전략 패턴, 성능 최적화를 위한 의도적 instanceof 의 사용(feat. 좋은 주석) 등등 오픈소스를 들어가봤을 뿐인데 많은 것들에 대해 배울 수 있었다

 

- 정리 부족

또한 나도 그림이나 keynote/ppt를 사용해서 이쁘게 정리한 글을 보고싶긴 하지만... 몇몇 블로그 글처럼 딱 맛있는 그림만 전달해주고 싶진 않았다. 또한 내부로 들어가면 갈수록 더 헷갈리는 부분도 있고, 현재 depth가 어느정도인지 몰랐던 적도 있고해서 그림그릴 실력은 안된다.. ㅎ

 

긴 글을 작성하느라 몇일이 걸렸는데.. 부족한 지식으로 실수한 부분이나 잘못된 정보를 전할 수도 있으니 그런 부분이 있다면 댓글로 알려주면 겸허히 받아들여서 수정하겠다!!

 

다음번에 기회가 되면 2.x.x버전대의 springboot의 starter data jpa에서도 똑같이 디버깅을 해보고 3점대 버전과 어떻게 달라졌는지 분석을 하거나 아니면 Spring Data MongoDB을 분석해보고 싶다!(희망, 언제가 될지 모른다는게 함정ㅋ)

 

- 도와준넘

ChatGPT: 지식탐구의 목적으로 무지성으로 이용하지 않고, 코드예측 -> 생각과 일치or불일치 시 검증의 목적으로만 활용했다

 

320x100

댓글