[Spring/Kotlin] @Transactional 내부 코루틴의 트랜잭션 가시성 문제 - @TransactionalEventListener로 해결하기
@Transactional 메서드 내부에서 코루틴을 실행하면 미커밋 데이터를 읽지 못하는 트랜잭션 가시성 문제가 발생합니다. Spring의 @TransactionalEventListener(AFTER_COMMIT)로 해결하는 과정을 공유합니다.
[Spring/Kotlin] @Transactional 내부 코루틴의 트랜잭션 가시성 문제 - @TransactionalEventListener로 해결하기
안녕하세요. duurian 팀에서 백엔드 개발을 담당하고 있는 정지원입니다.
이전 글 suspend 함수와 @Transactional의 위험한 조합에서는 코루틴의 스레드 전환으로 인한 트랜잭션 컨텍스트 유실 문제를 다뤘습니다.
이번에는 비슷하지만 다른 함정을 만났습니다. @Transactional 메서드 내부에서 코루틴을 launch하면, 코루틴의 새 트랜잭션이 부모 트랜잭션의 미커밋 데이터를 읽지 못하는 문제입니다. 이 글에서는 문제의 원인을 분석하고 @TransactionalEventListener(AFTER_COMMIT)로 해결하는 과정을 공유합니다.
1. 문제 상황: 보상이 지급되지 않는다
1.1 문제 발견 과정
flowchart LR
A["5번째 메시지 저장"] --> B["코루틴 launch"]
B --> C["별도 스레드에서 새 트랜잭션"]
C --> D["5번째 메시지 안 보임"]
D --> E["consecutiveDays = 0"]
E --> F["보상 미지급"]
style D fill:#f44336,color:#fff
style F fill:#f44336,color:#fff
duurian 서비스에서는 사용자가 5턴 대화를 완료하면 보상을 지급합니다. 그런데 대화 완료 후 RewardSkipHistory 저장과 createRewardIfExists 호출이 모두 동작하지 않는 버그가 발생했습니다.
1.2 문제의 코드
대화 처리의 핵심 흐름을 살펴보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ProcessConversationService.kt
@Transactional // 트랜잭션 시작
override fun processConversation(command: ProcessConversationCommand): ProcessConversationResult {
// 1. 5번째 사용자 메시지 저장 (아직 커밋 안 됨!)
commandConversationPort.saveConversation(userConversation)
val systemTurn = todayConversations.filter { !it.isAiModel && it.questionId == null }.size
if (systemTurn == MAX_TURNS) {
// 2. 후처리 비동기 실행 (코루틴 launch)
postTurnPort.handleAfterLastTurn(command.userId)
}
// 3. OpenAI API 호출 (수 초 소요) → 이 후에야 트랜잭션 커밋
return handleFollowUpTurn(command, todayConversations, systemTurn)
}
후처리 서비스는 코루틴으로 실행됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ProcessConversationPostTurnService.kt
override fun handleAfterLastTurn(userId: UUID) {
conversationPostTurnScope.launch(Dispatchers.IO) { // 별도 스레드!
val summaryList = summaryListDeferred.await()
launch {
val qualityResult = lowQualityConversationDetector.check(userId, summaryList)
// 여기서 보상 생성 시도
createRewardUseCase.createConversationReward(
CreateRewardCommand(userId = userId, ...)
)
}
}
}
📘 fire-and-forget (Coroutine launch)
launch는 결과를 반환하지 않는 코루틴 빌더입니다. 호출자는 즉시 다음 코드로 진행하고, 코루틴은 별도로 실행됩니다. Dispatchers.IO와 결합하면 별도 스레드에서 새 트랜잭션으로 실행되어, 부모 트랜잭션의 미커밋 데이터를 읽지 못하는 원인이 됩니다.
📘 CoroutineScope와 Structured Concurrency
Kotlin 코루틴의 Structured Concurrency는 코루틴이 특정 스코프 안에서 실행되어, 스코프가 종료되면 모든 하위 코루틴도 취소되는 원칙입니다. 커스텀 CoroutineScope를 사용하면 백그라운드 작업의 생명주기를 애플리케이션 수준에서 관리할 수 있습니다.
보상 생성 로직에서는 연속 대화 일수를 확인합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// CreateRewardService.kt
@Transactional
override fun createConversationReward(command: CreateRewardCommand): List<CommandRewardResult> {
val consecutiveDays = conversationDaysCalculator.calculateConsecutiveDays(command.userId)
if (consecutiveDays < 1) return emptyList() // ← 여기서 early return!
// RewardSkipHistory 저장과 createRewardIfExists 모두 이 아래에 있음
if (command.skipDailyReward) {
commandRewardSkipHistoryPort.save(...) // 도달 불가!
} else {
createRewardIfExists(...) // 도달 불가!
}
}
📘 Early Return 패턴
Early Return은 함수 시작 부분에서 유효하지 않은 조건을 먼저 걸러내고 즉시 반환하는 패턴입니다. 중첩된 if-else를 줄이고 코드 가독성을 높이지만, 이 포스트처럼 중요한 로직이 early return 뒤에 있으면 전체 로직이 실행되지 않는 함정이 될 수 있습니다. consecutiveDays가 잘못 계산되면 보상 로직 전체가 건너뛰어집니다.
1.3 증상 정리
| 증상 | 상세 |
|---|---|
| 보상 미지급 | 대화 완료 후 DAY1 보상이 생성되지 않음 |
| 미지급 이력 미저장 | 저품질 대화 시 RewardSkipHistory도 저장되지 않음 |
| early return | consecutiveDays = 0으로 계산되어 line 57에서 조기 반환 |
| 재현 조건 | 신규 사용자 또는 전날 대화하지 않은 사용자에서 100% 재현 |
2. 원인 분석: 트랜잭션 가시성(Transaction Visibility) 문제
2.1 전체 타임라인
이전 글에서 다뤘던 ThreadLocal 유실 문제와 다릅니다. 이번에는 부모 트랜잭션의 미커밋 데이터를 자식 트랜잭션에서 읽지 못하는 문제입니다.
sequenceDiagram
participant Main as Main Thread<br/>(processConversation)
participant DB as PostgreSQL
participant Coroutine as IO Thread<br/>(코루틴)
Note over Main: @Transactional 시작
Main->>DB: INSERT 5번째 메시지 (미커밋)
Main->>Coroutine: launch(Dispatchers.IO) - fire & forget
par 메인 스레드 (트랜잭션 진행 중)
Main->>DB: OpenAI API 호출 (수 초 소요)
Note over Main: 아직 트랜잭션 커밋 안 됨
and 코루틴 (별도 트랜잭션)
Note over Coroutine: 대화 요약 생성 (AI 호출)
Coroutine->>DB: SELECT conversations<br/>(새 트랜잭션, READ_COMMITTED)
Note over DB: 5번째 메시지는 미커밋<br/>→ 4턴만 보임!
Note over Coroutine: calculateConsecutiveDays()<br/>오늘 4턴 < 5턴 → 완료 안 됨
Note over Coroutine: consecutiveDays = 0<br/>→ early return!
end
Main->>DB: AI 응답 저장
Note over Main: @Transactional 커밋 ← 이제야 5번째 메시지 DB 반영
2.2 PostgreSQL의 READ_COMMITTED 격리 수준
PostgreSQL의 기본 격리 수준은 READ_COMMITTED입니다. 이 격리 수준에서는 다른 트랜잭션이 커밋한 데이터만 읽을 수 있습니다.
📘 READ_COMMITTED 격리 수준
PostgreSQL의 기본 격리 수준은 READ_COMMITTED입니다. 다른 트랜잭션이 아직 커밋하지 않은 데이터(dirty data)는 읽을 수 없습니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
READ_UNCOMMITTED | 가능 | 가능 | 가능 |
READ_COMMITTED (PostgreSQL 기본) | 방지 | 가능 | 가능 |
REPEATABLE_READ (MySQL InnoDB 기본) | 방지 | 방지 | 가능 |
SERIALIZABLE | 방지 | 방지 | 방지 |
Non-Repeatable Read (반복 읽기 불가능)
한 트랜잭션 내에서 동일한 행을 두 번 조회했을 때, 그 사이 다른 트랜잭션이 데이터를 수정(UPDATE)하거나 삭제(DELETE)하여 결과가 다르게 나타나는 현상 원인: 기존 레코드의 데이터 변경. 예시: A가 사용자 정보를 조회(나이: 20) → B가 해당 사용자의 나이를 21로 수정 후 커밋 → A가 다시 조회 시 나이가 21로 바뀜.
Phantom Read (유령 읽기)
한 트랜잭션 내에서 일정 범위의 데이터를 두 번 조회했을 때, 그 사이 다른 트랜잭션이 데이터를 삽입(INSERT)하여 첫 번째 조회에는 없던 ‘유령’ 레코드가 나타나는 현상 원인: 새로운 레코드의 삽입으로 인해 결과 셋(Result Set)의 행 수가 달라짐. 예시: A가 나이 20세 이상 목록 조회(2명) → B가 25세 신규 사용자 삽입 후 커밋 → A가 다시 조회 시 3명이 검색됨.
⚠️ Dirty Read / Uncommitted Data
Dirty Data는 아직 커밋되지 않은 트랜잭션이 기록한 데이터입니다. 이 데이터를 다른 트랜잭션이 읽는 것을 Dirty Read라 합니다. READ_COMMITTED는 dirty read를 방지하지만, 그로 인해 비동기 작업이 부모 트랜잭션의 쓰기를 볼 수 없는 타이밍 문제를 만듭니다 — 이것이 바로 이 포스트의 핵심 문제입니다.
flowchart TD
subgraph "Transaction A (메인 스레드)"
A1["INSERT 5번째 메시지"] --> A2["OpenAI 호출 (수 초)"]
A2 --> A3["AI 응답 저장"]
A3 --> A4["COMMIT"]
end
subgraph "Transaction B (코루틴)"
B1["SELECT conversations"] --> B2["4턴만 보임!"]
B2 --> B3["consecutiveDays = 0"]
B3 --> B4["return emptyList()"]
end
A1 -.->|"미커밋 상태"| B1
style A1 fill:#FF9800,color:#fff
style B2 fill:#f44336,color:#fff
style B4 fill:#f44336,color:#fff
style A4 fill:#4CAF50,color:#fff
| 시점 | Transaction A (메인) | Transaction B (코루틴) |
|---|---|---|
| T0 | INSERT 5번째 메시지 | - |
| T1 | OpenAI 호출 시작 | launch 시작, 요약 생성 AI 호출 |
| T2 | OpenAI 응답 대기 중 | 요약 완료, SELECT conversations → 4턴만 보임 |
| T3 | OpenAI 응답 대기 중 | consecutiveDays = 0 → early return |
| T4 | AI 응답 저장, COMMIT | (이미 실패 후) |
2.3 왜 5턴이 중요한가
ConversationDaysCalculator.calculateConsecutiveDays는 하루에 5턴 이상 대화한 날만 “완료된 날”로 인정합니다.
1
2
3
4
5
6
7
8
9
10
fun calculateConsecutiveDays(userId: UUID): Int {
val completedDates = conversations
.filter { !it.isAiModel && it.questionId == null }
.groupBy { convertUtcToSeoulDate(it.createdAt) }
.filter { (_, turns) -> turns.size >= 5 } // 5턴 기준!
.keys
if (completedDates.isEmpty()) return 0
// ...
}
코루틴에서 조회하면 5번째 메시지가 미커밋 상태이므로 오늘은 4턴으로 계산됩니다. 따라서:
- 오늘이 “완료된 날”로 인정되지 않음
- 어제도 대화하지 않았다면 →
consecutiveDays = 0 consecutiveDays < 1→ early return → 보상 로직에 도달 불가
3. 해결: @TransactionalEventListener(AFTER_COMMIT)
3.1 핵심 아이디어
트랜잭션이 커밋된 이후에 코루틴을 시작하면, 코루틴의 새 트랜잭션에서 모든 데이터를 조회할 수 있습니다.
Spring의 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)는 정확히 이 시점에 실행됩니다.
sequenceDiagram
participant Main as Main Thread
participant Spring as Spring Event
participant DB as PostgreSQL
participant Coroutine as IO Thread
Note over Main: @Transactional 시작
Main->>DB: INSERT 5번째 메시지
Main->>Spring: publishEvent(ConversationCompletedEvent)
Note over Spring: 이벤트 큐에 저장 (아직 실행 안 함)
Main->>DB: OpenAI 호출, AI 응답 저장
Main->>DB: COMMIT ✅
Note over DB: 5번째 메시지 + AI 응답 모두 커밋됨
Spring->>Coroutine: AFTER_COMMIT → 이벤트 리스너 실행
Coroutine->>DB: SELECT conversations (새 트랜잭션)
Note over DB: 5번째 메시지 보임! ✅
Note over Coroutine: consecutiveDays >= 1 → 보상 정상 지급 ✅
📘 @TransactionalEventListener와 TransactionPhase
@EventListener와 달리 @TransactionalEventListener는 이벤트 실행을 트랜잭션 생명주기에 연결합니다. publishEvent() 호출 시 이벤트는 큐에 저장되고, 지정된 phase에 도달해야 실행됩니다.
| TransactionPhase | 실행 시점 |
|---|---|
BEFORE_COMMIT | 트랜잭션 커밋 직전 |
AFTER_COMMIT | 트랜잭션 커밋 성공 후 (가장 많이 사용) |
AFTER_ROLLBACK | 트랜잭션 롤백 후 |
AFTER_COMPLETION | 커밋 또는 롤백 후 (항상 실행) |
3.2 구현: Before → After
Before - Port 인터페이스로 직접 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ProcessConversationService.kt
@Service
class ProcessConversationService(
private val postTurnPort: ProcessConversationPostTurnPort, // Port 직접 의존
// ...
) {
@Transactional
override fun processConversation(command: ProcessConversationCommand) {
commandConversationPort.saveConversation(userConversation)
if (systemTurn == MAX_TURNS) {
postTurnPort.handleAfterLastTurn(command.userId) // 트랜잭션 내에서 호출!
}
return handleFollowUpTurn(...) // OpenAI 호출 후 커밋
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// ProcessConversationPostTurnService.kt
@Service
class ProcessConversationPostTurnService(
// ...
) : ProcessConversationPostTurnPort {
override fun handleAfterLastTurn(userId: UUID) {
conversationPostTurnScope.launch(Dispatchers.IO) {
// 부모 트랜잭션 미커밋 → 5번째 메시지 안 보임!
// ...
}
}
}
After - 이벤트 기반으로 전환
1
2
3
4
// 1. 이벤트 클래스 생성
data class ConversationCompletedEvent(
val userId: UUID,
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 2. ProcessConversationService.kt - 이벤트 발행으로 변경
@Service
class ProcessConversationService(
private val eventPublisher: ApplicationEventPublisher, // 이벤트 퍼블리셔
// ...
) {
@Transactional
override fun processConversation(command: ProcessConversationCommand) {
commandConversationPort.saveConversation(userConversation)
if (systemTurn == MAX_TURNS) {
// 이벤트만 발행 → AFTER_COMMIT까지 실행 대기
eventPublisher.publishEvent(
ConversationCompletedEvent(userId = command.userId)
)
}
return handleFollowUpTurn(...)
}
}
✅ ApplicationEventPublisher (Event-Driven Architecture)
이벤트 기반 아키텍처는 컴포넌트 간 직접 메서드 호출 대신 이벤트를 발행/구독하는 방식으로 결합도를 낮춥니다. Spring의 ApplicationEventPublisher는 프로세스 내(in-process) 이벤트 버스입니다. 마이크로서비스 간 이벤트에는 Kafka, RabbitMQ 같은 메시지 브로커를 사용합니다.
flowchart LR
subgraph "직접 호출 (강결합)"
A1["ServiceA"] -->|"직접 의존"| B1["ServiceB"]
end
subgraph "이벤트 기반 (느슨한 결합)"
A2["ServiceA"] -->|"publishEvent"| E["Event"]
E -->|"@EventListener"| B2["ServiceB"]
end
style A1 fill:#FFCDD2
style B1 fill:#FFCDD2
style A2 fill:#C8E6C9
style E fill:#FFF9C4
style B2 fill:#C8E6C9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 3. ProcessConversationPostTurnService.kt - 이벤트 리스너로 변경
@Component
class ProcessConversationPostTurnService(
private val createConversationSummaryUseCase: CreateConversationSummaryUseCase,
private val createRewardUseCase: CreateRewardUseCase,
private val updateFriendshipAfterConversationService: UpdateFriendshipAfterConversationService,
private val lowQualityConversationDetector: LowQualityConversationDetector,
private val conversationPostTurnScope: CoroutineScope,
) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleConversationCompleted(event: ConversationCompletedEvent) {
val userId = event.userId
conversationPostTurnScope.launch(Dispatchers.IO) {
// 트랜잭션 커밋 후 실행 → 5번째 메시지 조회 가능!
val summaryList = summaryListDeferred.await()
launch {
val qualityResult = lowQualityConversationDetector.check(userId, summaryList)
createRewardUseCase.createConversationReward(
CreateRewardCommand(
userId = userId,
skipDailyReward = qualityResult.isLowQuality,
skipReasons = qualityResult.reasons,
)
)
}
// 친밀도 업데이트 ...
}
}
}
1
// 4. ProcessConversationPostTurnPort.kt 삭제 (더 이상 불필요)
✅ Port & Adapter 패턴 삭제 → 이벤트 기반 전환
Port 인터페이스를 통한 직접 호출은 호출자가 피호출자의 존재를 알아야 합니다. 이벤트 기반으로 전환하면 발행자는 구독자를 모르고, 구독자 추가/변경이 발행자 코드에 영향을 주지 않습니다. 이 포스트에서는 Port 삭제 + 이벤트 전환으로 결합도와 트랜잭션 가시성 문제를 동시에 해결했습니다.
3.3 변경 사항 요약
| 파일 | 변경 |
|---|---|
ConversationCompletedEvent.kt | 신규 - 대화 완료 이벤트 |
ProcessConversationService.kt | postTurnPort.handleAfterLastTurn() → eventPublisher.publishEvent() |
ProcessConversationPostTurnService.kt | Port 구현 → @TransactionalEventListener(AFTER_COMMIT) |
ProcessConversationPostTurnPort.kt | 삭제 - 불필요한 Port 인터페이스 제거 |
4. 실무 팁
@Transactional 메서드 안에서 비동기 작업을 실행할 때는 항상 “이 비동기 작업이 같은 트랜잭션의 데이터를 읽어야 하는가?”를 확인하세요. 코루틴 launch, @Async, CompletableFuture 모두 별도 스레드에서 새 트랜잭션을 시작하므로, 미커밋 데이터를 읽지 못합니다. 답이 Yes라면 @TransactionalEventListener(AFTER_COMMIT)가 안전한 선택입니다.
5. 마무리
💡 Key Takeaways
@Transactional내 비동기 = 트랜잭션 가시성 함정 — 코루틴launch는 별도 스레드에서 새 트랜잭션을 시작하므로, 부모의 미커밋 데이터를 읽지 못합니다. “같은 코드 블록 안에 있으니까 같은 트랜잭션이겠지”라는 가정이 가장 위험합니다.- 데이터 의존성이 있으면
AFTER_COMMIT—@TransactionalEventListener(AFTER_COMMIT)는 커밋 완료 후 실행을 보장하므로, 후처리 로직이 항상 완전한 데이터를 읽을 수 있습니다.
Before → After
| 항목 | Before | After |
|---|---|---|
| 호출 방식 | Port 인터페이스 직접 호출 | ApplicationEventPublisher + 이벤트 |
| 실행 시점 | 트랜잭션 진행 중 (미커밋) | 트랜잭션 커밋 후 (AFTER_COMMIT) |
| 데이터 가시성 | 5번째 메시지 안 보임 ❌ | 모든 데이터 조회 가능 ✅ |
| 결합도 | 서비스 → Port → 구현체 직접 결합 | 이벤트 기반 느슨한 결합 |
| 보상 지급 | consecutiveDays = 0 → 미지급 | 정상 계산 → 정상 지급 |
참고: 트랜잭션 없이
publishEvent를 호출하면 이벤트가 무시됩니다 (fallbackExecution = false기본값).
⚠️ fallbackExecution
@TransactionalEventListener의 fallbackExecution은 기본값이 false입니다. 트랜잭션 없이 publishEvent()를 호출하면 이벤트가 조용히 무시됩니다 — 에러도 없고 로그도 없어서 디버깅이 매우 어렵습니다. true로 설정하면 트랜잭션 없을 때 즉시 실행되지만, 데이터 가시성 보장이 안 되므로 주의가 필요합니다.
이전 글 suspend 함수와 @Transactional의 위험한 조합과 함께 읽으면 코루틴 + 트랜잭션의 두 가지 주요 함정을 모두 이해할 수 있습니다. 궁금한 점이나 유사한 경험이 있다면 댓글로 공유해주세요!