[Spring] EntityManager가 만들어지고 사용되는 과정 (feat.OSIV)
Spring 프레임워크와 관련된 내용들을 하나씩 채워가면서 이번에는 JPA의 영속성 컨텍스트, EntityManager 부분을
살펴보고 있었습니다.
지금까지 저는 EntityManager가 ThreadLocal로 관리되니까 쓰레드 당 1개의 EntityManager가 할당되어 각 요청마다
1개의 EntityManager만을 사용하는 줄 알았습니다. 그래야 영속성 컨텍스트도 EntityManager에 있으니 JPA의 이점인 1차 캐시를
계속해서 사용할 수 있을 것이니까요.
하지만 이것은 반은 맞고 반은 틀리단 것을 알게 되었습니다.
쓰레드는 항상 같은 EntityManager를 사용하지 않을 수 있다.
제가 알고있던 것과는 달리 EntityManager는 요청 당 항상 1개만 사용되는 것이라고 단정 지을 수 없습니다.
EntityManager는 @Transaction에 의해 관리 됩니다. 일반적으로는 @Transaction를 처리하는 AOP Interceptor에서 트랜잭션이
새로 생성되면 EntityManager를 새로 생성합니다. 그리고 Transaction이 종료되면 사용했던 EntityManager를 정리합니다.
따라서 EntityManager는 트랜잭션의 생명주기와 비슷하다고 볼 수 있습니다.
만약 DB로부터 조회한 데이터를 영속성 컨텍스트에 보관하고 있더라도 트랜잭션이 종료되었다면
다음 조회에서는 영속성 컨텍스트로부터 조회하는 것이 아닌 DB에서 조회해야합니다.
OSIV가 켜져 있다면 쓰레드의 Root EntityManager는 항상 동일하다.
그런데 항상 1개만 사용되는 것이 아니라고 했습니다.
그렇다면 요청 당 1개의 EntityManager를 사용하는 경우도 있다는 건데요. 그건 바로 OSIV가 활성화 되었을 경우입니다.
JPA를 사용하는 프로젝트의 경우 OSIV라는 단어를 종종 들어보게 됩니다.
Open-Session-In-View의 줄임말로 쉽게 말하면 DB Session(EntityManager)을 View/Controller까지 끌어오는 기능입니다.
그런데 Controller에서는 일반적으로 트랜잭션을 사용할 수 없습니다. 그렇기에 일반적으로 EntityManager도 존재하지 않게 됩니다.
따라서 Interceptor를 통해 Controller의 메서드가 실행되기 전에 쓰레드가 사용 할 Session과 EntityManager를 미리 할당합니다.
spring.jpa.open-in-view=true
Spring boot에서는 위 properties 설정으로 OSIV를 활성화/비활성화 합니다.
default 값은 true이기에 별다른 설정을 하지 않으면 OSIV를 활성화하게 됩니다.
Spring boot AutoConfiguration에 의해 open-in-view가 true라면 OpenEntityManagerInViewInterceptor를 추가합니다.
이 OpenEntityManagerInViewInterceptor가 요청을 처리하는 쓰레드가 단 1개의 EntityManager만을 사용하게 만들며
Controller에서도 EntityManager를 통해 영속성 컨텍스트에 접근할 수 있도록 하는 것입니다.
OpenEntityManagerInViewInterceptor의 로직입니다.
만약 현재 쓰레드에 EntityManager가 할당되어 있다면 그냥 mark 처리를 하고 넘어갑니다.
일반적으로 요청을 처리하기 전에 EntityManager가 할당되어 있는 상황은 드물기 때문에 일반적으로는 else 구문을 타게 됩니다.
else에서는 EntityManager를 생성하고 TransactionManager를 통해 쓰레드에 EntityManager를 할당하게 됩니다.
이로써 쓰레드는 이제 한 개의 EntityManager만을 바라보게 되었습니다.
@Transactional과 EntityManager
이제는 @Transactional 처리 로직이 어떻게 EntityManager를 사용하고 처리하는지를 살펴볼 차례입니다.
@Transactional은 AOP로 처리되며 TransactionInterceptor가 advisor로 처리됩니다.
디버깅을 하다보면 TransactionAspectSupport 클래스의 invokeWithinTrasaction 메서드를 시작으로 트랜잭션 처리가 됩니다.
먼저 createTransactionIfNecessary 메서드를 통해 트랜잭션을 생성합니다.
TransactionAspectSupport.createTransactionIfNecessary를 살펴보면 getTransaction 메서드를 통해 트랜잭션을
가져오는 것 같은데요.
AbstractPlatformTransactionManager.getTransaction를 살펴보면 내부적으로 doGetTransaction을 호출합니다.
doGetTransaction은 JpaTransactionManager 구현체 클래스의 메서드입니다.
위 로직을 살펴보니 emHolder에 등록된 EntityManager가 있는지 확인을 하는데요.
위 코드를 살펴보면 여기서 OSIV 설정에 따라 분기가 된다는 것을 알 수 있습니다.
만약 OSIV가 활성화 된 상태라면 모든 트랜잭션은 OpenEntityManagerInViewInterceptor가 만들었던
EntityManager를 사용하게 됩니다.
반면에 OSIV가 활성화 되어있지 않다면 txObject에는 상위 트랜잭션이 만든 EntityManager가 할당되거나, EntityManager가
할당되지 않을 수 있습니다.
getTransaction로 다시 돌아오게 되면 전달받은 txObject에 트랜잭션이 존재하는지 확인합니다.
검증 로직은 txObject에 entityManagerHolder가 할당되어 있는지, 그리고 entityManagerHolder의 isTransactionActive가
True인지 확인합니다.
아직까지는 그 어디서도 transaction을 활성시키지 않았기에 요청의 첫 트랜잭션이라고 가정하면 OSIV가 활성화 여부에 상관없이 isTransactionActive는 False입니다.
(물론 트랜잭션 in 트랜잭션인 경우는 True 입니다. 따라서 기존 트랜잭션을 가지고 처리합니다.)
첫 요청인 경우 / 상위 트랜잭션이 없는 경우 startTransaction 메서드가 호출되게 됩니다.
그리고 startTransaction 메서드 내부에서 doBegin 메서드가 호출됩니다.
doBegin 메서드의 내부 로직입니다.
첫번째 if문의 조건은 entityManagerHolder가 없거나 아직 트랜잭션과 EntityManager가 동기화 되어 있지 않은 것입니다.
여기서 또 OSIV 설정에 따라 분기가 되는데요.
OSIV가 활성화 된 상태라면 doGetTransaction에서 txObject에 EntityManager를 동기화 해놨기 때문에 if문에 들어가지 않습니다.
반면에 OSIV가 비활성화 된 상태라면 아직까지 EntityManager를 별도로 설정해주지 않았기에 if문에 들어가게 되며
새롭게 EntityManager를 생성하게 됩니다.
위 과정을 통해 알게 된 것은 OSIV 설정에 따라 doBegin 내부에서 새로운 EntityManager를 생성할 지 정해질 수 있다는 것입니다.
OSIV가 활성화 된 경우 -> Controller가 호출되기 전 미리 EntityManager를 생성하고 모든 트랜잭션에서 해당 EntityManager를 사용
OSIV가 비활성화 된 경우 -> 트랜잭션이 생성될 때 마다 새로운 EntityManager를 생성함
중간에 언급했지만 위 내용들은 상위 트랜잭션이 없는 경우에 해당하는 내용입니다.
OSIV가 활성화 된 상태라고 해도 Transaction 내에서 REQUIRES_NEW로 새로운 별도의 트랜잭션을 생성한다면
별개의 EntityManager를 사용하게 됩니다.
위 과정에서 보았듯이 새로운 EntityManager를 생성할 지의 여부는 Transaction을 생성하는 과정에서 결정되니까요.
지금까지의 긴 과정들은 트랜잭션을 만들기 위한 createTransactionIfNecessary의 과정이였습니다.
이제 트랜잭션이 적용된 상태에서 AOP의 프록시 패턴을 처리하게 됩니다. 따라서 기존 로직이 수행됩니다.
이제 마지막으로 commitTransactionAfterReturning 메서드를 호출합니다.
이름에서도 알 수 있듯이 사용한 트랜잭션을 정리하는 메서드라고 유추할 수 있습니다.
디버깅 과정을 쭉 따라 가다보면 doCleanupAfterCompletion 메서드를 만나게 됩니다.
여기서 또 OSIV 설정 여부에 따라 갈립니다.
OSIV가 활성화 된 경우에는 Interceptor가 만든 EntityManager를 사용한다고 했습니다.
그런데 Interceptor가 만드는 EntityManager는 newEntityManagerHolder의 값을 true로 설정하지 않습니다.
따라서 OSIV가 활성화 된 경우에는 if문 조건에 해당하지 않아 EntityManager를 정리하는 로직이 처리되지 않습니다.
반면에 OSIV가 비활성된 경우에는 if문 분기에 해당하게 되어 EntityManager를 정리하게 됩니다. 그 이유는 앞 과정에 볼 수 있습니다.
OSIV가 비활성화 된 경우에는 EntityManager는 트랜잭션이 새로 생성되고 난 이후 doBegin 메서드가 호출되는 시점인데
doBegin 메서드 내부에서 새로운 EntityManager를 생성할 때 newEntityManagerHolder를 true로 설정하기 때문입니다.
따라서 OSIV가 비활성화 된 경우에는 트랜잭션이 정리될 때 EntityManager가 같이 정리됩니다.
즉 쓰레드(요청) 당 1개의 EntityManager만을 사용한다는 것은 반은 맞고 반은 틀린 생각이었습니다.
OSIV가 활성화 된 경우 -> 트랜잭션이 종료될 때 EntityManager는 정리되지 않는다.
OSIV가 비활성화 된 경우 -> 트랜잭션이 종료될 때 EntityManager는 정리된다.
OSIV가 활성화 된 경우 -> 서로 다른 트랜잭션이라도 동일한 EntityManager를 사용하기에 영속성 컨텍스트를 공유한다.
OSIV가 비활성화 된 경우 -> 서로 다른 트랜잭션은 서로 다른 EntityManager를 사용한다.
이렇게 OSIV에 따른 트랜잭션 및 EntityManager 생성 과정을 살펴보았습니다.
확실히 프레임워크의 동작 과정을 살펴보는 것은 한번쯤은 꼭 필요하다는 것을 다시 느끼게 되었습니다.
EntityManager가 정리되지 않으니 세션을 유지하는 것이었다.
OSIV를 비활성화 해야하는 이유로 가장 많이 언급되는 것은 DB 커넥션을 오래동안 가져가는 것인데요.
이제는 DB 커넥션을 계속 유지하는 이유를 알 수 있습니다.
근데 저는 Interceptor에서 EntityManager를 설정해 준 순간부터 DB 커넥션을 사용할 줄 알았지만 그건 아니였습니다.
Controller에 진입했을 때 까지는 active가 0입니다. 즉 아직 사용중인 커넥션이 없습니다.
그렇게 되면 첫 트랜잭션 시작까지는 기다리는 것으로 예측할 수 있습니다.
실제로 Transaction이 선언 된 메서드에 진입한 시점에는 1개의 커넥션이 active 되었음을 확인할 수 있었습니다.
그렇게 중요한 내용은 아닌 것 같지만 궁금해서 디버깅 해본 결과 정확히는 위에서 보았던 doBegin 메서드 내부에서 beginTransaction 메서드가 호출되고 나서 실제 DB 커넥션이 시작되는 것을 확인할 수 있었습니다.
디버깅을 좀 깊게 해보니 LogicalConnectionManagedImpl에서 실질적인 Connection을 처리하는 걸 확인할 수 있었습니다.
connection이 null이라면 새로운 커넥션을 연결하고, null이 아니라면 기존 커넥션을 반환해주는 걸 볼 수 있었습니다.
좀 더 자세한 동작 방식을 알고 싶지만 너무 깊어지니 다음에 알아보도록 하고 일단은 EntityManager가 정리되지 않으면
기존에 연결해두었던 connection을 계속 사용한다 정도까지만 알면 될 것 같습니다.
실제로 OSIV를 활성한 상태에서는 제일 첫 트랜잭션만 if문 분기에 들어가고 그 이후 트랜잭션은 기존 physicalConnection을
리턴해주는 것을 확인할 수 있었습니다.
다시 OpenEntityManagerInViewInterceptor로 돌아와서 afterCompletion 메서드를 확인해보겠습니다.
interceptor의 afterCompletion 메서드는 HandlerMethod(Controller)가 종료되고 나서 실행됩니다.
afterCompletion에서는 closeEntityManager를 통해 사용했던 EntityManager를 정리해주는 로직을 볼 수 있었습니다.
내부를 쭉 따라가다보면 LogicalConnectionManagedImpl를 통해 connection을 종료해줍니다.
EntityManager 정리가 끝나게 되면 다시 active 커넥션은 줄어들게 됩니다.
위 과정을 살펴보며 알게 된 내용은 아래와 같습니다.
- EntityManager가 생성된다고 그 즉시 DB의 커넥션을 사용하는 것은 아니다.
- 실제 DB 커넥션을 연결하는 것은 트랜잭션이 시작되는 시점이다.
- EntityManager에 Session이 보관되며 EntityManager가 정리되면 Session도 정리되기 때문에 Session에 할당된
DB 커넥션도 반납된다.
2개 이상의 EntityManager를 사용하게 되는 경우
@Service
class OuterService(
private val innerService: InnerService,
) {
@Transactional
fun outerMethod() {
innerService.innerMethod()
}
}
@Service
class InnerService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun innerMethod() {
println("hello")
}
}
위 코드는 OuterService에서 먼저 트랜잭션을 생성합니다. 그리고 innerService의 innerMethod를 호출합니다.
innerMethod는 REQUIRES_NEW로 상위 트랜잭션을 따라가지 않고 별도의 트랜잭션을 생성합니다.
확인해보면 OSIV 활성화 상태임에도 불구하고 활성화된 DB 커넥션이 2개입니다. 즉 2개의 EntityManager가 사용 중입니다.
이제는 상위 트랜잭션을 그대로 물고 가는게 아니니까 별도의 EntityManager를 사용해야 한다는 것은 이해할 수 있습니다.
그럼 어떻게 분리되어 생성되는지 @Transactional 처리 과정을 살펴보겠습니다.
일반적인 흐름은 위에서 보았던 과정과 똑같이 진행되며 그렇기에 doGetTransaction 메서드를 지나게 됩니다.
그런데 이미 상위 트랜잭션이 존재하기 때문에 txObject에는 상위 트랜잭션에서 사용하는 EntityManager를 그대로 물려받게 됩니다.
위 코드는 잠시 스쳐 지나갔던 isExistingTransaction 부분이고 상위 트랜잭션이 존재하는가에 따른 분기 지점입니다.
이미 상위 트랜잭션이 존재하니까 일단 handleExistingTransaction 메서드가 호출됩니다.
그리고 handleExisingTransaction 내부 로직을 보면 REQUIRES_NEW인 경우 startTransaction 메서드를 호출합니다.
startTransaction 메서드를 지나 doBegin 메서드로 오게 됩니다.
txObject에 EntityManager가 존재하지 않다면 새로 생성해서 txObject에 할당해줍니다.
그런데 위에서 txObject에 상위 트랜잭션의 EntityManager를 설정해주었는데 어떻게 if문에 들어가게 되었을까요?
if문의 조건은 txObject에 hasEntityManagerHolder가 없어야 하는 조건인데 말이죠.
이것은 하나의 Thread에서 여러 개의 트랜잭션을 사용하는 방식 suspend, resume 덕분입니다.
다시 handleExisingTransaction로 돌아와서 보면 startTransaction 메서드가 호출되기 전에 suspend라는 메서드가 호출됩니다.
suspend 메서드 내부 로직을 보면 현재 트랜잭션을 doSuspend 메서드를 통해 가공 과정을 거치는데요.
doSuspend 메서드 내에서는 txObject에 할당되어 있던 EntityManager를 꺼내고
SuspendedResourcesHolder에 백업용으로 임시 보관하도록 합니다. 임시 보관 된 EntityManager는 하위 트랜잭션이 종료되고
복구 될 때 꺼내 쓰게 됩니다.
그리고 트랜잭션이 commit 되고 나서 트랜잭션 정리 로직들을 쭉 따라 가다보면 resume 처리를 하게 됩니다.
보면 SuspendedResourcesHolder에 보관해두었던 기존 상위 트랜잭션의 EntityManager도 다시 돌려놓는 걸 볼 수 있습니다.
이러한 메커니즘 덕분에 쓰레드는 하나의 txObject를 사용하면서 별도의 트랜잭션을 사용할 수 있었던 것 입니다.
- 트랜잭션 A 시작 -> 현재 txObject: EntityManagerA
- 트랜잭션 B 시작 -> 현재 txObject: EntityManagerB / suspend: EntityManagerA
- 트랜잭션 B 종료 -> 현재 txObject: EntityMangerA(resume)
위 과정을 살펴본 결과 OSIV가 활성화 된 상태더라도 REQUIRES_NEW를 사용한다면 해당 트랜잭션은 별도의 영속성 컨텍스트니까
1차 캐시가 공유가 안되지 않을 것이라고 예상할 수 있습니다.
@Service
class OuterService(
private val innerService: InnerService,
private val userRepository: UserRepository
) {
@Transactional
fun outerMethod() {
innerService.innerMethod()
userRepository.getUser(1L)
}
}
@Service
class InnerService(
private val userRepository: UserRepository,
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun innerMethod() {
userRepository.getUser(1L)
}
}
실험을 위해 outerMethod에서 먼저 innerMethod를 호출합니다. innerMethod는 userRepository에서 1번 유저를 조회합니다.
만약 영속성 컨텍스트를 공유한다면 outerMethod가 다시 1번 유저를 조회할 때 DB에 SQL를 사용하지 않고 영속성 컨텍스트에서
가져오게 될 것입니다.
당연한 결과이지만 2번의 SQL이 사용되었습니다. 즉 별도의 EntityManager / 영속성 컨텍스트를 사용합니다.
이처럼 OSIV라 할 지라도 EntityManager의 생명주기는 트랜잭션의 생명주기를 따라가기 때문에 독립된 새로운 트랜잭션이
생성되면 해당 트랜잭션의 EntityManager도 별도로 생성됩니다.
마무리
EntityManager는 ThreadLocal로 관리되니까 요청 당 1개만 사용하는게 맞나?
근데 당연히 1개가 맞지 않나? 여러 개의 EntityManager를 사용하게 되면 영속성 컨텍스트는 계속 분리되지 않나?
영속성 컨텍스트가 계속 분리되면 1차 캐시의 이점이 사라지는 거 아닌가?
갑작스럽게 든 고민을 해결해나가면서 많은 것을 깨달은 것 같습니다.
애초에 EntityManager의 생명주기는 일반적으로 트랜잭션 생명주기였던 점.
따라서 트랜잭션이 바뀌면 원래는 EntityManager도 바뀌기에 영속성 컨텍스트를 다시 만든다는 점.
그러나 OSIV를 사용하게 되면 Interceptor단에서 EntityManager를 1개로 고정하기에 요청 내내 동일한 영속성 컨텍스트를 사용하게
된다는 점.
하나씩 알아가면서 결국 EntityManager 처리 과정, Transactional 처리 과정, DB 커넥션이 처리되는 과정들을 알게 된 것 같습니다.
(JPA) 엔티티 매니저는 리퀘스트 당 하나만 생성되지 않을 수 있다.
3줄 요약 OSIV가 꺼져있으면 트랜잭션이 시작될 때 엔티티 매니저가 생성되고, 트랜잭션이 끝날 때 엔티티 매니저를 종료한다. OSIV가 꺼져있고, 다른 트랜잭션이라면 엔티티 매니저가 공유되지
perfectacle.github.io
저와 같은 생각을 하신 개발자분의 포스팅 덕에 제대로 이해할 수 있었던 것 같습니다.