트랜잭션은 여러 쓰기를 '전부 성공' 아니면 '전부 취소' 중 하나로만 끝나게 묶는 단위다. Spring에서는 메서드에 @Transactional을 붙여 그 메서드 한 번의 실행을 트랜잭션 하나로 만든다.
트랜잭션이 없을 때
주문 한 건을 저장하면서 주문 항목도 함께 저장하는 상황을 보자. 주문 항목 테이블은 주문 테이블의 id를 외래 키(FK, 다른 테이블의 행을 가리키는 컬럼)로 가리킨다.
// @Transactional 없음
fun placeOrder(userId: String, items: List<Item>) {
val order = orderRepository.save(Order(userId = userId))
orderItemRepository.saveAll(items.map { OrderItem(orderId = order.id!!, itemId = it.id) })
}위 코드는 저장 두 번이 각각 별개의 트랜잭션으로 실행된다. Spring Data JPA의 save()는 바깥에 트랜잭션이 없으면 자기 호출 하나만 감싸는 트랜잭션을 열고 바로 커밋하기 때문이다. 그래서 주문은 커밋됐는데 주문 항목 저장에서 예외가 나면, 항목 없는 주문 한 행이 DB에 남는다.
반대 방향, 즉 주문 없이 주문 항목만 남는 경우는 생기지 않는다. 주문 항목을 넣으려면 가리킬 주문이 먼저 있어야 해서 FK가 막는다. 결국 문제는 '먼저 저장된 쪽만 남는 경우'다.
@Transactional로 묶기
메서드에 @Transactional을 붙이면 두 저장이 한 트랜잭션 안에서 실행된다.
import org.springframework.transaction.annotation.Transactional
@Transactional
fun placeOrder(userId: String, items: List<Item>) {
// save()가 바깥 트랜잭션에 참여함; 따로 커밋하지 않음
val order = orderRepository.save(Order(userId = userId))
orderItemRepository.saveAll(items.map { OrderItem(orderId = order.id!!, itemId = it.id) })
}이제 주문 항목 저장이 실패하면 앞서 넣은 주문도 함께 롤백된다. jakarta.transaction.Transactional도 같은 이름으로 존재하지만, 전파 옵션이나 읽기 전용 같은 Spring 설정을 쓰려면 org.springframework.transaction.annotation.Transactional을 쓴다.
두 저장이 모두 성공하는 경우에는 @Transactional이 있든 없든 최종 DB 상태가 같다. 차이는 중간에 실패할 때만 드러난다. 그래서 @Transactional이 제대로 동작하는지 확인하는 테스트는 두 번째 저장을 일부러 실패시키고, 첫 번째 행이 남지 않았는지를 봐야 한다. 성공 경로만 검사하는 테스트는 어노테이션을 지워도 통과한다.
롤백되는 예외
@Transactional은 기본적으로 RuntimeException과 Error에서만 롤백한다. IOException 같은 체크 예외(checked exception, Exception을 상속하지만 RuntimeException은 아닌 예외)가 메서드 밖으로 나가면 롤백하지 않고 커밋한다.
Kotlin에는 체크 예외 문법이 없어서 이 규칙을 놓치기 쉽다. Kotlin 코드에서 던진 IOException도 Spring 입장에서는 체크 예외라 커밋된다. 체크 예외에서도 롤백하려면 rollbackFor를 지정한다.
// IOException이 나가도 롤백함
@Transactional(rollbackFor = [IOException::class])
fun importFile(file: File) { /* ... */ }공식 문서: Rolling Back a Declarative Transaction
프록시와 같은 클래스 안 호출
Spring은 @Transactional이 붙은 빈을 프록시(proxy, 원래 객체를 감싸서 호출을 가로채는 대리 객체)로 감싼다. 바깥에서 메서드를 부르면 프록시가 먼저 트랜잭션을 열고, 원래 메서드를 실행한 뒤 커밋하거나 롤백한다.
같은 클래스 안에서 this.someMethod()로 부르면 프록시를 거치지 않는다. 그래서 같은 클래스의 다른 메서드에 붙은 @Transactional은 적용되지 않는다.
@Service
class OrderService(/* ... */) {
fun placeOrderFromCart(cartId: String) {
// 프록시를 거치지 않음; placeOrder의 @Transactional이 적용되지 않음
placeOrder(cart.userId, cart.items)
}
@Transactional
fun placeOrder(userId: String, items: List<Item>) { /* ... */ }
}해결 방법은 트랜잭션이 필요한 메서드를 다른 빈으로 옮기거나, 아래의 TransactionTemplate으로 트랜잭션을 걸 구간을 코드에 직접 적는 것이다.
공식 문서: Using @Transactional
트랜잭션을 일부러 짧게 잡는 경우
트랜잭션은 열려 있는 동안 DB 커넥션 하나를 붙잡는다. 트랜잭션 안에서 외부 API 응답이나 LLM 응답을 몇 초씩 기다리면, 그동안 커넥션이 풀(pool)로 돌아가지 못한다. 이런 요청이 동시에 몰리면 커넥션 풀이 바닥나고 DB를 쓰는 다른 요청까지 대기하게 된다. 커넥션 풀의 동작은 MySQL 커넥션 풀에 정리했다.
그래서 느린 외부 호출은 트랜잭션 밖에 두고, DB 쓰기만 짧은 트랜잭션으로 감싼다. 메서드 하나 안에서 일부 구간만 트랜잭션으로 묶어야 하면 TransactionTemplate을 쓴다.
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
@Service
class UploadService(
transactionManager: PlatformTransactionManager,
private val fileStorage: FileStorage,
private val uploadRepository: UploadRepository,
) {
private val transaction = TransactionTemplate(transactionManager)
fun upload(userId: String, file: ByteArray): Long {
// 파일 저장소 업로드는 트랜잭션 밖; 느려도 커넥션을 잡지 않음
val key = fileStorage.put(userId, file)
// DB 쓰기만 짧은 트랜잭션으로 묶음
return transaction.execute {
uploadRepository.save(Upload(userId = userId, fileKey = key)).id!!
}!!
}
}TransactionTemplate.execute는 블록이 정상으로 끝나면 커밋하고, 블록 안에서 예외가 나면 롤백한 뒤 그 예외를 다시 던진다. 반환 타입이 Java의 @Nullable T라서 Kotlin에서는 T?로 보이고, 위 코드는 블록이 항상 값을 돌려주므로 !!로 단언했다.
이렇게 파일 업로드를 트랜잭션 밖으로 빼면 파일은 올라갔는데 DB 저장이 실패하는 경우가 새로 생긴다. 그때 남은 파일을 지우는 정리 코드가 따로 필요하다.
트랜잭션 안에서 DB 행을 잠그는 방법은 SELECT FOR UPDATE SKIP LOCKED에, 트랜잭션 안에서 JPA가 실제로 SQL을 보내는 시점은 JPA 쓰기 시점과 영속성 컨텍스트에 정리했다. 다른 ORM에서 같은 개념을 다룬 글은 MikroORM 트랜잭션이다.