Transactional 어노테이션 정리
1. @Transactional 완벽 동작 구조
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)
⚡ 핵심 포인트
- 프록시가 모든 것의 시작 — 프록시 없으면 트랜잭션 없음
- ThreadLocal이 핵심 — 같은 스레드 = 같은 트랜잭션
- setAutoCommit(false)가 진짜 시작점 — 이게 트랜잭션의 본질
- finally에서 정리 필수 — 안 하면 누수/오염
- 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. 기본 사용 예제
- 이벤트 정의
data class OrderCreatedEvent(
val orderId: Long
)
- 이벤트 발행(트랜잭션 내부)
@Transactional
fun createOrder() {
val order = orderRepository.save(Order())
applicationEventPublisher.publishEvent(
OrderCreatedEvent(order.id)
)
}
- 이벤트 처리(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 정합성은 트랜잭션으로 ㅗ장
- 느린 작업은 비동기로 ㅜㄴ리
- 메인 트랜잭션 지연 방지
데이터는 안전하게 커밋한 뒤 이후 작업은 느려도 상관없게 처리
예제코드
- 주문생성(메인 트랜잭션)
@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))
}
}
- 이벤트 리스너 (AFTER_COMMIT + Async)
@Component
class OrderEventListener(
private val emailService: EmailService
) {
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
@Async
fun handle(event: OrderCreatedEvent) {
emailService.sendOrderCompleteEmail(event.orderId)
}
}
- Async 설정(필수)
@Configuration
@EnableAsync
class AsyncConfig
- 실행 순서
[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 재시도 큐(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)
}
- 장애 복구 가능
- 서버 재시작시에도 안전
- 대규모 트래픽에 가장 적합
- 전략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()
)
)
}
}
- 전략 3 보상 트랜잭션
“되돌릴 수 없으면, 반대 동작을 실행한다”
주문생성 완료 → 이메일 실패 → 주문상태 Email_failed로 변경
catch (e: Exception) {
orderService.markEmailFailed(orderId)
}
- 전략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