들어가며
@Transactional을 쓸 때 대부분 이렇게 쓴다.kotlin
@Transactional
fun save() { ... }
@Transactional(readOnly = true)
fun findAll() { ... }
propagation이라는 옵션이 있다는 건 알았지만, 기본값인
REQUIRED 외에 실제로 쓸 일이 있을 거라고는 생각하지 못했다. 그런데 서버가 죽으면서 처음으로 propagation을 직접 지정해야 하는 상황을 겪었다.장애 상황
어느 날 밤, 모니터링 대시보드에서 CPU가 95%까지 치솟았다. API 호출이 없는 시간대를 떠나서 애초에 아직 런칭조차 하지 않은 사이드 프로젝트였다.

SSH로 들어가
top을 확인했더니 Java 프로세스가 CPU를 거의 전부 먹고 있었다. 그런데 top을 다시 실행할 때마다 PID가 바뀌고 있었다.top -o %CPU + Iris mode off (shift + I)

로그를 확인하니 원인은 명확했다.
plain
ERROR 1 --- [█████████████] [ main] o.s.boot.SpringApplication : Application run failed
org.springframework.beans.factory.BeanInitializationException:
Failed to process @EventListener annotation on bean with name 'notificationEventListener':
@TransactionalEventListener method must not be annotated with @Transactional
unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void ...
...
Caused by: java.lang.IllegalStateException: @TransactionalEventListener method
must not be annotated with @Transactional unless when declared as REQUIRES_NEW
or NOT_SUPPORTED: public void ...
at ...RestrictedTransactionalEventListenerFactory.createApplicationListener
at ...EventListenerMethodProcessor.processBean
at ...EventListenerMethodProcessor.afterSingletonsInstantiated
... 15 common frames omitted
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.5.6)
INFO 1 --- [█████████████] [ main] █.█.█.█████████████████████ :
Starting █████████████████████████ v0.0.1-SNAPSHOT using Java 21.0.10 with PID 1
BeanInitializationException가 발생했다. 빈 초기화는 앱 시작 단계에서 일어나기 때문에, 이 예외가 터지면 앱 자체가 뜨지 않는다. 즉, 앱이 시작조차 못하고 죽고, Docker가 다시 띄우고, 또 죽는 크래시 루프가 CPU 급증의 원인이었다.문제의 코드
kotlin
@Component
@Transactional // 모든 메서드가 알림을 생성하니까 붙인 @Transactional
class NotificationEventListener(
private val memberRepository: MemberRepository,
private val notificationRepository: NotificationRepository,
) {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleMemberJoined(event: MemberJoinedEvent) {
val members = memberRepository.findByTeamIdAndUserIdNot(event.teamId, event.triggeredBy)
val notifications = members.map { Notification(...) }
notificationRepository.saveAll(notifications)
}
}
서비스 클래스에서는 보통 클래스 레벨에
@Transactional(readOnly = true)를 걸고, 쓰기 메서드에만 @Transactional을 오버라이드한다. 이 이벤트 리스너도 알림을 생성하는 역할이니 같은 감각으로 클래스 레벨에 @Transactional을 붙였다.왜 안 되는 걸까
@TransactionalEventListener(phase = AFTER_COMMIT)은 트랜잭션이 커밋된 후에 실행된다. 당연히 새로운 트랜잭션을 잡을 거라고 생각했지만, 실제로는 원래 트랜잭션의 cleanup 단계에서 호출되기 때문에 원래 트랜잭션이 아직 완전히 끝나지 않은 상태다.Spring의 GitHub Issue #30679에 따르면:
“Those methods are invoked in the transaction cleanup phase of an original, other transaction. This means that this particular, other transaction is still running, although in an undefined state. With a simple@Transactionaldeclared on those methods, the code would be executed in an undefined transactional state. An obvious solution to this problem is to declare aPropagationofREQUIRES_NEWon the listener method, so that it will run in a new transaction.”(번역) 이 메서드들은 다른 트랜잭션의 정리 단계에서 호출되는데, 이는 해당 트랜잭션이 아직 실행 중이지만 정의되지 않은 상태에 있다는 뜻이다. 이 메서드들에 단순히 _@Transactional_을 선언하면, 코드가 정의되지 않은 트랜잭션 상태에서 실행되게 된다. 이 문제의 명확한 해결책은 리스너 메서드에 _Propagation.REQUIRES_NEW_를 선언하여 새로운 트랜잭션에서 실행되도록 하는 것이다.
@Transactional의 기본 propagation인 REQUIRED는 "현재 트랜잭션이 있으면 참여하고, 없으면 새로 만든다"는 뜻이다. 이 불완전한 트랜잭션에 참여하게 되어 예측할 수 없는 동작을 일으킬 수 있다. Spring Framework 6.2부터는 이 조합을 앱 시작 시점에 거부하며, 의도가 명확한 두 가지만 허용한다.REQUIRES_NEW— 기존 트랜잭션과 무관하게 항상 새 트랜잭션을 만든다NOT_SUPPORTED— 트랜잭션 없이 실행한다
해결
클래스 레벨의
@Transactional을 제거하고, 각 메서드에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 명시했다.kotlin
@Component
class NotificationEventListener(
private val memberRepository: MemberRepository,
private val notificationRepository: NotificationRepository,
) {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleMemberJoined(event: MemberJoinedEvent) {
val members = memberRepository.findByTeamIdAndUserIdNot(event.teamId, event.triggeredBy)
val notifications = members.map { Notification(...) }
notificationRepository.saveAll(notifications)
}
// 나머지 핸들러도 동일하게 적용
}
REQUIRES_NEW는 기존 트랜잭션과 무관하게 항상 새 트랜잭션을 만든다. cleanup 단계의 불완전한 트랜잭션에 참여하지 않으므로 문제가 사라진다. @Async로 별도 스레드에서 실행되는 것과도 의미적으로 자연스럽다.그런데 여기서 트랜잭션이 정말 필요한가?
수정하기 전에 한 가지 더 생각해봤다. 이 코드에 트랜잭션이 꼭 필요한가?
각 메서드가 하는 일은 단순하다.
kotlin
val members = memberRepository.findByTeamIdAndUserIdNot(...) // 조회
notificationRepository.saveAll(notifications) // 저장
saveAll은 Spring Data JPA의 SimpleJpaRepository에서 이미 @Transactional이 걸려 있다.java
// SimpleJpaRepository.java
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
그래서 외부에 트랜잭션을 걸지 않아도
saveAll 호출 자체는 하나의 트랜잭션으로 묶인다. 전부 저장되거나, 전부 롤백되거나.그리고 알림은 최선형(best-effort) 성격이다. 하나 누락되어도 서비스가 깨지지 않는다. 결제나 재고 차감과는 다르다.
결론: 지금은
@Transactional 자체가 필요 없다.그렇다면 언제 필요해지는가
여러 저장소에 걸쳐 저장할 때
kotlin
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleSomething(event: SomeEvent) {
notificationRepository.saveAll(notifications) // 성공 → 커밋됨
otherRepository.saveAll(others) // 실패 → 여기만 롤백
}
외부 트랜잭션이 없으면 각
saveAll이 독립된 트랜잭션이다. 첫 번째만 저장되고 두 번째는 롤백될 수 있다. 둘을 하나로 묶으려면 메서드 레벨 @Transactional(propagation = REQUIRES_NEW)가 필요하다.알림 저장과 상태 변경이 함께 일어날 때
kotlin
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleMemberJoined(event: MemberJoinedEvent) {
notificationRepository.saveAll(notifications)
memberRepository.updateLastNotifiedAt(members)
}
알림은 저장됐는데 마지막 알림 시각 갱신이 실패하면, 중복 알림이 발생할 수 있다. 이런 경우 하나의 트랜잭션에 묶어야 한다.
지금 코드에서는 이런 시나리오가 없다. 하지만 이벤트 리스너는 기능이 확장되면서 부가 로직이 추가되기 쉬운 지점이다. 변경이 생길 때마다 트랜잭션 필요 여부를 재검토하는 인지적 비용에 비하면, 트랜잭션 하나를 여닫는 런타임 비용은 무시할 수 있는 수준이라고 판단하여
REQUIRES_NEW를 붙여두었다.propagation 옵션 정리
이번 일을 계기로 정리해봤다.
| Propagation | 의미 | 사용 예시 |
|---|---|---|
REQUIRED (기본값) | 트랜잭션 있으면 참여, 없으면 생성 | 일반적인 서비스 메서드 |
REQUIRES_NEW | 항상 새 트랜잭션 생성 | 이벤트 리스너, 독립적인 로그/알림 저장 |
NOT_SUPPORTED | 트랜잭션 없이 실행 | 트랜잭션이 불필요한 단순 읽기 |
MANDATORY | 트랜잭션 필수, 없으면 에러 | 단독 호출되면 안 되는 내부 메서드 |
NEVER | 트랜잭션이 있으면 에러 | 트랜잭션 안에서 호출되면 안 되는 외부 API 호출 |
NESTED | 중첩 트랜잭션 (savepoint) | 부분 롤백이 필요한 배치 처리 |
해결 후
수정 배포 후 CPU가 즉시 정상화되었다. 크래시 루프가 멈추고, Java 프로세스의 CPU 사용률이 0.3%로 떨어졌다.

CloudWatch에서도 95%에서 1%대로 급락한 것을 확인할 수 있다.

헬스체크도 200 OK,
"status": "UP"을 반환했다.
돌아보며
- 클래스 레벨
@Transactional이 모든 메서드에 적합한지 확인하자. 서비스 클래스에서는 자연스러운 패턴이지만, 이벤트 리스너처럼 트랜잭션 생명주기가 다른 컴포넌트에서는 문제가 될 수 있다. - 트랜잭션이 정말 필요한지 먼저 생각하자. Spring Data JPA의
save/saveAll은 이미 자체 트랜잭션을 갖고 있다. 단일 저장소에 한 번 저장하는 거라면 굳이 외부 트랜잭션을 걸 필요가 없다. - propagation은 "언제 새 트랜잭션을 만들 것인가"의 문제다.
readOnly = true만큼 자주 쓰이지는 않지만, 이벤트 기반 아키텍처에서는REQUIRES_NEW가 반드시 필요한 순간이 온다. - 에러 메시지를 잘 읽자.
REQUIRES_NEW or NOT_SUPPORTED라고 답을 알려주고 있었다.