@Transactional을 붙이면 트랜잭션은 알아서 처리된다. 대부분은 그걸로 충분하다. 그런데 아래 질문 중 하나라도 막힌다면 그 내부를 한 번 들여다볼 때다.
@Transactional 메서드를 호출했더니 트랜잭션이 걸리지 않는다. 왜일까?save()를 호출하지 않았는데 UPDATE 쿼리가 나간다. 누가 보낸 걸까?UnexpectedRollbackException이 터진다.REQUIRES_NEW를 썼더니 트래픽이 몰릴 때 커넥션 풀이 바닥난다.readOnly = true는 정확히 무엇을 바꿀까?이 글은 @Transactional 메서드 하나가 호출되는 순간부터 커넥션이 풀로 돌아가는 순간까지를 순서대로 따라간다. 먼저 전체 지도와 14단계 흐름을 훑고, 이어서 각 단계를 소스 코드 수준에서 하나씩 뜯어본다. 단계마다 그 구조 때문에 생기는 함정도 함께 정리했다.
글 전체에서 아래 예제를 계속 사용한다. 회원과 상품을 조회하고, 재고를 줄이고, 주문을 저장하는 흔한 서비스 메서드다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final MemberRepository memberRepository;
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
@Transactional
public Long placeOrder(Long memberId, Long itemId, int count) {
Member member = memberRepository.findById(memberId).orElseThrow();
Item item = itemRepository.findById(itemId).orElseThrow();
item.removeStock(count); // 재고 감소. save() 호출 없음
Order order = Order.create(member, item, count);
orderRepository.save(order); // 주문 저장
return order.getId();
}
}
엔티티의 ID 생성 전략은 SEQUENCE라고 가정한다. IDENTITY일 때 달라지는 점은 3막에서 다룬다.
트랜잭션 하나에 관여하는 컴포넌트는 생각보다 많다. 각자 맡은 일은 단순하지만, 서로를 어떻게 찾아내는지 알아야 전체가 보인다.

왼쪽의 제어 흐름(프록시 → 트랜잭션 매니저)과 오른쪽의 데이터 접근(원본 객체 → 리포지토리)은 서로 다른 길로 내려오지만, 스레드별 저장소에서 같은 EntityManager를 만난다.
| 컴포넌트 | 하는 일 |
|---|---|
| 트랜잭션 프록시 | Spring이 원본 OrderService 대신 컨테이너에 등록해 둔 CGLIB 프록시. 호출을 가로채 TransactionInterceptor에 넘긴다. |
TransactionInterceptor | @Transactional 속성을 읽고 시작, 커밋, 롤백을 지휘한다. DB를 직접 만지지는 않는다. |
JpaTransactionManager | EntityManager를 만들고 트랜잭션을 실제로 시작하고 끝낸다. PlatformTransactionManager의 JPA 구현체다. |
TransactionSynchronizationManager | 현재 스레드에 묶인 자원(EntityManager, 커넥션)과 콜백 목록을 보관하는 ThreadLocal 저장소. |
EntityManager | 영속성 컨텍스트. 구현체는 Hibernate의 SessionImpl이며 1차 캐시, 스냅샷, 쓰기 지연 SQL 저장소를 가진다. |
JDBC Connection | 진짜 DB 트랜잭션의 주인. autoCommit=false로 시작해 commit() 또는 rollback()으로 끝난다. |
그림에서 기억할 것은 두 가지다.
첫째, JPA 트랜잭션도 결국은 JDBC 커넥션 하나의 트랜잭션이다. setAutoCommit(false)로 시작해 commit()이나 rollback()으로 끝난다. 영속성 컨텍스트는 그 사이에서 변경 사항을 모아 두었다가 커밋 직전에 SQL로 바꿔 한꺼번에 보낸다.
둘째, 트랜잭션 매니저와 리포지토리는 서로를 직접 참조하지 않는다. 매니저가 스레드 저장소에 넣어 둔 EntityManager를, 같은 스레드에서 실행되는 리포지토리가 꺼내 쓸 뿐이다. 트랜잭션에 관한 거의 모든 함정이 이 두 사실에서 나온다.
컨트롤러가 orderService.placeOrder()를 호출했을 때 일어나는 일을 시간 순서대로 나열했다. 세부 내용은 뒤에서 막별로 다시 다루니, 여기서는 흐름만 잡으면 된다.
프록시가 호출을 가로챈다 · CGLIB 프록시
컨트롤러가 주입받은 orderService는 OrderService를 상속한 프록시다. 호출은 원본보다 프록시에 먼저 도착하고, 프록시는 TransactionInterceptor에 일을 넘긴다.
트랜잭션 속성을 읽는다 · TransactionInterceptor
메서드의 @Transactional을 해석해 전파 속성(REQUIRED), 격리 수준, readOnly, 롤백 규칙을 얻는다. 해석 결과는 메서드별로 캐시된다.
진행 중인 트랜잭션이 있는지 본다 · JpaTransactionManager
현재 스레드의 저장소에 EntityManagerHolder가 있는지 확인한다. 비어 있으므로 새 트랜잭션을 시작하기로 한다.
EntityManager를 만든다 · JpaTransactionManager
EntityManagerFactory에서 새 EntityManager(Hibernate SessionImpl)를 생성한다. 이 트랜잭션의 영속성 컨텍스트가 여기서 태어난다.
커넥션을 얻고 자동 커밋을 끈다 · Hibernate
em.getTransaction().begin()이 호출되면 Hibernate가 커넥션 풀에서 커넥션을 빌려 setAutoCommit(false)를 호출한다. DB 입장에서는 이 순간 트랜잭션이 시작된다.
자원을 스레드에 묶는다 · TransactionSynchronizationManager
EntityManager와 커넥션을 현재 스레드의 저장소에 등록하고, 트랜잭션 이름·readOnly 여부와 콜백 목록을 초기화한다.
원본 메서드가 실행된다 · OrderService
인터셉터가 invocation.proceed()로 원본 OrderService.placeOrder()를 호출한다.
리포지토리가 같은 EntityManager를 꺼내 쓴다 · 공유 EM 프록시
findById()는 내부에서 em.find()를 부른다. 이 em은 프록시라서 스레드 저장소에서 4단계의 EntityManager를 찾아 위임한다. 리포지토리 메서드에도 @Transactional이 있지만 기존 트랜잭션에 참여할 뿐이다. 1차 캐시에 없으니 SELECT가 나간다.
변경은 메모리에만 쌓인다 · 영속성 컨텍스트
item.removeStock()은 자바 객체의 필드만 바꾼다. orderRepository.save()는 INSERT를 쓰기 지연 저장소(ActionQueue)에 넣는다. 아직 DB로 간 쓰기 SQL은 하나도 없다.
정상 반환, 커밋 요청 · TransactionInterceptor
예외 없이 반환되면 인터셉터가 transactionManager.commit()을 호출한다.
플러시: SQL이 DB로 간다 · Hibernate
커밋 직전에 Hibernate가 엔티티의 현재 값과 스냅샷을 비교해 UPDATE를 만들고, ActionQueue에 쌓인 SQL을 정해진 순서로 실행한다.
JDBC 커밋 · Connection
connection.commit(). 이제 변경이 DB에 확정된다.
커밋 후 콜백 · TransactionSynchronization
afterCommit, afterCompletion 콜백이 실행된다. @TransactionalEventListener가 동작하는 지점이 여기다.
정리 · JpaTransactionManager
스레드 저장소에서 자원을 떼어내고 EntityManager를 닫는다. 영속성 컨텍스트가 사라지고, 커넥션은 설정을 되돌린 뒤 풀로 돌아간다.
아래 설정을 켜면 이 흐름을 로그로 볼 수 있다. 직접 한 번 돌려 보기를 권한다.
logging:
level:
org.springframework.orm.jpa.JpaTransactionManager: DEBUG
org.hibernate.SQL: DEBUG
출력 예시다. 왼쪽 숫자는 위 단계 번호이고, 해시값은 실행마다 다르다.
03 | DEBUG JpaTransactionManager : Creating new transaction with name [com.example.shop.OrderService.placeOrder]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
04 | DEBUG JpaTransactionManager : Opened new EntityManager [SessionImpl(1846302731<open>)] for JPA transaction
06 | DEBUG JpaTransactionManager : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5b6e8f77]
08 | DEBUG JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(1846302731<open>)] for JPA transaction
08 | DEBUG JpaTransactionManager : Participating in existing transaction
08 | DEBUG org.hibernate.SQL : select m1_0.member_id,m1_0.grade,m1_0.name from member m1_0 where m1_0.member_id=?
08 | DEBUG JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(1846302731<open>)] for JPA transaction
08 | DEBUG JpaTransactionManager : Participating in existing transaction
08 | DEBUG org.hibernate.SQL : select i1_0.item_id,i1_0.name,i1_0.price,i1_0.stock from item i1_0 where i1_0.item_id=?
09 | DEBUG JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(1846302731<open>)] for JPA transaction
09 | DEBUG JpaTransactionManager : Participating in existing transaction
| (persist() 때의 시퀀스 조회 SQL은 생략)
10 | DEBUG JpaTransactionManager : Initiating transaction commit
11 | DEBUG JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1846302731<open>)]
11 | DEBUG org.hibernate.SQL : insert into orders (count,item_id,member_id,status,order_id) values (?,?,?,?,?)
11 | DEBUG org.hibernate.SQL : update item set name=?,price=?,stock=? where item_id=?
14 | DEBUG JpaTransactionManager : Closing JPA EntityManager [SessionImpl(1846302731<open>)] after transaction
로그에서 두 가지가 눈에 띈다. 리포지토리를 호출할 때마다 Participating in existing transaction이 찍힌다. 그리고 코드에서는 재고 감소가 주문 저장보다 먼저인데, 실제 SQL은 커밋 시점에 INSERT가 UPDATE보다 먼저 나간다. 두 가지 모두 아래에서 이유를 설명한다.
단계 1–2
애플리케이션이 뜰 때 자동 프록시 생성기(빈 후처리기)가 등록되는 빈을 하나씩 검사한다. 트랜잭션용 어드바이저(BeanFactoryTransactionAttributeSourceAdvisor)의 포인트컷이 @Transactional이 붙은 클래스나 메서드를 찾아내면, 원본 빈 대신 프록시를 컨테이너에 등록한다.
Spring Boot는 기본으로 클래스를 상속하는 CGLIB 프록시를 쓴다(spring.aop.proxy-target-class=true). 그래서 컨트롤러가 주입받는 orderService의 실제 타입은 원본이 아니라 하위 클래스다. 직접 찍어 보면 바로 확인할 수 있다.
System.out.println(orderService.getClass());
// class com.example.shop.OrderService$$SpringCGLIB$$0
프록시의 메서드는 TransactionInterceptor를 거쳐 TransactionAspectSupport.invokeWithinTransaction()에 도착한다. @Transactional의 동작 전체가 이 메서드 하나에 들어 있다.
// TransactionAspectSupport.invokeWithinTransaction() 요약
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
InvocationCallback invocation) throws Throwable {
TransactionAttribute txAttr = getTransactionAttributeSource()
.getTransactionAttribute(method, targetClass); // ① @Transactional 해석
PlatformTransactionManager tm = determineTransactionManager(txAttr);
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpoint); // ② 시작 또는 참여
Object retVal;
try {
retVal = invocation.proceedWithInvocation(); // ③ 원본 메서드 실행
} catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex); // ④ 롤백 규칙 판단
throw ex;
} finally {
cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo); // ⑤ 커밋
return retVal;
}
구조는 try-catch 하나다. 시작하고, 실행하고, 예외가 나면 롤백 규칙을 따지고, 아니면 커밋한다. 이 글의 나머지는 ①부터 ⑤까지를 하나씩 깊게 들어가는 이야기다.
①의 해석은 AnnotationTransactionAttributeSource가 맡는다. 메서드에 붙은 애너테이션이 클래스에 붙은 것보다 우선하고, 한 번 해석한 결과는 메서드별로 캐시한다. Spring의 @Transactional과 jakarta.transaction.Transactional을 둘 다 인식한다.
@Service
public class OrderService {
public void placeOrders(List<OrderRequest> requests) {
for (OrderRequest req : requests) {
placeOrder(req); // this.placeOrder(): 프록시를 거치지 않는다
}
}
@Transactional
public void placeOrder(OrderRequest req) { ... }
}
외부에서 placeOrders()를 호출하면 프록시는 이 메서드에 @Transactional이 없으니 그대로 원본 객체에 위임한다. 원본 안에서의 placeOrder() 호출은 this, 즉 원본 객체에 대한 호출이다. 프록시는 이 호출을 볼 방법이 없다.
결과적으로 placeOrder()는 트랜잭션 없이 실행된다. 안에서 부르는 리포지토리 메서드는 각자 자기 트랜잭션을 열고 바로 커밋하므로, 중간에 예외가 나도 앞에서 저장한 데이터는 롤백되지 않는다.
가장 깔끔한 해결은 트랜잭션 경계를 다른 빈으로 분리하는 것이다.
@Service
@RequiredArgsConstructor
public class OrderBatchService {
private final OrderService orderService; // 프록시가 주입된다
public void placeOrders(List<OrderRequest> requests) {
requests.forEach(orderService::placeOrder); // 매번 프록시를 거친다
}
}
코드 블록 단위로 트랜잭션을 걸어야 한다면 TransactionTemplate을 쓰는 방법도 있다. 프록시 대신 코드로 같은 일을 한다.
kotlin-spring 플러그인이 대신 열어 준다.ApplicationReadyEvent 리스너에서 다른 빈을 호출한다.단계 3–6
인터셉터는 JPA를 모른다. 알고 있는 건 아래 인터페이스 하나뿐이다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition);
void commit(TransactionStatus status);
void rollback(TransactionStatus status);
}
구현체는 데이터 접근 기술마다 있다. JDBC나 MyBatis는 DataSourceTransactionManager, JPA는 JpaTransactionManager, 분산 트랜잭션은 JtaTransactionManager. Spring Boot는 JPA 의존성이 있으면 JpaTransactionManager를 자동으로 등록한다.
구현체들은 모두 AbstractPlatformTransactionManager를 상속한다. 전파 속성 처리, 동기화 콜백 관리 같은 공통 흐름은 부모가 템플릿 메서드로 잡아 두고, 자식은 doGetTransaction, doBegin, doCommit, doRollback, doSuspend 같은 기술별 조각만 채운다.
// AbstractPlatformTransactionManager.getTransaction() 요약
public final TransactionStatus getTransaction(TransactionDefinition def) {
Object transaction = doGetTransaction(); // 스레드에 묶인 자원 조회
if (isExistingTransaction(transaction)) {
return handleExistingTransaction(def, transaction); // 전파 속성대로 참여·보류·예외
}
// 여기부터는 진행 중인 트랜잭션이 없는 경우
if (def.getPropagationBehavior() == PROPAGATION_MANDATORY) {
throw new IllegalTransactionStateException("No existing transaction found ...");
}
if (def.getPropagationBehavior() == PROPAGATION_REQUIRED ||
def.getPropagationBehavior() == PROPAGATION_REQUIRES_NEW ||
def.getPropagationBehavior() == PROPAGATION_NESTED) {
return startTransaction(def, transaction, ...); // doBegin() → prepareSynchronization()
}
// SUPPORTS, NOT_SUPPORTED, NEVER: 트랜잭션 없이 진행
return prepareTransactionStatus(def, null, true, ...);
}
JpaTransactionManager.doGetTransaction()은 스레드 저장소에서 EntityManagerFactory를 키로 EntityManagerHolder를 찾는다. 찾았고 그 안의 트랜잭션이 활성 상태면 isExistingTransaction()이 true가 되어 6막의 전파 로직으로 간다. 예제의 첫 호출에서는 저장소가 비어 있으니 startTransaction()으로 간다.
startTransaction()은 doBegin()을 부르고, 끝나면 prepareSynchronization()을 부른다. JPA에서 일어나는 일을 순서대로 펼치면 이렇다.
HibernateJpaDialect.beginTransaction()이 격리 수준과 readOnly를 커넥션에 적용하고 em.getTransaction().begin()을 호출한다. 이때 Hibernate가 커넥션을 얻어 autoCommit을 끈다. readOnly 트랜잭션이면 세션의 FlushMode를 MANUAL로 바꾼다.@Transactional(timeout = …)이 있으면 마감 시각을 기록해 두고, 이후 생성되는 쿼리에 남은 시간을 쿼리 타임아웃으로 건다.원리 · 커넥션은 언제 잡힐까
2번, 트랜잭션이 시작될 때다. Hibernate는 autoCommit을 끄기 위해
begin()에서 바로 물리 커넥션을 가져온다. 메서드 첫 줄이 외부 API 호출이라면 응답을 기다리는 동안에도 커넥션을 쥐고 있게 된다. 느린 작업은 트랜잭션 밖으로 빼는 게 기본이다.HikariCP의
auto-commit: false와 Hibernate의hibernate.connection.provider_disables_autocommit: true를 함께 설정하면 Hibernate가 autoCommit 변경을 건너뛰므로 획득을 첫 SQL까지 미룰 수 있다. 단, readOnly나 격리 수준을 지정한 트랜잭션은 Spring이 커넥션을 미리 준비하므로 효과가 없다.
"현재 트랜잭션"이라는 개념은 이 클래스의 ThreadLocal 필드들로 구현된다.
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Map<Object, Object>> resources; // 자원 보관소
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations; // 콜백 목록
private static final ThreadLocal<String> currentTransactionName;
private static final ThreadLocal<Boolean> currentTransactionReadOnly;
private static final ThreadLocal<Integer> currentTransactionIsolationLevel;
private static final ThreadLocal<Boolean> actualTransactionActive;
}
JPA 트랜잭션이 진행 중일 때 resources 맵에는 두 항목이 들어 있다.
| 키 | 값 | 누가 꺼내 쓰나 |
|---|---|---|
EntityManagerFactory | EntityManagerHolder | 리포지토리의 공유 EntityManager 프록시 |
DataSource | ConnectionHolder | JdbcTemplate, MyBatis 등 DataSourceUtils.getConnection()을 쓰는 코드 |
키가 객체 자체라는 점이 중요하다. EntityManagerFactory가 두 개인 멀티 DB 환경이라면 트랜잭션 매니저도 각각이고, 한 매니저가 시작한 트랜잭션은 다른 팩토리의 EntityManager에 영향을 주지 않는다. @Transactional("orderTransactionManager")처럼 매니저를 지정해야 하는 이유다.
자원이 ThreadLocal에 있으니 다른 스레드에서는 보이지 않는다. @Async, CompletableFuture, parallelStream() 안의 코드는 바깥 트랜잭션에도, 바깥 영속성 컨텍스트에도 참여하지 않는다.
@Transactional
public void placeOrder(OrderRequest req) {
orderRepository.save(Order.from(req));
CompletableFuture.runAsync(() ->
historyRepository.save(new OrderHistory(req)) // 다른 스레드: 자기 트랜잭션으로 커밋
);
validateStock(req); // 여기서 예외가 나 주문이 롤백돼도 이력은 이미 커밋됐을 수 있다
}
단계 7–9
@Repository
public class ItemQueryRepository {
@PersistenceContext
private EntityManager em; // 싱글톤 빈에 하나만 주입된다. 동시 요청은 어떻게 처리할까?
}
EntityManager는 스레드 안전하지 않다. 그런데 싱글톤 빈에 하나만 주입해도 문제가 없다. 주입되는 것이 진짜 EntityManager가 아니라 SharedEntityManagerCreator가 만든 공유 프록시이기 때문이다. Spring Data JPA의 SimpleJpaRepository도 같은 프록시를 받는다. 이 프록시는 메서드가 호출될 때마다 아래 일을 한다.
// SharedEntityManagerCreator.SharedEntityManagerInvocationHandler.invoke() 요약
EntityManager target = EntityManagerFactoryUtils
.doGetTransactionalEntityManager(emf, properties); // 스레드 저장소에서 조회
if (transactionRequiringMethods.contains(method.getName())) { // persist, merge, remove, flush ...
if (target == null || !TransactionSynchronizationManager.isActualTransactionActive()) {
throw new TransactionRequiredException(
"No EntityManager with actual transaction available for current thread ...");
}
}
if (target == null) {
target = emf.createEntityManager(); // 트랜잭션이 없으면 임시 EM을 만들고
isNewEm = true; // 호출이 끝나면 바로 닫는다
}
return method.invoke(target, args);
지도에서 본 연결이 바로 이 코드다. 트랜잭션 매니저가 4–6단계에서 넣어 둔 EntityManager를 프록시가 꺼내 위임한다. 트랜잭션 없이 조회 메서드를 부르면 호출마다 임시 EntityManager가 생겼다가 사라지고, 쓰기 메서드를 부르면 TransactionRequiredException이 난다.
결과적으로 영속성 컨텍스트의 수명은 트랜잭션과 같다. 트랜잭션이 시작될 때 생기고 끝날 때 사라진다. 같은 트랜잭션 안의 모든 리포지토리는 같은 EntityManager를 공유하므로, 같은 식별자의 엔티티는 항상 같은 인스턴스다.
@Transactional
public void identity() {
Member a = memberRepository.findById(1L).orElseThrow(); // SELECT
Member b = memberRepository.findById(1L).orElseThrow(); // 1차 캐시에서 반환. SQL 없음
assert a == b; // 같은 인스턴스
}
@Transactional을 지우면 두 findById()는 각자 자기 readOnly 트랜잭션과 자기 EntityManager에서 실행된다. SELECT가 두 번 나가고 a != b가 된다.
플러시 직전, 예제의 영속성 컨텍스트 안은 이렇게 생겼다.
1차 캐시 + 스냅샷
| 엔티티 | 현재 값 | 스냅샷 (영속화 시점) | 플러시 때 |
|---|---|---|---|
Order#100 | status=ORDER | status=ORDER | persist() 때 이미 INSERT가 적재됨 |
Item#7 | stock=8 | stock=10 | 값이 다름 → UPDATE 생성 |
Member#1 | grade=GOLD | grade=GOLD | 같음 → SQL 없음 |
ActionQueue (쓰기 지연 SQL 저장소)
| 칸 | 내용 |
|---|---|
| insertions | INSERT orders #100 (persist() 때 적재) |
| updates | UPDATE item #7 (플러시 때 변경 감지로 생성) |
| deletions | 비어 있음 |
플러시는 이 큐를 INSERT → UPDATE → DELETE 순서로 비우며 SQL을 JDBC 커넥션으로 보낸다.
Hibernate는 엔티티가 영속성 컨텍스트에 들어오는 순간(조회 결과를 엔티티로 만들거나 persist할 때) 각 필드 값을 배열로 복사해 둔다. 엔티티마다 붙는 EntityEntry의 loadedState가 이 스냅샷이다.
플러시 때 Hibernate는 관리 중인 모든 엔티티를 돌며 현재 필드 값과 스냅샷을 비교한다. 다른 필드가 하나라도 있으면 UPDATE 액션을 만들어 ActionQueue에 넣는다. 예제의 item.removeStock()에 save()가 필요 없는 이유다. 이미 관리 중인 엔티티에 save()를 호출하면 Spring Data는 merge()를 부르는데, 관리 중인 엔티티라 사실상 아무 일도 하지 않는다.
기본 UPDATE 문은 바뀐 컬럼만이 아니라 모든 컬럼을 갱신한다. 로그의 update item set name=?,price=?,stock=?가 그렇다. 엔티티당 UPDATE 문 모양이 하나로 고정되니 미리 만들어 둔 SQL과 JDBC 배치를 재사용하기 좋다. 바뀐 컬럼만 보내고 싶으면 엔티티에 @DynamicUpdate를 붙인다.
비교는 관리 중인 엔티티 수에 비례하는 비용이다. 수천 건을 조회만 하는 트랜잭션이라면 스냅샷 보관과 비교 모두 낭비다. 뒤에서 볼 readOnly = true가 이 비용을 없앤다.
persist()는 INSERT를 바로 실행하지 않고 ActionQueue에 넣는다. 모아 두었다가 플러시 때 한꺼번에 보내면 JDBC 배치로 묶을 수 있고, 커넥션을 오가는 횟수도 줄어든다.
예외 · IDENTITY 전략
@GeneratedValue(strategy = IDENTITY)는 DB가 INSERT를 실행해야 ID를 알 수 있다. 영속성 컨텍스트는 ID를 키로 엔티티를 관리하므로, Hibernate는persist()시점에 INSERT를 즉시 실행한다. 그래서 IDENTITY 엔티티는 쓰기 지연이 되지 않고 INSERT를 JDBC 배치로 묶을 수도 없다. 대량 INSERT가 중요하다면 SEQUENCE 전략과allocationSize를 검토한다.
em.find()는 1차 캐시부터 보므로 플러시하지 않는다.em.flush(), Spring Data의 saveAndFlush()나 flush().플러시와 커밋은 다르다. 플러시는 쌓인 SQL을 DB로 보내는 것이고, 커밋 전이라면 그 SQL은 여전히 롤백될 수 있다. 플러시한다고 영속성 컨텍스트가 비워지지도 않는다. 1차 캐시와 스냅샷은 그대로 남고, 스냅샷만 현재 값으로 갱신된다.
단계 10–14
원본 메서드가 정상 반환되면 인터셉터는 commit()을 부른다. 그런데 commit()은 무조건 커밋하지 않는다. 먼저 롤백 표시가 있는지 확인한다.
// AbstractPlatformTransactionManager.commit() 요약
public final void commit(TransactionStatus status) {
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
if (defStatus.isLocalRollbackOnly()) { // 코드에서 setRollbackOnly()를 호출한 경우
processRollback(defStatus, false);
return;
}
if (defStatus.isGlobalRollbackOnly()) { // 참여한 안쪽 트랜잭션이 롤백 표시를 남긴 경우
processRollback(defStatus, true); // 롤백 후 UnexpectedRollbackException
return;
}
processCommit(defStatus);
}
두 번째 분기가 6막에서 다룰 UnexpectedRollbackException의 출처다. 롤백 표시가 없으면 processCommit()으로 들어간다.
triggerBeforeCommit(): 등록된 TransactionSynchronization의 beforeCommit()을 부른다. 아직 커밋 전이라 여기서 예외가 나면 롤백된다.triggerBeforeCompletion(): 커밋이든 롤백이든 완료 직전에 공통으로 불리는 콜백.doCommit() → EntityTransaction.commit(): Hibernate가 먼저 플러시한다. 스냅샷과 비교해 UPDATE를 만들고 ActionQueue의 SQL을 정해진 순서로 실행한다. 그다음 connection.commit().――――― 여기부터 DB에 확정 ―――――
triggerAfterCommit(): afterCommit() 콜백. @TransactionalEventListener의 기본 단계인 AFTER_COMMIT이 여기서 실행된다.triggerAfterCompletion(): afterCompletion(STATUS_COMMITTED). 결과 상태를 받아 정리 작업을 한다.cleanupAfterCompletion(): 스레드 저장소에서 EntityManager와 커넥션을 떼어내고 EntityManager를 닫는다. 커넥션은 autoCommit, readOnly, 격리 수준을 원래대로 되돌린 뒤 풀로 돌아간다. 보류해 둔 바깥 트랜잭션이 있으면 여기서 재개한다.3번 doCommit()은 status.isNewTransaction()이 true일 때만 실행된다. 기존 트랜잭션에 참여한 안쪽 메서드의 commit()은 이 단계를 건너뛰므로 실제로는 아무것도 커밋하지 않는다. 6막에서 이 사실이 다시 중요해진다.
플러시가 Spring이 아니라 JPA 구현체 안에서 일어난다는 점도 눈여겨볼 만하다. Spring은 EntityTransaction.commit()을 부를 뿐이고, 커밋 전에 플러시하는 것은 Hibernate의 책임이다.
Hibernate의 ActionQueue는 SQL을 종류별로 모아 두고 플러시 때 정해진 순서로 실행한다. orphanRemoval 삭제, INSERT, UPDATE, 컬렉션 변경, DELETE 순이다. 앞의 로그에서 INSERT가 UPDATE보다 먼저 나간 이유가 이것이다. 이 순서는 유니크 제약이 있을 때 문제를 만든다.
@Transactional
public void reissue(Long couponId) {
Coupon old = couponRepository.findById(couponId).orElseThrow();
couponRepository.delete(old); // 코드상으로는 삭제가 먼저
couponRepository.save(new Coupon(old.getCode())); // 같은 code로 다시 발급
} // 플러시: INSERT가 DELETE보다 먼저 실행 → unique 제약 위반
코드 순서대로 실행돼야 한다면 delete() 직후에 couponRepository.flush()를 호출해 DELETE를 먼저 보낸다. 같은 행을 수정하는 쪽으로 설계를 바꿀 수 있다면 그편이 더 낫다.
@Transactional
public void changeEmail(Long memberId, String newEmail) {
Member member = memberRepository.findById(memberId).orElseThrow();
try {
member.changeEmail(newEmail); // UPDATE는 아직 나가지 않는다
} catch (DataIntegrityViolationException e) {
throw new DuplicateEmailException(); // 여기에는 절대 오지 않는다
}
} // ← 커밋 직전 플러시에서 UPDATE 실행, 여기서 중복 키 예외 발생
UPDATE는 메서드가 끝난 뒤 프록시의 커밋 단계에서 실행된다. 그때 나는 제약 위반은 JpaTransactionManager가 DataIntegrityViolationException으로 바꿔 프록시 밖으로 던진다. 메서드 안의 try-catch는 이미 지나간 뒤다.
예외를 메서드 안에서 처리하려면 그 지점에서 플러시해 SQL을 앞당긴다.
try {
member.changeEmail(newEmail);
memberRepository.flush(); // 여기서 UPDATE 실행
} catch (DataIntegrityViolationException e) {
throw new DuplicateEmailException();
}
다만 플러시 중 예외가 나면 Hibernate 세션은 더 이상 믿을 수 없는 상태가 되고, 트랜잭션은 롤백될 수밖에 없다. 예외를 변환해 다시 던지는 용도로만 쓰고, 잡아서 삼킨 뒤 같은 트랜잭션에서 작업을 이어 가지는 말자.
메일 발송이나 메시지 발행처럼 커밋이 성공했을 때만 해야 하는 일이 있다. 1번과 4번 단계의 콜백이 이런 용도다. 가장 쉬운 사용법은 @TransactionalEventListener다.
@Transactional
public Long placeOrder(...) {
...
events.publishEvent(new OrderPlaced(order.getId())); // 지금 바로 실행되지 않는다
return order.getId();
}
@Component
class OrderNotifier {
@TransactionalEventListener // 기본 phase = AFTER_COMMIT
public void on(OrderPlaced event) {
mailSender.sendOrderConfirmation(event.orderId()); // 커밋이 성공했을 때만 실행
}
}
이벤트를 발행하는 순간 리스너가 실행되지 않는다. 리스너는 현재 트랜잭션의 콜백 목록에 등록되었다가 커밋이 성공한 뒤 4번 단계에서 실행된다. 롤백되면 실행되지 않는다.
함정 · AFTER_COMMIT 안에서의 DB 쓰기
AFTER_COMMIT 시점에 DB 커밋은 끝났지만 EntityManager와 커넥션은 아직 스레드에 묶여 있다(정리는 6번 단계). 여기서 리포지토리로 저장하면 이미 끝난 트랜잭션에 참여하게 되고, 그 뒤에 커밋은 다시 오지 않는다. 저장은 조용히 사라진다.
리스너에서 DB에 써야 한다면
@Transactional(propagation = Propagation.REQUIRES_NEW)를 붙여 새 트랜잭션에서 실행한다. Spring 6.1(Boot 3.2)부터는@TransactionalEventListener메서드에 붙는@Transactional이 REQUIRES_NEW나 NOT_SUPPORTED가 아니면 시작 시점에 오류를 낸다.
원본 메서드가 예외를 던지면 인터셉터는 completeTransactionAfterThrowing()에서 롤백할지 말지를 판단한다.
// TransactionAspectSupport.completeTransactionAfterThrowing() 요약
if (txInfo.transactionAttribute.rollbackOn(ex)) {
txManager.rollback(txInfo.getTransactionStatus());
} else {
txManager.commit(txInfo.getTransactionStatus()); // 롤백 대상이 아니면 예외가 나도 커밋한다
}
// DefaultTransactionAttribute: 기본 롤백 규칙
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
| 상황 | 기본 동작 |
|---|---|
| RuntimeException (언체크 예외) | 롤백 |
| Error | 롤백 |
| Exception (체크 예외) | 커밋 |
| 예외를 잡아서 삼킨 경우 | 커밋. 프록시는 예외를 보지 못했다 |
체크 예외에서 커밋하는 것은 EJB에서 이어진 관례다. 체크 예외는 잔액 부족처럼 호출자가 처리할 수 있는 비즈니스 상황을 나타낸다고 보는 것이다. 체크 예외에서도 롤백해야 한다면 규칙을 명시한다.
@Transactional(rollbackFor = Exception.class)
public void transfer(...) throws InsufficientBalanceException { ... }
processRollback()은 beforeCompletion 콜백을 부른 뒤 상황에 따라 갈린다. 자기가 시작한 트랜잭션(isNewTransaction())이면 doRollback()으로 EntityTransaction.rollback()을 부르고, 이것이 connection.rollback()까지 내려간다. 기존 트랜잭션에 참여 중이었다면 물리 트랜잭션을 직접 롤백하지 않고 롤백 표시만 남긴다. 그 뒤 afterCompletion(STATUS_ROLLED_BACK)을 부르고 커밋과 똑같이 정리한다.
롤백되는 것은 DB뿐이다. 예제에서 롤백이 나도 메모리의 item은 여전히 stock=8이고, persist된 order는 할당받은 ID를 그대로 들고 있다. 트랜잭션이 끝나면 EntityManager도 닫히므로 이 객체들은 DB와 어긋난 준영속 객체가 된다. 롤백 이후에 이 객체들을 다시 쓰지 말고 필요하면 새로 조회한다.
OSIV처럼 EntityManager가 트랜잭션보다 오래 사는 경우에는 JpaTransactionManager가 롤백 때 em.clear()로 영속성 컨텍스트를 비운다. 롤백된 변경이 다음 트랜잭션에서 다시 플러시되는 일을 막기 위해서다.
@Transactional
public void dryRun(...) {
...
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
} // commit()이 isLocalRollbackOnly()를 보고 조용히 롤백한다
이 경우는 commit()의 첫 번째 분기로 가므로 UnexpectedRollbackException이 나지 않는다. 롤백을 요청한 쪽이 커밋을 요청한 쪽과 같기 때문이다.
예제에서 placeOrder()도, 그 안에서 부르는 findById()와 save()도 모두 @Transactional이다. 그런데 로그를 보면 커넥션은 하나, 커밋도 한 번뿐이었다. 두 개념을 나눠야 이해가 된다.
setAutoCommit(false)부터 commit()/rollback()까지.@Transactional 메서드 하나하나가 만드는 범위. 각자 TransactionStatus를 가진다.규칙은 단순하다. 물리 트랜잭션은 그것을 시작한 논리 트랜잭션이 끝날 때 한 번 커밋되거나 롤백된다. 참여한 논리 트랜잭션들이 모두 정상이어야 커밋되고, 하나라도 롤백을 원하면 전체가 롤백된다. 어떻게 참여할지를 정하는 것이 전파 속성이다. 진행 중인 트랜잭션이 있을 때 handleExistingTransaction()이 이 표대로 분기한다.
| 전파 속성 | 진행 중인 트랜잭션이 있으면 | 없으면 |
|---|---|---|
REQUIRED (기본) | 참여 | 새로 시작 |
REQUIRES_NEW | 기존 것을 보류하고 새로 시작 | 새로 시작 |
SUPPORTS | 참여 | 트랜잭션 없이 실행 |
MANDATORY | 참여 | IllegalTransactionStateException |
NOT_SUPPORTED | 기존 것을 보류하고 트랜잭션 없이 실행 | 트랜잭션 없이 실행 |
NEVER | IllegalTransactionStateException | 트랜잭션 없이 실행 |
NESTED | 세이브포인트 (JPA는 기본 미지원) | 새로 시작 |
REQUIRED와 REQUIRES_NEW를 시간축에 놓고 비교하면 차이가 분명해진다.

REQUIRED는 물리 트랜잭션 하나를 공유한다. REQUIRES_NEW는 바깥 물리 트랜잭션(A)을 보류한 채 두 번째 물리 트랜잭션(B)을 연다.
안쪽 메서드가 REQUIRED로 참여하면 getTransaction()은 새 자원을 만들지 않고 isNewTransaction=false인 TransactionStatus만 돌려준다. 안쪽 메서드가 정상 종료해 commit()이 불려도 doCommit()은 건너뛴다. 안쪽에서 롤백 대상 예외가 나면 물리 트랜잭션을 롤백하는 대신 공유 중인 트랜잭션에 rollback-only 표시를 남긴다. 이 표시가 다음 함정의 원인이다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final PointService pointService;
@Transactional
public void placeOrder(...) {
orderRepository.save(order);
try {
pointService.addPoint(memberId, 100); // @Transactional (REQUIRED)
} catch (RuntimeException e) {
log.warn("포인트 적립 실패, 주문은 계속 진행", e);
}
} // ← 여기서 UnexpectedRollbackException
}
@Service
public class PointService {
@Transactional
public void addPoint(Long memberId, int amount) {
throw new IllegalStateException("포인트 서버 오류");
}
}
예외를 잡았으니 주문은 저장될 것 같지만, 실제로는 주문도 롤백되고 예외까지 터진다. 순서대로 보면 이렇다.
addPoint()의 프록시가 RuntimeException을 보고 rollback()을 호출한다.isNewTransaction=false다. 물리 트랜잭션을 직접 롤백할 권한이 없다.EntityTransaction.setRollbackOnly()다.placeOrder()는 예외를 잡고 정상 반환한다. 프록시는 commit()을 호출한다.commit()은 isGlobalRollbackOnly()가 true인 것을 보고 커밋 대신 롤백한다. 커밋을 요청한 호출자에게 실제로는 롤백됐다는 사실을 알리려고 UnexpectedRollbackException을 던진다.DEBUG JpaTransactionManager : Participating in existing transaction
DEBUG JpaTransactionManager : Participating transaction failed - marking existing transaction as rollback-only
DEBUG JpaTransactionManager : Setting JPA transaction on EntityManager [SessionImpl(1846302731<open>)] rollback-only
WARN OrderService : 포인트 적립 실패, 주문은 계속 진행
DEBUG JpaTransactionManager : Global transaction is marked as rollback-only but transactional code requested commit
DEBUG JpaTransactionManager : Initiating transaction rollback
DEBUG JpaTransactionManager : Rolling back JPA transaction on EntityManager [SessionImpl(1846302731<open>)]
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
해결 방향은 의도에 따라 다르다.
addPoint()를 REQUIRES_NEW로 만든다. 독립된 물리 트랜잭션이라 안쪽만 롤백되고, 바깥은 예외를 잡은 뒤 정상 커밋된다.@Transactional이다. 리포지토리 호출에서 난 예외를 서비스에서 잡아 삼켜도 같은 일이 생긴다.REQUIRES_NEW는 진행 중인 트랜잭션의 자원을 스레드 저장소에서 떼어 보관해 둔다.
// JpaTransactionManager.doSuspend() 요약
protected Object doSuspend(Object transaction) {
JpaTransactionObject txObject = (JpaTransactionObject) transaction;
txObject.setEntityManagerHolder(null, false);
EntityManagerHolder emHolder = (EntityManagerHolder)
TransactionSynchronizationManager.unbindResource(obtainEntityManagerFactory());
ConnectionHolder conHolder = null;
if (getDataSource() != null && TransactionSynchronizationManager.hasResource(getDataSource())) {
conHolder = (ConnectionHolder) TransactionSynchronizationManager.unbindResource(getDataSource());
}
return new SuspendedResourcesHolder(emHolder, conHolder);
}
저장소가 비었으니 이어지는 doBegin()은 새 EntityManager를 만들고 새 커넥션을 얻는다. 안쪽 트랜잭션이 끝나면 cleanupAfterCompletion()이 보관해 둔 자원을 다시 묶는다. 로그에는 Suspending current transaction, creating new transaction with name [...]과 Resuming suspended transaction after completion of inner transaction이 찍힌다. 이 구조에서 두 가지 결과가 나온다.
함정 1 · 영속성 컨텍스트가 다르다
안쪽 트랜잭션은 바깥과 다른 EntityManager를 쓴다. 바깥에서 조회한 엔티티를 넘겨받아 안쪽에서 수정해도 안쪽 EntityManager는 그 엔티티를 모르므로 안쪽 커밋에 반영되지 않는다. 대신 그 엔티티를 관리하는 바깥 EntityManager가 나중에 바깥 커밋 때 플러시한다. 안쪽에서는 식별자만 넘겨받아 다시 조회하는 것이 안전하다.
함정 2 · 커넥션 풀 데드락
한 스레드가 커넥션 두 개를 동시에 쥔다. HikariCP 풀 크기가 기본값 10이고 동시 요청 10개가 모두 바깥 트랜잭션을 잡은 상태에서 REQUIRES_NEW에 들어가면, 11번째 커넥션은 영원히 오지 않는다. 모든 요청이 서로를 기다리다
connection-timeout(기본 30초) 뒤에 실패한다. REQUIRES_NEW를 쓴다면 풀 크기를 넉넉히 잡거나, 바깥 트랜잭션이 끝난 뒤에 호출하도록 흐름을 바꾼다.
NESTED는 JDBC 세이브포인트로 부분 롤백을 하는 전파 속성이다. JpaTransactionManager는 nestedTransactionAllowed가 기본 false라 그대로 쓰면 NestedTransactionNotSupportedException이 난다. 켜더라도 세이브포인트는 JDBC 커넥션에만 적용된다. 1차 캐시와 ActionQueue는 되돌아가지 않으니 영속성 컨텍스트와 DB 상태가 어긋난다. JPA 자체가 중첩 트랜잭션을 지원하지 않는다.
readOnly = true는 힌트 하나가 아니다. 세 층에서 각각 다른 일이 일어난다.
| 층 | 바뀌는 것 | 효과 |
|---|---|---|
| Spring | 스레드 저장소의 currentTransactionReadOnly = true | 라우팅 DataSource가 이 값을 보고 레플리카로 보낼 수 있다 |
| Hibernate | FlushMode를 MANUAL로, 세션을 기본 읽기 전용(setDefaultReadOnly)으로 | 커밋 때 플러시하지 않는다. 조회한 엔티티의 스냅샷을 만들지 않아 메모리와 비교 비용이 준다 |
| JDBC | Connection.setReadOnly(true) | 드라이버가 DB에 읽기 전용 트랜잭션임을 알린다. MySQL InnoDB는 읽기 전용 트랜잭션 최적화를 적용하고, PostgreSQL은 쓰기 SQL을 거부한다 |
세션 읽기 전용 설정은 트랜잭션이 직접 만든 EntityManager에만 적용된다. OSIV로 미리 열린 EntityManager에는 FlushMode 변경만 적용된다.
readOnly 트랜잭션 안에서 엔티티를 수정하면 UPDATE가 나가지 않는다. 예외도 나지 않으니 조용히 사라진다. 바깥이 readOnly인 상태에서 쓰기 메서드를 REQUIRED로 호출해도 마찬가지다. 안쪽은 바깥 트랜잭션에 참여할 뿐이고, 안쪽의 @Transactional 설정은 새 트랜잭션을 만들 때만 의미가 있다.
@Repository
@Transactional(readOnly = true) // 클래스 기본값: 읽기 전용
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
@Transactional // 쓰기 메서드만 다시 지정
public <S extends T> S save(S entity) { ... }
public Optional<T> findById(ID id) { ... } // readOnly 트랜잭션
}
그래서 리포지토리의 조회 메서드를 트랜잭션 없이 단독으로 부르면 readOnly 트랜잭션에서 실행된다. 서비스 트랜잭션 안에서 부르면 기존 트랜잭션에 참여하므로 서비스의 설정을 따른다. 인터페이스에 직접 선언한 쿼리 메서드(@Query, 메서드 이름 쿼리)에는 트랜잭션 설정이 적용되지 않는다. @Modifying 쿼리를 트랜잭션 밖에서 부르면 TransactionRequiredException이 나는 이유다.
원리 · 레플리카 라우팅에 LazyConnectionDataSourceProxy가 필요한 이유
AbstractRoutingDataSource로 readOnly 트랜잭션을 레플리카에 보내려 하면 처음에는 잘 되지 않는다. 2막에서 본 순서 때문이다. 커넥션은doBegin()안에서 획득되는데, 스레드에 readOnly 값을 기록하는prepareSynchronization()은doBegin()이 끝난 다음에 실행된다. 커넥션을 고르는 순간에는 readOnly 값이 아직 없다.
LazyConnectionDataSourceProxy로 감싸면 실제 커넥션 획득이 첫 SQL 실행 시점으로 미뤄진다. 그때는 readOnly 값이 기록되어 있으므로 라우팅이 제대로 동작한다.
지금까지는 영속성 컨텍스트의 수명이 트랜잭션과 같다고 했다. Spring Boot의 기본 설정에서는 그렇지 않다. spring.jpa.open-in-view가 기본 true이고, 그래서 부팅할 때마다 이런 경고가 찍힌다.
WARN JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
OSIV(Open Session In View)가 켜져 있으면 OpenEntityManagerInViewInterceptor가 요청이 들어올 때 EntityManager를 만들어 스레드에 묶는다. 이후 트랜잭션이 시작되면 doBegin()은 새 EntityManager를 만들지 않고 이미 묶여 있는 것을 쓴다. 트랜잭션이 끝나도 자기가 만든 것이 아니므로 닫지 않는다. EntityManager는 요청이 끝날 때 인터셉터가 닫는다.
요청 하나를 세 구간으로 나눠 각 자원이 언제 살아 있는지 표시하면 이렇다.
| 자원 | 컨트롤러 | 서비스 (@Transactional) | 컨트롤러 · 응답 직렬화 |
|---|---|---|---|
| 트랜잭션 | ● | ||
| 영속성 컨텍스트 · OSIV 끔 | ● | ||
| DB 커넥션 · OSIV 끔 | ● | ||
| 영속성 컨텍스트 · OSIV 켬 | ● | ● | ● |
| DB 커넥션 · OSIV 켬 | ● | ● |
장점은 컨트롤러나 뷰에서도 지연 로딩이 된다는 것이다. 대가는 커넥션이다. Spring의 HibernateJpaVendorAdapter는 Hibernate의 커넥션 처리 모드를 DELAYED_ACQUISITION_AND_HOLD로 설정한다. 한 번 얻은 커넥션을 EntityManager가 닫힐 때까지 놓지 않는다는 뜻이다. OSIV에서는 EntityManager가 요청 끝까지 살아 있으니, 트랜잭션이 끝난 뒤에도 응답을 만들고 보내는 동안 커넥션을 쥐고 있다. 응답이 느린 외부 호출이 컨트롤러에 섞여 있다면 그 시간만큼 커넥션 풀이 마른다.
@GetMapping("/orders/{id}")
public OrderView view(@PathVariable Long id) {
Order order = orderService.find(id); // 트랜잭션 종료. 하지만 EM은 열려 있다
order.getMember().maskName(); // 화면용으로 이름을 가린다 (엔티티 수정!)
viewLogService.record(id); // @Transactional → 같은 EM으로 새 트랜잭션
return OrderView.from(order); // ↑ 이 커밋에서 member UPDATE가 같이 나간다
}
트랜잭션 밖에서의 수정은 플러시되지 않으니 안전해 보인다. 하지만 viewLogService.record()의 트랜잭션은 요청 시작 때 열린 같은 EntityManager를 쓰고, 커밋 직전 플러시는 이 EntityManager가 관리하는 모든 엔티티를 검사한다. 앞에서 바꿔 둔 회원 이름까지 UPDATE된다.
트래픽이 많은 API 서버라면 spring.jpa.open-in-view: false로 끄고, 응답에 필요한 데이터는 트랜잭션 안에서 페치 조인 등으로 미리 읽어 DTO로 만들어 반환하는 편이 안전하다. 그러면 영속성 컨텍스트와 커넥션의 수명이 다시 트랜잭션과 같아진다.
@Transactional을 해석한다 (메서드별 캐시)getTransaction()commit()UnexpectedRollbackException을 던진다rollbackOn(ex)로 판단| 증상 | 원인 | 해결 |
|---|---|---|
| @Transactional이 적용되지 않는다 | 자기 호출, private·final 메서드, @PostConstruct 안의 호출 | 트랜잭션 메서드를 다른 빈으로 분리 |
| save()를 안 불렀는데 UPDATE가 나간다 | 커밋 직전 플러시의 변경 감지 | 의도된 동작. 조회 전용이면 readOnly = true |
| try-catch로 DB 예외를 못 잡는다 | 쓰기 SQL이 커밋 시점 플러시에서 실행된다 | 필요한 지점에서 flush() 또는 saveAndFlush() |
| UnexpectedRollbackException | 참여한 안쪽 트랜잭션이 rollback-only 표시를 남겼다 | REQUIRES_NEW로 분리하거나 예외 경계를 다시 설계 |
| 체크 예외가 났는데 커밋됐다 | 기본 롤백 규칙은 RuntimeException과 Error만 | rollbackFor = Exception.class |
| 커넥션 풀이 마른다 | REQUIRES_NEW 중첩, OSIV, 트랜잭션 안의 외부 API 호출 | 트랜잭션 범위 축소, open-in-view: false |
| delete 후 같은 키로 insert했는데 unique 위반 | ActionQueue는 INSERT를 DELETE보다 먼저 실행한다 | delete 직후 flush() |
| 다른 스레드에서 트랜잭션·지연 로딩이 안 된다 | 자원이 ThreadLocal에 묶여 있다 | 필요한 데이터를 미리 읽어 넘기거나 그 스레드에서 새 트랜잭션 |
| TransactionRequiredException | 트랜잭션 없이 persist·remove·flush 또는 @Modifying 쿼리 실행 | 호출 경로에 @Transactional 추가 |
| AFTER_COMMIT 리스너의 저장이 반영되지 않는다 | 이미 커밋된 트랜잭션에 참여했다 | 리스너에 @Transactional(propagation = REQUIRES_NEW) |
| 트랜잭션 밖에서 바꾼 값이 나중에 DB에 반영됐다 | OSIV로 살아 있는 영속성 컨텍스트가 다음 트랜잭션에서 플러시됐다 | open-in-view: false, 엔티티 대신 DTO 반환 |
@Transactional의 동작은 세 가지 장치로 정리된다. 호출을 가로채는 프록시, 자원을 스레드에 묶어 두는 ThreadLocal 저장소, 그리고 커밋 직전에 변경을 SQL로 바꿔 보내는 플러시. 트랜잭션이 이상하게 동작하면 이 셋 중 어디에서 생긴 일인지부터 따져 보자. 대부분은 거기서 답이 나온다.