Spring의 Transaction: @Transactional과 자주 만나는 함정

1. 개요

@Transactional은 Spring Boot에서 가장 많이 사용되는 annotation입니다. 하지만 동시에 찾기 어려운 버그의 원인이 되기도 합니다: 데이터가 rollback되지 않거나 transaction이 갑자기 사라지거나, UnexpectedRollbackException이 이유 없이 나타나는 경우들입니다.

이 글에서는 @Transactional이 proxy를 통해 어떻게 작동하는지 설명합니다. 그 후 자주 만나는 함정들을 하나씩 살펴볼 텐데, 각 함정마다 코드 예제와 해결 방법을 제시합니다. 이 글을 읽고 나면 transaction이 언제 실제로 열리는지, 그리고 언제 rollback되는지 알 수 있을 것입니다.

2. 준비

이 글에서는 Spring Data JPA와 H2 in-memory를 사용합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</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>

transaction이 언제 열리고 닫히는지 보기 위해 transaction interceptor 로깅을 활성화합니다:

logging.level.org.springframework.transaction.interceptor=TRACE

이 글에서 사용할 Entity와 repository입니다:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue
    private Long id;
    private String customerName;
    private BigDecimal amount;

    protected Order() {
    }

    public Order(String customerName, BigDecimal amount) {
        this.customerName = customerName;
        this.amount = amount;
    }

    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public BigDecimal getAmount() { return amount; }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
}

표의 이름이 orders인 이유는 order가 SQL 예약어이기 때문입니다.

3. @Transactional은 어떻게 작동하는가

3.1. 기본 예제

@Service
public class PaymentService {

    public void charge(String customer, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new IllegalStateException("결제 한도를 초과했습니다");
        }
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

placeOrder("An", new BigDecimal("5000"))를 호출하면 먼저 주문이 save됩니다. 그 다음 charge가 IllegalStateException을 던집니다. 이는 RuntimeException이므로 Spring이 전체를 rollback하고, orders 테이블은 비어있게 됩니다.

3.2. Proxy가 중심

Spring은 OrderService의 bytecode를 수정하지 않습니다. 대신 bean을 감싸는 proxy (Spring Boot에서는 기본적으로 CGLIB subclass)를 생성합니다. 다른 bean이 OrderService를 inject할 때 실제로는 이 proxy를 받습니다. 각 호출은 TransactionInterceptor를 통해 갑니다:

caller → proxy → [transaction 열기] → OrderService.placeOrder() → [commit/rollback] → caller

TRACE 로그에서 이를 명확히 볼 수 있습니다:

TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: 결제 한도를 초과했습니다

아래의 대부분의 함정들은 하나의 원칙에서 비롯됩니다: proxy를 통하지 않는 호출에는 transaction이 없다는 것입니다.

4. 함정 1: Self-invocation

@Service
public class OrderService {

    // ... constructor는 위와 같음

    public void placeOrders(List<String> customers) {
        for (String customer : customers) {
            placeOrder(customer, new BigDecimal("100")); // this를 통한 호출
        }
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        System.out.println("Transaction active: "
                + TransactionSynchronizationManager.isActualTransactionActive());
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

controller에서 orderService.placeOrders(List.of("An", "Bình"))를 호출하면 출력됩니다:

Transaction active: false
Transaction active: false

placeOrders에는 @Transactional이 없으므로 proxy는 바로 실제 객체로 호출을 전달합니다. 내부에서 placeOrder(...)는 this.placeOrder(...)와 같은데, 이때 this는 원본 객체지 proxy가 아닙니다. Annotation은 완전히 무시됩니다. 각 save는 SimpleJpaRepository의 독립적인 transaction에서 실행되고 즉시 commit됩니다. 두 번째 주문이 실패하더라도 첫 번째 주문은 여전히 DB에 남아있습니다.

해결 방법은 세 가지가 있으며, 우선순위 순서는 다음과 같습니다:

  1. placeOrder를 다른 bean으로 분리하세요. 예를 들어 OrderPlacementService로 만들고 inject합니다.
  2. 전체 배치를 하나의 transaction으로 만들고 싶다면 placeOrders에 @Transactional을 붙입니다.
  3. @Lazy를 통한 self-injection을 사용합니다. 이 방법은 작동하지만 읽기 어려우니 bean을 분리할 수 없을 때만 사용하세요.
@Service
public class OrderBatchService {

    private final OrderService orderService; // 이것은 proxy입니다

    public OrderBatchService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void placeOrders(List<String> customers) {
        customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
    }
}

이제 각 호출이 proxy를 통과하고, 로그에 Transaction active: true가 출력됩니다.

5. 함정 2: private 또는 final 메서드

Spring Framework 6.0부터는 class-based proxy (CGLIB)를 사용할 때 @Transactional이 public, protected, package-private 메서드에서 모두 작동합니다. 하지만 여전히 작동하지 않는 두 가지 경우가 있습니다:

  • private 메서드: subclass에서 override할 수 없으므로 proxy가 호출을 가로챌 수 없습니다.
  • final 메서드: CGLIB도 override할 수 없습니다. 호출이 직접 원본 코드로 실행되고 transaction이 없습니다.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // transaction 없음
    return orderRepository.save(new Order(customer, amount));
}

Spring은 이 경우에 오류를 throw하지 않습니다. INFO나 DEBUG 레벨의 로그 한 줄만 보이므로 쉽게 놓칠 수 있습니다.

참고: Kotlin은 기본적으로 모든 클래스와 메서드를 닫습니다 (final). Spring bean을 자동으로 열기 위해 kotlin-spring 플러그인 (all-open)을 사용하세요.

6. 함정 3: Checked exception은 rollback을 일으키지 않음

기본적으로 Spring은 RuntimeException이나 Error를 만날 때만 rollback합니다. Checked exception은 transaction을 commit하게 됩니다.

public class PaymentDeclinedException extends Exception {
    public PaymentDeclinedException(String message) {
        super(message);
    }
}

@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
    Order order = orderRepository.save(new Order(customer, amount));
    if (amount.signum() <= 0) {
        throw new PaymentDeclinedException("금액이 유효하지 않습니다");
    }
    return order;
}

amount = -5로 호출하면 exception은 여전히 caller에게 throw되지만, 음수 주문이 이미 저장됩니다. 해결 방법은 명시적으로 선언하는 것입니다:

@Transactional(rollbackFor = PaymentDeclinedException.class)

많은 팀에서는 전체 service에 rollbackFor = Exception.class를 사용하는 관례를 따릅니다. 더 간단한 방법은 비즈니스 exception을 RuntimeException을 상속하도록 설계하는 것입니다. 반대로도 할 수 있습니다: noRollbackFor = SomeRuntimeException.class로 오류가 있어도 commit을 유지할 수 있습니다.

7. 함정 4: Exception을 삼키고 UnexpectedRollbackException 발생

이것은 가장 이해하기 어려운 함정입니다. 창고에서 재고를 예약하는 service를 생각해봅시다:

@Service
public class InventoryService {

    @Transactional // propagation 기본값: REQUIRED
    public void reserve(String sku) {
        throw new IllegalStateException("재고 부족: " + sku);
    }
}

그리고 OrderService가 이를 호출하되, 오류를 잡은 후 “재고 예약 실패해도 여전히 주문을 만든다”는 의도로:

@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
    Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
    try {
        inventoryService.reserve(sku);
    } catch (IllegalStateException ex) {
        System.out.println("창고 오류 무시: " + ex.getMessage());
    }
    return order;
}

결과:

창고 오류 무시: 재고 부족: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only

일어나는 일은 다음과 같습니다. reserve는 placeOrderWithInventory와 동일한 물리적 transaction에 참여합니다. InventoryService의 proxy를 통해 exception이 나갈 때, Spring은 transaction을 rollback-only로 표시합니다. 바깥쪽에서 catch해도 이 표시를 제거할 수 없습니다. commit할 때 Spring이 이 표시를 감지하고, rollback한 후 caller가 commit된 줄 알았지만 실제로는 그렇지 않다는 것을 알리기 위해 UnexpectedRollbackException을 throw합니다.

해결 방법은 당신의 의도에 따라 다릅니다:

  • 창고 오류가 전체 주문을 취소해야 한다면, catch하지 마세요. Exception을 그대로 올려보내세요.
  • reserve가 독립적인 작업이라면, REQUIRES_NEW를 사용해 별도의 transaction을 주세요 (섹션 8 참조).
  • reserve가 DB에 쓸 필요가 없다면, @Transactional을 제거하세요.

참고: exception을 같은 메서드 내에서 삼키면 transaction이 표시되지 않습니다. 표시는 exception이 @Transactional proxy를 통과할 때만 설정됩니다.

8. Propagation REQUIRES_NEW: 유용하지만 비싼 비용

REQUIRES_NEW를 사용하는 합리적인 경우는 audit log를 기록할 때입니다. Log는 주 비즈니스 로직이 rollback되더라도 저장되어야 합니다:

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String action) {
        auditLogRepository.save(new AuditLog(action));
    }
}

Spring은 외부 transaction을 일시 중지하고 다른 connection에서 새로운 transaction을 엽니다. record가 끝나면 이 transaction은 독립적으로 commit됩니다.

대가:

  • 각 요청이 동시에 두 개의 connection을 유지합니다. 높은 부하에서는 pool (HikariCP의 기본값은 10개 connection)이 고갈될 수 있고, pool 레벨에서 deadlock을 일으킬 수 있습니다: 모든 thread가 하나의 connection을 보유하고 두 번째 connection을 기다립니다.
  • 새 transaction은 외부 transaction의 아직 commit되지 않은 데이터를 볼 수 없습니다. AuditLog에 방금 생성한 Order로의 foreign key가 있다면, 오류가 발생하거나 lock을 대기하게 됩니다.

REQUIRES_NEW는 정말로 독립적인 commit이 필요할 때만 사용하세요. rollback-only 오류에 대한 “임시방편”으로 사용하지 마세요.

9. readOnly와 너무 긴 transaction

@Transactional(readOnly = true)는 Hibernate에 flush mode를 MANUAL로 설정하고 dirty checking을 건너뛰라고 알립니다. 여러 entity를 반환하는 쿼리의 경우, 이는 메모리와 CPU를 크게 줄입니다:

@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
    return orderRepository.findAll().stream()
            .filter(o -> o.getCustomerName().equals(customer))
            .toList();
}

두 가지를 기억해야 합니다:

  • readOnly는 최적화 힌트일 뿐, 보호 메커니즘이 아닙니다. driver와 database에 따라 native UPDATE 문이 여전히 실행될 수 있습니다.
  • transaction 내에서 외부 HTTP API 호출, 이메일 전송, Thread.sleep을 하지 마세요. 그 동안 connection이 유지되고, 작성된 row의 lock도 유지됩니다. 먼저 외부에서 데이터를 가져온 후, 짧은 transaction을 열어서 쓰세요.

10. Rollback 동작 테스트

또 다른 함정은 test에 있습니다. test class에 @Transactional을 붙이면, 모든 것이 test의 transaction 내에서 실행되고 끝에서 rollback됩니다. 이는 service가 올바르게 commit되거나 rollback되는지 여부를 숨깁니다. transaction에 대한 test에서는 이 annotation을 제거하고 직접 데이터를 정리하세요:

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @AfterEach
    void cleanUp() {
        orderRepository.deleteAll();
    }

    @Test
    void whenPaymentFails_thenOrderIsRolledBack() {
        assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
                .isInstanceOf(IllegalStateException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
        assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
                .isInstanceOf(UnexpectedRollbackException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
        assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
                .isInstanceOf(PaymentDeclinedException.class);

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

세 번째 test는 checked exception에 대한 기본 동작을 “고정”합니다. rollbackFor을 추가하면 이 test는 실패하고, 예상을 isZero()로 변경합니다. 이것은 rollback 결정을 코드에서 바로 문서화하는 좋은 방법입니다.

11. 결론

@Transactional은 간단해 보이지만, 모든 동작은 proxy에 달려 있습니다. 주요 함정들의 요약:

  • Self-invocation: this를 통한 호출은 proxy를 우회합니다. Bean을 분리하세요.
  • private/final 메서드: intercept되지 않으며, Spring이 오류를 알리지 않습니다.
  • Checked exception: 기본값은 commit입니다. rollbackFor을 사용하거나 RuntimeException을 사용하세요.
  • Rollback-only: 내부 proxy를 통과하는 exception은 전체 transaction을 표시하고, 바깥에서 catch해도 도움이 되지 않습니다.
  • REQUIRES_NEW: 추가 connection을 사용하므로, 독립적인 commit이 필요할 때만 사용하세요.
  • Test에 @Transactional: 실제 commit/rollback 동작을 숨깁니다.

의심할 때는 TransactionInterceptor 로그를 TRACE 레벨로 활성화하고 TransactionSynchronizationManager.isActualTransactionActive()를 확인하세요. 이 두 도구는 transaction이 실제로 존재하는지 정확히 알려줍니다.

spring-boottransactionjpaspring-datatesting