실무에서 @Transactional 제대로 쓰기

2024년 12월 08일

spring

# Spring# Transaction# Kotlin# JPA# 실무

들어가며

@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 → Repositories

3.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
}

문제점:

  1. 외부 API는 롤백 안 됨: 결제 성공 → 이후 로직 실패 → DB만 롤백, 결제는 이미 완료
  2. 커넥션 점유: 외부 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를 붙이면:

  1. Dirty Checking 스킵: 엔티티 변경 감지를 안 하므로 메모리/CPU 절약
  2. 플러시 모드 NEVER: 불필요한 flush 방지
  3. 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은 단순해 보이지만 실무에서는 여러 고려사항이 있다:

  1. 어노테이션 선택: Spring 꺼 쓰자
  2. Multi DataSource: 커스텀 어노테이션으로 깔끔하게
  3. 레이어: Facade = 트랜잭션 경계
  4. 외부 API: 트랜잭션 밖으로 분리
  5. AOP 한계: 별도 클래스로 분리
  6. readOnly: 조회는 습관적으로
  7. 롤백: rollbackFor = [Exception::class] 고려

결국 핵심은 **“트랜잭션의 범위를 명확하게 인식하고, 그 안에서 어떤 일이 일어나는지 이해하는 것”**이다.


참고자료

© 2025, 미나리와 함께 만들었음