Spring Boot @Transactional

들어가며: @Transactional을 붙이면 끝이라는 착각

Spring Boot에서 @Transactional은 가장 많이 쓰이면서도 가장 많이 오해되는 어노테이션이다. “메서드에 붙이면 트랜잭션이 걸린다”는 수준에서 멈추면, 전파(propagation) 설정 실수로 데이터 정합성이 깨지거나, 체크 예외에서 롤백이 안 되는 버그를 운영에서 마주하게 된다.

이 글에서는 Spring Framework 6.x / Spring Boot 3.x 공식 문서(Transaction Management)를 근거로, @Transactional의 propagation · isolation · rollback 규칙을 실무 시나리오와 함께 정리한다.

1. @Transactional 동작 원리: 프록시 기반 AOP

1-1. 프록시가 트랜잭션을 건다

Spring 공식 문서에 따르면, @TransactionalAOP 프록시를 통해 동작한다. 빈(Bean)의 메서드를 호출할 때 프록시가 가로채서 PlatformTransactionManager를 이용해 트랜잭션을 시작·커밋·롤백한다.

이 구조에서 가장 중요한 제약이 하나 있다:

@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderDto dto) {
        // 트랜잭션 적용됨
        saveOrder(dto);
        sendNotification(dto); // 같은 클래스 내부 호출
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(OrderDto dto) {
        // ❌ REQUIRES_NEW가 동작하지 않음!
        // 프록시를 거치지 않는 내부 호출(self-invocation)
    }
}

같은 클래스 내부에서의 메서드 호출(self-invocation)은 프록시를 거치지 않으므로 @Transactional이 무시된다. Spring 공식 문서에 명시적으로 경고하는 사항이다.

1-2. 해결 방법

방법 설명 권장도
별도 Bean으로 분리 트랜잭션 경계가 다른 메서드를 별도 서비스 클래스로 추출 ✅ 권장
self 주입 @Lazy로 자기 자신을 주입받아 프록시 경유 호출 ⚠ 가능하지만 가독성 저하
AspectJ 위빙 컴파일/로드타임 위빙으로 프록시 없이 동작 ⚠ 설정 복잡도 높음

2. Propagation: 트랜잭션을 어떻게 전파할 것인가

2-1. 7가지 전파 옵션

Propagation 기존 트랜잭션 있을 때 기존 트랜잭션 없을 때 대표 사용 사례
REQUIRED (기본값) 기존 트랜잭션에 참여 새 트랜잭션 생성 일반적인 서비스 메서드
REQUIRES_NEW 기존 트랜잭션 일시 중단, 새 트랜잭션 생성 새 트랜잭션 생성 감사 로그, 알림 — 메인 트랜잭션과 독립적으로 커밋해야 할 때
NESTED 세이브포인트를 생성하여 중첩 새 트랜잭션 생성 부분 롤백이 필요한 배치 처리
SUPPORTS 기존 트랜잭션에 참여 트랜잭션 없이 실행 읽기 전용 메서드
NOT_SUPPORTED 기존 트랜잭션 일시 중단 트랜잭션 없이 실행 외부 API 호출(트랜잭션 밖에서 실행)
MANDATORY 기존 트랜잭션에 참여 예외 발생 반드시 트랜잭션 안에서만 호출되어야 하는 메서드
NEVER 예외 발생 트랜잭션 없이 실행 트랜잭션 안에서 호출되면 안 되는 메서드

2-2. REQUIRED vs. REQUIRES_NEW: 실무에서 가장 많이 헷갈리는 조합

@Service
public class PaymentService {

    @Transactional // REQUIRED (기본값)
    public void processPayment(PaymentDto dto) {
        deductBalance(dto);
        auditService.logAudit(dto); // REQUIRES_NEW
        updateOrderStatus(dto);
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAudit(PaymentDto dto) {
        // 별도 트랜잭션에서 실행
        // processPayment가 롤백되어도 감사 로그는 유지됨
    }
}

핵심 차이:

  • REQUIRED로 참여하면 — 부모가 롤백되면 자식도 함께 롤백
  • REQUIRES_NEW면 — 부모와 독립적으로 커밋/롤백
  • REQUIRES_NEW는 DB 커넥션을 하나 더 사용하므로, 커넥션 풀 고갈에 주의해야 한다

2-3. NESTED: 세이브포인트를 이용한 부분 롤백

@Transactional
public void batchProcess(List<Item> items) {
    for (Item item : items) {
        try {
            itemService.processItem(item); // NESTED
        } catch (Exception e) {
            // 이 아이템만 롤백, 나머지는 계속 처리
            log.warn("Item {} failed: {}", item.getId(), e.getMessage());
        }
    }
}

주의: NESTED는 JDBC 세이브포인트에 의존하므로 JPA/Hibernate에서는 JpaTransactionManager가 아닌 DataSourceTransactionManager를 사용해야 한다. JPA 환경에서 NESTED를 쓰면 NestedTransactionNotSupportedException이 발생할 수 있다.

3. Isolation: 동시 접근 제어 수준

3-1. 4단계 격리 수준

Isolation Dirty Read Non-Repeatable Read Phantom Read MySQL InnoDB 기본값
READ_UNCOMMITTED 발생 발생 발생
READ_COMMITTED 방지 발생 발생
REPEATABLE_READ 방지 방지 InnoDB: 방지* ✅ 기본값
SERIALIZABLE 방지 방지 방지

*MySQL InnoDB는 MVCC + Next-Key Lock으로 REPEATABLE_READ에서도 Phantom Read를 대부분 방지한다(MySQL 공식 문서 InnoDB Transaction Model).

3-2. Spring에서 격리 수준 지정

@Transactional(isolation = Isolation.READ_COMMITTED)
public BigDecimal getAccountBalance(Long accountId) {
    return accountRepository.findBalanceById(accountId);
}

@Transactionalisolation 속성은 트랜잭션 시작 시 DB 세션의 격리 수준을 변경한다. 대부분의 경우 DB 기본값(MySQL InnoDB: REPEATABLE_READ)을 그대로 사용하는 것이 권장되며, 특정 메서드에서만 격리 수준을 낮추거나 높일 때 사용한다.

4. Rollback 규칙: 체크 예외는 롤백되지 않는다

4-1. 기본 롤백 규칙

Spring 공식 문서에 따르면 @Transactional의 기본 롤백 규칙은:

  • RuntimeException(언체크 예외) → 롤백 ✅
  • Error → 롤백 ✅
  • Exception(체크 예외)롤백하지 않음 ❌ (커밋됨)

이것은 실무에서 가장 많은 버그를 만드는 규칙이다. 예를 들어:

@Transactional
public void transferMoney(Long from, Long to, BigDecimal amount)
        throws InsufficientBalanceException { // 체크 예외
    accountRepository.deduct(from, amount);
    accountRepository.add(to, amount);

    if (getCurrentBalance(from).compareTo(BigDecimal.ZERO) < 0) {
        throw new InsufficientBalanceException("잔액 부족");
        // ❌ 체크 예외이므로 롤백되지 않음!
        // deduct와 add가 커밋되어 데이터 정합성 깨짐
    }
}

4-2. 명시적 롤백 설정

// 방법 1: rollbackFor 지정
@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long from, Long to, BigDecimal amount)
        throws InsufficientBalanceException {
    // 이제 체크 예외에서도 롤백됨
}

// 방법 2: 특정 예외만 롤백 제외
@Transactional(
    rollbackFor = Exception.class,
    noRollbackFor = NotificationFailedException.class
)
public void processOrder(OrderDto dto) throws Exception {
    // NotificationFailedException 외의 모든 예외에서 롤백
}

4-3. 실무 권장: rollbackFor = Exception.class를 기본으로

체크 예외를 사용하는 프로젝트에서는 rollbackFor = Exception.class팀 컨벤션으로 강제하는 것이 안전하다. 또는 커스텀 어노테이션을 만들어 기본값을 재정의할 수 있다:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface AppTransactional {
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
}

5. readOnly 속성: 단순 힌트가 아니다

@Transactional(readOnly = true)는 읽기 전용 힌트를 JDBC 드라이버와 ORM에 전달한다.

레이어 readOnly=true 효과
JDBC Connection.setReadOnly(true) — DB에 따라 레플리카로 라우팅 가능
Hibernate/JPA FlushMode를 MANUAL로 변경 → dirty checking 비활성화 → 메모리·CPU 절약
MySQL InnoDB: 읽기 전용 트랜잭션에 대해 내부 최적화 적용
@Service
@Transactional(readOnly = true) // 클래스 레벨 기본값
public class ProductQueryService {

    public List<ProductDto> searchProducts(String keyword) {
        return productRepository.findByNameContaining(keyword)
            .stream()
            .map(ProductDto::from)
            .toList();
    }

    @Transactional // 쓰기 메서드만 오버라이드
    public void updatePrice(Long id, BigDecimal price) {
        Product product = productRepository.findById(id).orElseThrow();
        product.changePrice(price);
    }
}

클래스 레벨에 readOnly = true를 걸고, 쓰기 메서드에만 @Transactional을 오버라이드하는 패턴이 실무에서 가장 깔끔하다.

6. 실전 체크리스트

  1. self-invocation 확인: 같은 클래스 내부 호출에서 @Transactional이 동작하지 않음을 인지하고, 트랜잭션 경계가 다른 로직은 별도 Bean으로 분리했는가?
  2. rollbackFor 설정: 체크 예외를 사용하는 프로젝트에서 rollbackFor = Exception.class를 명시했는가?
  3. REQUIRES_NEW 커넥션 풀: REQUIRES_NEW 사용 시 중첩 깊이 × 동시 스레드 수가 커넥션 풀 크기를 초과하지 않는가?
  4. readOnly 분리: 읽기 전용 메서드에 readOnly = true를 적용하여 dirty checking 비용을 절약했는가?
  5. 트랜잭션 범위 최소화: 외부 API 호출, 파일 I/O 등 느린 작업을 트랜잭션 밖으로 빼서 DB 커넥션 점유 시간을 줄였는가?
  6. private 메서드 주의: @Transactionalpublic 메서드에서만 동작한다(프록시 기반 AOP 제약). Spring 공식 문서에 명시된 사항이다.

7. 흔한 실수와 방지법

실수 증상 방지법
체크 예외에서 롤백 기대 데이터 정합성 깨짐(커밋됨) rollbackFor = Exception.class 명시 또는 커스텀 어노테이션 사용
같은 클래스 내부 호출 전파/격리 설정 무시됨 별도 Bean으로 분리, 또는 ApplicationEventPublisher로 이벤트 기반 분리
트랜잭션 안에서 외부 API 호출 응답 지연 시 커넥션 풀 고갈 외부 호출을 트랜잭션 밖으로 이동. @TransactionalEventListener(phase = AFTER_COMMIT) 활용
REQUIRES_NEW 남용 커넥션 풀 고갈, 데드락 정말 독립 커밋이 필요한 경우(감사 로그 등)에만 사용. 커넥션 풀 크기를 중첩 깊이에 맞게 설정

정리

@Transactional은 “붙이면 끝”이 아니라, propagation · isolation · rollback · readOnly 네 가지 축을 의식적으로 설계해야 하는 선언이다. 특히 체크 예외 롤백 규칙과 self-invocation 제약은 운영 장애로 이어지기 쉬우므로, 팀 컨벤션과 코드 리뷰에서 반드시 확인해야 한다.

참고 자료

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux