지난 글에서는 Spring AI의 Advisor 패턴과 Function Calling을 다뤘다. 이번 글에서는 잠시 AI 주제에서 벗어나, 백엔드 개발의 가장 기본적인 안전장치 중 하나인 트랜잭션을 짚고 넘어간다.
이런 질문들을 생각해보자.
트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 것이다. 핵심은 All or Nothing — 전부 성공하거나, 전부 실패하거나.
주문 처리를 예로 들면, 주문 테이블 INSERT와 재고 UPDATE는 반드시 함께 성공하거나 함께 실패해야 한다. 주문만 들어가고 재고가 안 줄면 데이터가 엉망이 된다.
트랜잭션이 보장해야 하는 4가지 원칙이다.
| 원칙 | 의미 | 예시 |
|---|---|---|
| Atomicity (원자성) | 전부 성공 또는 전부 롤백 | 주문+재고 둘 다 성공해야 |
| Consistency (일관성) | 트랜잭션 전후 규칙 만족 | 이체 전후 총액 동일 |
| Isolation (격리성) | 트랜잭션 간 간섭 없음 | 동시 주문도 독립 처리 |
| Durability (지속성) | 커밋된 데이터는 영구 보존 | 서버 재시작 후에도 유지 |
이 중 AutoCommit이 켜진 상태에서 가장 먼저 무너지는 건 원자성(A)이다. SQL 한 줄마다 자동으로 커밋되니, 중간에 에러가 나도 이미 커밋된 앞 작업은 되돌릴 수 없다.
그래서 JDBC 트랜잭션 코드에서 가장 먼저 하는 게 이것이다.
connection.setAutoCommit(false); // 커밋 타이밍을 내가 직접 제어할게
개발자가 트랜잭션의 시작, 커밋, 롤백을 코드로 직접 제어하는 방식이다. Spring의 PlatformTransactionManager를 사용한다.
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()); // 트랜잭션 시작
try {
product.reduceStock(quantity);
productRepository.save(product);
transactionManager.commit(status); // 성공 시 커밋
} catch (Exception ex) {
transactionManager.rollback(status); // 실패 시 롤백
throw ex;
}
서비스 메서드가 100개라면 저 try-catch 블록이 100번 반복된다. 누락될 위험도 있고, 비즈니스 로직과 트랜잭션 로직이 뒤섞인다.
그럼에도 이 방식이 필요한 경우가 있다. 100개 배치 작업 중 1개가 실패해도 나머지 99개는 커밋해야 하는 부분 롤백 같은 경우다. 조건에 따라 트랜잭션 경계를 유연하게 조정해야 할 때 직접 제어가 빛을 발한다.
Spring이 AOP Proxy를 통해 자동으로 처리하는 방식이다. 메서드 앞뒤를 Proxy가 감싸서 트랜잭션을 시작하고, 성공하면 커밋, 예외가 나면 롤백한다.
// Before: 트랜잭션 코드가 비즈니스 로직과 섞임
public void updateStock(Long id, int qty) {
TransactionStatus status = transactionManager.getTransaction(...);
try {
product.reduceStock(qty);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
}
}
// After: 비즈니스 로직만 남음
@Transactional
public void updateStock(Long id, int qty) {
product.reduceStock(qty);
}
읽기 전용 메서드에는 readOnly = true를 붙인다. 이렇게 하면 Spring이 Dirty Checking(변경 감지)을 생략해서 불필요한 스냅샷 비교 오버헤드를 줄인다.
@Transactional(readOnly = true)
public Product getProduct(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new DomainException(PRODUCT_NOT_FOUND));
}
주의할 점은 AOP 기반이라는 것이다. private 메서드나 같은 클래스 내부 호출(self-invocation)에는 Proxy가 개입하지 못해서 트랜잭션이 적용되지 않는다. 이건 6단계에서 다뤘던 self-invocation 문제와 같은 맥락이다.
| 항목 | 선언적 (@Transactional) | 프로그래밍 방식 |
|---|---|---|
| 코드 간결성 | 어노테이션 한 줄 | try-catch 반복 |
| 유지보수 | 비즈니스 로직과 분리 | 혼재 가능성 |
| 제어 유연성 | 속성으로 설정 | 조건부 커밋/롤백 가능 |
| 적합한 상황 | 일반적인 CRUD | 부분 롤백, 복잡한 로직 |
트랜잭션은 결국 데이터 무결성을 지키는 안전장치다. Spring은 @Transactional이라는 어노테이션 하나로 그 안전장치를 AOP Proxy가 자동으로 달아준다. 단순 CRUD는 선언적 방식으로 충분하고, 세밀한 제어가 필요할 때만 프로그래밍 방식을 꺼내면 된다.