Spring Boot @Transactional이란? 트랜잭션 관리의 핵심
데이터베이스 작업에서 “전부 성공하거나, 전부 실패하거나”는 양보할 수 없는 원칙입니다. Spring Boot의 @Transactional은 이 원칙을 선언적으로 구현하는 핵심 어노테이션이지만, 내부 동작을 이해하지 못하면 데이터 정합성이 깨지는 치명적 버그를 만들 수 있습니다.
이 글에서는 @Transactional의 프록시 기반 동작 원리부터 7가지 전파(Propagation) 전략, 4가지 격리(Isolation) 수준, 롤백 규칙의 함정, self-invocation 문제, 읽기 전용 최적화, 그리고 분산 트랜잭션 대안까지 운영 수준에서 완전히 다룹니다.
@Transactional 동작 원리: 프록시와 AOP
Spring의 @Transactional은 AOP(Aspect-Oriented Programming) 프록시로 동작합니다. 빈(Bean)이 생성될 때 Spring은 원본 객체를 감싸는 프록시 객체를 만들고, 메서드 호출 전후에 트랜잭션 시작/커밋/롤백 로직을 삽입합니다.
// 개발자가 작성한 코드
@Service
public class OrderService {
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(new Order(request));
paymentService.charge(request.getPaymentInfo());
inventoryService.decrease(request.getItems());
}
}
// Spring이 실제로 만드는 프록시 (개념적 코드)
public class OrderService$$Proxy extends OrderService {
@Override
public void createOrder(OrderRequest request) {
TransactionStatus tx = txManager.getTransaction(definition);
try {
super.createOrder(request); // 원본 메서드 호출
txManager.commit(tx);
} catch (RuntimeException e) {
txManager.rollback(tx);
throw e;
}
}
}
프록시 모드: JDK Dynamic Proxy vs CGLIB
| 방식 | 조건 | 특징 |
|---|---|---|
| JDK Dynamic Proxy | 인터페이스 구현 시 | 인터페이스 메서드만 프록시, final 클래스 가능 |
| CGLIB (기본값) | 클래스 직접 상속 | final 클래스/메서드 불가, 성능 우수 |
Spring Boot 2.x부터 CGLIB가 기본입니다. spring.aop.proxy-target-class=false로 JDK Proxy로 전환할 수 있지만, 특별한 이유가 없으면 기본값을 유지하세요.
Propagation(전파) 7가지 전략: 트랜잭션 경계 설계
전파 전략은 “이미 트랜잭션이 존재할 때 어떻게 할 것인가?”를 결정합니다. 이것을 제대로 이해하지 못하면 의도치 않은 롤백이나 데이터 불일치가 발생합니다.
| Propagation | 기존 TX 있을 때 | 기존 TX 없을 때 | 실무 용도 |
|---|---|---|---|
REQUIRED (기본값) |
참여 | 새로 시작 | 대부분의 비즈니스 로직 |
REQUIRES_NEW |
기존 TX 일시 중단, 새 TX 시작 | 새로 시작 | 감사 로그, 독립 저장 |
NESTED |
SAVEPOINT 생성 | 새로 시작 | 부분 롤백 |
SUPPORTS |
참여 | TX 없이 실행 | 조회 메서드 |
NOT_SUPPORTED |
기존 TX 일시 중단 | TX 없이 실행 | 외부 API 호출 |
MANDATORY |
참여 | 예외 발생! | 반드시 TX 안에서 호출되어야 하는 메서드 |
NEVER |
예외 발생! | TX 없이 실행 | TX 밖에서만 실행 보장 |
REQUIRED vs REQUIRES_NEW: 실무 핵심 차이
@Service
public class OrderService {
@Transactional // REQUIRED (기본값)
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
// 감사 로그는 주문 실패와 무관하게 항상 저장
auditService.logOrderAttempt(request);
// 재고 차감 실패 시 주문도 롤백
inventoryService.decrease(request.getItems());
// 결제 실패 시 전체 롤백
paymentService.charge(order);
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderAttempt(OrderRequest request) {
// 새로운 독립 트랜잭션에서 실행
// 부모 TX가 롤백되어도 이 로그는 커밋됨
auditRepository.save(new AuditLog("ORDER_ATTEMPT", request));
}
}
⚠️ REQUIRES_NEW 주의점: 새 커넥션을 획득하므로 커넥션 풀 고갈 가능성이 있습니다. 중첩이 깊어지면 데드락이 발생할 수 있습니다 (부모 TX가 커넥션을 잡고 있는데, 자식 REQUIRES_NEW가 새 커넥션을 기다리는 상황).
NESTED: SAVEPOINT 기반 부분 롤백
@Service
public class OrderService {
@Transactional
public void processOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
try {
// NESTED: SAVEPOINT 생성 → 실패 시 여기까지만 롤백
bonusService.grantPoints(order);
} catch (Exception e) {
// 보너스 실패해도 주문은 유지
log.warn("보너스 적립 실패, 주문은 계속 진행", e);
}
paymentService.charge(order);
}
}
@Service
public class BonusService {
@Transactional(propagation = Propagation.NESTED)
public void grantPoints(Order order) {
// SAVEPOINT 이후 실행
// 실패 시 SAVEPOINT까지만 롤백, 부모 TX는 유지
pointRepository.save(new Point(order.getUserId(), order.getPoints()));
}
}
참고: NESTED는 JDBC SAVEPOINT를 사용하므로 JPA/Hibernate에서는 제한적으로 동작합니다. DataSourceTransactionManager에서 가장 잘 동작하며, JpaTransactionManager에서도 nestedTransactionAllowed=true 설정이 필요합니다.
Isolation(격리) 수준: 동시성 제어의 트레이드오프
격리 수준은 동시에 실행되는 트랜잭션들이 서로 얼마나 영향을 주는지를 결정합니다.
| Isolation | Dirty Read | Non-Repeatable Read | Phantom Read | 성능 |
|---|---|---|---|---|
READ_UNCOMMITTED |
⚠️ 발생 | ⚠️ 발생 | ⚠️ 발생 | 최고 |
READ_COMMITTED |
✅ 방지 | ⚠️ 발생 | ⚠️ 발생 | 높음 |
REPEATABLE_READ |
✅ 방지 | ✅ 방지 | ⚠️ 발생* | 보통 |
SERIALIZABLE |
✅ 방지 | ✅ 방지 | ✅ 방지 | 최저 |
* MySQL InnoDB의 REPEATABLE_READ는 Gap Lock으로 Phantom Read도 방지합니다. PostgreSQL은 MVCC 기반으로 동작이 다릅니다.
// 재고 차감처럼 정합성이 중요한 경우
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findByIdForUpdate(productId); // SELECT ... FOR UPDATE
if (product.getStock() < quantity) {
throw new InsufficientStockException();
}
product.decreaseStock(quantity);
}
// 통계 조회처럼 정확도보다 성능이 중요한 경우
@Transactional(isolation = Isolation.READ_UNCOMMITTED, readOnly = true)
public DashboardStats getDashboardStats() {
return statsRepository.getAggregatedStats();
}
실무 권장: 대부분의 경우 DB 기본 격리 수준(MySQL: REPEATABLE_READ, PostgreSQL: READ_COMMITTED)을 유지하고, 필요한 메서드에서만 명시적으로 변경하세요.
롤백 규칙: Checked vs Unchecked Exception의 함정
Spring @Transactional의 기본 롤백 규칙은 많은 개발자가 모르는 치명적 차이가 있습니다:
| 예외 타입 | 기본 동작 | 예시 |
|---|---|---|
| RuntimeException (Unchecked) | 롤백 | NullPointerException, IllegalArgumentException |
| Error | 롤백 | OutOfMemoryError |
| Exception (Checked) | 커밋! ⚠️ | IOException, SQLException |
// ❌ 위험: Checked Exception은 기본적으로 커밋됨!
@Transactional
public void transferMoney(Long fromId, Long toId, BigDecimal amount)
throws InsufficientBalanceException { // Checked Exception
Account from = accountRepository.findById(fromId).orElseThrow();
from.withdraw(amount); // DB에 반영
if (from.getBalance().compareTo(BigDecimal.ZERO) < 0) {
// InsufficientBalanceException이 Checked면 → 커밋됨!
// 출금은 완료되고 입금은 안 되는 참사 발생
throw new InsufficientBalanceException();
}
Account to = accountRepository.findById(toId).orElseThrow();
to.deposit(amount);
}
// ✅ 해결 1: rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount)
throws InsufficientBalanceException {
// Checked Exception이어도 롤백
}
// ✅ 해결 2: 비즈니스 예외를 RuntimeException으로 설계 (권장)
public class InsufficientBalanceException extends RuntimeException {
// Unchecked → 자동 롤백
}
실무 팁: 프로젝트 전체에 rollbackFor = Exception.class를 강제하고 싶다면, 커스텀 어노테이션을 만드세요:
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface SafeTransactional {
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
boolean readOnly() default false;
int timeout() default -1;
}
Self-Invocation 함정: 같은 클래스 내부 호출의 비극
Spring @Transactional에서 가장 흔하고 치명적인 버그입니다. 프록시 기반이므로, 같은 클래스 내부에서 호출하면 프록시를 우회하여 트랜잭션이 적용되지 않습니다.
@Service
public class OrderService {
// ❌ 외부에서 processOrder() 호출 시, 내부의 createOrder()는
// 프록시를 거치지 않아 @Transactional이 무시됨!
public void processOrder(OrderRequest request) {
validate(request);
createOrder(request); // this.createOrder() → 프록시 우회!
}
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(new Order(request));
paymentService.charge(request);
// 예외 발생해도 롤백 안 됨!
}
}
해결 방법 4가지
// ✅ 해결 1: 클래스 분리 (가장 권장)
@Service
public class OrderFacade {
private final OrderService orderService;
public void processOrder(OrderRequest request) {
validate(request);
orderService.createOrder(request); // 프록시를 통한 호출
}
}
@Service
public class OrderService {
@Transactional
public void createOrder(OrderRequest request) {
// 정상적으로 트랜잭션 적용
}
}
// ✅ 해결 2: self-injection
@Service
public class OrderService {
@Lazy
@Autowired
private OrderService self; // 프록시 객체 주입
public void processOrder(OrderRequest request) {
validate(request);
self.createOrder(request); // 프록시를 통한 호출!
}
@Transactional
public void createOrder(OrderRequest request) { ... }
}
// ✅ 해결 3: ApplicationContext에서 직접 가져오기
@Service
public class OrderService implements ApplicationContextAware {
private ApplicationContext context;
public void processOrder(OrderRequest request) {
validate(request);
context.getBean(OrderService.class).createOrder(request);
}
}
// ✅ 해결 4: @Transactional을 상위 메서드에 적용
@Service
public class OrderService {
@Transactional // processOrder 자체에 트랜잭션
public void processOrder(OrderRequest request) {
validate(request);
createOrder(request); // 이미 TX 안에 있으므로 OK
}
public void createOrder(OrderRequest request) { ... }
}
이 문제는 Spring Boot @Async의 self-invocation 함정과 동일한 원리입니다. 프록시 기반 AOP의 근본적 한계이므로, Spring을 사용하는 한 항상 인식하고 있어야 합니다.
readOnly = true: 단순 플래그가 아닌 성능 최적화
readOnly = true는 단순히 "쓰기를 금지한다"는 의미가 아닙니다. JPA/Hibernate와 DB 드라이버 수준에서 실질적인 최적화가 적용됩니다:
| 최적화 수준 | 동작 | 효과 |
|---|---|---|
| Hibernate | FlushMode.MANUAL 설정 | dirty checking 스킵 → CPU 절약 |
| Hibernate | 스냅샷 미생성 | 메모리 50% 절약 (엔티티당) |
| JDBC Driver | connection.setReadOnly(true) | DB가 읽기 최적화 실행 계획 사용 |
| MySQL | 읽기 전용 TX 최적화 | 트랜잭션 ID 미할당 → MVCC 부하 감소 |
| Connection Pool | 읽기 복제본 라우팅 | Master/Slave 분산 가능 |
// ✅ 조회 메서드에는 반드시 readOnly
@Transactional(readOnly = true)
public List<OrderResponse> getOrdersByUser(Long userId) {
return orderRepository.findByUserId(userId)
.stream()
.map(OrderResponse::from)
.toList();
}
// ✅ 클래스 레벨 readOnly + 메서드 레벨 오버라이드 패턴
@Service
@Transactional(readOnly = true) // 기본: 읽기 전용
public class OrderService {
public List<Order> findAll() { ... } // readOnly = true 상속
public Order findById(Long id) { ... } // readOnly = true 상속
@Transactional // 쓰기 메서드만 오버라이드 (readOnly = false)
public Order create(OrderRequest req) { ... }
@Transactional
public void delete(Long id) { ... }
}
timeout과 트랜잭션 타임아웃 설계
// 30초 내 완료되지 않으면 TransactionTimedOutException 발생
@Transactional(timeout = 30)
public void processLargeOrder(LargeOrderRequest request) {
// 외부 API 호출이 포함된 긴 작업
List<Item> items = request.getItems();
for (Item item : items) {
inventoryService.reserve(item); // 외부 서비스
}
orderRepository.saveAll(createOrders(items));
}
// ⚠️ 주의: timeout은 DB 쿼리 실행 시간만 측정
// Thread.sleep()이나 외부 HTTP 호출 시간은 포함되지 않을 수 있음
// → 외부 호출에는 별도 타임아웃 설정 필요
@TransactionalEventListener: 트랜잭션 커밋 후 이벤트 처리
트랜잭션 커밋 후에 이벤트를 발행해야 하는 경우(예: 알림 전송, 캐시 무효화)에 사용합니다:
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
// 이벤트 발행 (아직 TX 커밋 전)
eventPublisher.publishEvent(new OrderCreatedEvent(order));
return order;
}
}
@Component
public class OrderEventHandler {
// TX 커밋 후에만 실행 → 롤백되면 이벤트도 무시
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.getOrder());
// ⚠️ 이 메서드는 기본적으로 TX 밖에서 실행됨
// DB 작업이 필요하면 @Transactional(propagation = REQUIRES_NEW) 추가
}
// TX 롤백 후 실행 (보상 로직)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailed(OrderCreatedEvent event) {
alertService.notifyOrderFailure(event.getOrder());
}
}
멀티 DataSource 트랜잭션: @Transactional("txManager")
여러 데이터베이스를 사용하는 경우 트랜잭션 매니저를 명시적으로 지정해야 합니다:
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public PlatformTransactionManager orderTxManager(
@Qualifier("orderDataSource") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
@Bean
public PlatformTransactionManager paymentTxManager(
@Qualifier("paymentDataSource") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
}
@Service
public class OrderService {
@Transactional("orderTxManager") // 주문 DB TX
public void createOrder(OrderRequest req) { ... }
}
@Service
public class PaymentService {
@Transactional("paymentTxManager") // 결제 DB TX
public void processPayment(PaymentRequest req) { ... }
}
⚠️ 한계: 서로 다른 트랜잭션 매니저의 TX는 독립적입니다. 한쪽이 커밋되고 다른 쪽이 롤백되면 데이터 불일치가 발생합니다. 이 문제를 해결하려면 분산 트랜잭션(XA) 또는 Saga 패턴이 필요합니다.
테스트에서의 @Transactional: 자동 롤백의 양날의 검
@SpringBootTest
@Transactional // 테스트 후 자동 롤백 → DB 클린업 불필요
class OrderServiceTest {
@Test
void createOrder_success() {
OrderRequest request = new OrderRequest("item-1", 2);
orderService.createOrder(request);
// 테스트 종료 시 자동 롤백 → DB 오염 없음
Order saved = orderRepository.findByItemId("item-1");
assertThat(saved).isNotNull();
}
}
// ⚠️ 자동 롤백의 함정
@SpringBootTest
@Transactional
class OrderServiceIntegrationTest {
@Test
void testWithExternalService() {
// 문제 1: @TransactionalEventListener(AFTER_COMMIT)이 실행되지 않음
// → 커밋이 안 되니까!
// 문제 2: REQUIRES_NEW 전파의 내부 TX는 실제 커밋됨
// → 테스트 롤백이 안 됨
// 문제 3: LazyInitializationException이 발생하지 않음
// → 테스트 TX가 열려 있어서 지연 로딩이 항상 성공
}
}
// ✅ 해결: 통합 테스트에서는 @Transactional 제거 + 수동 클린업
@SpringBootTest
class OrderServiceIntegrationTest {
@AfterEach
void cleanup() {
orderRepository.deleteAll();
}
@Test
void testRealBehavior() {
// 실제 운영과 동일한 TX 동작
}
}
실무 안티패턴 5가지와 해결책
안티패턴 1: 트랜잭션 안에서 외부 API 호출
// ❌ 외부 API 응답 대기 중 DB 커넥션 점유
@Transactional
public void createOrder(OrderRequest req) {
orderRepository.save(new Order(req));
slackClient.sendNotification("주문 생성됨"); // 3초 대기?
emailService.sendConfirmation(req.getEmail()); // 5초 대기?
// 총 8초간 커넥션 점유 → 풀 고갈
}
// ✅ TX 밖에서 외부 호출
@Transactional
public Order createOrder(OrderRequest req) {
return orderRepository.save(new Order(req));
}
// 별도 메서드에서 알림
public void createOrderAndNotify(OrderRequest req) {
Order order = createOrder(req); // TX 종료
slackClient.sendNotification("주문 생성됨"); // TX 밖
emailService.sendConfirmation(req.getEmail()); // TX 밖
}
안티패턴 2: 과도하게 큰 트랜잭션
// ❌ 1000건을 하나의 TX로 처리
@Transactional
public void batchProcess(List<Item> items) {
for (Item item : items) {
process(item); // 999건 성공 후 1건 실패 → 전체 롤백
}
}
// ✅ 청크 단위로 분리
public void batchProcess(List<Item> items) {
List<List<Item>> chunks = Lists.partition(items, 100);
for (List<Item> chunk : chunks) {
processChunk(chunk); // 각 100건 독립 TX
}
}
@Transactional
public void processChunk(List<Item> chunk) {
chunk.forEach(this::process);
}
안티패턴 3: try-catch로 롤백 삼키기
// ❌ 예외를 잡아서 롤백이 안 됨
@Transactional
public void riskyOperation() {
try {
accountRepository.withdraw(fromId, amount);
accountRepository.deposit(toId, amount);
} catch (Exception e) {
log.error("실패", e);
// 예외가 프록시까지 전파되지 않음 → 커밋됨!
// 출금만 되고 입금은 안 된 상태로 커밋
}
}
// ✅ 예외를 다시 던지거나, 명시적 롤백
@Transactional
public void riskyOperation() {
try {
accountRepository.withdraw(fromId, amount);
accountRepository.deposit(toId, amount);
} catch (Exception e) {
log.error("실패", e);
throw e; // 재던지기 → 프록시가 롤백
// 또는: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
안티패턴 4: private 메서드에 @Transactional
// ❌ private 메서드는 프록시가 오버라이드할 수 없음
@Service
public class OrderService {
@Transactional // 무시됨!
private void saveOrder(Order order) {
orderRepository.save(order);
}
}
// ✅ public 또는 protected로 변경
@Transactional
public void saveOrder(Order order) { ... }
안티패턴 5: 불필요한 @Transactional
// ❌ 단일 SELECT는 @Transactional 불필요
@Transactional
public User findById(Long id) {
return userRepository.findById(id).orElseThrow();
}
// ✅ 지연 로딩이 필요하거나 여러 쿼리가 일관성을 가져야 할 때만 사용
@Transactional(readOnly = true)
public UserDetailResponse getUserDetail(Long id) {
User user = userRepository.findById(id).orElseThrow();
user.getOrders().size(); // 지연 로딩 → TX 필요
return UserDetailResponse.from(user);
}
정리: @Transactional 설계 체크리스트
| 항목 | 체크 |
|---|---|
| 조회 메서드에 readOnly = true 적용 | ☐ |
| Checked Exception에 rollbackFor = Exception.class 설정 | ☐ |
| Self-invocation 없음 확인 (같은 클래스 내부 호출 금지) | ☐ |
| TX 안에 외부 API/HTTP 호출 없음 | ☐ |
| try-catch에서 예외 재던지기 또는 setRollbackOnly() | ☐ |
| private 메서드에 @Transactional 미사용 | ☐ |
| REQUIRES_NEW 사용 시 커넥션 풀 고갈 검토 | ☐ |
| 배치 작업은 청크 단위 TX 분리 | ☐ |
| 테스트 @Transactional의 자동 롤백 부작용 인지 | ☐ |
| timeout 설정으로 롱 트랜잭션 방지 | ☐ |
Spring Boot @Transactional은 겉보기에는 간단한 어노테이션이지만, 프록시 동작 원리, 전파 전략, 롤백 규칙, 격리 수준의 상호작용을 정확히 이해해야 합니다. 특히 self-invocation, Checked Exception 커밋, TX 안 외부 호출 세 가지 함정은 프로덕션에서 데이터 정합성 장애를 일으키는 주범이므로, 코드 리뷰 시 반드시 체크해야 할 항목입니다.