트랜잭션은 데이터베이스 작업을 하나의 논리적 단위로 처리하며, 작업의 성공 여부에 따라 데이터 변경 사항을 저장하거나 이전 상태로 복구합니다. 커밋(commit)은 작업이 성공적으로 완료된 경우 변경 사항을 영구적으로 저장하며, 롤백(rollback)은 오류 발생 시 모든 작업을 취소하고 작업 전 상태로 되돌립니다. 이를 통해 데이터의 정합성과 무결성을 유지할 수 있습니다.
@Transactional
public void sellProduct(Long productId, int quantity) {
// 상품 조회: 존재하지 않으면 예외 발생
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT));
// 재고 차감: 엔티티 내 reduceStock 메서드를 활용
product.reduceStock(quantity);
// 변경 사항 저장: 트랜잭션 커밋 시점에 DB에 반영됨
productRepository.save(product);
} @Transactional
public void sellProductWithError(Long productId, int quantity) {
// 상품 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT));
// 재고 부족 체크: 재고가 부족하면 예외 발생하여 트랜잭션 롤백
if (product.getStock() < quantity) {
throw new ServiceException(ServiceExceptionCode.OUT_OF_STOCK_PRODUCT);
}
// 재고 차감
product.reduceStock(quantity);
// 변경 사항 저장: 예외가 없으면 커밋되어 DB에 반영됨
productRepository.save(product);
} @Transactional
public void sellProductAndNotify(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT));
product.reduceStock(quantity);
// 트랜잭션 외부에서 실행되는 외부 알림 서비스 호출
notificationService.sendStockReductionNotification(product);
// 이후 DB 저장 작업에서 예외 발생 시 전체 트랜잭션 롤백 (단, 알림은 이미 전송됨)
productRepository.save(product);
}@Transactional
public void processBulkSales(List<Sale> sales) {
for (Sale sale : sales) {
Product product = productRepository.findById(sale.getProductId())
.orElseThrow(() -> new ProductNotFoundException("상품이 존재하지 않습니다."));
// 재고 차감
product.reduceStock(sale.getQuantity());
// 특정 조건에서 예외 발생 시, 전체 트랜잭션이 롤백되어 모든 상품의 재고 변경이 취소됨
if (sale.getQuantity() > product.getStock()) {
throw new RuntimeException("일부 상품의 재고 부족으로 처리 실패: " + product.getName());
}
productRepository.save(product);
}
}트랜잭션의 커밋과 롤백은 데이터 정합성과 무결성을 유지하기 위한 핵심 메커니즘입니다.
특히, 외부 시스템과의 연동이나 대량 데이터 처리 시에는 추가적인 관리 전략(예: 보상 트랜잭션, 개별 처리 로직 등)이 필요함을 주의해야 합니다.
제공해주신 Product 엔티티의 reduceStock() 메서드와 함께 위 예시들을 참고하면, 트랜잭션 관리의 중요성과 발생 가능한 문제점들을 보다 쉽게 이해할 수 있습니다.
트랜잭션 롤백은 예외 상황에서 데이터의 정합성을 유지하기 위해 모든 변경 사항을 원래 상태로 되돌리는 중요한 메커니즘입니다. Spring은 트랜잭션 내에서 발생하는 예외 유형에 따라 롤백 동작을 자동으로 처리하며, 개발자가 필요에 따라 세부 동작을 제어할 수 있도록 설정을 제공합니다.
1. 체크 예외 (Checked Exception)
SQLException, IOException 등rollbackFor 속성을 설정해야 합니다.@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
/**
* 체크 예외(CustomCheckedException)가 발생하면, rollbackFor에 의해 트랜잭션이 롤백됩니다.
*/
@Transactional(rollbackFor = CustomCheckedException.class)
public void updateProductPrice(Long productId, BigDecimal newPrice) throws CustomCheckedException {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
product.setPrice(newPrice);
// 예외 발생 조건: 음수 가격은 허용하지 않음 (체크 예외)
if(newPrice.compareTo(BigDecimal.ZERO) < 0) {
throw new CustomCheckedException("가격은 음수가 될 수 없습니다.");
}
productRepository.save(product);
}
}// CustomCheckedException.java
public class CustomCheckedException extends Exception {
public CustomCheckedException(String message) {
super(message);
}
}2. 언체크 예외 (Unchecked Exception)
NullPointerException, IllegalArgumentException 등@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
/**
* 언체크 예외(IllegalArgumentException)가 발생해도 기본적으로 트랜잭션은 롤백되지만,
* noRollbackFor 속성을 통해 롤백하지 않도록 설정할 수 있습니다.
*/
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void reduceProductStockNoRollback(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT));
// 재고 부족 시 IllegalArgumentException 발생 (언체크 예외)
if (product.getStock() < quantity) {
throw new IllegalArgumentException("재고가 부족합니다.");
}
product.reduceStock(quantity);
productRepository.save(product);
}
}
Spring의 @Transactional은 rollbackFor와 noRollbackFor 속성을 통해 트랜잭션 롤백 동작을 제어할 수 있습니다.
1. 롤백 설정 (rollbackFor)
@Transactional(rollbackFor = CustomCheckedException.class)
public void updateProductPrice(Long productId, BigDecimal newPrice) throws CustomCheckedException {
// Product 엔티티를 통해 가격 업데이트 수행
// 새 가격이 음수면 CustomCheckedException 발생 -> 트랜잭션 롤백
}
2. 롤백 제외 설정 (noRollbackFor)
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void reduceProductStockNoRollback(Long productId, int quantity) {
// 재고가 부족할 경우 IllegalArgumentException 발생하더라도 트랜잭션은 롤백되지 않음
}3. 테스트 코드 예제
@SpringBootTest
public class ProductTransactionTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void testRollbackForCheckedException() {
// 기존 가격으로 복원하기 위한 Product 초기 상태 저장
Long productId = 1L;
Product originalProduct = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
BigDecimal originalPrice = originalProduct.getPrice();
// 음수 가격 업데이트 시도 -> CustomCheckedException 발생 및 트랜잭션 롤백
assertThrows(CustomCheckedException.class, () -> {
productService.updateProductPrice(productId, new BigDecimal("-10.00"));
});
// 롤백이 정상적으로 수행되어 가격이 변경되지 않았음을 확인
Product productAfter = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
assertEquals(originalPrice, productAfter.getPrice());
}
@Test
void testNoRollbackForUncheckedException() {
Long productId = 2L;
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
int originalStock = product.getStock();
// 재고 부족 상태에서 reduceProductStockNoRollback 호출
// IllegalArgumentException이 발생하더라도 noRollbackFor에 의해 트랜잭션은 커밋됨
assertThrows(IllegalArgumentException.class, () -> {
productService.reduceProductStockNoRollback(productId, originalStock + 10);
});
// 트랜잭션이 롤백되지 않았으므로, 재고가 변경되었는지 확인 (어플리케이션 로직에 따라 다를 수 있음)
Product productAfter = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
// 재고가 변경되지 않아야 정상이라면, 별도의 처리가 필요할 수 있음.
assertEquals(originalStock, productAfter.getStock());
}
}
이와 같이, Product 엔티티를 활용한 예제 코드를 통해 체크 예외와 언체크 예외에 따른 트랜잭션 롤백 동작을 세밀하게 제어할 수 있습니다.
rollbackFor를 통해 기본적으로 롤백되지 않는 체크 예외도 트랜잭션 롤백 대상으로 지정할 수 있으며,noRollbackFor를 통해 특정 언체크 예외에 대해서는 롤백을 제외하는 설정이 가능합니다.이를 통해 비즈니스 로직에 맞는 유연한 트랜잭션 관리가 가능해집니다.
트랜잭션 커밋은 작업이 성공적으로 완료된 경우 변경 사항을 데이터베이스에 영구적으로 저장하는 과정입니다. 작업의 성격과 데이터 양에 따라 전체 커밋과 부분 커밋 방식을 선택하여 효율성을 극대화할 수 있습니다.
1. 전체 커밋
@Transactional
public void updateProductPrices(List<Product> products, BigDecimal priceIncrement) {
for (Product product : products) {
product.setPrice(product.getPrice().add(priceIncrement));
productRepository.save(product);
}
}2. 부분 커밋
@Service
public class ProductBatchService {
@PersistenceContext
private final EntityManager entityManager;
/**
* 대량의 제품 목록에 대해 재고를 감소시키는 작업을 배치 단위로 처리합니다.
* 각 배치가 끝날 때마다 flush()와 clear()를 호출하여 메모리 사용량을 최적화합니다.
*/
@Transactional
public void batchUpdateProductStock(List<Product> productList, int stockDecrease) {
int batchSize = 10;
for (int i = 0; i < productList.size(); i++) {
Product product = productList.get(i);
// 판매 후 재고 감소 처리
product.reduceStock(stockDecrease);
entityManager.merge(product);
// 배치 단위마다 커밋 및 영속성 컨텍스트 초기화
if ((i + 1) % batchSize == 0) {
flushAndClear();
}
}
// 잔여 데이터 처리
flushAndClear();
}
private void flushAndClear() {
entityManager.flush(); // 변경 사항을 데이터베이스에 반영
entityManager.clear(); // 영속성 컨텍스트 초기화 (캐시된 엔티티 제거)
}
}