Spring @Transactional 전파·격리

Spring @Transactional이란?

Spring의 @Transactional은 선언적 트랜잭션 관리의 핵심 어노테이션입니다. AOP 프록시를 통해 메서드 실행 전후로 트랜잭션을 시작·커밋·롤백합니다. 단순히 붙이면 동작하지만, 전파(Propagation)격리(Isolation) 수준을 제대로 이해하지 않으면 데이터 정합성 문제가 프로덕션에서 발생합니다.

전파 수준(Propagation) 완전 정리

전파 수준은 트랜잭션이 이미 존재할 때 새 트랜잭션을 어떻게 처리할지 결정합니다.

전파 수준 기존 TX 있을 때 기존 TX 없을 때 용도
REQUIRED 참여 새로 생성 기본값, 대부분의 서비스
REQUIRES_NEW 기존 보류 + 새로 생성 새로 생성 독립 트랜잭션 (로그, 감사)
NESTED 세이브포인트 생성 새로 생성 부분 롤백
SUPPORTS 참여 TX 없이 실행 읽기 전용 선택적
NOT_SUPPORTED 기존 보류 TX 없이 실행 비트랜잭션 작업
MANDATORY 참여 예외 발생 TX 필수 보장
NEVER 예외 발생 TX 없이 실행 TX 금지 보장

REQUIRED vs REQUIRES_NEW 실전

가장 혼동하기 쉬운 두 전파 수준의 차이를 코드로 확인합니다.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;
    private final AuditLogService auditLogService;

    @Transactional  // REQUIRED (기본값)
    public void placeOrder(OrderRequest request) {
        Order order = createOrder(request);        // TX-A에 참여
        paymentService.processPayment(order);      // TX-A에 참여
        auditLogService.logOrderCreated(order);    // 독립 TX-B

        // paymentService에서 예외 → TX-A 전체 롤백
        // 하지만 auditLog는 TX-B(REQUIRES_NEW)이므로 이미 커밋됨!
    }
}

@Service
public class PaymentService {
    @Transactional  // REQUIRED → 호출자의 TX-A에 참여
    public void processPayment(Order order) {
        deductBalance(order.getUserId(), order.getAmount());
        // 여기서 예외 발생 시 → TX-A 전체 롤백
    }
}

@Service
public class AuditLogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderCreated(Order order) {
        // TX-A를 보류하고 새 TX-B 시작
        auditLogRepository.save(new AuditLog("ORDER_CREATED", order.getId()));
        // TX-B 커밋 → TX-A 재개
        // TX-A가 나중에 롤백되어도 이 로그는 살아있음
    }
}

REQUIRES_NEW의 핵심: 외부 트랜잭션과 완전히 독립적입니다. 감사 로그, 알림 기록, 실패 로그처럼 “메인 로직이 실패해도 반드시 남겨야 하는 데이터”에 사용합니다.

NESTED: 세이브포인트 활용

NESTED는 물리적으로 같은 트랜잭션 안에서 세이브포인트를 생성합니다.

@Service
public class BatchService {

    @Transactional
    public BatchResult processBatch(List<Item> items) {
        BatchResult result = new BatchResult();
        for (Item item : items) {
            try {
                processItem(item);  // NESTED → 세이브포인트 생성
                result.addSuccess(item);
            } catch (Exception e) {
                // 세이브포인트로 롤백 → 이 아이템만 실패
                // 외부 트랜잭션은 계속 진행
                result.addFailure(item, e.getMessage());
            }
        }
        return result;  // 성공한 아이템들만 커밋
    }

    @Transactional(propagation = Propagation.NESTED)
    public void processItem(Item item) {
        // 세이브포인트 안에서 실행
        validate(item);
        transform(item);
        save(item);
        // 예외 시 → 이 세이브포인트만 롤백
    }
}

NESTED vs REQUIRES_NEW: NESTED는 부모 트랜잭션이 롤백되면 함께 롤백됩니다. REQUIRES_NEW는 이미 커밋된 것은 유지됩니다. 배치 처리에서 “일부 실패해도 나머지는 커밋”하려면 NESTED가 적합합니다.

격리 수준(Isolation) 심화

격리 수준은 동시 트랜잭션 간 데이터 가시성을 제어합니다.

격리 수준 Dirty Read Non-Repeatable Phantom 성능
READ_UNCOMMITTED ⚠️ 발생 ⚠️ 발생 ⚠️ 발생 최고
READ_COMMITTED ✅ 방지 ⚠️ 발생 ⚠️ 발생 높음
REPEATABLE_READ ✅ 방지 ✅ 방지 ⚠️ 발생 보통
SERIALIZABLE ✅ 방지 ✅ 방지 ✅ 방지 최저
@Service
public class InventoryService {

    // 재고 차감: 동시성 문제 방지를 위해 SERIALIZABLE
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void deductStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow();
        if (product.getStock() < quantity) {
            throw new InsufficientStockException();
        }
        product.setStock(product.getStock() - quantity);
    }

    // 보고서 조회: 일관된 스냅샷 필요
    @Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
    public Report generateDailyReport(LocalDate date) {
        // 트랜잭션 시작 시점의 스냅샷으로 일관된 조회
        List<Order> orders = orderRepository.findByDate(date);
        BigDecimal total = calculateTotal(orders);
        return new Report(date, orders.size(), total);
    }
}

대부분의 RDBMS 기본값은 READ_COMMITTED(PostgreSQL, Oracle) 또는 REPEATABLE_READ(MySQL InnoDB)입니다. 격리 수준을 높이면 정합성은 올라가지만 동시성이 떨어지므로, 필요한 곳에만 높은 격리를 적용하세요.

readOnly 최적화

readOnly = true는 단순 힌트가 아닌 실질적 최적화입니다.

@Service
public class ReportService {

    @Transactional(readOnly = true)
    public List<OrderSummary> getOrderSummaries() {
        // 1. Hibernate: Dirty Checking 비활성화 → 메모리/CPU 절약
        // 2. JDBC: Connection.setReadOnly(true) → DB 레플리카 라우팅 가능
        // 3. 플러시 모드 MANUAL → 불필요한 SQL 방지
        return orderRepository.findAllSummaries();
    }
}

// DataSource 라우팅 예시
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? "replica" : "primary";
    }
}

readOnly = true와 라우팅 DataSource를 결합하면 읽기 쿼리를 자동으로 레플리카 DB로 보낼 수 있습니다. 대규모 트래픽에서 Primary DB 부하를 크게 줄이는 핵심 전략입니다.

프록시 함정: 자기 호출 문제

Spring @Transactional의 가장 위험한 함정입니다.

@Service
public class UserService {

    @Transactional
    public void createUser(UserDto dto) {
        User user = userRepository.save(new User(dto));
        sendWelcomeEmail(user);  // ⚠️ 트랜잭션 적용 안 됨!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendWelcomeEmail(User user) {
        // 같은 클래스 내부 호출 → 프록시를 거치지 않음
        // REQUIRES_NEW가 무시되고 기존 트랜잭션에서 실행됨!
        emailService.send(user.getEmail(), "Welcome!");
        emailLogRepository.save(new EmailLog(user.getId()));
    }
}

해결 방법:

// 방법 1: 별도 서비스로 분리 (권장)
@Service
public class EmailTransactionService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendWelcomeEmail(User user) { ... }
}

// 방법 2: 자기 주입
@Service
public class UserService {
    @Lazy @Autowired private UserService self;

    public void createUser(UserDto dto) {
        User user = userRepository.save(new User(dto));
        self.sendWelcomeEmail(user);  // 프록시를 통해 호출
    }
}

// 방법 3: TransactionTemplate (프로그래밍 방식)
@Service
public class UserService {
    private final TransactionTemplate txTemplate;

    public void createUser(UserDto dto) {
        User user = userRepository.save(new User(dto));
        txTemplate.execute(status -> {
            emailService.send(user.getEmail(), "Welcome!");
            return null;
        });
    }
}

이 문제는 Spring Bean Lifecycle에서 다루는 AOP 프록시 메커니즘을 이해하면 자연스럽게 파악할 수 있습니다.

롤백 규칙 커스터마이징

@Transactional(
    rollbackFor = BusinessException.class,           // Checked 예외도 롤백
    noRollbackFor = NotificationFailException.class  // 이 예외는 롤백 안 함
)
public void processOrder(OrderRequest request) {
    createOrder(request);                    // 실패 시 롤백
    try {
        notifyUser(request.getUserId());     // 실패해도 롤백 안 함
    } catch (NotificationFailException e) {
        log.warn("알림 실패, 주문은 정상 처리", e);
    }
}

기본적으로 Spring은 RuntimeException과 Error만 롤백합니다. Checked Exception은 롤백하지 않으므로, 비즈니스 예외가 Checked라면 rollbackFor를 명시해야 합니다. Spring Retry와 결합하면 재시도 후에도 실패할 때만 롤백하는 탄력적인 트랜잭션을 구현할 수 있습니다.

정리

Spring @Transactional의 전파 수준은 트랜잭션 경계를 설계하는 핵심 도구입니다. REQUIRED는 기본, REQUIRES_NEW는 독립 트랜잭션, NESTED는 부분 롤백에 사용합니다. 격리 수준은 동시성과 정합성의 트레이드오프이며, readOnly 최적화와 레플리카 라우팅을 적극 활용하세요. 자기 호출 문제는 프록시 기반 AOP의 근본적 한계이므로, 서비스 분리나 TransactionTemplate으로 해결해야 합니다.

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