T_era

트랜잭션 전파는 상황마다 어떤 속성을 써야할까? 본문

Programing/Spring

트랜잭션 전파는 상황마다 어떤 속성을 써야할까?

블스뜸 2025. 6. 10. 20:52

1. REQUIRED (기본값)

설명: 호출하는 쪽에 트랜잭션이 있으면 해당 트랜잭션에 참여하고, 없으면 새로운 트랜잭션을 시작한다. 대부분의 비즈니스 로직에 사용되는 가장 일반적인 전파 속성이다.

예시 상황: 온라인 쇼핑몰에서 상품을 주문하는 경우.

@Service
public class OrderService {

    @Autowired private ProductService productService;
    @Autowired private PaymentService paymentService;

    @Transactional(propagation = Propagation.REQUIRED) // REQUIRED (기본값)
    public void placeOrder(String userId, String productId, int quantity) {
        // 1. 주문 정보 저장
        orderRepository.save(new Order(userId, productId, quantity));

        // 2. 상품 재고 감소 (동일 트랜잭션 참여)
        productService.decreaseStock(productId, quantity);

        // 3. 결제 처리 (동일 트랜잭션 참여)
        paymentService.processPayment(userId, quantity * productPrice); // 상품 가격은 예시상수

        // 위 세 작업 중 하나라도 실패하면 전체 placeOrder 트랜잭션 롤백
    }
}

@Service
public class ProductService {
    @Transactional(propagation = Propagation.REQUIRED) // OrderService의 트랜잭션에 참여
    public void decreaseStock(String productId, int quantity) {
        // 재고 감소 로직
        // ...
        if (stock < quantity) {
            throw new RuntimeException("재고 부족");
        }
        productRepository.updateStock(productId, -quantity);
    }
}

@Service
public class PaymentService {
    @Transactional(propagation = Propagation.REQUIRED) // OrderService의 트랜잭션에 참여
    public void processPayment(String userId, double amount) {
        // 결제 처리 로직
        // ...
        if (paymentFailed) {
            throw new RuntimeException("결제 실패");
        }
        paymentRepository.save(new Payment(userId, amount));
    }
}

설명: OrderService의 placeOrder 메서드가 트랜잭션을 시작하고, productService.decreaseStock와 paymentService.processPayment는 이미 진행 중인 placeOrder 트랜잭션에 참여한다. 따라서 이 세 메서드 중 하나라도 예외를 발생시키면 모든 데이터 변경 사항이 롤백된다.

2. REQUIRES_NEW

설명: 항상 새로운 트랜잭션을 시작한다. 기존에 진행 중인 트랜잭션이 있다면 이를 일시 중단하고 새로운 트랜잭션을 생성한다. 새로운 트랜잭션이 완료된 후 기존 트랜잭션을 다시 시작한다.

예시 상황: 주문 처리 중 발생한 로그를 기록해야 하는데, 로그 기록이 주문 처리의 성공/실패와는 별개로 항상 저장되어야 하는 경우.

@Service
public class OrderService {

    @Autowired private ProductService productService;
    @Autowired private PaymentService paymentService;
    @Autowired private LogService logService; // 로그 서비스 주입

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(String userId, String productId, int quantity) {
        try {
            // 주문 처리 핵심 로직 (REQUIRED 트랜잭션)
            orderRepository.save(new Order(userId, productId, quantity));
            productService.decreaseStock(productId, quantity);
            paymentService.processPayment(userId, quantity * productPrice);

            logService.logOrderSuccess(userId, productId); // 성공 로그 (새로운 트랜잭션)
        } catch (Exception e) {
            logService.logOrderFailure(userId, productId, e.getMessage()); // 실패 로그 (새로운 트랜잭션)
            throw e; // 예외를 다시 던져 주문 트랜잭션 롤백
        }
    }
}

@Service
public class LogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW) // 항상 새로운 트랜잭션 시작
    public void logOrderSuccess(String userId, String productId) {
        logRepository.save(new LogEntry(userId, productId, "ORDER_SUCCESS", System.currentTimeMillis()));
        // 로그 저장 실패 시에도 주문 트랜잭션에는 영향을 주지 않음
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW) // 항상 새로운 트랜잭션 시작
    public void logOrderFailure(String userId, String productId, String errorMessage) {
        logRepository.save(new LogEntry(userId, productId, "ORDER_FAILURE", errorMessage, System.currentTimeMillis()));
        // 로그 저장 실패 시에도 주문 트랜잭션에는 영향을 주지 않음
    }
}

설명: placeOrder 메서드 내에서 logService.logOrderSuccess나 logService.logOrderFailure를 호출할 때, placeOrder의 트랜잭션은 일시 중단되고 LogService 메서드는 새로운 독립적인 트랜잭션을 시작한다. 따라서 로그 저장에 실패하더라도 주문 처리 트랜잭션에는 영향을 주지 않으며, 반대로 주문 처리가 롤백되더라도 로그는 계속 저장된다.

3. SUPPORTS

설명: 호출하는 쪽에 트랜잭션이 있으면 해당 트랜잭션에 참여하고, 없으면 트랜잭션 없이 진행한다. 트랜잭션이 필수는 아니지만, 있다면 활용하려는 경우에 사용한다.

예시 상황: 사용자 정보를 조회하는 기능.

@Service
public class UserService {

    @Transactional(propagation = Propagation.SUPPORTS)
    public User getUserById(String userId) {
        // 트랜잭션이 있다면 참여하고, 없다면 트랜잭션 없이 조회
        return userRepository.findById(userId);
    }
}

설명: getUserById 메서드는 트랜잭션이 필요한 작업이 아니다. 만약 이 메서드가 트랜잭션이 있는 다른 메서드에 의해 호출되면 해당 트랜잭션에 참여하여 조회가 이루어지고, 단독으로 호출되면 트랜잭션 없이 조회가 이루어진다.

4. NOT_SUPPORTED

설명: 트랜잭션 없이 진행한다. 호출하는 쪽에 트랜잭션이 있으면 이를 일시 중단한다.

예시 상황: 이메일 전송과 같이 트랜잭션으로 관리할 수 없는 외부 시스템과의 연동.

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void sendEmail(String emailAddress, String subject, String body) {
        // 이메일 전송 로직 (트랜잭션으로 관리되지 않음)
        // 예를 들어, SMTP 서버로 이메일 발송
        System.out.println("이메일 전송: " + emailAddress + " - " + subject);
    }
}

@Service
public class OrderService {
    @Autowired private NotificationService notificationService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrderAndNotify(String userId, String productId, int quantity) {
        // 주문 처리 로직
        orderRepository.save(new Order(userId, productId, quantity));

        // 이메일 전송 (OrderService 트랜잭션은 일시 중단되고 이메일은 트랜잭션 없이 발송)
        notificationService.sendEmail(userEmail, "주문 완료", "주문이 성공적으로 처리되었습니다.");
    }
}

설명: sendEmail 메서드가 호출될 때 OrderService의 트랜잭션은 일시 중단되고, 이메일 전송은 트랜잭션의 영향을 받지 않는다. 이메일 전송 실패가 주문 트랜잭션 롤백으로 이어지지 않으며, 반대로 주문 트랜잭션이 롤백되더라도 이미 전송된 이메일은 되돌릴 수 없다.

5. MANDATORY

설명: 호출하는 쪽에 반드시 트랜잭션이 있어야 한다. 없으면 IllegalTransactionStateException 예외를 발생시킨다.

예시 상황: 재고 감소와 같이 반드시 트랜잭션 컨텍스트 내에서 실행되어야 하는 핵심 비즈니스 로직.

@Service
public class ProductService {
    @Transactional(propagation = Propagation.MANDATORY) // 반드시 트랜잭션 내에서 호출되어야 함
    public void decreaseStock(String productId, int quantity) {
        // 재고 감소 로직
        // ...
        productRepository.updateStock(productId, -quantity);
    }
}

// Case 1: 트랜잭션 내에서 호출 (정상 동작)
@Service
public class OrderService {
    @Autowired private ProductService productService;
    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(String userId, String productId, int quantity) {
        productService.decreaseStock(productId, quantity); // OrderService 트랜잭션 내에서 호출
    }
}

// Case 2: 트랜잭션 없이 호출 (예외 발생)
public class SomeController {
    @Autowired private ProductService productService;
    public void someMethodWithoutTransaction(String productId, int quantity) {
        // 이 메서드는 트랜잭션이 없음
        productService.decreaseStock(productId, quantity); // IllegalTransactionStateException 발생
    }
}

설명: decreaseStock 메서드는 오직 트랜잭션이 있는 컨텍스트 내에서만 호출될 수 있도록 강제한다. 이는 중요한 데이터 변경 작업이 실수로 트랜잭션 없이 실행되는 것을 방지한다.

6. NEVER

설명: 트랜잭션 없이 진행해야 한다. 호출하는 쪽에 트랜잭션이 있으면 IllegalTransactionStateException 예외를 발생시킨다.

예시 상황: 특정 조회 로직이 절대로 트랜잭션 내에서 실행되어서는 안 되는 경우 (예: 오랜 시간 동안 트랜잭션을 점유하는 것을 방지하거나, 트랜잭션 독립적으로 동작해야 할 때).

@Service
public class ReportService {

    @Transactional(propagation = Propagation.NEVER)
    public List<ReportData> generateDailyReport() {
        // 대량의 데이터를 읽어와 리포트 생성 (트랜잭션 없이 실행)
        // 만약 이 메서드가 트랜잭션 내에서 호출되면 예외 발생
        return reportRepository.getDailyReportData();
    }
}

// Case 1: 트랜잭션 없이 호출 (정상 동작)
public class ReportScheduler {
    @Autowired private ReportService reportService;
    public void runDailyReport() {
        reportService.generateDailyReport(); // 트랜잭션 없이 호출
    }
}

// Case 2: 트랜잭션 내에서 호출 (예외 발생)
@Service
public class AdminService {
    @Autowired private ReportService reportService;
    @Transactional(propagation = Propagation.REQUIRED)
    public void doAdminOperationAndReport() {
        // 관리자 작업
        adminRepository.updateSomething();
        reportService.generateDailyReport(); // IllegalTransactionStateException 발생
    }
}

설명: generateDailyReport 메서드는 트랜잭션 내에서 호출되는 것을 금지한다. 이는 이 메서드가 매우 긴 시간 동안 데이터베이스를 읽거나, 트랜잭션 격리 수준의 영향을 받지 않아야 할 때 유용하다.

7. NESTED

설명: 중첩 트랜잭션을 지원한다. 이미 진행 중인 트랜잭션 내에서 새로운 중첩 트랜잭션을 시작한다. 롤백 시 중첩 트랜잭션의 변경 사항만 되돌린다. (JDBC savepoint 기능을 활용한다.)

예시 상황: 단일 비즈니스 작업 내에서 부분적인 실패가 발생해도 메인 트랜잭션은 유지하고 싶지만, 실패한 부분의 변경 사항만 롤백하고 싶을 때.

@Service
public class BatchProcessingService {

    @Transactional(propagation = Propagation.REQUIRED)
    public void processAllItems(List<Item> items) {
        for (Item item : items) {
            try {
                // 각 아이템 처리 (중첩 트랜잭션)
                processSingleItem(item);
            } catch (Exception e) {
                System.out.println("아이템 처리 실패: " + item.getId() + ", 오류: " + e.getMessage());
                // 여기서 예외를 다시 던지지 않으면 메인 트랜잭션은 계속 진행됨
            }
        }
        // 모든 아이템 처리 후 커밋 (부분 실패는 무시)
    }

    @Transactional(propagation = Propagation.NESTED) // 중첩 트랜잭션
    public void processSingleItem(Item item) {
        // 아이템 처리 로직
        itemRepository.updateItemStatus(item.getId(), "PROCESSED");
        // 예를 들어, 특정 조건에서만 실패
        if (item.getId().equals("faultyItem")) {
            throw new RuntimeException("처리 불가 아이템");
        }
        itemRepository.saveProcessedLog(item.getId());
    }
}

설명: processAllItems 메서드는 전체 아이템을 처리하는 메인 트랜잭션이다. 각 아이템을 처리하는 processSingleItem 메서드는 NESTED로 설정되어 중첩 트랜잭션을 시작한다. processSingleItem에서 예외가 발생하면 해당 아이템의 변경 사항만 롤백되고, processAllItems 트랜잭션은 계속 진행되어 다른 아이템을 처리할 수 있다. 이는 배치 처리 시 일부 실패를 허용하면서 전체 작업은 완료해야 할 때 유용하다. 다만, 이는 JDBC의 savepoint 기능을 사용하므로 모든 데이터베이스에서 지원되는 것은 아니다.