들어가며
@Transactional은 Spring 개발자라면 매일 사용하는 어노테이션이다. 하지만 막상 사용하다 보면 여러 고민이 생긴다.
- Spring의
@Transactional과 Jakarta의@Transactional중 뭘 써야 하지? - Multi DataSource 환경에서 매번
transactionManager를 지정해야 하나? - 트랜잭션은 어느 레이어에서 관리해야 하지?
- 외부 API 호출이 섞이면 어떻게 해야 하나?
이 글에서는 이런 실무에서 마주치는 @Transactional 관련 고민들을 정리해본다.
1. 어떤 @Transactional을 써야 할까?
1.1 두 가지 @Transactional
import할 때 두 가지 선택지가 있다:
import org.springframework.transaction.annotation.Transactional // Spring
import jakarta.transaction.Transactional // Jakarta (구 javax)IDE 자동 import에서 잘못 선택하면 의도치 않은 동작이 발생할 수 있다.
1.2 비교
| 기능 | Spring | Jakarta |
|---|---|---|
readOnly |
✅ | ❌ |
timeout |
✅ | ❌ |
rollbackFor |
✅ 세밀한 제어 | 제한적 |
| 전파 옵션 | 7가지 | 3가지 |
isolation |
✅ | ❌ |
1.3 결론: Spring 꺼 쓰자
Jakarta의 @Transactional은 JTA(Java Transaction API) 표준이지만, Spring 환경에서는 기능이 부족하다.
// ✅ 이걸 쓰자
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true, timeout = 30)
fun getOrder(id: Long): Order { ... }특히 readOnly 옵션을 자주 사용한다면 Spring의 @Transactional을 써야 한다.
2. Multi DataSource 환경에서 트랜잭션 관리
2.1 문제 상황
멀티 모듈 + 멀티 데이터소스 환경에서 매번 transactionManager를 지정하는 건 번거롭다:
// 매번 이렇게 써야 함
@Transactional(transactionManager = "postgresTransactionManager")
fun saveOrder() { ... }
@Transactional(transactionManager = "mysqlTransactionManager")
fun saveLog() { ... }문자열이라 오타 위험도 있고, IDE 자동완성도 안 된다.
2.2 해결: 커스텀 어노테이션
각 Storage 모듈에서 자신만의 트랜잭션 어노테이션을 제공하면 된다:
// storages:postgres 모듈
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Transactional(transactionManager = "postgresTransactionManager")
annotation class PostgresTransactional(
val readOnly: Boolean = false,
val propagation: Propagation = Propagation.REQUIRED,
val isolation: Isolation = Isolation.DEFAULT,
val timeout: Int = -1,
val rollbackFor: Array<KClass<out Throwable>> = [],
)// storages:mysql 모듈
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Transactional(transactionManager = "mysqlTransactionManager")
annotation class MysqlTransactional(
val readOnly: Boolean = false,
val propagation: Propagation = Propagation.REQUIRED,
)2.3 사용 예시
// domain:order 모듈 (postgres 사용)
@Service
class OrderService(private val orderRepository: OrderRepository) {
@PostgresTransactional
fun createOrder(request: CreateOrderRequest): Order {
// ...
}
@PostgresTransactional(readOnly = true)
fun getOrder(id: Long): Order {
return orderRepository.findById(id)
}
}
// domain:log 모듈 (mysql 사용)
@Service
class LogService(private val logRepository: LogRepository) {
@MysqlTransactional
fun saveLog(log: Log) {
logRepository.save(log)
}
}2.4 장점
- 문자열 오타 방지 (
"postgersTransactionManager"같은 실수 없음) - IDE 자동완성 지원
- Storage 모듈이 트랜잭션 어노테이션을 제공하므로 의존성 방향이 자연스러움
- 내부 구현 변경 시 어노테이션만 수정하면 됨
3. 어느 레이어에서 트랜잭션을 관리할까?
3.1 일반적인 레이어 구조
Controller → Facade → Domain Services → Repositories3.2 각 레이어별 트랜잭션
Controller - ❌ 비추천
@RestController
class OrderController {
@Transactional // 너무 넓음
@PostMapping("/orders")
fun createOrder(@RequestBody request: CreateOrderRequest): OrderResponse {
// HTTP 요청/응답 처리까지 트랜잭션에 포함됨
}
}Controller는 HTTP 요청/응답을 다루는 레이어다. 트랜잭션 범위가 너무 넓어진다.
Repository - △ 이미 있음
// SimpleJpaRepository에 이미 @Transactional 있음
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepository<T, ID> {
@Transactional
public <S extends T> S save(S entity) { ... }
}단일 쿼리에는 이미 트랜잭션이 있다. 별도로 붙일 필요 없음.
Service - △ 상황에 따라
@Service
class OrderService {
@Transactional
fun createOrder(request: CreateOrderRequest): Order {
// 단일 도메인 로직
}
}Service가 단독으로 호출되는 경우가 많다면 여기에 붙여도 된다.
Facade - ✅ 추천
@Service
class OrderFacade(
private val orderService: OrderService,
private val stockService: StockService,
private val pointService: PointService,
) {
@PostgresTransactional
fun createOrder(request: CreateOrderRequest): Order {
val order = orderService.create(request) // 주문 생성
stockService.decrease(order.items) // 재고 차감
pointService.use(order.userId, order.point) // 포인트 사용
return order
}
}Facade는 하나의 유스케이스를 조합하는 레이어다. 이 단위가 트랜잭션 경계로 적합하다.
3.3 Facade + Service 조합
Facade에서 트랜잭션을 관리한다면, Service는 어떻게 해야 할까?
// Service - 트랜잭션 없이 순수 로직
@Service
class OrderService {
fun create(request: CreateOrderRequest): Order {
// 검증, 생성 로직
return orderRepository.save(order)
}
}또는 단독 호출을 방지하고 싶다면:
// Service - 트랜잭션 필수
@Service
class OrderService {
@Transactional(propagation = Propagation.MANDATORY) // 트랜잭션 없이 호출하면 에러
fun create(request: CreateOrderRequest): Order {
// ...
}
}MANDATORY는 기존 트랜잭션이 없으면 IllegalTransactionStateException을 던진다.
3.4 정리
| 레이어 | 트랜잭션 | 역할 |
|---|---|---|
| Controller | ❌ | HTTP 요청/응답 |
| Facade | ✅ 시작점 | 유스케이스 조합, 트랜잭션 경계 |
| Service | ❌ 또는 MANDATORY | 단일 도메인 로직 |
| Repository | (이미 있음) | 단일 쿼리 |
4. 외부 API 호출이 섞일 때
4.1 문제 상황
@PostgresTransactional
fun createOrder(request: CreateOrderRequest): Order {
val order = orderService.create(request) // DB
stockService.decrease(order.items) // DB
paymentClient.pay(order.totalPrice) // 외부 API ← 문제!
pointService.use(order.userId, order.point) // DB
return order
}문제점:
- 외부 API는 롤백 안 됨: 결제 성공 → 이후 로직 실패 → DB만 롤백, 결제는 이미 완료
- 커넥션 점유: 외부 API가 느리면 DB 커넥션을 오래 물고 있음
4.2 원칙: 트랜잭션 안에서 외부 호출 ❌
// ❌ 나쁜 예
@Transactional
fun createOrder() {
dbOperation()
externalApi.call() // 트랜잭션 안에서 외부 호출
dbOperation()
}4.3 해결 방법 1: 외부 호출을 트랜잭션 밖으로
// Facade - 트랜잭션 없음, 흐름 제어만
@Service
class OrderFacade(
private val orderTransactionService: OrderTransactionService,
private val paymentClient: PaymentClient,
) {
fun createOrder(request: CreateOrderRequest): Order {
// 1. 외부 API 먼저 (실패하면 여기서 끝)
val paymentResult = paymentClient.pay(request.price)
// 2. 성공하면 DB 작업
return orderTransactionService.saveOrder(request, paymentResult)
}
}
// 트랜잭션 담당
@Service
class OrderTransactionService(
private val orderService: OrderService,
private val stockService: StockService,
) {
@PostgresTransactional
fun saveOrder(request: CreateOrderRequest, payment: PaymentResult): Order {
val order = orderService.create(request, payment)
stockService.decrease(request.items)
return order
}
}4.4 해결 방법 2: 이벤트 기반
@Service
class OrderFacade {
@PostgresTransactional
fun createOrder(request: CreateOrderRequest): Order {
val order = orderService.create(request)
stockService.decrease(order.items)
// 이벤트 발행 (트랜잭션 커밋 후 처리)
eventPublisher.publishEvent(OrderCreatedEvent(order.id, order.totalPrice))
return order
}
}
// 별도 리스너 - 트랜잭션 커밋 후 실행
@Component
class OrderEventListener(private val paymentClient: PaymentClient) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleOrderCreated(event: OrderCreatedEvent) {
paymentClient.pay(event.totalPrice)
}
}결제 실패 시? → 별도 재시도 로직, 보상 트랜잭션으로 처리.
4.5 정리
| 상황 | 전략 |
|---|---|
| 외부 API 실패해도 DB 저장은 해야 함 | @TransactionalEventListener(AFTER_COMMIT) |
| 외부 API 필수 (결제 등) | 외부 먼저 → DB 저장 |
| 여러 외부 시스템 조합 | Saga + 보상 트랜잭션 |
5. Spring AOP 프록시의 한계
5.1 내부 호출 문제
@Service
class OrderFacade {
fun createOrder(request: CreateOrderRequest): Order {
val paymentResult = paymentClient.pay(request.price)
return saveOrder(request, paymentResult) // 내부 호출 - 트랜잭션 안 걸림!
}
@PostgresTransactional
fun saveOrder(request: CreateOrderRequest, payment: PaymentResult): Order {
// 트랜잭션이 적용되지 않음
}
}this.saveOrder()는 프록시를 거치지 않고 직접 호출되므로 @Transactional이 무시된다.
5.2 private 메서드 문제
@Service
class OrderService {
@Transactional
private fun save(): Order { // private은 프록시 불가
// ...
}
}private 메서드는 오버라이드할 수 없으므로 프록시가 생성되지 않는다.
5.3 해결: 별도 클래스로 분리
// Facade - 외부 호출, 흐름 제어
@Service
class OrderFacade(
private val orderTransactionService: OrderTransactionService,
private val paymentClient: PaymentClient,
) {
fun createOrder(request: CreateOrderRequest): Order {
val paymentResult = paymentClient.pay(request.price)
return orderTransactionService.saveOrder(request, paymentResult) // 다른 빈 호출 → 프록시 통과
}
}
// 트랜잭션 담당
@Service
class OrderTransactionService {
@PostgresTransactional
fun saveOrder(request: CreateOrderRequest, payment: PaymentResult): Order {
// 트랜잭션 정상 작동
}
}5.4 (참고) Self Injection
@Service
class OrderFacade {
@Autowired
private lateinit var self: OrderFacade // 자기 자신 주입
fun createOrder(request: CreateOrderRequest): Order {
val paymentResult = paymentClient.pay(request.price)
return self.saveOrder(request, paymentResult) // 프록시 통해서 호출
}
@PostgresTransactional
fun saveOrder(request: CreateOrderRequest, payment: PaymentResult): Order {
// 트랜잭션 작동
}
}동작은 하지만 코드가 어색하다. 별도 클래스 분리를 추천한다.
6. readOnly = true 제대로 쓰기
6.1 readOnly의 효과
@Transactional(readOnly = true)
fun getOrder(id: Long): Order {
return orderRepository.findById(id)
}readOnly를 붙이면:
- Dirty Checking 스킵: 엔티티 변경 감지를 안 하므로 메모리/CPU 절약
- 플러시 모드 NEVER: 불필요한 flush 방지
- DB 레플리카 라우팅: DataSource 라우팅 설정 시 읽기 전용 DB로 분산 가능
6.2 주의: readOnly여도 쓰기 가능
@Transactional(readOnly = true)
fun updateOrder(id: Long) {
val order = orderRepository.findById(id)
order.status = OrderStatus.COMPLETED // 변경됨
orderRepository.save(order) // 저장됨! (DB에 따라 다름)
}readOnly = true는 힌트일 뿐, 강제하지 않는다. DB나 드라이버에 따라 동작이 다를 수 있다.
실제로 쓰기를 막으려면 읽기 전용 커넥션을 사용하거나, 코드 리뷰로 잡아야 한다.
6.3 조회 메서드에는 습관적으로 붙이자
@Service
class OrderService {
@PostgresTransactional(readOnly = true)
fun getOrder(id: Long): Order { ... }
@PostgresTransactional(readOnly = true)
fun getOrders(userId: Long): List<Order> { ... }
@PostgresTransactional // 쓰기는 기본값
fun createOrder(request: CreateOrderRequest): Order { ... }
}7. 트랜잭션 전파 옵션 실무
7.1 주요 전파 옵션
| 옵션 | 동작 | 사용 예 |
|---|---|---|
REQUIRED (기본) |
있으면 참여, 없으면 생성 | 일반적인 경우 |
REQUIRES_NEW |
항상 새로 생성 | 로그 저장 (메인 롤백돼도 로그는 남기기) |
MANDATORY |
있어야 함, 없으면 에러 | 반드시 트랜잭션 내에서 호출되어야 하는 경우 |
NOT_SUPPORTED |
있어도 참여 안 함 | 트랜잭션 없이 실행해야 하는 경우 |
NESTED |
중첩 트랜잭션 (Savepoint) | 부분 롤백이 필요한 경우 |
7.2 REQUIRES_NEW 사용 예
@Service
class OrderFacade {
@PostgresTransactional
fun createOrder(request: CreateOrderRequest): Order {
val order = orderService.create(request)
// 주문 실패해도 로그는 남기고 싶음
auditLogService.log("ORDER_CREATED", order.id)
stockService.decrease(order.items) // 여기서 실패하면?
return order
}
}
@Service
class AuditLogService {
@MysqlTransactional(propagation = Propagation.REQUIRES_NEW)
fun log(action: String, targetId: Long) {
// 새로운 트랜잭션에서 실행 → 메인 롤백돼도 로그는 커밋됨
auditLogRepository.save(AuditLog(action, targetId))
}
}7.3 MANDATORY 사용 예
@Service
class StockService {
@Transactional(propagation = Propagation.MANDATORY)
fun decrease(items: List<OrderItem>) {
// 반드시 트랜잭션 내에서 호출되어야 함
// 단독 호출 시 IllegalTransactionStateException
}
}8. 예외와 롤백
8.1 기본 롤백 규칙
Spring의 기본 롤백 규칙:
- RuntimeException (Unchecked): 롤백 ✅
- Exception (Checked): 롤백 ❌ (커밋됨)
- Error: 롤백 ✅
// Java에서의 문제
@Transactional
public void process() throws IOException {
repository.save(entity);
throw new IOException("file error"); // Checked Exception → 롤백 안 됨!
}8.2 Kotlin은 다르다
Kotlin은 Checked Exception 개념이 없다. 모든 예외가 Unchecked처럼 동작한다.
@Transactional
fun process() {
repository.save(entity)
throw IOException("file error") // Kotlin에서도 롤백 안 됨 (타입은 Checked)
}JVM 레벨에서는 여전히 Checked Exception이므로 Spring의 롤백 규칙이 그대로 적용된다.
8.3 안전하게: rollbackFor 지정
@Transactional(rollbackFor = [Exception::class])
fun process() {
// 모든 예외에서 롤백
}또는 커스텀 어노테이션에 기본값으로 포함:
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Transactional(
transactionManager = "postgresTransactionManager",
rollbackFor = [Exception::class] // 기본적으로 모든 예외에서 롤백
)
annotation class PostgresTransactional(
val readOnly: Boolean = false,
// ...
)9. 테스트에서 @Transactional
9.1 테스트의 @Transactional
@SpringBootTest
@Transactional // 테스트 후 자동 롤백
class OrderServiceTest {
@Test
fun `주문 생성 테스트`() {
val order = orderService.create(request)
assertThat(order.id).isNotNull()
// 테스트 끝나면 자동 롤백 → DB 깨끗
}
}편하지만 함정이 있다.
9.2 문제: 실제 커밋이 안 됨
@Test
@Transactional
fun `unique 제약조건 테스트`() {
userRepository.save(User(email = "test@test.com"))
userRepository.save(User(email = "test@test.com")) // 예외 발생해야 함
// 실제로는 flush 전까지 DB에 안 보내져서 통과할 수도 있음
}9.3 해결: 명시적 flush 또는 @Commit
@Test
@Transactional
fun `unique 제약조건 테스트`() {
userRepository.save(User(email = "test@test.com"))
entityManager.flush() // 명시적 flush
assertThrows<DataIntegrityViolationException> {
userRepository.save(User(email = "test@test.com"))
entityManager.flush()
}
}또는 아예 트랜잭션을 빼고 @AfterEach에서 정리:
@SpringBootTest
class OrderServiceTest {
@AfterEach
fun cleanup() {
orderRepository.deleteAll()
}
@Test
fun `주문 생성 테스트`() {
val order = orderService.create(request)
// 실제 커밋됨 → 실제 환경과 동일
}
}10. 흔한 실수들
10.1 LazyInitializationException
@PostgresTransactional
fun getOrder(id: Long): Order {
return orderRepository.findById(id) // Order.items는 LAZY
}
// Controller에서
fun getOrderResponse(id: Long): OrderResponse {
val order = orderService.getOrder(id)
return OrderResponse(
items = order.items.map { ... } // LazyInitializationException!
)
}트랜잭션 밖에서 LAZY 컬렉션에 접근하면 예외 발생.
해결:
// 1. fetch join
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
fun findByIdWithItems(id: Long): Order
// 2. DTO로 변환 후 반환
@PostgresTransactional(readOnly = true)
fun getOrder(id: Long): OrderDto {
val order = orderRepository.findById(id)
return OrderDto(
id = order.id,
items = order.items.map { ItemDto(it) } // 트랜잭션 안에서 변환
)
}10.2 긴 트랜잭션 → 커넥션 고갈
@Transactional
fun processLargeData() {
val items = repository.findAll() // 10만 건
items.forEach {
// 오래 걸리는 처리
heavyProcess(it)
}
}트랜잭션 동안 커넥션을 물고 있으므로, 다른 요청이 커넥션을 못 얻을 수 있다.
해결:
// 배치 처리, 페이징
fun processLargeData() {
var page = 0
do {
val items = processBatch(page++)
} while (items.isNotEmpty())
}
@Transactional
fun processBatch(page: Int): List<Item> {
val items = repository.findAll(PageRequest.of(page, 1000))
items.forEach { heavyProcess(it) }
return items.content
}10.3 @Async + @Transactional
@Async
@Transactional
fun asyncProcess() {
// 새 스레드에서 실행 → 기존 트랜잭션 컨텍스트 없음
}@Async는 별도 스레드에서 실행되므로, 호출한 쪽의 트랜잭션과 무관하다. 새로운 트랜잭션이 시작된다.
마치며
@Transactional은 단순해 보이지만 실무에서는 여러 고려사항이 있다:
- 어노테이션 선택: Spring 꺼 쓰자
- Multi DataSource: 커스텀 어노테이션으로 깔끔하게
- 레이어: Facade = 트랜잭션 경계
- 외부 API: 트랜잭션 밖으로 분리
- AOP 한계: 별도 클래스로 분리
- readOnly: 조회는 습관적으로
- 롤백:
rollbackFor = [Exception::class]고려
결국 핵심은 **“트랜잭션의 범위를 명확하게 인식하고, 그 안에서 어떤 일이 일어나는지 이해하는 것”**이다.