Spring Boot + Kotlin Coroutines에서 발생한 트랜잭션 문제 해결하기

궁금하면 500원·2024년 10월 11일

미생의 개발 이야기

목록 보기
14/60

1. 문제 상황

메시지 읽음 처리 기능 구현 중 다음과 같은 에러가 발생했습니다

Copyjakarta.persistence.TransactionRequiredException: Executing an update/delete query

프론트엔드에서는 다음과 같은 에러를 확인할 수 있었습니다

POST http://localhost:8090/api/messages/2/read 500 (Internal Server Error)

2. 문제 분석

2.1 초기 코드 상태

초기에는 다음과 같은 구조로 코드가 작성되어 있었습니다

@Transactional
suspend fun markMessageAsReadAndGetUnreadCount(messageId: Long, recipientId: Long): Long {
    messageRepository.markMessageAsRead(messageId, recipientId)
    return messageRepository.countUnreadMessagesByRecipientId(recipientId)
}

2.2 문제의 원인

1. Coroutines와 Spring @Transactional의 불일치

  • Spring의 트랜잭션 관리는 ThreadLocal을 기반으로 동작합니다.
  • Coroutines는 스레드를 전환할 수 있어 트랜잭션 컨텍스트가 유실될 수 있습니다.

2. JPA 업데이트 쿼리와 트랜잭션 범위

  • JPA의 업데이트 쿼리는 반드시 트랜잭션 내에서 실행되어야 합니다
  • Coroutines 환경에서 트랜잭션 범위가 제대로 유지되지 않습니다.

3. 해결 방법

3.1 아키텍처 변경

트랜잭션 처리를 위해 두 개의 계층으로 분리합니다

1. Non-suspend 트랜잭션 처리 함수

@Transactional
fun markMessageAsReadAndGetUnreadCount(messageId: Long, recipientId: Long): Long {
    messageRepository.markMessageAsRead(messageId, recipientId)
    return messageRepository.countUnreadMessagesByRecipientId(recipientId)
}

2. Suspend 래퍼 함수

suspend fun markMessageAsReadAndGetUnreadCountSuspend(messageId: Long, recipientId: Long): Long {
    return withContext(Dispatchers.IO) {
        markMessageAsReadAndGetUnreadCount(messageId, recipientId)
    }
}

3.2 추가적인 개선사항

1.Repository 레벨 트랜잭션 보장

@Modifying
@Query("UPDATE MessageRecipient mr...")
@Transactional
fun markMessageAsRead(messageId: Long, recipientId: Long)

2.컨트롤러의 에러 처리 강화

try {
    val unreadCount = messageService.markMessageAsReadAndGetUnreadCountSuspend(messageId, currentUser.id)
    logger.info("Unread count after marking as read: $unreadCount")
    return ResponseEntity.ok(UnreadCountResponse(unreadCount))
} catch (e: Exception) {
    logger.error("Error in markMessageAsRead: ${e.message}", e)
    throw e
}

4. 기술적 고려사항

4.1 왜 이런 구조를 선택했나?

1.트랜잭션 안정성

  • Non-suspend 함수에서 트랜잭션을 처리하여 Spring의 트랜잭션 관리 메커니즘을 온전히 활용
  • ThreadLocal 기반 트랜잭션 관리의 안정성 확보

2. 코루틴 지원

  • withContext(Dispatchers.IO)를 사용하여 DB 작업을 IO 스레드에서 실행 합니다.
  • 비동기 처리의 이점을 유지하면서 트랜잭션 안정성 확보 합니다.

3.에러 처리와 모니터링

  • 상세한 로깅 추가로 운영 시 문제 추적 용이 합니다.
  • 구조화된 예외 처리로 클라이언트에 적절한 에러 응답 제공 합니다.

5. 결론 및 교훈

  1. Kotlin Coroutines와 Spring 트랜잭션을 함께 사용할 때는 세심한 설계가 필요 하는것을 배웠습니다.

  2. 트랜잭션 범위와 코루틴 컨텍스트의 관계를 명확히 알아야한다는것을 알게되었습니다.

  3. 적절한 로깅과 에러 처리는 운영 단계에서 매우 중요하는것을 배웠습니다.

  4. Kotlin Coroutines와 Spring 트랜잭션의 통합 과정에서 발생하는 문제를 정확히 파악하는 것이 가장 어려웠습니다.
    ThreadLocal 기반의 트랜잭션 관리와 코루틴의 스레드 전환 특성이 충돌하는 지점을 이해하고 해결방안을 도출하는 과정이 까다로웠습니다.

  5. 트랜잭션의 안정성과 코루틴의 비동기 처리 이점을 모두 활용하기 위해서입니다.
    Non-suspend 함수에서 트랜잭션을 처리하고, 이를 suspend 함수로 래핑하는 방식을 통해 두 가지 목표를 모두 달성할 수 있었습니다.

  6. Spring의 트랜잭션 전파 설정을 변경하는 방법도 고려했습니다.
    완전히 리액티브한 방식으로 전환하는 것도 검토했으나, 기존 시스템과의 호환성 문제로 현재의 해결방식을 선택했습니다.

7. 참고 자료

Spring 공식 문서: Kotlin 지원
Kotlin Coroutines 공식 문서
Spring Transaction Management 문서

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글