Loading the catalog…
Loading the catalog…
들어가며 @Transactional 을 붙이면 트랜잭션은 알아서 처리된다. 대부분은 그걸로 충분하다. 그런데 아래 질문 중 하나라도 막힌다면 그 내부를 한 번 들여다볼 때다. 같은 클래스 안에서 @Transactional 메서드를 호출했더니 트랜잭션이 걸리지 않는다. 왜일까? save() 를 호출하지 않았는데 UPDATE 쿼리가 나간다. 누가 보낸 걸까? 예외를 try-catch로 잡았는데도 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를, 같은 스레드에서 실행되는 리포지토리가 꺼내 쓸 뿐이다. 트랜잭션에 관한 거의 모든 함정이 이 두 사실에서 나온다. 한 번의 호출, 14단계 컨트롤러가 orderService.placeOrder() 를 호출했을 때 일어나는 일을 시간 순서대로 나열했다. 세부 내용은 뒤에서 막별로 다시 다루니, 여기서는 흐름만 잡으면 된다. 시작 (1–6) 프록시가 호출을 가로챈다 · 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 여부와 콜백 목록을 초기화한다. 실행 (7–9) 원본 메서드가 실행된다 · OrderService 인터셉터가 invocation.proceed() 로 원본 OrderService.placeOrder() 를 호출한다. 리포지토리가 같은 EntityManager를 꺼내 쓴다 · 공유 EM 프록시 findById() 는 내부에서 em.find() 를 부른다. 이 em은 프록시라서 스레드 저장소에서 4단계의 EntityManager를 찾아 위임한다. 리포지토리 메서드에도 @Transactional 이 있지만 기존 트랜잭션에 참여할 뿐이다. 1차 캐시에 없으니 SELECT가 나간다. 변경은 메모리에만 쌓인다 · 영속성 컨텍스트 item.removeStock() 은 자바 객체의 필드만 바꾼다. orderRepository.save() 는 INSERT를 쓰기 지연 저장소(ActionQueue)에 넣는다. 아직 DB로 간 쓰기 SQL은 하나도 없다. 종료 (10–14) 정상 반환, 커밋 요청 · 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막. 프록시: 호출을 가로채는 문지기 단계 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); // 1 @Transactional 해석 PlatformTransactionManager tm = determineTransactionManager(txAttr); TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpoint); // 2 시작 또는 참여 Object retVal; try { retVal = invocation.proceedWithInvocation(); // 3 원본 메서드 실행 } catch (Throwable ex) { completeTransactionAfterThrowing(txInfo, ex); // 4 롤백 규칙 판단 throw ex; } finally { cleanupTransactionInfo(txInfo); } commitTransactionAfterReturning(txInfo); // 5 커밋 return retVal; } 구조는 try-catch 하나다. 시작하고, 실행하고, 예외가 나면 롤백 규칙을 따지고, 아니면 커밋한다. 이 글의 나머지는 1부터 5까지를 하나씩 깊게 들어가는 이야기다. 1의 해석은 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 을 쓰는 방법도 있다. 프록시 대신 코드로 같은 일을 한다. 프록시가 적용되지 않는 메서드 private 메서드 : 하위 클래스가 오버라이드할 수 없으니 프록시가 끼어들 자리가 없다. 애너테이션은 조용히 무시된다. final 메서드와 final 클래스 : 같은 이유로 적용되지 않는다. Kotlin은 클래스가 기본 final이라 kotlin-spring 플러그인이 대신 열어 준다. protected, package-private 메서드 : Spring 6.0부터 CGLIB 프록시에서 적용된다. 그 이전 버전은 public 메서드만 지원했다. @PostConstruct 안에서의 호출 : 결국 자기 호출이고, 초기화 콜백이 실행되는 시점에는 프록시가 아직 만들어지지도 않았다. 시작 시점에 트랜잭션이 필요하면 ApplicationReadyEvent 리스너에서 다른 빈을 호출한다. 2막. 트랜잭션 매니저: 시작은 어떻게 이뤄지나 단계 3–6 PlatformTransactionManager: 기술을 감추는 추상화 인터셉터는 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(T
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
JPA 트랜잭션 해부. 들어가며 @Transactional 을 붙이면 트랜잭션은 알아서 처리된다. 대부분은 그걸로 충분하다. 그런데 아래 질문 중 하나라도 막힌다면 그 내부를 한 번 들여다볼 때다. 같은 클래스 안에서 @Transactional 메서드를 호출했더니 트랜잭션이 걸리지 않는다. 왜일까? save() 를 호출하지 않았는데 UPDATE 쿼리가 나간다. 누가 보낸 걸까? 예외를 try-catch로 잡았는데도 UnexpectedRollbackException 이 터진다. REQUIRES_NEW 를 썼더니 트래픽이 몰릴 때 커넥션 풀이 바닥난다. readOnly = true 는 정확히 무엇을 바꿀까? 이 글은 @Transactional 메서드 하나가 호출되는 순간부터 커넥션이 풀로 돌아가는 순간까지를…
Open source