JPA 트랜잭션 R2DBC 트랜잭션 비교

JPA 트랜잭션 R2DBC 트랜잭션 비교해보기 #


최근 프로젝트를 구현하면서 R2DBC의 트랜잭션 처리 방식이 궁금해졌다. 특히 “논블로킹 환경에서 Spring은 트랜잭션 상태를 어디에 저장하고, 어떻게 같은 트랜잭션으로 묶을까?”라는 질문이 생겼다.

공식 문서 기준으로 보면, imperative transaction은 현재 실행 thread에 트랜잭션 상태와 리소스를 바인딩하고, reactive transaction은 Reactor Context, 정확히는 subscriber context에 트랜잭션 상태와 리소스를 바인딩한다. 즉 두 방식의 핵심 차이는 단순히 블로킹/논블로킹이 아니라, 트랜잭션 상태를 어떤 실행 맥락에 연결하느냐에 있다.

왜 글을 쓰게 되었나? #

처음에는 JPA는 blocking 방식이라 호출한 스레드 안에서 트랜잭션을 유지하고, R2DBC는 non-blocking 방식이라 각 쿼리가 비동기적으로 실행되니 데이터베이스가 그 쿼리들이 같은 트랜잭션에 속한다는 사실을 어떻게 아는지 궁금했다.

공식 문서를 보고 정리해보니, 이 질문의 핵심은 “R2DBC가 즉시 쿼리를 실행하느냐”가 아니라, 같은 reactive pipeline 안에서 동일한 transaction connection을 계속 사용하느냐에 있었다. Spring의 reactive transaction은 subscription 시점에 시작되고, 해당 트랜잭션에 참여하는 publisher들은 하나의 context-bound connection을 공유한다.

전통적인 스프링에서의 트랜잭션 처리 with JPA #

@Transactional은 기본적으로 Spring의 proxy mode에서 동작한다. 이때 Spring AOP는 대상이 인터페이스를 구현하면 JDK dynamic proxy를, 인터페이스가 없으면 CGLIB proxy를 사용한다. 또한 proxy mode에서는 프록시를 통해 들어오는 외부 호출만 트랜잭션 대상으로 가로채며, 같은 클래스 내부에서 다른 메서드를 호출하는 self-invocation은 트랜잭션 적용 대상이 아니다.

Imperative transaction은 PlatformTransactionManager를 기반으로 현재 실행 thread에 transaction state를 바인딩한다. JPA를 사용할 때는 JpaTransactionManager가 EntityManager를 thread에 바인딩하고, 같은 호출 흐름 안의 JPA 작업은 이 thread-bound EntityManager를 통해 같은 트랜잭션에 참여한다.

여기서 중요한 점은 JPA를 “메서드가 끝난 뒤에만 쿼리를 보내는 방식”으로 이해하면 안 된다는 것이다. 조회 쿼리는 실행 시점에 즉시 나갈 수 있고, 엔티티 변경은 영속성 컨텍스트에 반영된 뒤 flush 시점에 SQL로 동기화된다. Hibernate의 기본 flush mode인 AUTO에서는 commit 직전뿐 아니라 query execution 전에 자동 flush가 일어날 수도 있다. 따라서 JPA의 핵심은 “모든 쿼리를 마지막에 몰아서 실행한다”가 아니라, 영속성 컨텍스트와 flush 규칙으로 DB 반영 시점을 제어한다는 데 있다.

즉 JPA 트랜잭션 흐름은 다음처럼 이해하는 편이 더 정확하다. Spring이 프록시를 통해 트랜잭션 경계를 만들고, thread-bound transaction과 EntityManager를 준비한다. 비즈니스 로직이 실행되는 동안 조회 쿼리는 필요 시 즉시 실행될 수 있고, 변경 사항은 flush 시점에 SQL로 반영된다. 그리고 정상 종료 시 commit이 진행되며, 기본 규칙상 RuntimeException이나 Error에는 rollback이 적용된다.

트랜잭션 처리 과정:

  1. 클라이언트가 @Transactional 메서드를 프록시를 통해 호출한다.
  2. TransactionInterceptor가 호출을 가로채고 invokeWithinTransaction(…)으로 위임한다.
  3. 트랜잭션 속성(propagation, isolation, timeout, readOnly 등)을 해석하고, transaction manager가 현재 thread에 transaction resource/synchronization을 바인딩한다.
  4. JPA에서는 JpaTransactionManager가 트랜잭션용 EntityManager를 생성해 thread에 바인딩한다.
  5. JDBC Connection은 provider와 설정에 따라 즉시 또는 첫 SQL이 필요한 시점에 확보된다.
  6. 비즈니스 로직이 실행된다. 조회는 필요 시 즉시 SQL이 나갈 수 있고, 변경은 영속성 컨텍스트에 반영된다.
  7. flush는 commit 직전에 일어날 수 있고, 기본 AUTO에서는 일부 query 실행 전에도 일어날 수 있다.
  8. 메서드가 정상 종료되면 Spring이 commit을 시도한다.
  9. commit 또는 rollback 후 transaction resource와 EntityManager가 정리된다. 10.결과가 반환되거나 예외가 전파된다.

트랜잭션 롤백 처리 과정:

  1. transactional method 실행 중 예외가 발생하거나, 내부적으로 rollback-only가 설정된다.
  2. 기본 규칙상 처리되지 않은 RuntimeException/Error는 rollback 대상이다. checked exception은 기본값으로는 rollback 대상이 아니다.
  3. 예외가 flush 시점에 터질 수도 있고, commit 시점에 터질 수도 있으며, 애플리케이션 코드에서 바로 터질 수도 있다.
  4. Spring이 rollback을 수행한다.
  5. 이후 transaction resource와 EntityManager를 정리한다.
  6. 예외가 호출자에게 전달된다.

내가 생각한 JPA 트랜잭션 동작과정 #

fc4d8e68-c875-4734-8f43-32d94235c57c

리액티브 스프링에서의 트랜잭션 처리 with R2DBC #

Reactive transaction도 @Transactional과 프록시를 사용한다는 점은 같다. 다만 imperative transaction이 현재 실행 스레드에 트랜잭션 상태를 바인딩하는 것과 달리, reactive transaction은 Reactor Context에 트랜잭션 상태와 리소스를 바인딩한다. Spring의 TransactionInterceptor는 imperative와 reactive를 모두 지원하며, 메서드의 반환 타입을 보고 어떤 방식의 트랜잭션 관리를 적용할지 결정한다. 메서드가 Publisher 또는 Kotlin Flow를 반환하면 reactive transaction management가 적용되고, 그 외 반환 타입은 imperative transaction management 경로를 따른다. 따라서 reactive @TransactionalMonoFlux 같은 reactive pipeline 위에서 동작한다고 이해하는 것이 맞다.

R2dbcTransactionManagerConnectionFactory에서 얻은 Connection을 현재 subscriber context에 바인딩한다. Spring 공식 javadoc은 이를 “one context-bound Connection per ConnectionFactory”라고 설명하며, 애플리케이션 코드는 보통 ConnectionFactoryUtils를 통해 이 연결을 조회한다. Spring의 DatabaseClient도 같은 전략을 사용하므로, 같은 트랜잭션에 참여하는 R2DBC 작업들은 하나의 context-bound connection 위에서 수행된다.

이 지점이 “데이터베이스는 각 쿼리가 어떤 트랜잭션에 속하는지 어떻게 아는가?”에 대한 핵심 답이다. 데이터베이스가 Spring의 별도 트랜잭션 ID를 아는 것은 아니다. 대신 beginTransaction()으로 열린 동일한 connection 위에서 실행되는 statement들을 같은 트랜잭션으로 본다. R2DBC SPI도 SQL statement가 connection의 문맥 안에서 실행된다고 설명하고 있으며, beginTransaction()은 새 트랜잭션을 시작하면서 auto-commit을 비활성화한다.

또 “R2DBC는 논블로킹이므로 여러 쿼리를 즉시 한꺼번에 날린다”는 표현은 조금 다듬는 편이 좋다. Reactive transaction은 reactive pipeline 안에서 동작하고, beginTransaction()Statement.execute() 모두 Publisher 기반 API다. 즉 non-blocking은 “모든 쿼리를 즉시 병렬로 던진다”기보다, 요청과 완료를 비동기 신호로 처리하면서 같은 트랜잭션 connection을 이어 간다는 의미에 가깝다.

원문에서 적어둔 “같은 트랜잭션 안에서는 사실상 직렬화가 필요한 부분이 있다”는 직관은 꽤 타당하다. R2DBC Connection 문서도 최대의 이식성(maximum portability)을 위해 connection은 동기적으로 사용되어야 한다고 설명한다. 따라서 하나의 트랜잭션 connection 위에서 여러 DB 작업을 병렬로 섞는 설계는 주의가 필요하며, 보통은 하나의 reactive chain 안에서 순서 있게 흘리는 쪽이 더 안전하다.

트랜잭션 처리 과정:

  1. 클라이언트가 @Transactional이 붙은 메서드를 프록시를 통해 호출한다. Spring의 TransactionInterceptor가 호출을 가로채고 invokeWithinTransaction(...)으로 트랜잭션 처리를 위임한다. 반환 타입이 PublisherFlow이면 reactive transaction management 경로가 선택된다.
  2. 인터셉터는 트랜잭션 속성(propagation, isolation, timeout, readOnly 등)을 해석하고, reactive transaction에서는 이 상태와 관련 리소스를 Reactor Context에 바인딩한다. 이 때문에 같은 트랜잭션에 참여하는 데이터 접근 코드는 반드시 같은 Reactor Context, 같은 reactive pipeline 안에서 실행되어야 한다.
  3. R2dbcTransactionManagerConnectionFactory에서 독립적인 Connection을 얻어 현재 subscriber context에 바인딩한다. 이후 ConnectionFactoryUtilsDatabaseClient를 사용하는 코드는 이 context-bound connection을 재사용하게 된다.
  4. 실제 트랜잭션 시작은 Connection.beginTransaction()을 통해 이루어진다. 이 API는 Publisher<Void>를 반환하며, 새 트랜잭션을 시작하면서 auto-commit을 끈다. 쿼리 실행 역시 Statement.execute()Publisher<? extends Result>를 반환하는 방식으로 이루어진다. 즉 트랜잭션 시작과 쿼리 실행 모두 reactive stream 위에서 진행된다.
  5. 비즈니스 로직이 실행되는 동안 같은 reactive pipeline 안의 DB 작업들은 같은 connection을 따라간다. 스레드가 바뀌더라도 트랜잭션 소속이 유지되는 이유는 스레드 자체가 아니라 Reactor Context에 바인딩된 connection을 기준으로 작업이 이어지기 때문이다.
  6. reactive pipeline이 정상적으로 완료되면 트랜잭션 매니저가 commit을 수행하고, 오류가 전파되면 rollback을 수행한다. Spring의 ReactiveTransactionManager API도 commit(ReactiveTransaction)rollback(ReactiveTransaction)을 제공하며, 공식 예제 역시 비즈니스 로직 뒤에 commit을 연결하고 오류 시 rollback으로 이어지는 형태를 보여준다. 이후 연결과 관련 리소스가 정리된다.

리액티브는 논블럭킹이기에 호출한 스레드로컬 대신 리액티브 컨텍스트를 사용하여 트랜잭션의 정보를 저장한다. 스레드 전환이 일어나도 트랜잭션 시작 시, 트랜잭션 ID나 Connection 정보가 Reactor Context에 저장되어 해당 쿼리가 어떤 트랜잭션에 속하는지 식별할수있다.
그럼 데이터베이스에서는 해당 쿼리가 어떤 트랜잭션에 속하는지 어떻게 알 수 있단말인가? 즉시 호출을 하는 리액티브에서 커넥션을 여러개 얻는게 아닌 커넥션을 재사용해야한다.

시나리오로 이해하기 #

같은 트랜잭션 안에서 스레드가 바뀌더라도, 핵심은 같은 connection을 계속 사용하느냐다. 따라서 아래처럼 이해하면 된다. 트랜잭션이 시작되면 하나의 connection이 현재 subscriber context에 바인딩된다. 이후 쿼리 1이 실행되고, 이후 연산이 다른 스레드에서 이어지더라도 쿼리 2와 쿼리 3은 여전히 같은 connection을 사용한다. 마지막으로 pipeline이 정상 종료되면 commit, 오류가 발생하면 rollback이 수행되고 connection이 반환된다. 즉 reactive transaction에서 중요한 것은 “같은 스레드”가 아니라 같은 Reactor Context와 같은 connection이다.

짧게 정리하면, imperative transaction은 thread-bound, reactive transaction은 context-bound이며, R2DBC에서는 그 context에 묶인 동일 connection이 트랜잭션의 실체가 된다. 그래서 reactive라고 해서 트랜잭션의 일관성이 사라지는 것이 아니라, 단지 그 일관성을 유지하는 기준이 ThreadLocal에서 Reactor Context로 바뀐 것에 가깝다.

내가 생각한 리액티브 트랜잭션 동작과정 #

eae2d8f6-06c4-4e86-a6c5-761e2030cf74

JPA 트랜잭션과 R2DBC 트랜잭션 차이점 #

정리하면, JPA/imperative transaction의 핵심은 thread-bound transaction과 thread-bound EntityManager이고, R2DBC/reactive transaction의 핵심은 subscriber context-bound transaction과 context-bound connection이다. 따라서 두 방식의 가장 큰 차이는 “블로킹 vs 논블로킹” 그 자체보다도, 트랜잭션 상태와 리소스를 어떤 실행 맥락에 묶느냐에 있다.

쿼리 실행 관점에서도 차이가 있다. JPA는 영속성 컨텍스트와 flush 규칙이 개입하므로 조회 쿼리는 즉시 실행될 수 있고 변경 SQL은 flush 시점에 반영된다. 반면 R2DBC는 Statement.execute()가 Publisher를 반환하는 reactive API이므로, query execution과 결과 소비가 reactive stream 위에서 이루어진다. 따라서 “JPA는 나중에 몰아서 실행, R2DBC는 즉시 실행”처럼 단순 비교하기보다, JPA는 persistence context + flush model, R2DBC는 connection-bound reactive execution model이라고 비교하는 편이 더 정확하다.

트랜잭션 전파에 대해서도 원문은 수정이 필요하다. Spring의 propagation semantics는 transaction abstraction 차원의 개념이고, R2dbcTransactionManager도 공식 문서에서 specified propagation behavior에 따라 connection을 연관짓는다고 설명한다. 또한 현재 Spring Framework의 R2dbcTransactionManager는 6.0.10부터 savepoint 기반 nested transaction을 지원한다. 다만 savepoint 자체는 드라이버/데이터베이스가 지원해야 하며, R2DBC Connection API 역시 savepoint 미지원 시 UnsupportedOperationException이 가능하다고 명시한다. 따라서 “R2DBC는 NESTED, REQUIRES_NEW를 제공하지 않는다”라고 단정하기보다는, Spring 추상화 차원에서는 propagation semantics가 적용되지만, 실제 동작은 별도 connection 확보 가능 여부와 driver/database의 savepoint 지원에 영향을 받는다고 쓰는 편이 맞다.

마무리 #

처음에는 “R2DBC는 논블로킹이니 DB가 트랜잭션 소속을 어떻게 구분하지?”라는 의문에서 출발했지만, 공식 문서를 기준으로 다시 정리해보면 핵심은 의외로 단순하다. imperative transaction은 thread에, reactive transaction은 subscriber context에 상태와 자원을 바인딩하고, R2DBC에서는 그 자원으로 같은 transaction connection을 계속 재사용한다는 점이다. 결국 JPA와 R2DBC의 차이는 단순한 성능 비교보다도, 트랜잭션 상태를 어떤 실행 모델에 맞춰 전달하고 유지하느냐의 차이라고 볼 수 있다.