11.2 @Transactional 선언적 트랜잭션과 MyBatis 연동
통계나 단일 조회 기능을 제외하면, 현대 백엔드 애플리케이션의 거의 모든 비즈니스 수집/수정 로직은 내부적으로 여러 단계의 DB 상태 변경 작업(INSERT, UPDATE 등)을 거치게 됩니다.
1. 트랜잭션(Transaction)의 필요성
가령 "주문 결제" 서비스의 경우
- 회원의 잔고 차감 (UPDATE)
- 재고 감소 (UPDATE)
- 주문 영수증 기록 (INSERT)
이 일련의 과정 중 1, 2번은 성공했는데 서버에 장애가 생겨 3번에서 터졌다고 가정해 봅시다. 이미 차감된 잔금과 재고는 영영 미아 데이터가 됩니다. 따라서 이 세 작업은 전부 한꺼번에 성공하여 적용되거나(COMMIT), 하나라도 실패하면 시작 전 1번의 상태로 리셋(ROLLBACK) 되어야 합니다.
2. Spring + MyBatis 트랜잭션 동작 원리
스프링 부트에서 mybatis-spring-boot-starter를 사용하면, MyBatis의 SqlSession 작업이 스프링의 DataSourceTransactionManager 에 의해 자동으로 관리됩니다.
- 동작 방식:
@Transactional이 선언된 메서드가 호출되면, 스프링 AOP 프록시가 해당 요청을 가로채 DB 커넥션을 획득하고 트랜잭션을 시작합니다. - MyBatis 연동: MyBatis의 Mapper 메서드들이 실행될 때, 스프링이 관리하는 동일한 커넥션을 공유하여 실행됩니다. 작업이 끝나면 프록시가 커밋이나 롤백을 수행합니다.
3. 실전 예제 (MyBatis 연동)
@Service
@RequiredArgsConstructor
public class ShopService {
private final UserMapper userMapper; // MyBatis Mapper
private final ProductMapper productMapper; // MyBatis Mapper
@Transactional(rollbackFor = Exception.class)
public void buyItem(Long userId, Long productId, int quantity) {
// 1. 유저 잔고 차감 (MyBatis UPDATE)
userMapper.updateBalance(userId, -10000);
// 2. 상품 재고 차감 (MyBatis UPDATE)
int updatedRows = productMapper.updateStock(productId, -quantity);
// 비즈니스 예외 처리 (강제 롤백 유도)
if (updatedRows == 0) {
throw new RuntimeException("재고가 부족하여 주문할 수 없습니다.");
}
}
}
4. 트랜잭션 전파(Propagation)와 격리 수준(Isolation)
상황에 따라 트랜잭션의 범위를 세밀하게 조정할 수 있습니다.
| 속성 | 설명 | 비고 |
|---|---|---|
| REQUIRED | 부모 트랜잭션이 있으면 합류, 없으면 새로 생성 | 기본값 (가장 많이 사용) |
| REQUIRES_NEW | 부모와 상관없이 항상 새 트랜잭션 생성 | 로그 기록 등 독립적 작업 시 사용 |
| READ_COMMITTED | 다른 트랜잭션이 커밋한 데이터만 읽음 | 격리 수준 기본값 |
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
public void saveLog() { ... }
5. 기본 롤백 규칙과 주의점
스프링의 @Transactional은 AOP(관점 지향 프로그래밍) 프록시를 기반으로 동작하므로 무척 강력하지만, 주의해야 할 롤백 규칙이 있습니다.
- 기본적으로 스프링은 Unchecked Exception(
RuntimeException과 그 하위 예외) 및Error가 튀어나왔을 때만 롤백을 수행합니다. - 만약 자바의 컴파일러가 강제하는 Checked Exception(예:
IOException,SQLException)이 발생하면 롤백을 수행하지 않고 그대로 커밋(Commit)해 버립니다.
해결 방안:rollbackFor = Exception.class 옵션을 지정하여 모든 예외에 대해 롤백하도록 설정하거나, 비즈니스 예외를 런타임 예외로 설계합니다.
6. timeout과 readOnly (실무 활용)
장시간 걸리는 트랜잭션이 DB 커넥션을 붙잡지 않도록 timeout 을 두고, 조회 전용 메서드는 readOnly 로 표시하면 일부 DB/드라이버에서 읽기 부하 최적화가 가능합니다.
| 속성 | 설명 | 예시 |
|---|---|---|
| timeout | 트랜잭션 최대 대기 시간(초). 초과 시 예외 발생. | timeout = 5 |
| readOnly | 읽기 전용 트랜잭션. 쓰기 시 예외. 힌트로 DB/드라이버 최적화 가능. | readOnly = true |
| noRollbackFor | 지정 예외 시 롤백하지 않음. 비즈니스 예외로 “실패로 처리하되 DB는 유지”할 때 사용. | noRollbackFor = BusinessException.class |
@Transactional(readOnly = true, timeout = 10)
public List<OrderDto> findOrdersByUser(Long userId) {
return orderRepository.findByUserId(userId);
}
@Transactional(rollbackFor = Exception.class, timeout = 5, noRollbackFor = DuplicateOrderException.class)
public void createOrder(OrderRequest dto) {
// 중복 주문은 예외만 던지고 롤백하지 않을 때
}