본문 바로가기

Spring Framework/Spring

[Spring] 트랜잭션 완전 정리 - @Transactional의 내부 동작과 함정들

Spring Boot 3.x 기준으로 작성하였습니다.


1. PlatformTransactionManager

Spring의 트랜잭션 추상화 계층 최상단에는 PlatformTransactionManager 인터페이스가 있습니다.

public interface PlatformTransactionManager {
    TransactionStatus getTransaction(TransactionDefinition definition);
    void commit(TransactionStatus status);
    void rollback(TransactionStatus status);
}

이 인터페이스 덕분에 @Transactional은 특정 기술에 종속되지 않습니다. JPA를 쓰든 MyBatis를 쓰든 @Transactional 코드는 바뀌지 않고, 구현체만 교체됩니다. 이것이 Spring의 PSA(Portable Service Abstraction) 대표 예시입니다.

 

구현체 사용 시점 현재 상태
JpaTransactionManager JPA 사용 시 현재 표준
DataSourceTransactionManager JDBC, MyBatis 현재도 유효
HibernateTransactionManager 순수 Hibernate 레거시
JtaTransactionManager 분산 트랜잭션 (다중 DB) 별도 설정 필요

 

Spring Boot는 의존성에 따라 TransactionManager자동으로 등록합니다. spring-boot-starter-data-jpa가 있으면 JpaTransactionManager, JPA 없이 DataSource만 있으면 DataSourceTransactionManager가 등록됩니다. 별도 설정이 필요하지 않습니다.

 


2. @Transactional과 프록시 패턴

@Transactional프록시 패턴으로 동작합니다. Spring은 @Transactional이 붙은 Bean을 그대로 등록하는 것이 아니라, 트랜잭션 처리 코드가 추가된 프록시 객체를 대신 등록합니다.

// 실제 UserService 대신 이런 프록시가 Bean으로 등록됨 (개념적 표현)
public class UserService_CGLIB extends UserService {

    @Override
    public void save(User user) {
        TransactionStatus tx = transactionManager.getTransaction(...);
        try {
            super.save(user);           // 실제 로직 호출
            transactionManager.commit(tx);
        } catch (RuntimeException e) {
            transactionManager.rollback(tx);
            throw e;
        }
    }
}

외부에서 userService.save()를 호출하면 이 프록시가 먼저 받아서 트랜잭션을 열고, 실제 로직을 실행한 뒤, 트랜잭션을 닫습니다.

 


3. JDK Dynamic Proxy vs CGLIB

Spring이 프록시를 만드는 방식은 두 가지입니다.

 

항목 JDK Dynamic Proxy CGLIB
기반 인터페이스 구현 클래스 상속
인터페이스 필요 여부 필수 불필요
프록시 생성 방식 리플렉션 바이트코드 직접 조작
메서드 호출 속도 느림 빠름
생성자 호출 횟수 1번 2번
구현 클래스 타입으로 주입 불가 가능
final 클래스/메서드 해당 없음 프록시 불가
Spring Boot 기본값 X O (2.0+)

 

Spring Boot가 CGLIB을 기본으로 바꾼 이유

Spring Boot 2.0(2018) 이전까지는 JDK Dynamic Proxy가 기본이었습니다. 그런데 JDK 프록시는 인터페이스 타입으로 생성되기 때문에, 구현 클래스 타입으로 주입받으려 하면 예외가 발생했습니다.

public interface UserService { ... }

@Service
public class UserServiceImpl implements UserService { ... }

// JDK 프록시 시절
@Autowired
private UserServiceImpl userService;
// BeanNotOfRequiredTypeException 발생
// 프록시는 $Proxy1 implements UserService 타입 → UserServiceImpl로 캐스팅 불가

 

CGLIB은 클래스를 상속해서 프록시를 만들기 때문에 구현 클래스 타입으로 주입받아도 문제가 없습니다. 여기에 메서드 호출 성능도 더 빠르고, Spring 4.x부터 CGLIB이 Spring 코어에 내장되어 별도 의존성도 필요 없어졌습니다. 이 세 가지 이유로 CGLIB이 기본값이 되었습니다.

 

CGLIB과 생성자 이중 호출

CGLIB은 원본 클래스를 상속하므로, 프록시 생성 시 super()가 자동으로 호출됩니다. 결과적으로 생성자가 두 번 실행됩니다.

@Service
public class UserService {
    public UserService() {
        System.out.println("생성자 호출");
        // 1번: 실제 UserService 인스턴스 생성 시
        // 2번: UserService_CGLIB 인스턴스 생성 시 super() 자동 호출
    }
}

 

이 때문에 생성자에 무거운 초기화 로직을 넣으면 의도치 않게 두 번 실행됩니다. 초기화 로직은 @PostConstruct 넣는 것이 올바른 방식입니다.

@Service
public class UserService {
    @PostConstruct
    public void init() {
        // 실제 객체에서만 한 번 실행됨
    }
}

 


4. @Transactional 적용 범위와 주의사항

클래스 레벨 vs 메서드 레벨

프록시는 클래스 단위로 하나만 생성됩니다. 클래스 레벨과 메서드 레벨의 차이는 프록시 생성 방식이 아니라, 어떤 메서드에 트랜잭션을 적용할지의 차이입니다.

@Transactional      // 클래스 레벨: 모든 public 메서드에 적용
@Service
public class UserService {
    public void save() { ... }   // 적용
    public void find() { ... }   // 적용 (읽기에도 트랜잭션 → 불필요한 오버헤드)
}

 

클래스 레벨로 걸면 읽기 메서드에도 트랜잭션이 열려 오버헤드가 생깁니다. 실무에서는 아래 혼합 패턴을 사용합니다.

@Transactional(readOnly = true)     // 클래스 기본값: 읽기 전용
@Service
public class UserService {

    public User findById(Long id) { ... }   // readOnly = true 적용

    @Transactional                          // 메서드 레벨이 클래스 레벨보다 우선
    public void save(User user) { ... }     // readOnly = false

    @Transactional
    public void delete(Long id) { ... }     // readOnly = false
}

readOnly = true는 단순히 "읽기만 허용"이 아니라 실질적인 성능 이점이 있습니다.

  • 영속성 컨텍스트의 변경 감지(Dirty Checking)를 수행하지 않습니다.
  • 스냅샷 저장을 생략하여 메모리 사용량이 줄어듭니다.
  • DB나 JDBC 드라이버에 따라 읽기 전용 최적화(커넥션 재사용 등)가 적용될 수 있습니다.

 

트랜잭션이 조용히 무시되는 케이스

아래 케이스들은 예외도 없이 트랜잭션이 그냥 무시되기 때문에 특히 주의해야 합니다.

// 1. 인터페이스에 붙이는 경우 → CGLIB 환경에서 인식하지 못할 수 있음 (비권장)
public interface UserService {
    @Transactional
    void save(User user);
}

// 2. final 메서드 → 오버라이드 불가 → 트랜잭션 무시, 예외도 없음
public class UserService {
    @Transactional
    public final void save() { ... }
}

// 3. private 메서드 → 오버라이드 불가 → 트랜잭션 무시
public class UserService {
    @Transactional
    private void save() { ... }
}

final 클래스@Transactional을 붙이면 프록시 생성 자체가 실패하여 예외가 발생합니다. 반면 final 메서드private 메서드는 프록시 생성은 되지만 해당 메서드의 트랜잭션만 조용히 빠집니다. 에러가 나지 않아서 더 위험합니다.

실무 규칙: @Transactional은 항상 구현 클래스의 public 메서드에 붙입니다.

 


5. 자기 호출(Self Invocation) 함정

Spring AOP의 가장 대표적인 함정입니다. 같은 클래스 내부에서 this.메서드()를 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다.

 

@Service
public class UserService {

    public void saveAll(List<User> users) {
        for (User user : users) {
            save(user);   // this.save() → 프록시 우회 → 트랜잭션 없음
        }
    }

    @Transactional
    public void save(User user) {
        // @Transactional이 붙어 있지만 트랜잭션이 적용되지 않음
    }
}

외부에서 userService.saveAll()을 호출하면 프록시를 통해 들어오지만, saveAll() 내부의 save() 호출은 this 참조로 실제 객체를 직접 호출합니다. 프록시는 외부에서 들어오는 호출만 가로챌 수 있기 때문입니다.

 

해결 방법: 클래스 분리

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserSaveService userSaveService;

    public void saveAll(List<User> users) {
        for (User user : users) {
            userSaveService.save(user);   // 외부 호출 → 프록시 통과 → 트랜잭션 적용
        }
    }
}

@Service
public class UserSaveService {

    @Transactional
    public void save(User user) { ... }
}

자기 호출이 필요한 상황이 생긴다면 설계가 잘못된 신호로 보고 클래스를 분리하는 것이 좋습니다. Self Injection이나 AopContext.currentProxy()로 해결하는 방법도 있지만, 코드가 AOP 구현에 종속되고 가독성이 나빠져 권장하지 않습니다.

 


6. Hibernate 레거시 API 현황

토비의 스프링 등 구형 문헌을 읽을 때 등장하는 클래스들입니다. Spring Boot 환경에서는 직접 쓸 일이 없지만, 레거시 코드를 읽을 때 참고용으로 알아두면 됩니다.

클래스 상태 비고
AnnotationSessionFactoryBean 제거됨 (Spring 4+) Spring 3.1에서 LocalSessionFactoryBean에 흡수
HibernateTemplate 제거됨 (Spring 6) @Repository + @Transactional + EntityManager로 대체
HibernateTransactionManager 레거시 순수 Hibernate만 쓰는 구형 프로젝트에서만 사용
LocalSessionFactoryBean 존재하나 불필요 Boot 자동 구성이 대체
SessionFactory 현재도 존재 EntityManagerFactory 내부에서 동작 중

SessionFactory 자체는 사라지지 않았습니다. EntityManagerFactory가 내부적으로 이를 래핑하고 있으며, Hibernate 전용 기능이 필요할 때만 꺼내 쓸 수 있습니다.

// 특수한 경우에만 사용
Session session = entityManager.unwrap(Session.class);
session.setJdbcBatchSize(50);   // JPA 표준에 없는 Hibernate 전용 기능

 


정리

개념 핵심 요약
PlatformTransactionManager 트랜잭션 추상화 인터페이스. Boot에서 의존성에 따라 자동 등록
JpaTransactionManager Spring Boot + JPA 환경의 현재 표준
프록시 방식 Spring Boot 2.0+ 기본은 CGLIB. 인터페이스 없어도 되고, 구현 클래스 타입 주입 가능
생성자 이중 호출 CGLIB 상속 특성. 초기화 로직은 @PostConstruct
readOnly = true 혼합 패턴 클래스 레벨 기본값으로 걸고, 쓰기 메서드만 @Transactional 오버라이드
트랜잭션 조용한 무시 final 메서드, private 메서드, 인터페이스 어노테이션 → 에러 없이 무시됨
자기 호출 this.메서드() 는 프록시를 우회 → 클래스 분리로 해결