JPA 와 mybatis 를 같은 Transaction 에서 사용하기
by Jeongjin Kim
수정일: 2026-10-11. 2019년의 Spring Boot 2.1·JUnit 4 예제를 Boot 3·Jakarta Persistence·JUnit 5 기준으로 다시 정리했다. 검증 범위는 Spring/MyBatis 공식 문서의 트랜잭션 참여 조건·버전 호환표와, 본문 코드를 그대로 추출한 H2 통합 테스트 2개다. JDK 21.0.2·Maven 3.10.0에서 두 테스트가 통과했다. Java 17은 컴파일 기준이며 Java 17 런타임, Boot 4, 운영 DB의 격리 수준·잠금·배치 동작은 실행 검증하지 않았다. 검증용 JDK 버전은 운영 배포 추천 버전이라는 뜻이 아니다.
JPA로 저장한 데이터를 같은 서비스 메서드에서 MyBatis로 조회하거나, 복잡한 SQL만 MyBatis로 실행하고 싶을 때가 있다. 단일 데이터베이스라면 두 기술이 하나의 JDBC 트랜잭션에 참여하게 할 수 있다. 다만 @Transactional만 붙이면 어떤 구성에서도 된다는 뜻은 아니다.
핵심은 세 가지다.
- JPA와 MyBatis가 같은 DataSource를 사용한다.
- 바깥 트랜잭션은 같은 JpaTransactionManager로 시작하고, MyBatis는 Spring 관리 세션으로 참여한다.
- 트랜잭션 공유와 별개로 SQL 실행 시점(flush) 및 양쪽 캐시의 일관성을 관리한다.
같은 트랜잭션이 되는 조건
Spring의 JpaTransactionManager 문서는 하나의 EntityManagerFactory를 관리하면서 같은 DataSource의 JDBC 접근도 지원한다고 설명한다. JDBC 커넥션을 노출할 수 있는 공급자별 JpaDialect가 필요하며, 아래 예제는 Boot가 구성하는 Hibernate 통합을 사용한다.
MyBatis-Spring은 Spring이 관리하는 JDBC 커넥션을 사용해 여기에 참여한다. MyBatis-Spring 트랜잭션 문서의 기본 예시는 DataSourceTransactionManager이지만, JPA와 혼용하는 이 예제에서는 JpaTransactionManager 하나가 경계를 관리한다. MyBatis 전용 트랜잭션 매니저를 하나 더 만드는 방식이 아니다.
구체적으로 다음 조건을 확인한다.
EntityManagerFactory,JpaTransactionManager,SqlSessionFactory가 같은 DataSource 빈을 참조한다. JDBC URL만 같고 커넥션 풀이 서로 다르면 충분하지 않다. 프록시를 쓰는 환경에서는 같은 대상 DataSource를 가리키는지도 확인한다.- MyBatis의
SqlSessionFactoryBean은 기본값인 SpringManagedTransactionFactory를 사용한다. 이름이 비슷한 MyBatis의ManagedTransactionFactory는 같은 설정이 아니다. - Mapper는 Spring이 주입한 SqlSessionTemplate에 연결되어야 한다. 직접
openSession()으로 만든 세션이나 별도 커넥션을 섞지 않는다. 관리 세션에서commit(),rollback(),close()를 직접 호출하지 않는다. - JPA는
@PersistenceContext로 주입받은 EntityManager를 사용한다.createEntityManager()로 별도 컨텍스트와 트랜잭션을 만들지 않는다. - 같은 스레드의 같은 트랜잭션 경계 안에서 호출한다.
REQUIRES_NEW는 독립된 트랜잭션이고,@Async나 새 스레드로 기존 트랜잭션이 자동 전파되지 않는다.
이 글의 범위는 단일 DataSource의 로컬 트랜잭션이다. 서로 다른 DB의 원자적 커밋, XA/JTA, 분산 트랜잭션을 해결하는 구성이 아니다. 여러 로컬 트랜잭션 매니저를 차례로 커밋해도 원자성은 생기지 않는다. 또 JPA 영속성 컨텍스트가 JDBC savepoint 기반의 NESTED 롤백까지 함께 되돌아간다고 가정해서는 안 된다.
flush는 commit이 아니다
persist()와 엔티티 변경은 SQL 실행과 시점이 다를 수 있다. JPA가 모아 둔 변경을 DB에 반영하는 것이 flush()이고, 그 트랜잭션을 최종 확정하는 것이 commit이다. flush한 INSERT도 나중에 트랜잭션을 롤백하면 취소된다.
MyBatis는 JPA 영속성 컨텍스트를 조회하지 않으며, Mapper 호출 자체가 JPA의 자동 flush를 보장하지도 않는다. 따라서 앞선 JPA 변경을 MyBatis SQL에서 읽어야 하는 경계에 명시적으로 em.flush()를 둔다. 같은 커넥션에서는 아직 커밋되지 않은 자기 트랜잭션의 변경을 읽을 수 있다. 다른 커넥션에서도 보인다는 의미는 아니다.
기존 글의 “flush하지 않으면 반드시 아무것도 조회되지 않는다”는 설명은 너무 단정적이었다. ID 생성 전략이나 이미 발생한 flush 등에 따라 SQL이 먼저 나갈 수 있다. 우연한 실행 순서에 기대지 않는 것이 요점이다. saveAndFlush()도 커밋은 아니다.
반대 방향: MyBatis 쓰기 후 JPA 객체는 낡을 수 있다
MyBatis의 UPDATE는 JPA가 관리 중인 객체를 자동 변경하지 않는다. 이미 로딩한 엔티티를 다시 find()하면 영속성 컨텍스트의 같은 객체가 반환되어 예전 값이 보일 수 있다.
- 특정 관리 엔티티만 다시 읽으려면
em.refresh(entity)를 사용한다. 그 객체의 아직 flush하지 않은 변경을 덮어쓸 수 있다. - 여러 객체를 비우고 다시 조회하려면
em.clear()를 사용할 수 있다. 모든 관리 엔티티가 분리되며, flush하지 않은 변경은 유실될 수 있다. 기존 객체 참조가 새 데이터로 바뀌는 것도 아니다. - 일반적인 순서는
JPA 변경 → flush → MyBatis 쓰기 → refresh 또는 clear 후 재조회다. SQL 변경 후 낡은 JPA 객체를 다시 수정하고 flush하면 SQL로 바꾼 값을 덮어쓸 위험이 있다. - 2차 캐시·쿼리 캐시를 사용한다면
clear()만으로 해결되지 않는다. SQL로 변경하는 데이터의 캐시 무효화 정책을 별도로 정해야 한다. MyBatis SQL은 JPA의@Version처리나 엔티티 콜백도 자동 수행하지 않는다.
반대편 캐시도 주의한다. JPA로 변경한 사실을 MyBatis의 세션 캐시나 2차 캐시가 자동으로 알지는 못한다. 아래 재현 예제는 이 변수를 배제하려고 MyBatis 로컬 캐시를 STATEMENT로 제한하고 2차 캐시를 끈다. 운영에 일괄 적용하라는 성능 튜닝 권고는 아니다.
버전 선택: Boot 3 예제와 Boot 4 호환성을 구분하자
2026-10-11 확인한 MyBatis starter 공식 호환표는 다음 조합을 제시한다.
- Starter 3.0 계열: MyBatis-Spring 3.0, Spring Boot 3.2~3.5, Java 17 이상.
- Starter 4.0 계열: MyBatis-Spring 4.0, Spring Boot 4.0+, Java 17 이상.
- 공식 저장소 README는 더 세분화하여
4.0.x브랜치를 Boot 4.0에,master의 MyBatis-Spring 4.1 조합을 Boot 4.1에 대응시킨다. 따라서 “4.0+”만 보고 모든 후속 버전을 같은 조합으로 취급하지 말고 사용하는 릴리스의 호환성을 다시 확인한다.
아래는 Boot 3.5.0 + MyBatis starter 3.0.5 + Java 17 컴파일 기준으로 고정한 학습용 재현 예제다. 최신 패치 또는 현재 운영 권장 버전이라는 뜻이 아니다. 운영 적용 시 지원 중인 패치와 보안 공지를 확인하고 같은 테스트를 다시 실행한다. Boot 4로 옮길 때는 starter도 대응 계열로 맞춰야 하며, 아래 코드가 Boot 4에서 실행 검증되었다는 뜻은 아니다.
JPA import는 javax.persistence가 아니라 jakarta.persistence다. 단, Java SE API인 javax.sql.DataSource는 그대로다. 수동 persistence.xml, EntityManagerFactory 생성·종료, Lombok, Mapper XML 없이 재현한다.
작은 재현 프로젝트
아래 경로대로 파일을 배치한다. 패키지는 모두 example.mixed다. create-drop과 H2 설정은 테스트 전용이며 운영 설정으로 복사하지 않는다.
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<relativePath/>
</parent>
<groupId>example</groupId>
<artifactId>jpa-mybatis-transaction</artifactId>
<version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
src/test/resources/application.properties
spring.datasource.url=jdbc:h2:mem:mixed;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.cache.use_second_level_cache=false
spring.jpa.properties.hibernate.cache.use_query_cache=false
mybatis.configuration.local-cache-scope=STATEMENT
mybatis.configuration.cache-enabled=false
mybatis.configuration.default-executor-type=SIMPLE
SIMPLE 실행기를 기준으로 한다. BATCH라면 MyBatis 쓰기도 지연될 수 있어 flushStatements() 등 별도의 실행 시점을 고려해야 한다. 이 글의 테스트 범위에는 배치 실행기가 없다.
src/main/java/example/mixed/MixedApplication.java
package example.mixed;
import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.orm.jpa.JpaTransactionManager;
@SpringBootApplication
public class MixedApplication {
public static void main(String[] args) {
SpringApplication.run(MixedApplication.class, args);
}
@Bean
JpaTransactionManager transactionManager(EntityManagerFactory emf,
DataSource dataSource) {
JpaTransactionManager manager = new JpaTransactionManager(emf);
manager.setDataSource(dataSource);
return manager;
}
}
Boot는 하나의 DataSource와 Hibernate EntityManagerFactory를 구성한다. MyBatis starter는 그 DataSource로 SqlSessionFactory와 SqlSessionTemplate을 만들고 @Mapper를 연결한다. 여기서는 경계를 명확히 보이기 위해 transactionManager 빈만 명시했다. 기본 SpringManagedTransactionFactory는 바꾸지 않는다.
src/main/java/example/mixed/Member.java
package example.mixed;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "mixed_member")
public class Member {
@Id
private Long id;
private String name;
protected Member() {
}
public Member(Long id, String name) {
this.id = id;
this.name = name;
}
public String getName() {
return name;
}
}
ID는 직접 지정해 IDENTITY 생성에 따른 조기 INSERT 같은 변수를 피한다.
src/main/java/example/mixed/MemberMapper.java
package example.mixed;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface MemberMapper {
@Select("select name from mixed_member where id = #{id}")
String findName(@Param("id") long id);
@Insert("insert into mixed_member(id, name) values(#{id}, #{name})")
int insert(@Param("id") long id, @Param("name") String name);
@Update("update mixed_member set name = #{name} where id = #{id}")
int rename(@Param("id") long id, @Param("name") String name);
}
src/main/java/example/mixed/MemberService.java
package example.mixed;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
@PersistenceContext
private EntityManager em;
private final MemberMapper mapper;
public MemberService(MemberMapper mapper) {
this.mapper = mapper;
}
@Transactional(transactionManager = "transactionManager")
public Observation saveBoth(long id, boolean fail) {
Member member = new Member(id, "jpa");
em.persist(member);
em.flush(); // SQL을 실행하지만 아직 commit하지 않는다.
String seenByMyBatis = mapper.findName(id);
if (!"jpa".equals(seenByMyBatis)) {
throw new IllegalStateException("JPA write was not visible");
}
mapper.insert(id + 1, "mybatis");
mapper.rename(id, "sql");
String beforeRefresh = member.getName(); // 여전히 "jpa"
em.refresh(member);
String afterRefresh = member.getName(); // "sql"
if (fail) {
throw new IllegalStateException("forced rollback");
}
return new Observation(seenByMyBatis, beforeRefresh, afterRefresh);
}
public record Observation(String seenByMyBatis,
String beforeRefresh,
String afterRefresh) {
}
}
커밋과 예외 롤백을 따로 테스트하기
테스트 클래스와 메서드에는 @Transactional을 붙이지 않는다. 테스트 자체의 기본 롤백에 기대면 서비스 메서드가 실제로 커밋했는지를 놓치기 쉽다. Spring이 주입한 서비스 프록시를 호출하고, 반환 또는 예외 이후에 JdbcTemplate로 DB 상태를 읽는다. 이 조회는 서비스의 JPA·MyBatis 캐시를 거치지 않는다.
src/test/java/example/mixed/MixedTransactionTest.java
package example.mixed;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaTransactionManager;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
class MixedTransactionTest {
@Autowired MemberService service;
@Autowired JdbcTemplate jdbc;
@Autowired DataSource dataSource;
@Autowired JpaTransactionManager transactionManager;
@Autowired SqlSessionFactory sqlSessionFactory;
@BeforeEach
void cleanAndCheckWiring() {
assertThat(transactionManager.getDataSource()).isSameAs(dataSource);
var environment = sqlSessionFactory.getConfiguration().getEnvironment();
assertThat(environment.getDataSource()).isSameAs(dataSource);
assertThat(environment.getTransactionFactory())
.isInstanceOf(SpringManagedTransactionFactory.class);
jdbc.update("delete from mixed_member");
}
@Test
void commitsJpaAndMyBatisWrites() {
var result = service.saveBoth(100L, false);
assertThat(result.seenByMyBatis()).isEqualTo("jpa");
assertThat(result.beforeRefresh()).isEqualTo("jpa");
assertThat(result.afterRefresh()).isEqualTo("sql");
assertThat(jdbc.queryForList(
"select name from mixed_member order by id", String.class))
.containsExactly("sql", "mybatis");
}
@Test
void runtimeExceptionRollsBackBothWritesEvenAfterFlush() {
assertThatThrownBy(() -> service.saveBoth(200L, true))
.isInstanceOf(IllegalStateException.class)
.hasMessage("forced rollback");
assertThat(jdbc.queryForObject(
"select count(*) from mixed_member", Long.class))
.isZero();
}
}
Java 17 이상과 Maven을 준비하고 프로젝트 루트에서 실행한다.
mvn test
실행 결과: 2026-10-11 23:29 KST, JDK 21.0.2·Maven 3.10.0·Hibernate 6.6.15.Final·H2 2.3.232에서 다음 결과를 확인했다. 본문 코드와 동일한 별도 프로젝트에서 실행한 로그의 요약이다.
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
첫 테스트는 JPA가 만든 100번 행의 최종 이름 sql과 MyBatis가 만든 101번 행의 이름 mybatis가 커밋 후 남는지 확인한다. 두 번째는 JPA flush와 MyBatis 쓰기가 모두 실행된 뒤 예외를 던져 200·201번 행 모두 남지 않는지 확인한다. 예외 메시지도 검사하므로, 앞 단계의 다른 오류로 실패한 것을 의도한 롤백 성공으로 착각하지 않는다.
실제 DB 적용 전에는 H2 대신 사용하는 DB·드라이버로 이 테스트를 실행하고, 격리 수준·동시 수정·제약조건·캐시 설정을 추가 검증한다. 스키마 생성은 마이그레이션 도구로 관리한다. Spring 소유의 EntityManagerFactory를 테스트의 @AfterEach에서 직접 닫지 않는다.
@Transactional이 있어도 실패하는 지점
- 프록시를 통하지 않는 호출: 기본 프록시 모드에서
new MemberService(...)로 만든 객체나 같은 객체 내부의this.saveBoth(...)호출에는 새 트랜잭션 경계가 적용되지 않는다. 내부 호출 시 이미 바깥 트랜잭션이 있다면 그 경계에서 실행될 뿐, 내부 메서드의 전파·롤백 설정을 새로 적용하지 않는다. - 예외를 삼키는 코드: 기본 설정은 프록시 밖으로 전파된
RuntimeException과Error를 롤백 대상으로 삼는다. checked exception은 기본적으로 롤백하지 않는다. 필요한 경우rollbackFor = YourCheckedException.class를 명시한다. 이 설명은 기본 규칙이며 전역 롤백 정책이나 개별 설정으로 달라질 수 있다. - catch 후 정상 반환: 예외를 잡고 정상 반환하면 트랜잭션 인터셉터가 그 예외를 보지 못한다. 이미 rollback-only로 표시되었는지에 따라 결과가 달라질 수 있으므로, 실패를 성공처럼 감추지 않는다.
- 다른 매니저 또는 트랜잭션 밖 Mapper: 매니저가 여러 개라면 선택을 명시한다. MyBatis-Spring은 Spring 트랜잭션 밖의 Mapper 호출을 자동 커밋하므로, 나중에 JPA를 롤백해도 되돌릴 수 있다고 기대하면 안 된다.
정리하면 같은 DataSource와 JpaTransactionManager로 원자성을 확보하고, flush로 실행 순서를 맞추며, refresh·clear·캐시 정책으로 메모리 상태를 맞춘다. 이 세 가지는 서로 대신할 수 없다.
공식 문서
아래 문서는 2026-10-11 확인했다. current 문서와 저장소 기본 브랜치는 이후 바뀔 수 있으므로 실제 의존성 버전도 함께 확인한다.
- Spring JpaTransactionManager: 단일 EntityManagerFactory, JDBC 혼용 조건, 다중 자원·중첩 트랜잭션 제약.
- MyBatis-Spring Transactions: 같은 DataSource, Spring 관리 세션, 트랜잭션 밖 자동 커밋.
- MyBatis-Spring SqlSessionTemplate: 현재 Spring 트랜잭션의 세션 사용.
- SqlSessionFactoryBean API: 기본 SpringManagedTransactionFactory.
- MyBatis starter 호환표와 자동 구성, 공식 저장소의 버전별 요구사항.
- Spring @Transactional과 프록시, 기본 롤백 규칙.
- Jakarta Persistence 명세: 영속성 컨텍스트 동기화, refresh와 clear의 의미.
- MyBatis 캐시 설정: localCacheScope와 cacheEnabled.
함께 읽을 기존 글
- Spring Transaction: Multiple DataSources Transaction: 프로그램 방식의 트랜잭션 경계와 비교할 때 참고한다. 다만 과거 글의 “JpaTransactionManager로 여러 DB를 하나의 트랜잭션으로 묶는다”는 설명은 정확하지 않다. 이 글은 단일 DB 구성이고, 여러 로컬 트랜잭션의 순차 커밋도 원자적 커밋이 아니다.
- JPA 물리적인 테이블과 컬럼 이름 전략: JPA 매핑과 MyBatis SQL이 같은 테이블·컬럼을 가리키는지 확인할 때 참고한다. 과거 버전의 구현 코드는 현재 Hibernate 버전에 그대로 복사하지 않는다.
Subscribe via RSS