이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 열아홉 번째 글입니다. 17편에서 트랜잭션을 누가 열고 닫는지(PlatformTransactionManager, @Transactional 프록시, 스레드에 자원을 묶어 두는 TransactionSynchronizationManager)를 봤고, 18편에서 JdbcTemplate이 그 묶인 커넥션을 DataSourceUtils로 찾아 쓰는 것을 봤습니다. 이번 글은 그 틀 위에 두 가지가 어떻게 얹히는지를 봅니다. 하나는 JPA(Hibernate 같은 ORM), 다른 하나는 스레드 자체가 없는 리액티브 데이터 접근(R2DBC) 입니다. 이 글로 1편 지도의 Phase 4(트랜잭션과 데이터 접근)가 끝납니다.
답하려는 질문은 네 가지입니다.
- JPA의
EntityManagerFactory는 어떻게 빈이 되고,@PersistenceContext로 받은EntityManager하나를 여러 스레드가 같이 써도 왜 안전한가 JpaTransactionManager는 17편의 틀을 어떻게 채우고, 그 아래서JdbcTemplate이 어떻게 같은 커넥션을 쓰게 되는가@Repository를 붙이면 JPA 예외가 Spring의DataAccessException으로 바뀌는데, 그 일은 누가 어디서 하는가- 스레드가 없는 리액티브 세계에서는 "현재 트랜잭션"을 어디에 두는가 —
R2dbcTransactionManager와 ReactorContext
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-orm, spring-r2dbc, spring-tx (transaction.reactive, dao.support, dao.annotation) |
| 주로 읽는 클래스 | LocalContainerEntityManagerFactoryBean, SharedEntityManagerCreator, JpaTransactionManager, PersistenceExceptionTranslationInterceptor, R2dbcTransactionManager, TransactionContextManager |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
원래 공부 노트는 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
JPA(Jakarta Persistence API)는 Java 객체와 데이터베이스 테이블을 서로 맞춰 주는(ORM, 객체-관계 매핑) 표준 인터페이스입니다. 인터페이스일 뿐이라 실제 일을 하는 구현체가 따로 필요한데, 이 글의 예제는 Hibernate를 씁니다. JPA에서 핵심 객체는 둘입니다. **EntityManagerFactory**는 무겁고 하나만 만드는 공장이고, **EntityManager**는 그 공장이 찍어 내는 가벼운 작업 창구로 persist(저장)·find(조회) 같은 메서드가 여기 있습니다. EntityManager는 스레드 안전하지 않다는 것이 JPA의 규칙입니다.
영속성 유닛(persistence unit)은 "이 데이터베이스에 이 엔티티 클래스들을 쓴다"는 설정 묶음입니다. 원래는 META-INF/persistence.xml 파일에 적지만, Spring은 파일 없이 패키지만 알려 줘도 됩니다.
**FactoryBean**은 컨테이너에 들어가는 빈인데 자기 자신이 아니라 자기가 만든 다른 객체를 빈으로 내놓는 작은 공장입니다. 3편 6절에서 다뤘습니다. 이 글의 EntityManagerFactory가 이 방식으로 빈이 됩니다.
트랜잭션 매니저와 스레드에 묶인 자원. 17편의 핵심 두 가지만 되짚습니다. PlatformTransactionManager는 트랜잭션을 열고(getTransaction) 닫는(commit/rollback) 인터페이스이고, 그 뼈대 구현 AbstractPlatformTransactionManager는 "언제 새로 열지"를 정하고 "어떻게 여는지"는 doBegin 같은 추상 메서드에 맡깁니다(524–532행, 1159행). 열린 트랜잭션의 커넥션 같은 자원은 TransactionSynchronizationManager가 스레드별 보관함(ThreadLocal) 에 "열쇠 → 자원" 꼴로 묶어 둡니다(75–78행). 열쇠는 보통 DataSource 같은 자원 공장입니다.
리액티브 스트림과 Reactor. 결과를 바로 돌려주는 대신 "나중에 값이 흘러올 통로"(Mono는 0~1개, Flux는 여러 개)를 돌려주고, 실제 일은 누군가 구독(subscribe)할 때 일어나는 방식입니다. 한 작업이 여러 스레드를 오가며 진행되므로 ThreadLocal이 쓸모없어집니다. 대신 Reactor에는 구독마다 따라다니는 작은 맵 Context 가 있습니다. R2DBC는 JDBC의 리액티브 짝으로, ConnectionFactory에서 Connection을 얻는 구조는 같지만 모든 메서드가 Mono/Flux를 돌려줍니다.
1. EntityManagerFactory는 어떻게 빈이 되나
JPA 설정은 보통 이렇게 생겼습니다. 예제 폴더 ex/u17/의 JpaConfig에서 핵심만 옮깁니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Bean
LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(dataSource);
emf.setPackagesToScan("ex.u17"); // persistence.xml 대신 패키지를 훑어 @Entity 를 찾는다
HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter();
adapter.setGenerateDdl(true); // hibernate.hbm2ddl.auto=update → 테이블을 만들어 준다
adapter.setShowSql(false); // SQL 로그를 끈다
emf.setJpaVendorAdapter(adapter);
return emf;
}
@Bean
PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean 메서드가 돌려주는 것은 LocalContainerEntityManagerFactoryBean인데, 두 번째 메서드는 EntityManagerFactory를 주입받습니다. 타입이 다른데 어떻게 이어질까요. 첫 번째 클래스가 FactoryBean이기 때문입니다.
public abstract class AbstractEntityManagerFactoryBean implements
SmartFactoryBean<EntityManagerFactory>, BeanClassLoaderAware, BeanFactoryAware,
BeanNameAware, InitializingBean, SmartInitializingSingleton, DisposableBean,
EntityManagerFactoryInfo, PersistenceExceptionTranslator, Serializable {
spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java 98–101행
이 한 줄 선언에 이 절과 4절의 내용이 다 들어 있습니다. SmartFactoryBean<EntityManagerFactory>라서 컨테이너에 들어가는 빈은 이 객체가 아니라 getObject()가 돌려주는 EntityManagerFactory입니다(655–657행). InitializingBean이라서 속성 주입이 끝나면 afterPropertiesSet()이 불리고, 거기서 공장을 실제로 만듭니다. PersistenceExceptionTranslator는 4절의 예외 변환에 쓰입니다.
공장이 만들어지는 순서. 파란 상자는 Spring의 절차, 회색은 우리가 적은 설정과 Hibernate가 만든 것, 초록이 컨테이너에 들어가는 빈입니다.
영속성 유닛 — persistence.xml이 없어도 되는 이유
LocalContainerEntityManagerFactoryBean은 안에 DefaultPersistenceUnitManager를 하나 품고 있고(102행), setPackagesToScan은 그 안쪽 객체에 그대로 넘깁니다(223–225행). afterPropertiesSet()은 먼저 그 관리자를 준비시킨 뒤 영속성 유닛 정보를 정합니다(384–391행). 관리자가 정보를 읽는 곳이 이 메서드입니다.
private List<SpringPersistenceUnitInfo> readPersistenceUnitInfos() {
List<SpringPersistenceUnitInfo> infos = new ArrayList<>(1);
String defaultName = this.defaultPersistenceUnitName;
boolean buildDefaultUnit = (this.persistenceConfiguration != null || this.managedTypes != null ||
this.packagesToScan != null || this.mappingResources != null);
boolean foundDefaultUnit = false;
PersistenceUnitReader reader = new PersistenceUnitReader(this.resourcePatternResolver, this.dataSourceLookup);
SpringPersistenceUnitInfo[] readInfos = reader.readPersistenceUnitInfos(this.persistenceXmlLocations);
// ...
if (buildDefaultUnit) {
// ...
infos.add(buildDefaultPersistenceUnitInfo());
// ...
}
return infos;
}
persistence.xml은 기본 위치(classpath*:META-INF/persistence.xml, 91행)에서 있으면 읽고, 없어도 괜찮습니다. packagesToScan이 설정되어 있으면 buildDefaultUnit이 참이 되어 기본 유닛을 Spring이 직접 만듭니다. 그 메서드 안에서 PersistenceManagedTypesScanner가 패키지를 훑어 @Entity 클래스를 모읍니다(557–598행, 스캐너는 572–574행). JPA 표준은 XML 파일을 요구하지만 Spring은 그 파일이 하던 일을 코드로 대신해 주는 셈입니다.
벤더 어댑터 — Hibernate 설정이 모이는 자리
AbstractEntityManagerFactoryBean.afterPropertiesSet()은 JpaVendorAdapter가 있으면 거기서 세 가지를 받아 옵니다.
@Override
public void afterPropertiesSet() throws PersistenceException {
JpaVendorAdapter jpaVendorAdapter = getJpaVendorAdapter();
if (jpaVendorAdapter != null) {
if (this.persistenceProvider == null) {
this.persistenceProvider = jpaVendorAdapter.getPersistenceProvider();
}
PersistenceUnitInfo pui = getPersistenceUnitInfo();
Map<String, ?> vendorPropertyMap = (pui != null ? jpaVendorAdapter.getJpaPropertyMap(pui) :
jpaVendorAdapter.getJpaPropertyMap());
// ...
if (this.jpaDialect == null) {
this.jpaDialect = jpaVendorAdapter.getJpaDialect();
}
}
// ...
this.nativeEntityManagerFactory = buildNativeEntityManagerFactory();
// ...
// Wrap the EntityManagerFactory in a factory implementing all its interfaces.
// This allows interception of createEntityManager methods to return an
// application-managed EntityManager proxy that automatically joins
// existing transactions.
this.entityManagerFactory = createEntityManagerFactoryProxy(this.nativeEntityManagerFactory);
spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java 372–423행
PersistenceProvider— JPA 구현체의 진입점.HibernateJpaVendorAdapter는 Hibernate의 제공자를 Spring용으로 조금 감싼 것을 돌려줍니다(88행, 144–146행).- 속성 맵 —
setGenerateDdl(true)가hibernate.hbm2ddl.auto=update로,setShowSql이hibernate.show_sql로 번역됩니다(164–188행). 우리가 Hibernate 속성 이름을 외우지 않아도 되는 이유입니다. JpaDialect— 트랜잭션 시작, JDBC 커넥션 꺼내기, 예외 변환처럼 JPA 표준에 없는 벤더별 일을 맡는 객체(JpaDialect51행). Hibernate용은HibernateJpaDialect입니다(78행, 264행). 3절과 4절에서 다시 나옵니다.
그다음 buildNativeEntityManagerFactory()가 추상 메서드 createNativeEntityManagerFactory()를 부르고(437–440행, 566행), LocalContainerEntityManagerFactoryBean의 구현은 제공자에게 영속성 유닛 정보와 속성 맵을 넘겨 진짜 공장을 받아 옵니다(423–447행, 호출은 442–443행). 여기서 Hibernate가 엔티티 클래스를 읽고 테이블을 만듭니다.
빈이 되는 것은 프록시입니다
마지막 줄이 중요합니다. 받아 온 공장을 그대로 내놓지 않고 createEntityManagerFactoryProxy()로 한 번 감쌉니다.
protected EntityManagerFactory createEntityManagerFactoryProxy(@Nullable EntityManagerFactory emf) {
Set<Class<?>> ifcs = new LinkedHashSet<>();
Class<?> entityManagerFactoryInterface = this.entityManagerFactoryInterface;
if (entityManagerFactoryInterface != null) {
ifcs.add(entityManagerFactoryInterface);
}
// ...
ifcs.add(EntityManagerFactoryInfo.class);
try {
return (EntityManagerFactory) Proxy.newProxyInstance(this.beanClassLoader,
ClassUtils.toClassArray(ifcs), new ManagedEntityManagerFactoryInvocationHandler(this));
}
spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java 480–497행
10편에서 본 JDK 동적 프록시입니다. 인터페이스 목록에 EntityManagerFactoryInfo가 하나 더 들어가는데, 이 인터페이스를 통해 Spring의 다른 코드(트랜잭션 매니저, 공유 프록시)가 "이 공장이 쓰는 DataSource는 무엇인가", "JpaDialect는 무엇인가", "네이티브 공장은 무엇인가"를 물어볼 수 있습니다(EntityManagerFactoryInfo 75행, 100행, 112행). 위 소스 주석대로 createEntityManager 호출을 가로채는 용도이기도 합니다.
헷갈리기 쉬운 곳
getBean(EntityManagerFactory.class)로 받는 것은 Hibernate의 공장이 아니라 Spring의 프록시입니다. 예제 출력(2절)에emf is JDK proxy : true가 찍힙니다. Hibernate 고유 API가 필요하면EntityManagerFactoryInfo.getNativeEntityManagerFactory()나 JPA의unwrap으로 벗겨야 합니다(769–778행).FactoryBean자체를 받고 싶으면&entityManagerFactory입니다. 3편 6절의&규칙 그대로입니다. 4절에서 이FactoryBean객체가 예외 변환기 구실도 한다는 것이 나옵니다.
2. @PersistenceContext EntityManager는 왜 스레드 안전한가
JPA 규칙에서 EntityManager는 스레드 안전하지 않습니다. 그런데 Spring에서는 싱글톤 빈의 필드에 EntityManager를 하나 넣어 두고 여러 요청 스레드가 같이 씁니다. 답은 그 필드에 들어가는 것이 진짜 EntityManager가 아니라 공유 프록시라는 데 있습니다.
누가 넣나 — PersistenceAnnotationBeanPostProcessor
@PersistenceContext를 처리하는 것은 6편에서 본 BeanPostProcessor 가운데 하나입니다.
public class PersistenceAnnotationBeanPostProcessor implements InstantiationAwareBeanPostProcessor,
DestructionAwareBeanPostProcessor, MergedBeanDefinitionPostProcessor, BeanRegistrationAotProcessor,
PriorityOrdered, BeanFactoryAware, Serializable {
따로 등록하지 않아도 됩니다. 8편에서 본 AnnotationConfigUtils.registerAnnotationConfigProcessors가 클래스패스에 JPA가 있으면(jakarta.persistence.EntityManagerFactory가 보이면) 이 후처리기를 함께 등록합니다(122–124행, 177–189행). 동작은 5편의 AutowiredAnnotationBeanPostProcessor와 같은 꼴입니다. postProcessProperties에서 주입 지점 목록을 찾아 주입하라고 시키고(399–409행), 주입 지점 하나가 "무엇을 넣을지"를 정하는 곳이 getResourceToInject입니다(758–771행). @PersistenceUnit이면 공장을, @PersistenceContext면 EntityManager를 넣는데, 후자의 기본 경로가 이것입니다.
private EntityManager resolveEntityManager(@Nullable String requestingBeanName) {
// Obtain EntityManager reference from JNDI?
EntityManager em = getPersistenceContext(this.unitName, false);
if (em == null) {
// ...
emf = findEntityManagerFactory(this.unitName, requestingBeanName);
// ...
// Inject a shared transactional EntityManager proxy.
if (emf instanceof EntityManagerFactoryInfo emfInfo && emfInfo.getEntityManagerInterface() != null) {
// Create EntityManager based on the info's vendor-specific type
// (which might be more specific than the field's type).
em = SharedEntityManagerCreator.createSharedEntityManager(
emf, this.properties, this.synchronizedWithTransaction);
}
// ...
}
return em;
}
주석이 답을 말해 줍니다. "공유 트랜잭션 EntityManager 프록시를 주입한다." 만드는 쪽은 SharedEntityManagerCreator이고, 역시 JDK 프록시입니다(165–178행). 인터페이스 목록 끝에 EntityManagerProxy를 하나 더 붙이는데(174행), 이 인터페이스의 getTargetEntityManager()로 "지금 이 프록시 뒤에 있는 진짜 객체"를 꺼낼 수 있습니다(EntityManagerProxy 44행). 예제에서 씁니다.
프록시는 호출마다 "지금 트랜잭션의 EntityManager"를 찾습니다
프록시의 모든 메서드 호출은 invoke 하나로 들어옵니다. 특별 취급하는 메서드 몇 개를 거른 뒤의 본론입니다.
@Override
public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args) throws Throwable {
// Invocation on EntityManager interface coming in...
switch (method.getName()) {
// ...
case "close" -> {
// Handle close method: suppress, not valid.
return null;
}
case "getTransaction" -> {
throw new IllegalStateException(
"Not allowed to create transaction on shared EntityManager - " +
"use Spring transactions or EJB CMT instead");
}
}
// Determine current EntityManager: either the transactional one
// managed by the factory or a temporary one for the given invocation.
EntityManager target = EntityManagerFactoryUtils.doGetTransactionalEntityManager(
this.targetFactory, this.properties, this.synchronizedWithTransaction);
// ...
if (transactionRequiringMethods.contains(method.getName())) {
// We need a transactional target now, according to the JPA spec.
// Otherwise, the operation would get accepted but remain unflushed...
if (target == null || (!TransactionSynchronizationManager.isActualTransactionActive() &&
!target.getTransaction().isActive())) {
throw new TransactionRequiredException("No EntityManager with actual transaction available " +
"for current thread - cannot reliably process '" + method.getName() + "' call");
}
}
// Regular EntityManager operations.
boolean newTarget = false;
if (target == null) {
// ...
target = EntityManagerFactoryUtils.createEntityManager(this.targetFactory, this.properties);
newTarget = true;
}
// ...
return invokeMethod(method, args, target, newTarget);
}
spring-orm/src/main/java/org/springframework/orm/jpa/SharedEntityManagerCreator.java 316–414행
프록시가 호출을 받을 때마다 하는 일. 노란 상자가 17편의 스레드 보관함을 들여다보는 곳입니다.
순서대로 읽으면 이렇습니다.
close()는 무시하고getTransaction()은 막습니다. 공유 객체를 한 사용자가 닫거나 직접 트랜잭션을 열면 다른 사용자가 망가지기 때문입니다. 트랜잭션은 Spring이 연다는 뜻입니다.doGetTransactionalEntityManager로 "지금 스레드의 트랜잭션에 묶인EntityManager"를 찾습니다. 이 메서드는TransactionSynchronizationManager.getResource(emf), 곧 17편의 스레드 보관함을 공장을 열쇠로 들여다봅니다(EntityManagerFactoryUtils207행).EntityManagerHolder가 묶여 있으면 그 안의EntityManager를 돌려주고(231행), 트랜잭션 동기화조차 켜져 있지 않으면null을 돌려줍니다(253–256행).persist·merge·remove·flush같은 메서드는 트랜잭션이 없으면TransactionRequiredException입니다. 목록은 76–82행에 있습니다. 주석의 이유가 좋습니다 — 받아 주면 조용히 저장되지 않은 채 남기 때문입니다.- 그 밖의 메서드(
find등)는 트랜잭션이 없으면 이 호출만을 위한EntityManager를 새로 만들어 쓰고 끝나면 닫습니다(invokeMethod의finally, 287–289행).
그래서 스레드 안전합니다. 프록시 자체는 상태가 없고, 진짜 EntityManager는 스레드마다, 트랜잭션마다 따로 있으며 프록시가 호출 때마다 그것을 찾아 줄 뿐입니다. 18편의 JdbcTemplate이 DataSourceUtils.getConnection으로 스레드에 묶인 커넥션을 찾는 것과 같은 모양입니다. 이 EntityManagerHolder를 묶는 쪽은 3절의 JpaTransactionManager입니다.
예제: 트랜잭션 밖과 안
ex/u17/SharedEntityManagerExample.java입니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9와 Hibernate 7.1.36으로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Component
static class MemberDao {
@PersistenceContext
EntityManager em; // PersistenceAnnotationBeanPostProcessor 가 넣는다
@Autowired
EntityManager autowiredEm; // 7.0 부터: FactoryBean 이 SmartFactoryBean.getObject(Class) 로 내준다
}
// ... main 에서: 트랜잭션 밖
System.out.println("em is JDK proxy : " + Proxy.isProxyClass(dao.em.getClass()));
// ...
try {
((EntityManagerProxy) dao.em).getTargetEntityManager();
}
catch (IllegalStateException ex) {
System.out.println("getTargetEntityManager : " + ex.getMessage());
}
try {
dao.em.persist(new Member("kim"));
}
catch (TransactionRequiredException ex) {
System.out.println("persist : " + ex.getClass().getSimpleName() + " - " + ex.getMessage());
}
// 읽기는 된다 — 호출 한 번을 위한 EntityManager 를 만들었다가 닫는다
System.out.println("find(1L) : " + dao.em.find(Member.class, 1L));
// ... 트랜잭션 안
TransactionTemplate tx = new TransactionTemplate(ctx.getBean(PlatformTransactionManager.class));
tx.executeWithoutResult(status -> {
// ...
EntityManager t1 = ((EntityManagerProxy) dao.em).getTargetEntityManager();
EntityManager t2 = ((EntityManagerProxy) dao.autowiredEm).getTargetEntityManager();
System.out.println("same target for both fields? " + (t1 == t2));
dao.em.persist(new Member("kim"));
// ...
});
em is JDK proxy : true
em.toString() : Shared EntityManager proxy for target factory [org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean@185339ed]
em is EntityManagerProxy : true
@Autowired em : Shared EntityManager proxy for target factory [org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean@185339ed]
emf is JDK proxy : true
--- outside transaction
EntityManagerHolder bound to thread? false
getTargetEntityManager : No transactional EntityManager available
persist : TransactionRequiredException - No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call
find(1L) : null
--- inside transaction
EntityManagerHolder bound to thread? true
same target for both fields? true
persist : ok
after commit, find(1L) : Member[1, kim]
트랜잭션 밖에서는 보관함이 비어 있고(false), 진짜 객체를 달라고 하면 없다고 하고, persist는 거부되지만 find는 됩니다. 트랜잭션 안에서는 보관함에 EntityManagerHolder가 있고, 두 필드의 프록시가 같은 진짜 객체를 가리킵니다.
헷갈리기 쉬운 곳: @PersistenceContext와 @Autowired EntityManager
위 출력에서 @Autowired EntityManager도 같은 공유 프록시를 받았습니다. 어떻게 되는지 보면, 1절의 FactoryBean이 SmartFactoryBean의 getObject(Class)를 구현해 EntityManager 타입을 달라고 하면 자기가 만들어 둔 공유 프록시를 내주기 때문입니다(667–676행, 프록시를 만들어 두는 곳은 425행). 컨테이너 쪽에서 이 메서드를 부르는 자리는 FactoryBeanRegistrySupport에 있습니다(202–203행). SmartFactoryBean.getObject(Class)의 설명 주석에는 @since 7.0이라고 적혀 있습니다(63행). 그 전 버전에서 @Autowired EntityManager가 어떻게 되는지는 이 글에서 소스로 확인한 것이 아닙니다.
다만 두 어노테이션이 완전히 같지는 않습니다. @PersistenceContext에는 unitName(공장이 여럿일 때 고르기), type = EXTENDED(트랜잭션보다 오래 사는 EntityManager), properties가 있고 getResourceToInject가 그것을 봅니다. EXTENDED는 다른 경로(ExtendedEntityManagerCreator, 810–822행)로 가며 이 글에서는 다루지 않습니다.
3. JpaTransactionManager — 17편의 틀을 채우기
JpaTransactionManager는 17편의 AbstractPlatformTransactionManager를 상속하고 doGetTransaction·doBegin·doCommit·doRollback을 채웁니다(117–118행). 전파 속성을 보고 "새로 열까, 참여할까"를 정하는 일은 부모가 그대로 하므로, 읽을 것은 "JPA에서는 여는 것이 무엇인가"뿐입니다.
doGetTransaction()은 스레드 보관함을 두 열쇠로 들여다봅니다 — 공장(EntityManagerFactory)으로 EntityManagerHolder를, DataSource로 ConnectionHolder를(357–378행). DataSource는 따로 설정하지 않아도 afterPropertiesSet()이 1절의 EntityManagerFactoryInfo에게 물어 알아냅니다(334–346행). 왜 두 열쇠인지는 doBegin에 있습니다.
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
JpaTransactionObject txObject = (JpaTransactionObject) transaction;
if (txObject.hasConnectionHolder() && !txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
throw new IllegalTransactionStateException(
"Pre-bound JDBC Connection found! JpaTransactionManager does not support " +
"running within DataSourceTransactionManager if told to manage the DataSource itself. " +
"It is recommended to use a single JpaTransactionManager for all transactions " +
"on a single DataSource, no matter whether JPA or JDBC access.");
}
try {
if (!txObject.hasEntityManagerHolder() ||
txObject.getEntityManagerHolder().isSynchronizedWithTransaction()) {
EntityManager newEm = createEntityManagerForTransaction();
// ...
txObject.setEntityManagerHolder(new EntityManagerHolder(newEm), true);
}
EntityManager em = txObject.getEntityManagerHolder().getEntityManager();
// Delegate to JpaDialect for actual transaction begin.
int timeoutToUse = determineTimeout(definition);
Object transactionData = getJpaDialect().beginTransaction(em,
new JpaTransactionDefinition(definition, timeoutToUse, txObject.isNewEntityManagerHolder()));
// ...
// Register the JPA EntityManager's JDBC Connection for the DataSource, if set.
if (getDataSource() != null) {
ConnectionHandle conHandle = getJpaDialect().getJdbcConnection(em, definition.isReadOnly());
if (conHandle != null) {
ConnectionHolder conHolder = new ConnectionHolder(conHandle);
// ...
TransactionSynchronizationManager.bindResource(getDataSource(), conHolder);
txObject.setConnectionHolder(conHolder);
}
// ...
}
// Bind the entity manager holder to the thread.
if (txObject.isNewEntityManagerHolder()) {
TransactionSynchronizationManager.bindResource(
obtainEntityManagerFactory(), txObject.getEntityManagerHolder());
}
txObject.getEntityManagerHolder().setSynchronizedWithTransaction(true);
}
// ...
}
spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java 385–459행
트랜잭션 하나가 스레드에 묶어 두는 것은 둘입니다. 노란 상자 둘이 그 보관함의 항목이고, 아래 회색 둘이 각자의 열쇠로 찾아오는 쪽입니다.
- 이 트랜잭션용
EntityManager를 새로 엽니다(400행, 470–484행). 2절의 공유 프록시가 찾게 될 진짜 객체가 이것입니다. JpaDialect.beginTransaction으로 JPA 트랜잭션을 시작합니다(411행). Hibernate용 다이얼렉트는 격리 수준이나 읽기 전용이 지정됐으면 그것을 커넥션에 적용한 뒤 표준 호출entityManager.getTransaction().begin()을 부릅니다(HibernateJpaDialect147행).- Hibernate가 쥔 JDBC 커넥션을 꺼내
DataSource열쇠로 묶습니다(423–433행). 이것이 두 번째 열쇠의 이유입니다.HibernateJpaDialect.getJdbcConnection은 세션을 벗겨 그 안의 물리 커넥션을 돌려주는 손잡이를 만듭니다.
private static class HibernateConnectionHandle implements ConnectionHandle {
private final SessionImplementor session;
public HibernateConnectionHandle(SessionImplementor session) {
this.session = session;
}
@Override
public Connection getConnection() {
return this.session.getJdbcCoordinator().getLogicalConnection().getPhysicalConnection();
}
}
spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java 263–275행
EntityManagerHolder를 공장 열쇠로 묶습니다(444–446행).
이제 18편의 JdbcTemplate이 같은 스레드에서 DataSourceUtils.getConnection(dataSource)를 부르면 DataSource 열쇠로 보관함을 보고(DataSourceUtils 103–106행) Hibernate가 쓰고 있는 바로 그 커넥션을 받습니다. 한 트랜잭션에서 JPA와 SQL을 섞어 써도 커밋과 롤백이 한 번에 되는 이유입니다. 소스의 클래스 설명도 이 점을 강조합니다(80–85행).
doCommit은 EntityTransaction.commit()을 부르고, 거기서 나는 예외를 JpaDialect로 번역해 던집니다(544–567행). doRollback은 rollback()을 부르고(570–596행), 끝난 뒤 doCleanupAfterCompletion이 두 열쇠를 모두 풀고 EntityManager를 닫습니다(609–646행).
예제: JdbcTemplate이 같은 커넥션을 쓰는지
ex/u17/JpaJdbcSameConnectionExample.java의 핵심 부분입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
tx.executeWithoutResult(status -> {
// ...
System.out.println("EntityManagerHolder bound? " + TransactionSynchronizationManager.hasResource(emf));
System.out.println("ConnectionHolder bound? " + TransactionSynchronizationManager.hasResource(ds));
Connection fromJdbc = DataSourceUtils.getConnection(ds); // JdbcTemplate 이 쓰는 길
Connection fromJpa = em.unwrap(Session.class).doReturningWork(con -> con); // Hibernate 세션의 커넥션
System.out.println("same JDBC Connection? " + (fromJdbc == fromJpa));
em.persist(new Member("via-jpa"));
em.flush(); // INSERT 를 지금 보낸다
jdbc.update("insert into member(id, name) values (?, ?)", 100, "via-jdbc");
System.out.println("rows JdbcTemplate sees : "
+ jdbc.queryForObject("select count(*) from member", Integer.class));
status.setRollbackOnly();
});
transactionManager : JpaTransactionManager
dataSource auto-detected from EMF? true
--- outside transaction: ConnectionHolder bound? false
--- inside transaction
EntityManagerHolder bound? true
ConnectionHolder bound? true
same JDBC Connection? true
rows JdbcTemplate sees : 2
--- after rollback
rows : 0
ConnectionHolder bound? false
두 열쇠가 모두 묶여 있고, 두 길로 받은 Connection이 같은 객체이며, JPA로 넣은 행과 SQL로 넣은 행이 한 번의 롤백으로 함께 사라집니다.
헷갈리기 쉬운 곳
JpaTransactionManager와DataSourceTransactionManager를 같은DataSource에 함께 두지 않습니다.doBegin첫머리의 검사가 그 경우를 막고, 예외 메시지가 권고를 그대로 적고 있습니다 — "JPA든 JDBC든 한DataSource의 모든 트랜잭션에는JpaTransactionManager하나를 쓰라"(389–394행). 위에서 봤듯JpaTransactionManager가 JDBC 쪽 열쇠까지 묶어 주므로 둘째 매니저는 필요 없습니다.- 중첩 트랜잭션(세이브포인트)은 기본으로 꺼져 있습니다. 클래스 설명에 따르면 세이브포인트는 JDBC 커넥션에만 적용되고 JPA의
EntityManager와 그 캐시에는 적용되지 않기 때문입니다(94–101행). - JDBC 커넥션을 꺼낼 수 있느냐는 다이얼렉트에 달렸습니다. 기본
DefaultJpaDialect.getJdbcConnection은null을 돌려주고(146–149행), 그러면doBegin은 "JDBC 트랜잭션으로 노출하지 않는다"는 디버그 로그만 남기고 넘어갑니다(435–439행). 벤더 어댑터를 지정해야 하는 이유 가운데 하나입니다.
4. 예외 변환 — @Repository는 무엇을 하나
18편에서 JdbcTemplate이 SQLException을 Spring의 DataAccessException 계층으로 바꾸는 것을 봤습니다. JPA 코드는 JdbcTemplate을 거치지 않으니 다른 장치가 필요한데, 그것이 @Repository입니다. @Repository는 @Component에 뜻을 하나 더한 어노테이션일 뿐이고(Repository 57–61행), 실제 일은 PersistenceExceptionTranslationPostProcessor라는 후처리기를 빈으로 등록했을 때 일어납니다.
@Repository 빈의 메서드가 던진 예외가 지나는 길. 빨간 상자는 번역하지 않고 그대로 보내는 경우입니다.
- 후처리기가 프록시로 감쌉니다. 이 후처리기는 11편의 자동 프록시 생성기와는 다른, 어드바이저 하나만 가진 작은 계열(
AbstractAdvisingBeanPostProcessor)입니다. 초기화 후 훅에서 어드바이저가 적용되는 클래스면ProxyFactory로 프록시를 만들어 돌려줍니다(88–133행, 적용 여부는AopUtils.canApply, 170행). 어드바이저는setBeanFactory에서 만들어집니다(PersistenceExceptionTranslationPostProcessor82–90행). - 어드바이저 = 포인트컷 + 인터셉터. 포인트컷은 "클래스에
@Repository가 있나"를 보는AnnotationMatchingPointcut이고, 어드바이스는PersistenceExceptionTranslationInterceptor입니다(PersistenceExceptionTranslationAdvisor68–73행). 어노테이션 타입은 바꿀 수 있어서 Spring 것이 아닌 어노테이션으로도 같은 효과를 낼 수 있습니다(76–79행). - 인터셉터가 예외를 잡아 번역합니다.
@Override
public @Nullable Object invoke(MethodInvocation mi) throws Throwable {
try {
return mi.proceed();
}
catch (RuntimeException ex) {
// Let it throw raw if the type of the exception is on the throws clause of the method.
if (!this.alwaysTranslate && ReflectionUtils.declaresException(mi.getMethod(), ex.getClass())) {
throw ex;
}
else {
PersistenceExceptionTranslator translator = this.persistenceExceptionTranslator;
if (translator == null) {
// ...
translator = detectPersistenceExceptionTranslators(this.beanFactory);
// ...
this.persistenceExceptionTranslator = translator;
}
throw DataAccessUtils.translateIfNecessary(ex, translator);
}
}
}
번역기는 따로 지정하지 않으면 컨테이너에 있는 PersistenceExceptionTranslator 빈을 전부 모아 하나로 묶은 것입니다(169–173행). 1절의 선언을 떠올리면 AbstractEntityManagerFactoryBean이 바로 그 인터페이스를 구현하고 있습니다(101행). 그러니 JPA 설정을 한 것만으로 번역기가 하나 생긴 셈이고, 그 번역기는 JpaDialect에게 일을 넘깁니다(579–582행). 인터페이스 자체는 메서드 하나짜리입니다 — "번역할 수 있으면 DataAccessException을, 모르는 예외면 null을 돌려주라"(PersistenceExceptionTranslator 35–56행). null이면 원래 예외가 그대로 나갑니다(DataAccessUtils 342–348행).
- Hibernate 다이얼렉트는 두 단계로 번역합니다.
HibernateException이면 Hibernate 고유 예외 표(제약 위반 →DataIntegrityViolationException등,HibernateExceptionTranslator167–169행)를 쓰고, 아니면 JPA 표준 예외 표로 넘깁니다(103–112행). JPA 표준 표가 이것입니다.
public static @Nullable DataAccessException convertJpaAccessExceptionIfPossible(RuntimeException ex) {
// ...
if (ex instanceof EntityNotFoundException entityNotFoundException) {
return new JpaObjectRetrievalFailureException(entityNotFoundException);
}
if (ex instanceof NoResultException) {
return new EmptyResultDataAccessException(ex.getMessage(), 1, ex);
}
if (ex instanceof NonUniqueResultException) {
return new IncorrectResultSizeDataAccessException(ex.getMessage(), 1, ex);
}
// ...
if (ex instanceof TransactionRequiredException) {
return new InvalidDataAccessApiUsageException(ex.getMessage(), ex);
}
// If we have another kind of PersistenceException, throw it.
if (ex instanceof PersistenceException) {
return new JpaSystemException(ex);
}
// If we get here, we have an exception that resulted from user code,
// rather than the persistence provider, so we return null to indicate
// that translation should not occur.
return null;
}
spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerFactoryUtils.java 477–525행
예제: 같은 코드, 어노테이션만 다르게
ex/u17/RepositoryExceptionTranslationExample.java는 똑같은 두 메서드를 가진 클래스를 @Repository와 @Component로 하나씩 등록합니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Repository
static class MemberRepository {
@PersistenceContext
EntityManager em;
Member findByName(String name) {
return em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getSingleResult(); // 없으면 jakarta.persistence.NoResultException
}
void save(Member member) {
em.persist(member);
}
}
@Repository bean class : RepositoryExceptionTranslationExample$MemberRepository$$SpringCGLIB$$0
@Component bean class : PlainDao
--- getSingleResult() with no row
@Component : jakarta.persistence.NoResultException
@Repository : org.springframework.dao.EmptyResultDataAccessException (cause: NoResultException)
--- persist() outside a transaction
@Component : jakarta.persistence.TransactionRequiredException
@Repository : org.springframework.dao.InvalidDataAccessApiUsageException (cause: TransactionRequiredException)
--- duplicate name, failure surfaces at commit
[main] WARN org.hibernate.orm.jdbc.error - HHH000247: ErrorCode: 23505, SQLState: 23505
[main] WARN org.hibernate.orm.jdbc.error - Unique index or primary key violation: "PUBLIC.UK9ESVGIKRMTI1V7CI6V453IMDC_INDEX_8 ON PUBLIC.MEMBER(NAME NULLS FIRST) VALUES ( /* 1 */ 'dup' )"; SQL statement:
insert into member (name,id) values (?,?) [23505-232]
@Repository : org.springframework.dao.DataIntegrityViolationException (cause: ConstraintViolationException)
@Repository 쪽만 클래스 이름이 $$SpringCGLIB$$로 끝나는 프록시이고, 같은 호출에서 JPA 예외 대신 Spring 예외가 나옵니다. 가운데 두 줄의 WARN은 Hibernate가 찍은 로그입니다.
헷갈리기 쉬운 곳
- 마지막 경우(중복 이름)는 인터셉터가 번역한 것이 아닙니다.
persist는 성공하고INSERT는 커밋 시점에 나가므로, 예외는repo.save()를 이미 빠져나온 뒤JpaTransactionManager.doCommit에서 납니다. 그곳이 같은JpaDialect로 번역하기 때문에(554–560행) 결과는 같은DataIntegrityViolationException이지만,@Repository가 없었어도 이 경우는 번역됐을 것입니다. - 메서드의
throws절에 적어 둔 예외는 번역하지 않습니다(139–141행). 일부러 그 예외를 밖에 알리겠다고 선언한 것으로 봅니다. @Repository만 붙이고 후처리기를 등록하지 않으면 아무 일도 일어나지 않습니다. 어노테이션은 표시일 뿐이고 프록시를 만드는 것은 후처리기입니다. 예제 설정의 마지막@Bean이 그것입니다.
5. 리액티브 트랜잭션 — 스레드가 없는 세계
여기서부터는 spring-r2dbc와 spring-tx의 transaction.reactive 패키지입니다. 17편까지의 모든 장치는 "현재 스레드"에 기대고 있었습니다. 트랜잭션 매니저는 스레드에 자원을 묶고, JdbcTemplate과 공유 프록시는 스레드에서 자원을 찾습니다. 그런데 리액티브 코드는 한 작업이 여러 스레드를 옮겨 다니고, 한 스레드가 여러 작업을 번갈아 처리합니다. ThreadLocal에 넣은 것은 다음 단계에서 없거나 남의 것일 수 있습니다.
인터페이스는 짝으로 있습니다
| 블로킹 (17편) | 리액티브 (이 글) |
|---|---|
PlatformTransactionManager (47행) |
ReactiveTransactionManager (36행) — 둘 다 표식용 TransactionManager(28행)를 상속 |
getTransaction → TransactionStatus |
getReactiveTransaction → Mono<ReactiveTransaction> (63행) |
AbstractPlatformTransactionManager |
AbstractReactiveTransactionManager (84–85행) — 같은 전파 규칙, doBegin 등은 Mono<Void>를 돌려줌 (817행) |
DataSourceTransactionManager |
R2dbcTransactionManager (84행) |
TransactionTemplate |
TransactionalOperator (53행) |
transaction.support.TransactionSynchronizationManager |
transaction.reactive.TransactionSynchronizationManager |
마지막 줄이 이 절의 핵심입니다. 이름이 같은 클래스가 둘입니다.
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
public class TransactionSynchronizationManager {
private final TransactionContext transactionContext;
// ...
public static Mono<TransactionSynchronizationManager> forCurrentTransaction() {
return TransactionContextManager.currentContext().map(TransactionSynchronizationManager::new);
}
// ...
public @Nullable Object getResource(Object key) {
Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
return doGetResource(actualKey);
}
/**
* Actually check the value of the resource that is bound for the given key.
*/
private @Nullable Object doGetResource(Object actualKey) {
return this.transactionContext.getResources().get(actualKey);
}
블로킹 쪽은 static 메서드와 ThreadLocal이고, 리액티브 쪽은 인스턴스이며 TransactionContext라는 객체 하나를 감싼 얇은 껍데기입니다. 자원 맵은 그 객체 안에 있습니다(TransactionContext 37–41행). 그러면 TransactionContext는 어디에 있을까요.
Reactor Context에 싣기 — TransactionContextManager
public static Mono<TransactionContext> currentContext() {
return Mono.deferContextual(ctx -> {
if (ctx.hasKey(TransactionContext.class)) {
return Mono.just(ctx.get(TransactionContext.class));
}
if (ctx.hasKey(TransactionContextHolder.class)) {
TransactionContextHolder holder = ctx.get(TransactionContextHolder.class);
if (holder.hasContext()) {
return Mono.just(holder.currentContext());
}
}
return Mono.error(new NoTransactionInContextException());
});
}
Mono.deferContextual은 "구독이 일어날 때 그 구독의 Context를 보고 결정하라"는 Reactor 연산자입니다. TransactionContext는 Reactor Context에 TransactionContext.class를 열쇠로 들어 있습니다. 스레드가 아니라 구독에 붙어 다니므로 작업이 스레드를 옮겨도 따라갑니다. 없으면 NoTransactionException의 가벼운 변종을 냅니다(117–121행).
넣는 쪽은 getOrCreateContext()와 getOrCreateContextHolder()입니다(85–92행, 103–109행). 홀더가 따로 있는 이유가 설명 주석에 적혀 있습니다 — 트랜잭션을 열고 닫는 일은 위에서 아래로 진행되는데 Reactor의 Context는 아래(구독자)에서 위로 전달되는 불변 맵이라, 가변 홀더(안에 스택이 있습니다, TransactionContextHolder 32–34행)를 하나 두고 그 안에서 바꾼다는 것입니다(96–101행).
이 둘을 실제로 붙이는 곳이 TransactionalOperator의 구현입니다.
@Override
public <T> Flux<T> execute(TransactionCallback<T> action) throws TransactionException {
return TransactionContextManager.currentContext().flatMapMany(context ->
Flux.usingWhen(
this.transactionManager.getReactiveTransaction(this.transactionDefinition),
action::doInTransaction,
this.transactionManager::commit,
this::rollbackOnException,
this.transactionManager::rollback)
.onErrorMap(this::unwrapIfResourceCleanupFailure))
.contextWrite(TransactionContextManager.getOrCreateContext())
.contextWrite(TransactionContextManager.getOrCreateContextHolder());
}
리액티브 트랜잭션에서 "현재 트랜잭션"이 사는 곳. 노란 상자가 Reactor Context 안의 TransactionContext이고, 모두가 거기로 모입니다. 빨간 상자는 아무도 보지 않는 곳입니다.
Flux.usingWhen(자원, 사용, 성공 시, 실패 시, 취소 시)은 Reactor의 "자원을 열고 쓰고 닫기" 연산자입니다. 17편의 TransactionTemplate.execute가 try/catch로 하던 일(열기 → 실행 → 커밋 / 예외면 롤백)을 이 연산자 하나가 합니다. 그리고 체인의 끝에 contextWrite 두 줄이 붙어 있습니다. Reactor에서 contextWrite는 아래쪽(구독자 쪽)에 적어야 위쪽 연산자가 볼 수 있으므로 가장 마지막에 붙습니다. 트랜잭션 매니저의 getReactiveTransaction은 첫 줄에서 forCurrentTransaction()으로 그 Context를 꺼내 시작합니다(AbstractReactiveTransactionManager 116–120행).
@Transactional은 어떻게 리액티브 쪽으로 가나
17편의 TransactionInterceptor는 그대로입니다. 그 부모 TransactionAspectSupport.invokeWithinTransaction의 앞부분에 분기가 하나 있습니다.
final TransactionManager tm = determineTransactionManager(txAttr, targetClass);
if (this.reactiveAdapterRegistry != null && tm instanceof ReactiveTransactionManager rtm) {
// ...
ReactiveTransactionSupport txSupport = this.transactionSupportCache.computeIfAbsent(method, key -> {
// ...
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(reactiveType);
if (adapter == null) {
throw new IllegalStateException("Cannot apply reactive transaction to non-reactive return type [" +
method.getReturnType() + "] with specified transaction manager: " + tm);
}
return new ReactiveTransactionSupport(adapter);
});
return txSupport.invokeWithinTransaction(method, targetClass, invocation, txAttr, rtm);
}
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
고른 트랜잭션 매니저가 ReactiveTransactionManager이면(그리고 클래스패스에 Reactive Streams가 있으면, 109행, 192–197행) 메서드의 반환 타입이 Mono/Flux 같은 리액티브 타입인지 ReactiveAdapterRegistry에 묻고, 아니면 예외를 던집니다. 리액티브면 안쪽 클래스 ReactiveTransactionSupport로 넘기는데(908행), 그 안의 모양은 위의 TransactionalOperatorImpl과 같습니다 — Mono.usingWhen으로 열고·실행하고·커밋/롤백하고, 끝에 contextWrite 두 줄(921–941행).
R2dbcTransactionManager.doBegin
리액티브 매니저의 doBegin은 인자로 TransactionSynchronizationManager를 받습니다(183–184행). 블로킹 쪽은 static으로 아무 데서나 불렀지만 여기서는 "지금 구독의 것"을 넘겨받아야 하기 때문입니다. 본문은 커넥션을 열고(connectionFactory.create(), 197행), con.beginTransaction(...)을 부르고(242행), 성공하면 ConnectionFactory를 열쇠로 ConnectionHolder를 묶습니다.
// Bind the connection holder to the thread.
if (txObject.isNewConnectionHolder()) {
synchronizationManager.bindResource(obtainConnectionFactory(), txObject.getConnectionHolder());
}
주석은 "스레드에 묶는다"고 적혀 있지만 실제로는 Context 안의 맵에 들어갑니다. 18편 DataSourceTransactionManager의 같은 줄과 모양이 같아서 그대로 남은 주석으로 보입니다. doCommit/doRollback은 Mono를 돌려주고 R2DBC 예외를 DataAccessException으로 바꿉니다(297–317행).
헷갈리기 쉬운 곳: 리액티브에서 ThreadLocal을 쓰면 안 되는 이유
위에서 본 대로 리액티브 트랜잭션 매니저는 ThreadLocal 쪽 TransactionSynchronizationManager를 건드리지 않습니다. 그래서 리액티브 트랜잭션 안에서 블로킹 쪽 isActualTransactionActive()를 부르면 false입니다(아래 예제 출력에 있습니다). 같은 이유로, 리액티브 체인 안에서 JdbcTemplate이나 공유 EntityManager 프록시를 부르면 그것들은 빈 ThreadLocal을 보게 되어 트랜잭션 밖에서 도는 셈이 됩니다. 두 세계의 장치는 섞이지 않습니다.
6. DatabaseClient와 ConnectionFactoryUtils — DataSourceUtils의 짝
마지막으로 R2DBC 쪽 "템플릿"입니다. DatabaseClient는 JdbcTemplate보다 JdbcClient에 가까운 유창한 API로, DatabaseClient.create(connectionFactory)로 만듭니다(63행, 109–111행). 구현 DefaultDatabaseClient가 커넥션을 얻는 곳은 한 줄이고(163–165행), 그 한 줄이 18편 DataSourceUtils.getConnection의 짝인 ConnectionFactoryUtils.getConnection입니다.
public static Mono<Connection> doGetConnection(ConnectionFactory connectionFactory) {
Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
return TransactionSynchronizationManager.forCurrentTransaction().flatMap(synchronizationManager -> {
ConnectionHolder conHolder = (ConnectionHolder) synchronizationManager.getResource(connectionFactory);
if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
conHolder.requested();
if (!conHolder.hasConnection()) {
return fetchConnection(connectionFactory).doOnNext(conHolder::setConnection);
}
return Mono.just(conHolder.getConnection());
}
// Else we either got no holder or an empty thread-bound holder here.
Mono<Connection> con = fetchConnection(connectionFactory);
// ...
return con;
}).onErrorResume(NoTransactionException.class, ex -> Mono.from(connectionFactory.create()));
}
spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/ConnectionFactoryUtils.java 114–152행
18편의 DataSourceUtils.doGetConnection(103–106행)과 나란히 두면 구조가 같습니다. 보관함을 ConnectionFactory 열쇠로 보고, ConnectionHolder가 있으면 그 커넥션을, 없으면 새로 엽니다. 다른 점은 셋입니다. 보관함을 forCurrentTransaction()으로 Context에서 꺼낸다는 것, 결과가 Mono<Connection>이라는 것, 그리고 마지막 줄 — 트랜잭션 Context가 아예 없으면(NoTransactionException) 그냥 새 커넥션을 연다는 것입니다. 트랜잭션 밖에서 DatabaseClient를 써도 되는 이유가 이 줄입니다. 반납하는 쪽 doReleaseConnection도 같은 대칭입니다 — 묶인 커넥션이면 닫지 않고, 아니면 닫습니다(187–196행). DefaultDatabaseClient는 그 위에 Mono.usingWhen과 close()를 막는 커넥션 프록시를 한 겹 더 씌웁니다(116–135행, 191–195행).
| 역할 | JDBC (18편) | R2DBC |
|---|---|---|
| 자원 공장 (보관함의 열쇠) | DataSource |
ConnectionFactory |
| 보관함 항목 | jdbc.datasource.ConnectionHolder (43행) |
r2dbc.connection.ConnectionHolder (42행) |
| 커넥션 찾기 | DataSourceUtils.getConnection |
ConnectionFactoryUtils.getConnection |
| 보관함 | ThreadLocal |
Reactor Context 안의 TransactionContext |
| 템플릿 | JdbcTemplate, JdbcClient |
DatabaseClient |
18편 DataSourceUtils.getConnection과 같은 모양이고, 보관함이 ThreadLocal 대신 Reactor Context에 있습니다. "같은 커넥션 → true"는 아래 예제 3번의 실제 출력입니다.
예제: 롤백과 Context
ex/u17/R2dbcTransactionExample.java입니다. r2dbc-h2 드라이버로 H2 인메모리에 붙습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
R2dbcTransactionManager tm = new R2dbcTransactionManager(cf);
TransactionalOperator txOp = TransactionalOperator.create(tm);
// 두 번째 INSERT 가 기본 키 충돌로 실패하는 작업
Mono<Void> work = client.sql("insert into member values (1, 'kim')").then()
.then(client.sql("insert into member values (1, 'dup')").then());
// ... 아래 세 호출은 try/catch 와 출력을 줄여 적었습니다
txOp.transactional(work).block(); // 1. 트랜잭션 안 → 첫 INSERT 도 롤백
work.block(); // 2. 트랜잭션 없이 → 첫 INSERT 는 남는다
Mono<String> probe = Mono.deferContextual(ctx ->
Mono.just("Reactor Context has TransactionContext? " + ctx.hasKey(TransactionContext.class)))
.doOnNext(System.out::println)
.then(TransactionSynchronizationManager.forCurrentTransaction() // transaction.reactive 쪽
.map(sm -> "reactive TSM: actualTransactionActive=" + sm.isActualTransactionActive()
+ ", ConnectionHolder bound=" + sm.hasResource(cf)))
.doOnNext(System.out::println)
.then(Mono.fromCallable(() -> "ThreadLocal TSM (transaction.support): actualTransactionActive="
+ org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()
+ " on thread " + Thread.currentThread().getName()))
.doOnNext(System.out::println)
.then(Mono.zip(ConnectionFactoryUtils.getConnection(cf), ConnectionFactoryUtils.getConnection(cf))
.map(t -> "same Connection twice inside the transaction? " + (t.getT1() == t.getT2())))
.doOnNext(System.out::println);
txOp.transactional(probe).block(); // 3. 트랜잭션은 어디에 있나
--- 1. inside a transaction: the first insert is rolled back too
[main] WARN io.r2dbc.h2.H2Connection - Option{name='readOnly', sensitive=false} + isn't supported in H2 at the transaction level. You must set it on conenction URL. See http://www.h2database.com/html/features.html#read_only
error : DuplicateKeyException
rows : 0
--- 2. without a transaction: the first insert stays
error : DuplicateKeyException
rows : 1
--- 3. where does the transaction live?
[main] WARN io.r2dbc.h2.H2Connection - Option{name='readOnly', sensitive=false} + isn't supported in H2 at the transaction level. You must set it on conenction URL. See http://www.h2database.com/html/features.html#read_only
Reactor Context has TransactionContext? true
reactive TSM: actualTransactionActive=true, ConnectionHolder bound=true
ThreadLocal TSM (transaction.support): actualTransactionActive=false on thread main
same Connection twice inside the transaction? true
--- 4. outside: no TransactionContext in the Reactor Context
NoTransactionInContextException : No transaction in context
WARN 두 줄은 H2 드라이버가 찍는 것으로 이 글과 관계없습니다. 1번과 2번을 비교하면 TransactionalOperator로 감싼 쪽만 첫 INSERT가 되돌려졌습니다. 3번이 이 절의 요지입니다 — 같은 main 스레드에서 돌았는데도 Reactor Context에는 트랜잭션이 있고 ThreadLocal에는 없으며, ConnectionFactoryUtils.getConnection을 두 번 불러도 같은 커넥션이 나옵니다. 4번처럼 트랜잭션 밖에서 currentContext()를 부르면 "No transaction in context"입니다.
더 깊이 들어가면
이 글에서 뺀 것의 이름과 위치만 적어 둡니다. 트랜잭션보다 오래 사는 EXTENDED 영속성 컨텍스트와 그것을 만드는 ExtendedEntityManagerCreator(PersistenceAnnotationBeanPostProcessor 810–822행). 공장을 백그라운드 스레드에서 만드는 bootstrapExecutor(411–417행). JPA 없이 Hibernate API를 직접 쓰는 orm.jpa.hibernate 패키지의 LocalSessionFactoryBean과 HibernateTransactionManager. JPA 4.0의 EntityAgent를 공유 프록시로 내주는 SharedEntityAgentInvocationHandler(설명 주석에 @since 7.0.4, SharedEntityManagerCreator 425행 부근). 리액티브 쪽에서는 Kotlin 코루틴(suspend 함수)을 Mono/Flux로 다루는 분기(TransactionAspectSupport 342–349행), R2DBC 세이브포인트(R2dbcTransactionManager 188–191행), DatabaseClient의 이름 붙은 파라미터(:name)를 드라이버별 ?나 $1로 바꾸는 NamedParameterExpander(r2dbc.core 패키지)와 BindMarkersFactory(r2dbc.core.binding 패키지), 그리고 리액티브 트랜잭션 이벤트를 위한 TransactionalEventPublisher가 있습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
LocalContainerEntityManagerFactoryBean |
FactoryBean. afterPropertiesSet에서 영속성 유닛을 정하고 제공자로 공장을 만든 뒤 프록시로 감싸 내놓는다 |
packagesToScan |
persistence.xml 없이 DefaultPersistenceUnitManager가 기본 유닛을 만들고 @Entity를 훑는다 |
JpaVendorAdapter |
제공자·속성·JpaDialect 세 가지로 벤더 차이를 가둔다 |
EntityManagerFactory 빈 |
JDK 프록시. EntityManagerFactoryInfo로 DataSource·다이얼렉트·네이티브 공장을 알려 준다 |
@PersistenceContext |
PersistenceAnnotationBeanPostProcessor가 공유 프록시를 넣는다. @Autowired EntityManager도 7.0부터 같은 프록시를 받는다 |
공유 EntityManager 프록시 |
호출마다 스레드 보관함에서 현재 트랜잭션의 EntityManager를 찾는다. 없으면 쓰기는 예외, 읽기는 임시 객체 |
JpaTransactionManager.doBegin |
EntityManager를 열고 JPA 트랜잭션을 시작한 뒤, 공장 열쇠와 DataSource 열쇠로 두 홀더를 묶는다 |
JPA와 JdbcTemplate |
DataSource 열쇠로 Hibernate의 커넥션을 받으므로 한 트랜잭션에서 커밋·롤백이 함께 된다 |
@Repository |
표시일 뿐. PersistenceExceptionTranslationPostProcessor가 프록시를 씌우고 인터셉터가 PersistenceExceptionTranslator 빈들로 번역한다 |
ReactiveTransactionManager |
PlatformTransactionManager의 짝. 같은 전파 규칙, 결과는 Mono |
transaction.reactive.TransactionSynchronizationManager |
이름만 같은 다른 클래스. ThreadLocal 대신 Reactor Context 안의 TransactionContext를 본다 |
TransactionalOperator / 리액티브 @Transactional |
usingWhen으로 열고·실행하고·닫고, 끝에 contextWrite로 TransactionContext를 싣는다 |
ConnectionFactoryUtils.getConnection |
DataSourceUtils.getConnection의 짝. Context에 묶인 커넥션을 돌려주고, 트랜잭션이 없으면 새로 연다 |
다음에 볼 것
이 글로 1편 지도의 Phase 4(트랜잭션과 데이터 접근, U15–U17)가 끝났습니다. 17편의 트랜잭션 추상화, 18편의 JdbcTemplate, 그리고 이 글의 JPA·R2DBC 연동까지 — 모두 "자원을 열쇠로 보관함에 묶고, 쓰는 쪽은 열쇠로 찾는다"는 한 가지 모양이었습니다. 보관함이 스레드냐 Context냐만 달랐습니다.
다음은 Phase 5, 웹입니다. 웹 요청 하나가 들어와 응답이 되기까지를 따라가기 전에, 먼저 spring-web 모듈의 바닥을 봅니다. 요청 본문을 객체로 바꾸는 HttpMessageConverter, 서블릿 컨테이너 안에서 Spring 컨테이너를 켜는 WebApplicationContext, 그리고 요청 앞뒤에 끼어드는 필터들입니다.
- 다음 글: Spring 소스 파헤치기 20: spring-web 기반 — HttpMessageConverter, WebApplicationContext, 필터
- 앞 글: 18편: JdbcTemplate — 템플릿-콜백 패턴과 예외 변환, 17편: @Transactional — 트랜잭션은 누가 열고 누가 닫나
- 이 글이 기댄 글: 3편: BeanFactory 계층 구조와 BeanDefinition (
FactoryBean), 6편: 생명주기 콜백과 PostProcessor, 10편: AOP 프록시는 어떻게 만들어지나 - 1편: AI와 함께 소스를 읽는 방법과 분석 지도