개발을 하다 보면
@Transactional어노테이션을 자주 사용하게 된다. 단순히 붙이기만 하면 트랜잭션이 적용된다는 건 알지만, 실제로 어떻게 동작하는지, 어떤 상황에서 주의해야 하는지 명확히 아는 개발자는 많지 않다. (나포함) 이 글에서는 Spring의@Transactional이 어떻게 동작하는지, DB 커넥션과 어떤 관계가 있는지, 개발 시 주의해야 할 점들을 살펴보려 한다.
@Transactional이 붙은 메서드가 호출되면 다음과 같은 프로세스로 처리된다:
@Service
public class ProductService {
@Transactional
public void saveProduct(Product product) {
// 메서드 로직
productRepository.save(product);
}
}
@Transactional이 붙은 클래스나 메서드에 대해 프록시 객체를 생성한다. 이 프록시는 AOP(Aspect-Oriented Programming)를 기반으로 동작한다.saveProduct() 메서드가 호출되면, 프록시는 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이때 TransactionStatus 객체가 생성된다.productRepository.save(product))이 실행될 때 비로소 커넥션 풀에서 커넥션을 획득한다.이 프로세스의 핵심은 실제 DB 커넥션이 필요한 시점까지 획득을 지연한다는 것이다. 따라서 메서드의 앞부분에 DB와 관련 없는 작업(예: 검증 로직)이 있다면, 그 동안에는 DB 커넥션을 점유하지 않는다.
@Transactional 어노테이션에는 여러 속성이 있지만, 그중에서도 propagation 속성은 트랜잭션의 동작 방식을 크게 변화시킬 수 있다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveWithNewTransaction() {
// 항상 새로운 트랜잭션을 시작
}
주요 전파 속성은 다음과 같다:
REQUIRES_NEW는 DB 커넥션 관리 측면에서 특히 중요하다. 이 속성을 사용하면 메서드마다 별도의 트랜잭션(따라서 별도의 DB 커넥션)을 사용하게 된다.
트랜잭션과 DB 커넥션의 생명주기는 밀접하게 연관되어 있다:
@Transactional
public void processData() {
// 아직 DB 커넥션을 획득하지 않음
doSomethingWithoutDB(); // DB 작업 없음
// 이 시점에서 DB 커넥션 획득
User user = userRepository.findById(1L);
// 커넥션 유지 중
doSomethingElse();
// 같은 커넥션 사용
userRepository.save(user);
} // 메서드 종료 시 트랜잭션 커밋 및 커넥션 반환
@Transactional 메서드 시작 시에는 실제 DB 커넥션을 획득하지 않는다.userRepository.findById(1L))이 발생하면 커넥션을 획득한다.@Transactional 없이 repository의 save 메서드를 호출하면 어떻게 될까?
public void saveWithoutTransaction(Product product, OtherEntity otherEntity) {
// 트랜잭션 없이 저장
productRepository.save(product); // 자체 트랜잭션 생성, 실행 완료 시 즉시 DB에 커밋됨
// 다른 저장 작업
otherRepository.save(otherEntity); // 별도의 트랜잭션으로 처리됨
// 만약 이 시점에서 오류가 발생해도 위의 두 저장 작업은 롤백되지 않음
// 각각 별도의 트랜잭션으로 이미 커밋되었기 때문
}
@Transactional 없이 save() 메서드를 호출하면, Spring Data JPA는 내부적으로 각 메서드 호출마다 자체적인 트랜잭션을 생성한다.saveAndFlush() 메서드를 사용하는 경우에도 트랜잭션의 유무에 따라 다르게 동작한다:
외부 API 호출이 포함된 트랜잭션 메서드는 성능 문제를 일으킬 수 있다:
@Transactional
public ProductDto createProduct(ProductRequest request) {
// DB 작업 (커넥션 획득)
Product product = productRepository.save(new Product(request.getName()));
// 외부 API 호출 (DB 커넥션을 계속 점유)
StockResponse stockResponse = stockClient.createStock(product.getId());
return new ProductDto(product, stockResponse);
}
위 코드의 문제점:
개선된 접근법: @Transactional 어노테이션 제거
public ProductDto createProduct(ProductRequest request) {
// 1. 제품 저장 (별도 트랜잭션 - 이 시점에 실제 db에 즉시 반영됨)
Product product = productRepository.save(new Product(request.getName()));
// 2. 외부 API 호출 (DB 트랜잭션 외부에서)
StockResponse stockResponse = stockClient.createStock(product.getId());
return new ProductDto(product, stockResponse);
}
이 접근법의 장점:
하지만 데이터 일관성 측면에서는 주의가 필요하다. 외부 API 호출이 실패하면 데이터 불일치가 발생할 수 있기 때문이다.
외부 API 호출과 DB 작업을 분리했을 때 발생할 수 있는 데이터 불일치 문제를 해결하기 위해 보상 트랜잭션을 사용할 수 있다:
public ProductDto createProduct(ProductRequest request) {
// 1. 제품 저장 (별도 트랜잭션)
Product product = saveProductInTransaction(request);
try {
// 2. 외부 API 호출
StockResponse stockResponse = stockClient.createStock(product.getId());
return new ProductDto(product, stockResponse);
} catch (Exception e) {
// 3. 외부 API 호출 실패 시 보상 트랜잭션으로 제품 삭제
deleteProductInTransaction(product.getId());
throw new ServiceException("재고 생성 실패로 제품 생성이 롤백되었습니다", e);
}
}
// 별도의 클래스로 사용되어야함. 또는 자기참조
@Transactional
protected Product saveProductInTransaction(ProductRequest request) {
return productRepository.save(new Product(request.getName()));
}
@Transactional
protected void deleteProductInTransaction(Long productId) {
productRepository.deleteById(productId);
}
이 접근법은 외부 API 호출이 실패할 경우 이미 저장된 제품을 삭제하는 보상 트랜잭션을 실행하여 데이터 일관성을 유지한다. 하지만 보상 트랜잭션 자체가 실패할 가능성도 존재한다.
트랜잭션 범위를 결정할 때는 데이터 일관성과 성능 사이의 균형을 고려해야 한다:
// 방법 1: 모든 작업을 하나의 트랜잭션으로 (일관성 ↑, 성능 ↓)
@Transactional
public void processAllInOneTransaction() {
// DB 작업 1, 2, 3, ...
// 외부 API 호출
}
// 방법 2: 작업을 여러 트랜잭션으로 분리 (일관성 ↓, 성능 ↑)
public void processWithSeparateTransactions() {
// 각 작업에 대해 별도의 @Transactional 메서드 호출
}
선택 기준:
개발 중에 트랜잭션이 제대로 적용되고 있는지 확인하려면 다음과 같은 방법을 사용할 수 있다:
import org.springframework.transaction.support.TransactionSynchronizationManager;
public void checkTransaction() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
if (isActive) {
System.out.println("현재 트랜잭션이 활성화되어 있습니다.");
System.out.println("트랜잭션 이름: " + TransactionSynchronizationManager.getCurrentTransactionName());
} else {
System.out.println("현재 트랜잭션이 활성화되어 있지 않습니다.");
}
}
더 상세한 트랜잭션 로그를 보려면 application.yml에 다음과 같이 설정한다:
logging:
level:
org:
springframework:
transaction:
interceptor: TRACE
jdbc:
datasource:
DataSourceTransactionManager: DEBUG
orm:
jpa:
JpaTransactionManager: DEBUG
같은 클래스 내에서 @Transactional 메서드를 호출할 때 발생하는 문제:
@Service
public class ProductService {
public void processProduct(Product product) {
// 트랜잭션이 적용되지 않음
saveProduct(product); // 내부 호출은 프록시를 거치지 않음
}
@Transactional
public void saveProduct(Product product) {
// 트랜잭션 로직
}
}
해결책:
// 방법 1: 클래스 분리 (권장)
@Service
public class ProductFacadeService {
private final ProductService productService;
public ProductFacadeService(ProductService productService) {
this.productService = productService;
}
public void processProduct(Product product) {
// 트랜잭션이 제대로 적용됨
productService.saveProduct(product); // 외부 호출
}
}
@Service
public class ProductService {
@Transactional
public void saveProduct(Product product) {
// 트랜잭션 로직
}
}
// 방법 2: Self-Injection (권장하지 않음)
@Service
public class ProductService {
@Autowired
private ProductService self; // Self-Injection
public void processProduct(Product product) {
// 트랜잭션이 제대로 적용됨
self.saveProduct(product); // 프록시를 통한 호출
}
@Transactional
public void saveProduct(Product product) {
// 트랜잭션 로직
}
}
모든 예외가 롤백을 트리거하지는 않는다:
@Transactional // 기본적으로 RuntimeException과 Error만 롤백
public void saveWithRollback() {
try {
// DB 작업
} catch (Exception e) {
throw new RuntimeException(e); // 롤백됨
}
}
@Transactional
public void saveWithoutRollback() throws Exception {
// DB 작업
throw new Exception("오류 발생"); // 롤백되지 않음 (Checked Exception)
}
해결책:
@Transactional(rollbackFor = Exception.class) // 모든 예외에 대해 롤백
public void saveWithRollbackForAllExceptions() throws Exception {
// DB 작업
}
@Transactional은 강력한 도구지만, 실제 동작 방식을 정확히 이해하고 사용해야 한다. 핵심 포인트를 정리하면:
@Transactional이 없는 상태에서 repository 메서드(save, delete 등)를 호출하면 각 메서드마다 자체적인 트랜잭션이 생성되어 즉시 DB에 반영된다.결국 트랜잭션 관리는 데이터 일관성과 성능 사이의 균형을 잡는 작업이다. 비즈니스 요구사항, 시스템 아키텍처, 그리고 성능 특성을 고려하여 최적의 접근법을 선택해야 한다. 무턱대고 높은 일관성을 기대하다간 병목이 발생할지도 모른다. 나도 여전히 어렵지만 ! 경험을 쌓아나가며 적절한 타협점을 찾아나가보자 (정답은 없으니까)