포스트

[Spring/Kotlin] @Transactional 내부 코루틴의 트랜잭션 가시성 문제 - @TransactionalEventListener로 해결하기

@Transactional 메서드 내부에서 코루틴을 실행하면 미커밋 데이터를 읽지 못하는 트랜잭션 가시성 문제가 발생합니다. Spring의 @TransactionalEventListener(AFTER_COMMIT)로 해결하는 과정을 공유합니다.

[Spring/Kotlin] @Transactional 내부 코루틴의 트랜잭션 가시성 문제 - @TransactionalEventListener로 해결하기

[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 returnconsecutiveDays = 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 ReadNon-Repeatable ReadPhantom 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 (코루틴)
T0INSERT 5번째 메시지-
T1OpenAI 호출 시작launch 시작, 요약 생성 AI 호출
T2OpenAI 응답 대기 중요약 완료, SELECT conversations → 4턴만 보임
T3OpenAI 응답 대기 중consecutiveDays = 0early return
T4AI 응답 저장, 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 < 1early 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.ktpostTurnPort.handleAfterLastTurn()eventPublisher.publishEvent()
ProcessConversationPostTurnService.ktPort 구현 → @TransactionalEventListener(AFTER_COMMIT)
ProcessConversationPostTurnPort.kt삭제 - 불필요한 Port 인터페이스 제거

4. 실무 팁

@Transactional 메서드 안에서 비동기 작업을 실행할 때는 항상 “이 비동기 작업이 같은 트랜잭션의 데이터를 읽어야 하는가?”를 확인하세요. 코루틴 launch, @Async, CompletableFuture 모두 별도 스레드에서 새 트랜잭션을 시작하므로, 미커밋 데이터를 읽지 못합니다. 답이 Yes라면 @TransactionalEventListener(AFTER_COMMIT)가 안전한 선택입니다.


5. 마무리

💡 Key Takeaways

  1. @Transactional 내 비동기 = 트랜잭션 가시성 함정 — 코루틴 launch는 별도 스레드에서 새 트랜잭션을 시작하므로, 부모의 미커밋 데이터를 읽지 못합니다. “같은 코드 블록 안에 있으니까 같은 트랜잭션이겠지”라는 가정이 가장 위험합니다.
  2. 데이터 의존성이 있으면 AFTER_COMMIT@TransactionalEventListener(AFTER_COMMIT)는 커밋 완료 후 실행을 보장하므로, 후처리 로직이 항상 완전한 데이터를 읽을 수 있습니다.

Before → After

항목BeforeAfter
호출 방식Port 인터페이스 직접 호출ApplicationEventPublisher + 이벤트
실행 시점트랜잭션 진행 중 (미커밋)트랜잭션 커밋 후 (AFTER_COMMIT)
데이터 가시성5번째 메시지 안 보임 ❌모든 데이터 조회 가능 ✅
결합도서비스 → Port → 구현체 직접 결합이벤트 기반 느슨한 결합
보상 지급consecutiveDays = 0 → 미지급정상 계산 → 정상 지급

참고: 트랜잭션 없이 publishEvent를 호출하면 이벤트가 무시됩니다 (fallbackExecution = false 기본값).

⚠️ fallbackExecution

@TransactionalEventListenerfallbackExecution은 기본값이 false입니다. 트랜잭션 없이 publishEvent()를 호출하면 이벤트가 조용히 무시됩니다 — 에러도 없고 로그도 없어서 디버깅이 매우 어렵습니다. true로 설정하면 트랜잭션 없을 때 즉시 실행되지만, 데이터 가시성 보장이 안 되므로 주의가 필요합니다.


이전 글 suspend 함수와 @Transactional의 위험한 조합과 함께 읽으면 코루틴 + 트랜잭션의 두 가지 주요 함정을 모두 이해할 수 있습니다. 궁금한 점이나 유사한 경험이 있다면 댓글로 공유해주세요!

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.