Spring Boot @Transactional

Spring Boot @Transactional이란? 트랜잭션 관리의 핵심

데이터베이스 작업에서 “전부 성공하거나, 전부 실패하거나”는 양보할 수 없는 원칙입니다. Spring Boot의 @Transactional은 이 원칙을 선언적으로 구현하는 핵심 어노테이션이지만, 내부 동작을 이해하지 못하면 데이터 정합성이 깨지는 치명적 버그를 만들 수 있습니다.

이 글에서는 @Transactional의 프록시 기반 동작 원리부터 7가지 전파(Propagation) 전략, 4가지 격리(Isolation) 수준, 롤백 규칙의 함정, self-invocation 문제, 읽기 전용 최적화, 그리고 분산 트랜잭션 대안까지 운영 수준에서 완전히 다룹니다.

@Transactional 동작 원리: 프록시와 AOP

Spring의 @TransactionalAOP(Aspect-Oriented Programming) 프록시로 동작합니다. 빈(Bean)이 생성될 때 Spring은 원본 객체를 감싸는 프록시 객체를 만들고, 메서드 호출 전후에 트랜잭션 시작/커밋/롤백 로직을 삽입합니다.

// 개발자가 작성한 코드
@Service
public class OrderService {
    @Transactional
    public void createOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentService.charge(request.getPaymentInfo());
        inventoryService.decrease(request.getItems());
    }
}

// Spring이 실제로 만드는 프록시 (개념적 코드)
public class OrderService$$Proxy extends OrderService {
    @Override
    public void createOrder(OrderRequest request) {
        TransactionStatus tx = txManager.getTransaction(definition);
        try {
            super.createOrder(request);  // 원본 메서드 호출
            txManager.commit(tx);
        } catch (RuntimeException e) {
            txManager.rollback(tx);
            throw e;
        }
    }
}

프록시 모드: JDK Dynamic Proxy vs CGLIB

방식 조건 특징
JDK Dynamic Proxy 인터페이스 구현 시 인터페이스 메서드만 프록시, final 클래스 가능
CGLIB (기본값) 클래스 직접 상속 final 클래스/메서드 불가, 성능 우수

Spring Boot 2.x부터 CGLIB가 기본입니다. spring.aop.proxy-target-class=false로 JDK Proxy로 전환할 수 있지만, 특별한 이유가 없으면 기본값을 유지하세요.

Propagation(전파) 7가지 전략: 트랜잭션 경계 설계

전파 전략은 “이미 트랜잭션이 존재할 때 어떻게 할 것인가?”를 결정합니다. 이것을 제대로 이해하지 못하면 의도치 않은 롤백이나 데이터 불일치가 발생합니다.

Propagation 기존 TX 있을 때 기존 TX 없을 때 실무 용도
REQUIRED (기본값) 참여 새로 시작 대부분의 비즈니스 로직
REQUIRES_NEW 기존 TX 일시 중단, 새 TX 시작 새로 시작 감사 로그, 독립 저장
NESTED SAVEPOINT 생성 새로 시작 부분 롤백
SUPPORTS 참여 TX 없이 실행 조회 메서드
NOT_SUPPORTED 기존 TX 일시 중단 TX 없이 실행 외부 API 호출
MANDATORY 참여 예외 발생! 반드시 TX 안에서 호출되어야 하는 메서드
NEVER 예외 발생! TX 없이 실행 TX 밖에서만 실행 보장

REQUIRED vs REQUIRES_NEW: 실무 핵심 차이

@Service
public class OrderService {

    @Transactional  // REQUIRED (기본값)
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        
        // 감사 로그는 주문 실패와 무관하게 항상 저장
        auditService.logOrderAttempt(request);
        
        // 재고 차감 실패 시 주문도 롤백
        inventoryService.decrease(request.getItems());
        
        // 결제 실패 시 전체 롤백
        paymentService.charge(order);
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderAttempt(OrderRequest request) {
        // 새로운 독립 트랜잭션에서 실행
        // 부모 TX가 롤백되어도 이 로그는 커밋됨
        auditRepository.save(new AuditLog("ORDER_ATTEMPT", request));
    }
}

⚠️ REQUIRES_NEW 주의점: 새 커넥션을 획득하므로 커넥션 풀 고갈 가능성이 있습니다. 중첩이 깊어지면 데드락이 발생할 수 있습니다 (부모 TX가 커넥션을 잡고 있는데, 자식 REQUIRES_NEW가 새 커넥션을 기다리는 상황).

NESTED: SAVEPOINT 기반 부분 롤백

@Service
public class OrderService {

    @Transactional
    public void processOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        
        try {
            // NESTED: SAVEPOINT 생성 → 실패 시 여기까지만 롤백
            bonusService.grantPoints(order);
        } catch (Exception e) {
            // 보너스 실패해도 주문은 유지
            log.warn("보너스 적립 실패, 주문은 계속 진행", e);
        }
        
        paymentService.charge(order);
    }
}

@Service
public class BonusService {
    @Transactional(propagation = Propagation.NESTED)
    public void grantPoints(Order order) {
        // SAVEPOINT 이후 실행
        // 실패 시 SAVEPOINT까지만 롤백, 부모 TX는 유지
        pointRepository.save(new Point(order.getUserId(), order.getPoints()));
    }
}

참고: NESTED는 JDBC SAVEPOINT를 사용하므로 JPA/Hibernate에서는 제한적으로 동작합니다. DataSourceTransactionManager에서 가장 잘 동작하며, JpaTransactionManager에서도 nestedTransactionAllowed=true 설정이 필요합니다.

Isolation(격리) 수준: 동시성 제어의 트레이드오프

격리 수준은 동시에 실행되는 트랜잭션들이 서로 얼마나 영향을 주는지를 결정합니다.

Isolation Dirty Read Non-Repeatable Read Phantom Read 성능
READ_UNCOMMITTED ⚠️ 발생 ⚠️ 발생 ⚠️ 발생 최고
READ_COMMITTED ✅ 방지 ⚠️ 발생 ⚠️ 발생 높음
REPEATABLE_READ ✅ 방지 ✅ 방지 ⚠️ 발생* 보통
SERIALIZABLE ✅ 방지 ✅ 방지 ✅ 방지 최저

* MySQL InnoDB의 REPEATABLE_READ는 Gap Lock으로 Phantom Read도 방지합니다. PostgreSQL은 MVCC 기반으로 동작이 다릅니다.

// 재고 차감처럼 정합성이 중요한 경우
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findByIdForUpdate(productId); // SELECT ... FOR UPDATE
    if (product.getStock() < quantity) {
        throw new InsufficientStockException();
    }
    product.decreaseStock(quantity);
}

// 통계 조회처럼 정확도보다 성능이 중요한 경우
@Transactional(isolation = Isolation.READ_UNCOMMITTED, readOnly = true)
public DashboardStats getDashboardStats() {
    return statsRepository.getAggregatedStats();
}

실무 권장: 대부분의 경우 DB 기본 격리 수준(MySQL: REPEATABLE_READ, PostgreSQL: READ_COMMITTED)을 유지하고, 필요한 메서드에서만 명시적으로 변경하세요.

롤백 규칙: Checked vs Unchecked Exception의 함정

Spring @Transactional의 기본 롤백 규칙은 많은 개발자가 모르는 치명적 차이가 있습니다:

예외 타입 기본 동작 예시
RuntimeException (Unchecked) 롤백 NullPointerException, IllegalArgumentException
Error 롤백 OutOfMemoryError
Exception (Checked) 커밋! ⚠️ IOException, SQLException
// ❌ 위험: Checked Exception은 기본적으로 커밋됨!
@Transactional
public void transferMoney(Long fromId, Long toId, BigDecimal amount) 
        throws InsufficientBalanceException {  // Checked Exception
    Account from = accountRepository.findById(fromId).orElseThrow();
    from.withdraw(amount);  // DB에 반영
    
    if (from.getBalance().compareTo(BigDecimal.ZERO) < 0) {
        // InsufficientBalanceException이 Checked면 → 커밋됨!
        // 출금은 완료되고 입금은 안 되는 참사 발생
        throw new InsufficientBalanceException();
    }
    
    Account to = accountRepository.findById(toId).orElseThrow();
    to.deposit(amount);
}

// ✅ 해결 1: rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) 
        throws InsufficientBalanceException {
    // Checked Exception이어도 롤백
}

// ✅ 해결 2: 비즈니스 예외를 RuntimeException으로 설계 (권장)
public class InsufficientBalanceException extends RuntimeException {
    // Unchecked → 자동 롤백
}

실무 팁: 프로젝트 전체에 rollbackFor = Exception.class를 강제하고 싶다면, 커스텀 어노테이션을 만드세요:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface SafeTransactional {
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
    boolean readOnly() default false;
    int timeout() default -1;
}

Self-Invocation 함정: 같은 클래스 내부 호출의 비극

Spring @Transactional에서 가장 흔하고 치명적인 버그입니다. 프록시 기반이므로, 같은 클래스 내부에서 호출하면 프록시를 우회하여 트랜잭션이 적용되지 않습니다.

@Service
public class OrderService {

    // ❌ 외부에서 processOrder() 호출 시, 내부의 createOrder()는
    //    프록시를 거치지 않아 @Transactional이 무시됨!
    public void processOrder(OrderRequest request) {
        validate(request);
        createOrder(request);  // this.createOrder() → 프록시 우회!
    }

    @Transactional
    public void createOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentService.charge(request);
        // 예외 발생해도 롤백 안 됨!
    }
}

해결 방법 4가지

// ✅ 해결 1: 클래스 분리 (가장 권장)
@Service
public class OrderFacade {
    private final OrderService orderService;
    
    public void processOrder(OrderRequest request) {
        validate(request);
        orderService.createOrder(request); // 프록시를 통한 호출
    }
}

@Service
public class OrderService {
    @Transactional
    public void createOrder(OrderRequest request) {
        // 정상적으로 트랜잭션 적용
    }
}

// ✅ 해결 2: self-injection
@Service
public class OrderService {
    @Lazy
    @Autowired
    private OrderService self;  // 프록시 객체 주입

    public void processOrder(OrderRequest request) {
        validate(request);
        self.createOrder(request);  // 프록시를 통한 호출!
    }

    @Transactional
    public void createOrder(OrderRequest request) { ... }
}

// ✅ 해결 3: ApplicationContext에서 직접 가져오기
@Service
public class OrderService implements ApplicationContextAware {
    private ApplicationContext context;

    public void processOrder(OrderRequest request) {
        validate(request);
        context.getBean(OrderService.class).createOrder(request);
    }
}

// ✅ 해결 4: @Transactional을 상위 메서드에 적용
@Service
public class OrderService {
    @Transactional  // processOrder 자체에 트랜잭션
    public void processOrder(OrderRequest request) {
        validate(request);
        createOrder(request);  // 이미 TX 안에 있으므로 OK
    }

    public void createOrder(OrderRequest request) { ... }
}

이 문제는 Spring Boot @Async의 self-invocation 함정과 동일한 원리입니다. 프록시 기반 AOP의 근본적 한계이므로, Spring을 사용하는 한 항상 인식하고 있어야 합니다.

readOnly = true: 단순 플래그가 아닌 성능 최적화

readOnly = true는 단순히 "쓰기를 금지한다"는 의미가 아닙니다. JPA/Hibernate와 DB 드라이버 수준에서 실질적인 최적화가 적용됩니다:

최적화 수준 동작 효과
Hibernate FlushMode.MANUAL 설정 dirty checking 스킵 → CPU 절약
Hibernate 스냅샷 미생성 메모리 50% 절약 (엔티티당)
JDBC Driver connection.setReadOnly(true) DB가 읽기 최적화 실행 계획 사용
MySQL 읽기 전용 TX 최적화 트랜잭션 ID 미할당 → MVCC 부하 감소
Connection Pool 읽기 복제본 라우팅 Master/Slave 분산 가능
// ✅ 조회 메서드에는 반드시 readOnly
@Transactional(readOnly = true)
public List<OrderResponse> getOrdersByUser(Long userId) {
    return orderRepository.findByUserId(userId)
        .stream()
        .map(OrderResponse::from)
        .toList();
}

// ✅ 클래스 레벨 readOnly + 메서드 레벨 오버라이드 패턴
@Service
@Transactional(readOnly = true)  // 기본: 읽기 전용
public class OrderService {

    public List<Order> findAll() { ... }        // readOnly = true 상속
    public Order findById(Long id) { ... }      // readOnly = true 상속

    @Transactional  // 쓰기 메서드만 오버라이드 (readOnly = false)
    public Order create(OrderRequest req) { ... }

    @Transactional
    public void delete(Long id) { ... }
}

timeout과 트랜잭션 타임아웃 설계

// 30초 내 완료되지 않으면 TransactionTimedOutException 발생
@Transactional(timeout = 30)
public void processLargeOrder(LargeOrderRequest request) {
    // 외부 API 호출이 포함된 긴 작업
    List<Item> items = request.getItems();
    for (Item item : items) {
        inventoryService.reserve(item);  // 외부 서비스
    }
    orderRepository.saveAll(createOrders(items));
}

// ⚠️ 주의: timeout은 DB 쿼리 실행 시간만 측정
// Thread.sleep()이나 외부 HTTP 호출 시간은 포함되지 않을 수 있음
// → 외부 호출에는 별도 타임아웃 설정 필요

@TransactionalEventListener: 트랜잭션 커밋 후 이벤트 처리

트랜잭션 커밋 후에 이벤트를 발행해야 하는 경우(예: 알림 전송, 캐시 무효화)에 사용합니다:

@Service
public class OrderService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        
        // 이벤트 발행 (아직 TX 커밋 전)
        eventPublisher.publishEvent(new OrderCreatedEvent(order));
        
        return order;
    }
}

@Component
public class OrderEventHandler {

    // TX 커밋 후에만 실행 → 롤백되면 이벤트도 무시
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreated(OrderCreatedEvent event) {
        notificationService.sendOrderConfirmation(event.getOrder());
        // ⚠️ 이 메서드는 기본적으로 TX 밖에서 실행됨
        // DB 작업이 필요하면 @Transactional(propagation = REQUIRES_NEW) 추가
    }

    // TX 롤백 후 실행 (보상 로직)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleOrderFailed(OrderCreatedEvent event) {
        alertService.notifyOrderFailure(event.getOrder());
    }
}

멀티 DataSource 트랜잭션: @Transactional("txManager")

여러 데이터베이스를 사용하는 경우 트랜잭션 매니저를 명시적으로 지정해야 합니다:

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public PlatformTransactionManager orderTxManager(
            @Qualifier("orderDataSource") DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }

    @Bean
    public PlatformTransactionManager paymentTxManager(
            @Qualifier("paymentDataSource") DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }
}

@Service
public class OrderService {

    @Transactional("orderTxManager")  // 주문 DB TX
    public void createOrder(OrderRequest req) { ... }
}

@Service
public class PaymentService {

    @Transactional("paymentTxManager")  // 결제 DB TX
    public void processPayment(PaymentRequest req) { ... }
}

⚠️ 한계: 서로 다른 트랜잭션 매니저의 TX는 독립적입니다. 한쪽이 커밋되고 다른 쪽이 롤백되면 데이터 불일치가 발생합니다. 이 문제를 해결하려면 분산 트랜잭션(XA) 또는 Saga 패턴이 필요합니다.

테스트에서의 @Transactional: 자동 롤백의 양날의 검

@SpringBootTest
@Transactional  // 테스트 후 자동 롤백 → DB 클린업 불필요
class OrderServiceTest {

    @Test
    void createOrder_success() {
        OrderRequest request = new OrderRequest("item-1", 2);
        orderService.createOrder(request);

        // 테스트 종료 시 자동 롤백 → DB 오염 없음
        Order saved = orderRepository.findByItemId("item-1");
        assertThat(saved).isNotNull();
    }
}

// ⚠️ 자동 롤백의 함정
@SpringBootTest
@Transactional
class OrderServiceIntegrationTest {

    @Test
    void testWithExternalService() {
        // 문제 1: @TransactionalEventListener(AFTER_COMMIT)이 실행되지 않음
        // → 커밋이 안 되니까!

        // 문제 2: REQUIRES_NEW 전파의 내부 TX는 실제 커밋됨
        // → 테스트 롤백이 안 됨

        // 문제 3: LazyInitializationException이 발생하지 않음
        // → 테스트 TX가 열려 있어서 지연 로딩이 항상 성공
    }
}

// ✅ 해결: 통합 테스트에서는 @Transactional 제거 + 수동 클린업
@SpringBootTest
class OrderServiceIntegrationTest {

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

    @Test
    void testRealBehavior() {
        // 실제 운영과 동일한 TX 동작
    }
}

실무 안티패턴 5가지와 해결책

안티패턴 1: 트랜잭션 안에서 외부 API 호출

// ❌ 외부 API 응답 대기 중 DB 커넥션 점유
@Transactional
public void createOrder(OrderRequest req) {
    orderRepository.save(new Order(req));
    slackClient.sendNotification("주문 생성됨");  // 3초 대기?
    emailService.sendConfirmation(req.getEmail());  // 5초 대기?
    // 총 8초간 커넥션 점유 → 풀 고갈
}

// ✅ TX 밖에서 외부 호출
@Transactional
public Order createOrder(OrderRequest req) {
    return orderRepository.save(new Order(req));
}

// 별도 메서드에서 알림
public void createOrderAndNotify(OrderRequest req) {
    Order order = createOrder(req);  // TX 종료
    slackClient.sendNotification("주문 생성됨");  // TX 밖
    emailService.sendConfirmation(req.getEmail());  // TX 밖
}

안티패턴 2: 과도하게 큰 트랜잭션

// ❌ 1000건을 하나의 TX로 처리
@Transactional
public void batchProcess(List<Item> items) {
    for (Item item : items) {
        process(item);  // 999건 성공 후 1건 실패 → 전체 롤백
    }
}

// ✅ 청크 단위로 분리
public void batchProcess(List<Item> items) {
    List<List<Item>> chunks = Lists.partition(items, 100);
    for (List<Item> chunk : chunks) {
        processChunk(chunk);  // 각 100건 독립 TX
    }
}

@Transactional
public void processChunk(List<Item> chunk) {
    chunk.forEach(this::process);
}

안티패턴 3: try-catch로 롤백 삼키기

// ❌ 예외를 잡아서 롤백이 안 됨
@Transactional
public void riskyOperation() {
    try {
        accountRepository.withdraw(fromId, amount);
        accountRepository.deposit(toId, amount);
    } catch (Exception e) {
        log.error("실패", e);
        // 예외가 프록시까지 전파되지 않음 → 커밋됨!
        // 출금만 되고 입금은 안 된 상태로 커밋
    }
}

// ✅ 예외를 다시 던지거나, 명시적 롤백
@Transactional
public void riskyOperation() {
    try {
        accountRepository.withdraw(fromId, amount);
        accountRepository.deposit(toId, amount);
    } catch (Exception e) {
        log.error("실패", e);
        throw e;  // 재던지기 → 프록시가 롤백
        // 또는: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

안티패턴 4: private 메서드에 @Transactional

// ❌ private 메서드는 프록시가 오버라이드할 수 없음
@Service
public class OrderService {
    @Transactional  // 무시됨!
    private void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

// ✅ public 또는 protected로 변경
@Transactional
public void saveOrder(Order order) { ... }

안티패턴 5: 불필요한 @Transactional

// ❌ 단일 SELECT는 @Transactional 불필요
@Transactional
public User findById(Long id) {
    return userRepository.findById(id).orElseThrow();
}

// ✅ 지연 로딩이 필요하거나 여러 쿼리가 일관성을 가져야 할 때만 사용
@Transactional(readOnly = true)
public UserDetailResponse getUserDetail(Long id) {
    User user = userRepository.findById(id).orElseThrow();
    user.getOrders().size();  // 지연 로딩 → TX 필요
    return UserDetailResponse.from(user);
}

정리: @Transactional 설계 체크리스트

항목 체크
조회 메서드에 readOnly = true 적용
Checked Exception에 rollbackFor = Exception.class 설정
Self-invocation 없음 확인 (같은 클래스 내부 호출 금지)
TX 안에 외부 API/HTTP 호출 없음
try-catch에서 예외 재던지기 또는 setRollbackOnly()
private 메서드에 @Transactional 미사용
REQUIRES_NEW 사용 시 커넥션 풀 고갈 검토
배치 작업은 청크 단위 TX 분리
테스트 @Transactional의 자동 롤백 부작용 인지
timeout 설정으로 롱 트랜잭션 방지

Spring Boot @Transactional은 겉보기에는 간단한 어노테이션이지만, 프록시 동작 원리, 전파 전략, 롤백 규칙, 격리 수준의 상호작용을 정확히 이해해야 합니다. 특히 self-invocation, Checked Exception 커밋, TX 안 외부 호출 세 가지 함정은 프로덕션에서 데이터 정합성 장애를 일으키는 주범이므로, 코드 리뷰 시 반드시 체크해야 할 항목입니다.

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