← Back to list

Transactional 어노테이션 정리

1. @Transactional 완벽 동작 구조

Cloud · 2026-01-19 03:47 · 0 claps · 39.5 min read
#transactions
Open on Medium ↗

Transactional 어노테이션 정리

1. @Transactional 완벽 동작 구조

1️⃣ Bean 생성 단계 (Application Startup)

Step 1: Component Scan

@ComponentScan → BeanDefinition 생성
- @Service, @Repository, @Component 스캔
- 메타데이터에 @Transactional 정보 포함

Step 2: BeanPostProcessor 등록

AbstractAutoProxyCreator (BeanPostProcessor 구현체) 등록
├─ AnnotationAwareAspectJAutoProxyCreator (AOP 전반)
└─ InfrastructureAdvisorAutoProxyCreator (트랜잭션 전용)
내부에 BeanFactoryTransactionAttributeSourceAdvisor 포함

Step 3: 프록시 생성 여부 결정

for (Bean bean : allBeans) {
    if (hasTransactionalAnnotation(bean)) {
        if (hasInterface(bean)) {
            return JDK_DYNAMIC_PROXY;
        } else {
            return CGLIB_PROXY;
        }
    }
}

프록시 생성 조건:

  • 클래스 또는 메서드에 @Transactional 존재
  • public 메서드 (private은 프록시 불가)
  • final이 아닌 클래스/메서드 (CGLIB 사용 시)

2️⃣ 런타임 호출 단계

Step 1: 클라이언트가 메서드 호출

Controller → ServiceProxy.method()
                 ↓
            (프록시가 가로챔)

Step 2: TransactionInterceptor 실행

// Spring 내부 코드 (간소화)
Object invoke(MethodInvocation invocation) {
    // 1️⃣ TransactionAttribute 조회
    TransactionAttribute txAttr = getTransactionAttribute(invocation);

    // 2️⃣ TransactionManager 결정
    PlatformTransactionManager tm = determineTransactionManager(txAttr);

    // 3️⃣ 트랜잭션 시작
    TransactionStatus status = tm.getTransaction(txAttr);

    try {
        // 4️⃣ 실제 메서드 실행
        Object result = invocation.proceed();

        // 5️⃣ 커밋
        tm.commit(status);
        return result;

    } catch (Throwable ex) {
        // 6️⃣ 롤백 판단
        if (shouldRollback(ex, txAttr)) {
            tm.rollback(status);
        } else {
            tm.commit(status);
        }
        throw ex;

    } finally {
        // 7️⃣ 리소스 정리
        cleanupTransactionInfo();
    }
}

3️⃣ TransactionAttribute 조회 (우선순위)

1순위: 타겟 메서드의 @Transactional
2순위: 타겟 클래스의 @Transactional  
3순위: 인터페이스 메서드의 @Transactional (JDK Proxy)
4순위: 인터페이스의 @Transactional (JDK Proxy)

예제:

@Transactional(timeout = 30)  // 2순위
class ServiceImpl : MyService {

    @Transactional(propagation = REQUIRES_NEW)  // 1순위 (최우선)
    override fun method() {
        // REQUIRES_NEW 적용
    }
}

4️⃣ TransactionManager 결정

// 1. @Transactional의 명시적 지정
@Transactional(transactionManager = "customTxManager")
// 2. 없으면 기본 빈 탐색
@Primary PlatformTransactionManager
// 3. 없으면 타입으로 조회 (단일 빈)
// 4. 여러 개면 예외 발생 (NoUniqueBeanDefinitionException)

5️⃣ 트랜잭션 시작 (내부 상세)

JDBC 기반 (DataSourceTransactionManager)

// 1. ThreadLocal에서 기존 트랜잭션 확인
ConnectionHolder holder = TransactionSynchronizationManager
    .getResource(dataSource);
if (holder == null || 전파옵션이_REQUIRES_NEW) {
    // 2. 새 Connection 획득
    Connection conn = dataSource.getConnection();

    // 3. AutoCommit 비활성화 (⭐ 핵심)
    conn.setAutoCommit(false);

    // 4. Isolation Level 설정
    conn.setTransactionIsolation(txAttr.getIsolationLevel());

    // 5. ThreadLocal에 바인딩
    TransactionSynchronizationManager.bindResource(
        dataSource, 
        new ConnectionHolder(conn)
    );
}

JPA 기반 (JpaTransactionManager)

// 1. EntityManager 생성 또는 재사용
EntityManager em = entityManagerFactory.createEntityManager();
// 2. JPA 트랜잭션 시작
EntityTransaction tx = em.getTransaction();
tx.begin();
// 3. ThreadLocal에 바인딩
TransactionSynchronizationManager.bindResource(
    entityManagerFactory,
    new EntityManagerHolder(em)
);

6️⃣ 전파 옵션 처리

REQUIRED (기본값)

  • 있으면: 기존 트랜잭션 참여
  • 없으면: 새 트랜잭션 생성
@Transactional  // 부모
fun outer() {
    inner()  // 자식도 @Transactional
}
// 결과: 같은 트랜잭션 공유
// outer 실패 → inner도 롤백
// inner 실패 → outer도 롤백

REQUIRES_NEW

  • 있으면: 기존 트랜잭션 중단
  • 무조건 새 트랜잭션 생성
@Transactional
fun outer() {
    inner()  // REQUIRES_NEW
}

@Transactional(propagation = REQUIRES_NEW)
fun inner() { }
// 내부 동작:
// 1. outer 트랜잭션 중단 (suspend)
// 2. inner 새 트랜잭션 시작
// 3. inner 커밋
// 4. outer 트랜잭션 재개 (resume)
// 결과: 독립적
// inner 실패 → outer는 커밋 가능

SUPPORTS

  • 트랜잭션 있으면 참여, 없으면 비트랜잭션 실행
@Transactional(propagation = SUPPORTS)
fun method() { }

NESTED

@Transactional
fun outer() {
    inner()  // NESTED
}

@Transactional(propagation = NESTED)
fun inner() { }
// JDBC Savepoint 사용
// inner 실패 → outer는 계속 진행 가능 (부분 롤백)

7️⃣ 정상 종료 (Commit)

JDBC

try {
    // 비즈니스 로직 실행

    // 커밋
    connection.commit();

} finally {
    // Connection Pool 반환
    connection.close();  // 실제로는 Pool에 반환

    // ThreadLocal 정리
    TransactionSynchronizationManager.unbindResource(dataSource);
    TransactionSynchronizationManager.clearSynchronization();
}

JPA

try {
    // 비즈니스 로직 실행

    // 1. Flush (SQL 실행)
    entityManager.flush();

    // 2. Commit
    transaction.commit();

} finally {
    // EntityManager는 유지될 수 있음 (OSIV)
    TransactionSynchronizationManager.unbindResource(emf);
}

8️⃣ 예외 발생 (Rollback)

롤백 판단 로직

boolean shouldRollback(Throwable ex, TransactionAttribute attr) {
    // 1순위: noRollbackFor 확인
    if (attr.getNoRollbackFor().contains(ex.getClass())) {
        return false;  // 커밋
    }

    // 2순위: rollbackFor 확인
    if (attr.getRollbackFor().contains(ex.getClass())) {
        return true;  // 롤백
    }

    // 3순위: 기본 규칙
    return (ex instanceof RuntimeException || ex instanceof Error);
}

실제 롤백

try {
    // 비즈니스 로직
} catch (Throwable ex) {
    if (shouldRollback(ex)) {
        // JDBC
        connection.rollback();

        // JPA
        transaction.rollback();
        entityManager.clear();  // 영속성 컨텍스트 초기화
    }
    throw ex;
} finally {
    // 리소스 정리
    cleanupResources();
}

9️⃣ ThreadLocal 관리

트랜잭션 정보 저장

// TransactionSynchronizationManager 내부
private static final ThreadLocal<Map<Object, Object>> resources = 
    new NamedThreadLocal<>("Transactional resources");
// 저장
public static void bindResource(Object key, Object value) {
    Map<Object, Object> map = resources.get();
    if (map == null) {
        map = new HashMap<>();
        resources.set(map);
    }
    map.put(key, value);
}

정리 (필수!)

finally {
    // Connection 반환
    DataSourceUtils.releaseConnection(conn, dataSource);

    // ThreadLocal 정리
    TransactionSynchronizationManager.unbindResource(dataSource);
    TransactionSynchronizationManager.clearSynchronization();
    TransactionSynchronizationManager.clear();
}

정리 안 하면:

  • Thread Pool 환경에서 다음 요청에 이전 트랜잭션 정보 남음
  • Connection Leak
  • 데이터 오염

🔟 작동하지 않는 경우

1. 자기 자신 호출 (Self-Invocation)

@Service
class MyService {
    fun outer() {
        this.inner()  // ❌ 프록시 우회
    }

    @Transactional
    fun inner() { }  // 트랜잭션 적용 안 됨
}
// 해결책 1: 다른 빈으로 분리
// 해결책 2: self-injection
@Autowired
private lateinit var self: MyService
fun outer() {
    self.inner()  // ✅ 프록시 사용
}

2. Private 메서드

@Service
class MyService {
    @Transactional  // ❌ 무시됨
    private fun method() { }
}

3. Final 메서드/클래스 (CGLIB)

@Service
final class MyService {  // ❌ CGLIB 프록시 생성 불가
    @Transactional
    fun method() { }
}

4. New로 생성한 객체

val service = MyService()  // ❌ Spring Bean 아님
service.method()  // 트랜잭션 적용 안 됨

5. Checked Exception을 삼킴

@Transactional
fun method() {
    try {
        throw IOException()
    } catch (e: Exception) {
        // 예외를 먹어버림 → 롤백 안 됨
    }
}

6. 비동기 메서드

@Transactional
@Async  // 새 스레드 → ThreadLocal 공유 안 됨
fun asyncMethod() { }

7. 트랜잭션 타임아웃

@Transactional(timeout = 5)
fun method() {
    Thread.sleep(10_000)  // TimeoutException
}

📊 전체 흐름 요약

[Application Startup]
ComponentScan → BeanDefinition → BeanPostProcessor → Proxy 생성
[Runtime]
Client → Proxy.method()
         ↓
    TransactionInterceptor
         ↓
    TransactionAttribute 조회 (메서드 → 클래스 → 인터페이스)
         ↓
    TransactionManager 결정
         ↓
    트랜잭션 시작
    - Connection.setAutoCommit(false)
    - ThreadLocal에 바인딩
         ↓
    실제 메서드 실행
         ↓
    성공 → Commit / 실패 → Rollback
         ↓
    리소스 정리 (ThreadLocal, Connection)

⚡ 핵심 포인트

  1. 프록시가 모든 것의 시작 — 프록시 없으면 트랜잭션 없음
  2. ThreadLocal이 핵심 — 같은 스레드 = 같은 트랜잭션
  3. setAutoCommit(false)가 진짜 시작점 — 이게 트랜잭션의 본질
  4. finally에서 정리 필수 — 안 하면 누수/오염
  5. RuntimeException만 기본 롤백 — Checked는 명시 필요

2. @ Transactional 동작원리 모든 경우의 수 정리

# Case1 단일 메서드에 @ Transactional

@Transactional
fun method1() {
    // 1. 트랜잭션 시작
    repository.save(entity1)
    repository.save(entity2)
    // 2. 메서드 정상 종료 시 커밋
}

동작

  • 메서드 진입 시 트랜잭션 시작
  • 정상 종료 -> 커밋
  • 예외 발생 -> 롤백

결과

  • 성공 시 : entity1, entity2 모두 커밋
  • 실패 시 : 모두 롤백

# Case2 @ Transactional 메서드가 다른 @ Transactional 메서드 호출

@Transactional
fun outer() {
    save1()
    save2()
}

@Transactional
fun save1() {
    repository.save(entity1)
}

@Transactional
fun save2() {
    repository.save(entity2)
}

핵심 포인트

  • 기본 전파 = REQUIRED
  • 이미 트랜잭션이 있으면 같은 트랜잭션에 참여

결과

  • outer() 실패 → save1, save2() 모두 ㅗㄹ백
  • save1()에서 예외 -> 전체 롤백

# Case3 트랜잭션 없는 메서드가 @ Transactional 호출

fun outer() { // ❌ 트랜잭션 없음
    save1()   // 새 트랜잭션 → 즉시 커밋
    save2()   // 새 트랜잭션 → 즉시 커밋
    throw Exception()
}

@Transactional
fun save1() {
    repository.save(entity1)
}

@Transactional
fun save2() {
    repository.save(entity2)
}

동작

  • save1() -> 트랜잭션 생성 후 커밋
  • save2() -> 트랜잭션 생성 후 커밋
  • outer() 예외 -> 롤백 불가

결과

  • entity1, entity2 모두 DB에 남음

해결책

  • outer()에 @ Transactional 선언
  • 의도적으로 REQUIRES_NEW를 설계에 반영

# Case4 같은 클래스 내부 호출

@Service
class MyService {

    fun outer() {
        inner() // ❌ 프록시 우회
    }

    @Transactional
    fun inner() {
        repository.save(entity)
    }
}

왜 안될까?

  • Spring AOP는 프록시 객체를 통한 외부 호출만 감지
  • this.inner()는 프록시를 거치지 않음

결과

  • inner()의 @ Transactional 무시

해결방법

  • 메서드를 다른 Service로 분리
@Service
class OuterService(
    private val innerService: InnerService
) {
    fun outer() {
        innerService.inner()
    }
}

@Service
class InnerService {

    @Transactional
    fun inner() {
        repository.save(entity)
    }
}
  • 자기 자신을 프록시로 주입해서 호출
@Service
class MyService(
    @Lazy private val self: MyService
) {

    fun outer() {
        self.inner()   // ✅ 프록시 경유
    }

    @Transactional
    fun inner() {
        repository.save(entity)
    }
}
  • ApplicationContext에서 프록시 직접 꺼내기
@Service
class MyService(
    private val applicationContext: ApplicationContext
) {

    fun outer() {
        val proxy = applicationContext.getBean(MyService::class.java)
        proxy.inner() // ✅ 프록시 호출
    }

    @Transactional
    fun inner() {
        repository.save(entity)
    }
}

# Case5 Propagation 옵션 정리

REQUIRED (기본값)

@Transactional(propagation = Propagation.REQUIRED)
  • 기존 트랜잭션 참여
  • 없으면 새로 생성

REQUIRES_NEW

@Transactional
fun outer() {
    inner()
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
fun inner() {
    repository.save(entity)
}
  • 항상 새 트랜잭션
  • outer() 롤백되어도 inner()는 커밋됨

MANDATORY

@Transactional(propagation = Propagation.MANDATORY)
  • 기존 트랜잭션 필수
  • 없으면 예외

NEVER

@Transactional(propagation = Propagation.NEVER)
  • 트랜잭션 존재 시 예외

# Case6 예외 타입에 따른 롤백 규칙

@Transactional
fun method() {
    throw IllegalArgumentException() // RuntimeException → 롤백
    throw IOException()              // Checked Exception → 커밋
}

기본규칙

  • RuntimeException -> 롤백
  • Error -> 롤백
  • Checked Exception -> 롤백 안됨

강제 롤백

@Transactional(rollbackFor = [Exception::class])

# Case7 readonly=true

@Transactional(readOnly = true)
fun readMethod() {
    repository.findAll()   // OK
    repository.save(entity) // DB/플러시 시점에 예외 가능
}
  • JPA flush 방지 힌트
  • DB 설정에 따라 write 시도 시 예외

# Case8 private / final 메서드에 @ Transactional

@Service
class MyService {

    @Transactional
    private fun save() {
        repository.save(entity)
    }
}

왜 안될까?

  • Spring AOP 프록시는 override 가능한 메서드만 감싼다.
  • private, final -> 프록시 불가

결과

  • @ Transactional 완전 무시

규칙

  • public
  • open 클래스 + open 메서드
@Service
open class MyService {

    @Transactional
    open fun save() { ... }
}

# Case9 Kotlin+open 누락

@Service
class MyService {

    @Transactional
    fun save() { ... }
}

문제 :

  • Kotlin 클래스/메서드는 기본이 final
  • 프록시 생성 불가

결과: 트랜잭션 안걸림

해결

plugins {
    kotlin("plugin.spring")
}

or

open class MyService {
    open fun save() {}
}

# Case10 @ Transactional이 intereface에만 붙어있는 경우

interface MyService {
    @Transactional
    fun save()
}

@Service
class MyServiceImpl : MyService {
    override fun save() { ... }
}

주의 :

  • JDK Dynamic Proxy 사용시 : 인터페이스 기준으로 동작 -> OK
  • CGLIB 사용 시 : 구현체 기준->인터페이스 어노테이션 무시가능

권장 : 구현체 메서드에 붙이기

# Case11 @ Async+@ Transactional

@Async
@Transactional
fun asyncSave() {
    repository.save(entity)
}

핵심

  • @ Async -> 다른 스레드
  • 트랜잭션은 Thread-bound

결과 :

  • 호출자 트랜잭션 X 전달안됨
  • 항상 새 트랜잭션 이거나 아에 없다

규칙

  • @ Async메서드는 항상 독립 트랜잭션으로 취급
  • 공유 트랜잭션 기대X

# Case12 try-catch로 예외 삼킨경우

@Transactional
fun method() {
    try {
        repository.save(entity)
        throw RuntimeException()
    } catch (e: Exception) {
        // 로그만 찍고 끝
    }
}

결과

  • 예외가 밖으로 안 나감
  • 정상 종료로 판단 -> 커밋됨

해결

catch (e: Exception) {
    TransactionAspectSupport.currentTransactionStatus()
        .setRollbackOnly()
    throw e
}

# Case13 내부에서 예외 변환

@Transactional
fun method() {
    try {
        repository.save(entity)
    } catch (e: RuntimeException) {
        throw IOException() // Checked Exception
    }
}

결과 : 롤백 X -> 커밋 성공

해결 :

@Transactional(rollbackFor = [Exception::class])

# Case14 테스트 코드에서 트랜잭션 오해

@SpringBootTest
@Transactional
class MyTest {

    @Test
    fun test() {
        service.save()
    }
}

테스트 특징

  • 테스트 종료 시 자동 롤백
  • 실제 서비스 환경과 다름

사고 패턴 : 테스트에서는 롤백되나 실서버에서는 안됨

# Case15 여러 DataSource 사용 시

@Transactional
fun method() {
    db1.save()
    db2.save()
}
  • Spring 트랜잭션은 단일 DataSource
  • 분산 트랜잭션 X

결과 : db1 성공, db2 실패 -> 부분커밋

JTA / Saga / 보상 트랜잭션 필요

3. @ TransactionalEventListener + AFTER_COMMIT 정리

DB 커밋됐고, 그 다음에만 이메일 알림 외부 API를 호출하고 싶다.

문제를 해결하는 방법이

@ TransactionalEventListener(phase=AFTER_COMMIT)이다.

Step1. 왜 필요한가?

@Transactional
fun createOrder() {
    orderRepository.save(order)

    emailService.send()   // ❌ 위험
    kafkaProducer.send() // ❌ 위험
}
  • 트랜잭션 아직 커밋 안됨
  • 이후 예외 발생 시 DB는 롤백 되나 메일/메시지는 이미 발생된 상태

=> 외부 시스템과 DB상태 불일치 발생

Step2. 기본 개념

핵심 아이디어

  • 도메인 이벤트 발행은 트랜잭션 안
  • 실제 처리는 커밋 이후
TX BEGIN
  └─ save()
  └─ publishEvent()
TX COMMIT
  └─ AFTER_COMMIT 리스너 실행

Step3. 기본 사용 예제

  1. 이벤트 정의
data class OrderCreatedEvent(
    val orderId: Long
)
  1. 이벤트 발행(트랜잭션 내부)
@Transactional
fun createOrder() {
    val order = orderRepository.save(Order())

    applicationEventPublisher.publishEvent(
        OrderCreatedEvent(order.id)
    )
}
  1. 이벤트 처리(AFTER_COMMIT)
@Component
class OrderEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handle(event: OrderCreatedEvent) {
        emailService.send(event.orderId)
        kafkaProducer.send(event.orderId)
    }
}
  • 트랜잭션 커밋 성공 → 실행
  • 롤백 → 아예 호출되지않음

4. TransactionPhase 전체 정리

5. @ EventListener vs @ TransactionalEventListener

@EventListener
fun handle(event: OrderCreatedEvent) { ... }

트랜잭션 상태 무시, 커밋 여부와 상관없이 실행

@TransactionalEventListener
fun handle(event: OrderCreatedEvent) { ... }

트랜잭션 단계 인지, 커밋/롤백 안정성 확보

Step4. 자주 터지는 실수

트랜잭션 없는 곳에서 이벤트 발행

@Service
class OrderService(
    private val publisher: ApplicationEventPublisher
) {

    fun createOrder() {
        // ❌ 트랜잭션 없음
        publisher.publishEvent(OrderCreatedEvent(1L))
    }
}
@Component
class OrderEventListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handle(event: OrderCreatedEvent) {
        println("이벤트 처리")
    }
}
  • 현제 스래드에 활성 트랜잭션X
  • AFTER_COMMIT을 대기할 대상 자체가 없음

따라서 트랜잭션이 없으니 바로 실행 @TransactionalEventListener트랜잭션이 존재할 때만 의미가 있다

리스너에 @Transactional을 붙이면 원 트랜잭션과 이어질 거라 기대함

@Transactional
fun createOrder() {
    orderRepository.save(Order(...))
    publisher.publishEvent(OrderCreatedEvent(1L))
}
@Component
class OrderEventListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional
    fun handle(event: OrderCreatedEvent) {
        notificationRepository.save(Notification(...))
    }
}

설계 시의 생각한 점 (이렇게 동작X)

[하나의 트랜잭션]
 ├─ 주문 저장
 ├─ 이벤트 발행
 ├─ 알림 저장
 └─ 커밋

실제 Spring 실행 흐름

[트랜잭션 A]
@Transactional createOrder()
 ├─ orderRepository.save()
 ├─ publishEvent()
 └─ commit
      ↓
[트랜잭션 B]
@TransactionalEventListener 실행
 └─ @Transactional → 새로운 트랜잭션 시작
      └─ notificationRepository.save()
      └─ commit
  • 트랜잭션 A : 이미 종료
  • 리스터 실행 시점:

커넥션 없음, EntityManager없음, 트랜잭션 컨텍스트 없음

  • 리스너의 @ Transactional은 항상 새 트랜잭션

즉 리스너에 @Transactional을 붙여서 원 트랜잭션의 일부처럼 사용하면 안 된다

Step5. @ TransactionalEventListenr + @ Async 조합

  • DB 정합성은 트랜잭션으로 ㅗ장
  • 느린 작업은 비동기로 ㅜㄴ리
  • 메인 트랜잭션 지연 방지

데이터는 안전하게 커밋한 뒤 이후 작업은 느려도 상관없게 처리

예제코드

  1. 주문생성(메인 트랜잭션)
@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val publisher: ApplicationEventPublisher
) {

    @Transactional
    fun createOrder() {
        val order = Order.create()
        orderRepository.save(order)

        // 커밋 이후 실행할 이벤트 등록
        publisher.publishEvent(OrderCreatedEvent(order.id))
    }
}
  1. 이벤트 리스너 (AFTER_COMMIT + Async)
@Component
class OrderEventListener(
    private val emailService: EmailService
) {

    @TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT
    )
    @Async
    fun handle(event: OrderCreatedEvent) {
        emailService.sendOrderCompleteEmail(event.orderId)
    }
}
  1. Async 설정(필수)
@Configuration
@EnableAsync
class AsyncConfig
  1. 실행 순서
[Thread-1] HTTP 요청
 └─ @Transactional createOrder()
     ├─ orderRepository.save()
     ├─ publishEvent()
     └─ commit
          ↓
[Thread-2] (Async Executor)
 └─ handle(event)
     └─ emailService.send()
  • 이벤트는 커밋 이후에만 실행
  • 리스너는 완전히 다른 스레드
  • 원 트랜잭션과 컨텍스트 공유 ❌
  • 실패해도 주문 데이터는 안전

Step6. AFTER_COMMIT 이후 실패 처리 전략

AFTER_COMMIT 시점 = 롤백 불가

  • 메인 트랜잭션X
  • 되돌릴 방법X
  • 실패는 반드시 별도 처리 필요
  1. 전략1 재시도 큐(Kafka/Redis)
  • 실패 시 즉시 재시도X → 메시지 큐에 넣고 나중에 처리

예시 : kafka 재시도 이벤트

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderCreatedEvent) {
    try {
        emailService.send(event.orderId)
    } catch (e: Exception) {
        retryProducer.publish(
            EmailRetryEvent(event.orderId)
        )
    }
}

# kafka consumer
@KafkaListener(topics = ["email-retry"])
fun retry(event: EmailRetryEvent) {
    emailService.send(event.orderId)
}
  • 장애 복구 가능
  • 서버 재시작시에도 안전
  • 대규모 트래픽에 가장 적합
  1. 전략2 실패 이벤트 DB저장
  • 실패한 이벤트를 DB에 기록
  • 배치/ 관리자/ 워커가 재처리

실패 로그 엔티티

@Entity
class FailedEvent(
    @Id @GeneratedValue
    val id: Long = 0,
    val type: String,
    val payload: String,
    val retryCount: Int = 0
)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderCreatedEvent) {
    try {
        emailService.send(event.orderId)
    } catch (e: Exception) {
        failedEventRepository.save(
            FailedEvent(
                type = "EMAIL",
                payload = event.orderId.toString()
            )
        )
    }
}
  1. 전략 3 보상 트랜잭션

“되돌릴 수 없으면, 반대 동작을 실행한다”

주문생성 완료 → 이메일 실패 → 주문상태 Email_failed로 변경

catch (e: Exception) {
    orderService.markEmailFailed(orderId)
}
  1. 전략4 단기 재시도
  • 일시적 네트워크 오류, DB장애 아님, 외부 API 불안정
@Service
class EmailService {

    @Retryable(
        value = [IOException::class],
        maxAttempts = 3,
        backoff = Backoff(delay = 1000)
    )
    fun send(orderId: Long) {
        // 이메일 발송
    }
}

Step7. @ Async리스너에서 언제 @ Transactional을 쓰면안될때!

금지 케이스 1 : 원 트랜잭션과 원자적으로 묶여야 하는 작업

// ❌ 잘못된 설계
@Transactional
fun createOrder() {
    orderRepository.save(order)
    publisher.publishEvent(DeductPointEvent(order.userId))
    // 커밋됨
}

@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional  // ❌ 새 트랜잭션 (분리됨)
fun handle(event: DeductPointEvent) {
    pointRepository.deduct(event.userId)  // 실패 가능
}

안되는 이유

[스레드 A - Main Thread]
1. 트랜잭션 A 시작
2. Order 저장
3. 이벤트 발행 (메모리에만)
4. 트랜잭션 A 커밋 ✅
5. AFTER_COMMIT 리스너 실행 대기열 등록

[스레드 B - Async Thread Pool]
6. 비동기 리스너 실행
7. 트랜잭션 B 시작 (새 Connection)
8. Point 차감 시도
9. 실패! 💥
10. 트랜잭션 B 롤백

결과:
- Order는 DB에 저장됨 (트랜잭션 A 커밋됨)
- Point는 차감 안 됨 (트랜잭션 B 롤백됨)
- 데이터 불일치! 🚨
  • 주문은 저장 됐으나 포인트는 깎이지않음
// 올바른 방법
@Transactional
fun createOrder() {
    orderRepository.save(order)
    pointRepository.deduct(order.userId)
}

금지 케이스 2 : 실패 시 원 트랜잭션을 롤백해야 하는 경우

// Spring 내부 코드 (간소화)
try {
    // 비즈니스 로직 실행
    transactionManager.commit(status);  // 1. 커밋

    // 2. AFTER_COMMIT 리스너 실행
    eventPublisher.publishEvent(event);

} catch (Exception e) {
    // 3. 리스너 실패해도 이미 커밋됨!
    // 롤백 불가능!
}
// 잘못된 코드
// ❌ 이런 기대는 절대 불가능
@Asnyc
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional
fun handle(event: PaymentEvent) {
    accountingRepository.save(...)
    throw Exception()  // 💥
    // "Payment도 롤백되겠지?" ← 절대 안 됨!
}

// Payment는 이미 커밋됨
// Accounting만 롤백됨
// 👉 데이터 불일치
  • AFTER_COMMIT 시점 이미 원트랜잭션 종료 이 리스너가 실패해도 원 트랜잭션 롤백 불가

금지케이스 3 : 중복 실행이 절대 허용되지 않는 작업

// 잘못된 코드
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional
fun handle(event: CouponIssuedEvent) {
    couponRepository.issue(event.userId)
}

중복 실행이 발생하는 실제 케이스:

Case A: 서버 재시작

1. 이벤트 발행 → 큐에 저장
2. 서버 재시작
3. 큐에서 이벤트 재처리
4. 쿠폰 중복 발급 💥

Case B: 네트워크 타임아웃

1. 이벤트 처리 시작
2. DB에 저장 완료
3. 응답 전 네트워크 끊김
4. 메시지 큐가 "실패"로 판단 → 재시도
5. 쿠폰 중복 발급 💥

####  Case C: @Async 자체 재시도
@Async
@Retryable(maxAttempts = 3)  // 스프링 재시도
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional
fun handle(event: CouponIssuedEvent) {
    couponRepository.issue(event.userId)
    // 첫 시도: DB 저장 성공, 응답 전 타임아웃
    // 두 번째 시도: 중복 발급 💥
}
  • Async 중복 실행 가능 → 네트워크/서버 장애 시 재시도 → 트랜잭션 분리 → 쿠폰 중복발급

해결방법

// 방법 1: 유니크 키
@Entity
@Table(
    uniqueConstraints = [
        UniqueConstraint(columnNames = ["user_id", "coupon_id"])
    ]
)
class IssuedCoupon

// 방법 2: 멱등성 체크
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional
fun handle(event: CouponIssuedEvent) {
    // 이미 발급되었는지 확인
    if (couponRepository.existsByUserIdAndCouponId(event.userId, event.couponId)) {
        return  // 중복 실행 무시
    }
    couponRepository.issue(event.userId)
}

// 방법 3: 이벤트 ID 저장 (가장 안전)
@Transactional
fun handle(event: CouponIssuedEvent) {
    // 1. 이벤트 ID로 처리 여부 확인
    if (processedEventRepository.exists(event.eventId)) {
        return
    }

    // 2. 쿠폰 발급
    couponRepository.issue(event.userId)

    // 3. 이벤트 ID 저장 (같은 트랜잭션)
    processedEventRepository.save(event.eventId)
}

금지케이스4 : DB락 / 장시간 트랜잭션이 발생하는 작업

@Configuration
class AsyncConfig {
    @Bean
    fun taskExecutor() = ThreadPoolTaskExecutor().apply {
        corePoolSize = 10  // 최대 10개 스레드
        maxPoolSize = 10
    }
}

@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional  // ❌ 커넥션도 점유
fun handle(event: ReportEvent) {
    // 5분 걸리는 작업
    reportRepository.generateHeavyReport()
}

발생하는 문제:

요청 1: 이벤트 10개 발행
→ 스레드 풀 10개 모두 사용
→ 각각 5분씩 실행

요청 2: 이벤트 1개 발행
→ 대기 (스레드 풀 고갈)

요청 3: 이벤트 1개 발행
→ 대기

...

👉 시스템 전체 마비
👉 DB 커넥션 풀도 고갈
  • Async 스레드풀 잠김
  • 커넥션 장시간 점유
  • 시스템 전체 성능 저하

올바른 대안

  • 트랜잭션 제거
  • 배치 / 워커 분리
  • 메시지 큐 사용

금지케이스5 : 외부 시스템 호출을 트랜잭션으로 감싸는 경우

외부 API가 포함된 작업을 하나의 트랜잭션으로 묶어 롤백해버리면, “외부는 성공 · 나는 실패” 상태가 되어 중복 호출을 스스로 만들어낸다.

// ❌ 최악의 코드
@Async
@Transactional
fun sendToExternalApi(event: OrderEvent) {
    // 1. DB에 발송 이력 저장
    sendHistoryRepository.save(SendHistory(event.orderId))

    // 2. 외부 API 호출 (5초 걸림)
    externalApi.sendOrder(event)  // 타임아웃 발생 💥

    // 3. 트랜잭션 롤백
    // 4. SendHistory 롤백됨
    // 5. 재시도 시 중복 발송 가능!
}

실제 발생 문제:

시도 1:
- SendHistory 저장
- API 호출 → 성공했지만 응답이 느림
- 타임아웃으로 예외 발생
- 트랜잭션 롤백 (SendHistory 삭제)

시도 2 (재시도):
- SendHistory 확인 → 없음 (롤백되어서)
- API 호출 → 중복 발송! 💥
// ✅ 트랜잭션 분리
@Async
fun sendToExternalApi(event: OrderEvent) {
    // 1. 발송 이력 저장 (독립 트랜잭션)
    saveSendHistory(event)

    // 2. 외부 API 호출 (트랜잭션 밖에서)
    try {
        externalApi.sendOrder(event)
        updateSendHistorySuccess(event.orderId)
    } catch (e: Exception) {
        updateSendHistoryFailed(event.orderId, e.message)
        // 재시도 큐에 등록
    }
}

@Transactional
fun saveSendHistory(event: OrderEvent) {
    sendHistoryRepository.save(SendHistory(event.orderId, PENDING))
}

@Transactional
fun updateSendHistorySuccess(orderId: Long) {
    sendHistoryRepository.updateStatus(orderId, SUCCESS)
}

금지 케이스 6 : BEFORE_COMMIT에서 @ Async

// ❌ 절대 금지
@Async
@TransactionalEventListener(phase = BEFORE_COMMIT)  // 💥
@Transactional
fun handle(event: Event) {
    // 이건 동작조차 안 함!
}

왜 안 되는가:

BEFORE_COMMIT:
- 원 트랜잭션 커밋 전에 실행되어야 함
- 하지만 @Async는 별도 스레드

실행 순서:
1. 원 트랜잭션 커밋 준비
2. @Async 스레드에 작업 위임
3. 원 트랜잭션 커밋 (기다리지 않고) ✅
4. @Async 스레드 실행 (이미 커밋됨) 💥

👉 BEFORE_COMMIT 의미 상실
👉 Spring이 경고 로그 출력

금지 케이스 7 : Propagation.REQUIRES_NEW와 혼용

// ❌ 의미 없는 조합
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)  // 의미 없음
fun handle(event: Event) {
    // 이미 별도 스레드 → 이미 새 트랜잭션
    // REQUIRES_NEW는 불필요
}

# 써도 되는 경우 : 메인 로직에 영향이 없을 때

// ✅ 예외 처리 + 재시도
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun saveAuditLog(event: OrderEvent) {
    try {
        saveAuditLogWithTransaction(event)
    } catch (e: Exception) {
        // 실패 시 로컬 파일 또는 별도 큐에 저장
        fallbackLogger.log(event, e)
    }
}

@Transactional
fun saveAuditLogWithTransaction(event: OrderEvent) {
    auditLogRepository.save(AuditLog(event))
}

메타데이터
post_id
b686d3c0607f
slug
transactional-어노테이션-정리-b686d3c0607f
url
https://medium.com/@siwol406/transactional-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EC%A0%95%EB%A6%AC-b686d3c0607f
canonical_url
https://medium.com/@siwol406/transactional-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EC%A0%95%EB%A6%AC-b686d3c0607f
author_url
https://medium.com/@siwol406
status
ok
fetched_at
2026-06-29 22:44:20