namalzi

← 글 목록

Spring 소스 파헤치기 19: ORM과 R2DBC 연동 — JPA 통합과 리액티브 트랜잭션

Spring · 게시

JPA의 EntityManagerFactory가 어떻게 빈이 되고, @PersistenceContext로 받은 EntityManager가 왜 여러 스레드가 함께 써도 안전한지, JpaTransactionManager 아래서 JdbcTemplate이 어떻게 같은 커넥션을 쓰는지, @Repository의 예외가 어디서 바뀌는지를 소스로 확인합니다. 이어서 스레드가 없는 리액티브 세계에서 R2DBC 트랜잭션이 Reactor Context에 실리는 방식을 봅니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 열아홉 번째 글입니다. 17편에서 트랜잭션을 누가 열고 닫는지(PlatformTransactionManager, @Transactional 프록시, 스레드에 자원을 묶어 두는 TransactionSynchronizationManager)를 봤고, 18편에서 JdbcTemplate이 그 묶인 커넥션을 DataSourceUtils로 찾아 쓰는 것을 봤습니다. 이번 글은 그 틀 위에 두 가지가 어떻게 얹히는지를 봅니다. 하나는 JPA(Hibernate 같은 ORM), 다른 하나는 스레드 자체가 없는 리액티브 데이터 접근(R2DBC) 입니다. 이 글로 1편 지도의 Phase 4(트랜잭션과 데이터 접근)가 끝납니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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절의 예외 변환에 쓰입니다.

LocalContainerEntityManagerFactoryBean 의 afterPropertiesSet 에서 DefaultPersistenceUnitManager 가 persistence.xml 을 읽거나 packagesToScan 으로 엔티티를 찾아 PersistenceUnitInfo 를 만들고, AbstractEntityManagerFactoryBean.afterPropertiesSet 이 JpaVendorAdapter 에서 제공자·속성·다이얼렉트를 받아 createNativeEntityManagerFactory 로 Hibernate 의 EntityManagerFactory 를 만든 뒤 createEntityManagerFactoryProxy 로 감싸고, getObject 가 그 프록시를 빈으로 내놓는 흐름 공장이 만들어지는 순서. 파란 상자는 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;
	}

spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManager.java 522–551행

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행

그다음 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 호출을 가로채는 용도이기도 합니다.

헷갈리기 쉬운 곳

2. @PersistenceContext EntityManager는 왜 스레드 안전한가

JPA 규칙에서 EntityManager는 스레드 안전하지 않습니다. 그런데 Spring에서는 싱글톤 빈의 필드에 EntityManager를 하나 넣어 두고 여러 요청 스레드가 같이 씁니다. 답은 그 필드에 들어가는 것이 진짜 EntityManager가 아니라 공유 프록시라는 데 있습니다.

누가 넣나 — PersistenceAnnotationBeanPostProcessor

@PersistenceContext를 처리하는 것은 6편에서 본 BeanPostProcessor 가운데 하나입니다.

public class PersistenceAnnotationBeanPostProcessor implements InstantiationAwareBeanPostProcessor,
		DestructionAwareBeanPostProcessor, MergedBeanDefinitionPostProcessor, BeanRegistrationAotProcessor,
		PriorityOrdered, BeanFactoryAware, Serializable {

spring-orm/src/main/java/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.java 189–191행

따로 등록하지 않아도 됩니다. 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;
		}

spring-orm/src/main/java/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.java 783–808행

주석이 답을 말해 줍니다. "공유 트랜잭션 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행

@PersistenceContext 필드에 공유 프록시가 들어가고, 호출마다 invoke 가 EntityManagerFactoryUtils.doGetTransactionalEntityManager 로 스레드에 묶인 EntityManagerHolder 를 찾는다. 있으면 그 트랜잭션의 EntityManager 로 실행하고, 없으면 persist 같은 트랜잭션 필수 메서드는 TransactionRequiredException 을 던지며, 그 밖의 메서드는 이 호출만을 위한 EntityManager 를 만들어 쓰고 닫는다. 프록시가 호출을 받을 때마다 하는 일. 노란 상자가 17편의 스레드 보관함을 들여다보는 곳입니다.

순서대로 읽으면 이렇습니다.

  1. close()는 무시하고 getTransaction()은 막습니다. 공유 객체를 한 사용자가 닫거나 직접 트랜잭션을 열면 다른 사용자가 망가지기 때문입니다. 트랜잭션은 Spring이 연다는 뜻입니다.
  2. doGetTransactionalEntityManager로 "지금 스레드의 트랜잭션에 묶인 EntityManager"를 찾습니다. 이 메서드는 TransactionSynchronizationManager.getResource(emf), 곧 17편의 스레드 보관함을 공장을 열쇠로 들여다봅니다(EntityManagerFactoryUtils 207행). EntityManagerHolder가 묶여 있으면 그 안의 EntityManager를 돌려주고(231행), 트랜잭션 동기화조차 켜져 있지 않으면 null을 돌려줍니다(253–256행).
  3. persist·merge·remove·flush 같은 메서드는 트랜잭션이 없으면 TransactionRequiredException입니다. 목록은 76–82행에 있습니다. 주석의 이유가 좋습니다 — 받아 주면 조용히 저장되지 않은 채 남기 때문입니다.
  4. 그 밖의 메서드(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행

JpaTransactionManager.doBegin 이 트랜잭션용 EntityManager 를 새로 열고, JpaDialect.beginTransaction 으로 JPA 트랜잭션을 시작한 뒤, JpaDialect.getJdbcConnection 으로 Hibernate 세션이 쥔 JDBC 커넥션을 꺼내 dataSource 열쇠로 ConnectionHolder 를, entityManagerFactory 열쇠로 EntityManagerHolder 를 스레드에 묶는다. JdbcTemplate 은 dataSource 열쇠로, 공유 EntityManager 프록시는 entityManagerFactory 열쇠로 찾아 같은 JDBC 커넥션을 쓴다. 트랜잭션 하나가 스레드에 묶어 두는 것은 둘입니다. 노란 상자 둘이 그 보관함의 항목이고, 아래 회색 둘이 각자의 열쇠로 찾아오는 쪽입니다.

  1. 이 트랜잭션용 EntityManager를 새로 엽니다(400행, 470–484행). 2절의 공유 프록시가 찾게 될 진짜 객체가 이것입니다.
  2. JpaDialect.beginTransaction으로 JPA 트랜잭션을 시작합니다(411행). Hibernate용 다이얼렉트는 격리 수준이나 읽기 전용이 지정됐으면 그것을 커넥션에 적용한 뒤 표준 호출 entityManager.getTransaction().begin()을 부릅니다(HibernateJpaDialect 147행).
  3. 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행

  1. 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로 넣은 행이 한 번의 롤백으로 함께 사라집니다.

헷갈리기 쉬운 곳

4. 예외 변환 — @Repository는 무엇을 하나

18편에서 JdbcTemplate이 SQLException을 Spring의 DataAccessException 계층으로 바꾸는 것을 봤습니다. JPA 코드는 JdbcTemplate을 거치지 않으니 다른 장치가 필요한데, 그것이 @Repository입니다. @Repository는 @Component에 뜻을 하나 더한 어노테이션일 뿐이고(Repository 57–61행), 실제 일은 PersistenceExceptionTranslationPostProcessor라는 후처리기를 빈으로 등록했을 때 일어납니다.

@Repository 빈을 PersistenceExceptionTranslationPostProcessor 가 postProcessAfterInitialization 에서 프록시로 감싼다. 어드바이저의 포인트컷은 클래스에 @Repository 가 있는지 보고, 인터셉터는 RuntimeException 을 잡아 메서드의 throws 절에 적힌 예외면 그대로 던지고 아니면 컨테이너의 모든 PersistenceExceptionTranslator 를 묶은 체인에 번역을 맡긴다. LocalContainerEntityManagerFactoryBean 이 그 번역기 가운데 하나이고 JpaDialect 를 거쳐 HibernateExceptionTranslator 가 DataAccessException 으로 바꾼다. @Repository 빈의 메서드가 던진 예외가 지나는 길. 빨간 상자는 번역하지 않고 그대로 보내는 경우입니다.

  1. 후처리기가 프록시로 감쌉니다. 이 후처리기는 11편의 자동 프록시 생성기와는 다른, 어드바이저 하나만 가진 작은 계열(AbstractAdvisingBeanPostProcessor)입니다. 초기화 후 훅에서 어드바이저가 적용되는 클래스면 ProxyFactory로 프록시를 만들어 돌려줍니다(88–133행, 적용 여부는 AopUtils.canApply, 170행). 어드바이저는 setBeanFactory에서 만들어집니다(PersistenceExceptionTranslationPostProcessor 82–90행).
  2. 어드바이저 = 포인트컷 + 인터셉터. 포인트컷은 "클래스에 @Repository가 있나"를 보는 AnnotationMatchingPointcut이고, 어드바이스는 PersistenceExceptionTranslationInterceptor입니다(PersistenceExceptionTranslationAdvisor 68–73행). 어노테이션 타입은 바꿀 수 있어서 Spring 것이 아닌 어노테이션으로도 같은 효과를 낼 수 있습니다(76–79행).
  3. 인터셉터가 예외를 잡아 번역합니다.
	@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);
			}
		}
	}

spring-tx/src/main/java/org/springframework/dao/support/PersistenceExceptionTranslationInterceptor.java 132–160행

번역기는 따로 지정하지 않으면 컨테이너에 있는 PersistenceExceptionTranslator 빈을 전부 모아 하나로 묶은 것입니다(169–173행). 1절의 선언을 떠올리면 AbstractEntityManagerFactoryBean이 바로 그 인터페이스를 구현하고 있습니다(101행). 그러니 JPA 설정을 한 것만으로 번역기가 하나 생긴 셈이고, 그 번역기는 JpaDialect에게 일을 넘깁니다(579–582행). 인터페이스 자체는 메서드 하나짜리입니다 — "번역할 수 있으면 DataAccessException을, 모르는 예외면 null을 돌려주라"(PersistenceExceptionTranslator 35–56행). null이면 원래 예외가 그대로 나갑니다(DataAccessUtils 342–348행).

  1. Hibernate 다이얼렉트는 두 단계로 번역합니다. HibernateException이면 Hibernate 고유 예외 표(제약 위반 → DataIntegrityViolationException 등, HibernateExceptionTranslator 167–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가 찍은 로그입니다.

헷갈리기 쉬운 곳

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");

spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java 75–78행

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);
	}

spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronizationManager.java 70–120행

블로킹 쪽은 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());
		});
	}

spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionContextManager.java 52–65행

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());
	}

spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionalOperatorImpl.java 74–86행

TransactionalOperatorImpl.execute 가 contextWrite 로 Reactor Context 에 TransactionContext 를 넣고, R2dbcTransactionManager.getReactiveTransaction 이 forCurrentTransaction 과 TransactionContextManager.currentContext 로 그 Context 에서 TransactionContext 를 꺼내 doBegin 에서 ConnectionHolder 를 묶는다. DatabaseClient 는 ConnectionFactoryUtils.doGetConnection 으로 같은 Context 에서 ConnectionHolder 를 찾는다. ThreadLocal 쪽 TransactionSynchronizationManager 는 비어 있다. 리액티브 트랜잭션에서 "현재 트랜잭션"이 사는 곳. 노란 상자가 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);

spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java 339–360행

고른 트랜잭션 매니저가 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());
						}

spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/R2dbcTransactionManager.java 218–221행

주석은 "스레드에 묶는다"고 적혀 있지만 실제로는 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

ConnectionFactoryUtils.doGetConnection(cf)(114–152행)은 TransactionSynchronizationManager.forCurrentTransaction() 으로 Reactor Context 에서 TransactionContext 를 찾는다. Context 에 트랜잭션이 없으면 NoTransactionException 이 나고 connectionFactory.create() 로 그때그때 새 커넥션을 연다(트랜잭션과 무관). 있으면 보관함에서 cf 열쇠로 ConnectionHolder 를 찾는다. holder 가 있고 커넥션도 있으면 그 커넥션을 돌려준다 — 예제 3번에서 두 번 불러도 같은 커넥션이 true 로 나온 길이다. holder 가 트랜잭션에 묶였지만 커넥션이 비었으면 fetchConnection 으로 열어 holder 에 채운다. holder 가 없으면(또는 빈 holder 면) fetchConnection 으로 새로 열고, 동기화가 켜져 있으면 새 ConnectionHolder 를 bindResource(cf, holder) 로 묶어(빈 holder 가 있었으면 거기에 채워) 다음 호출부터 같은 커넥션 길을 타게 한다 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, 그리고 요청 앞뒤에 끼어드는 필터들입니다.

다른 글