Spring R2DBC 단일 DB Multi-Schema 환경에서 트랜잭션 롤백 실패 트러블슈팅

존스노우·2025년 7월 29일
0

기타

목록 보기
11/11

🔥 문제 발생

시스템 구조

  • 단일 PostgreSQL 데이터베이스
  • 논리적으로 분리된 여러 스키마 운영
    main_db
    ├── order_schema      (주문 서비스)
    ├── payment_schema    (결제 서비스) 
    ├── auth_schema       (인증)
    └── common_schema     (공통)

증상

서비스 통합 후 결제 처리 API에서 트랜잭션 롤백이 작동하지 않음

  • 10건 중 7건 성공, 3건 실패 시
  • 실패한 3건만 롤백되고 성공한 7건은 커밋됨
  • 결제 데이터 정합성 깨짐 🚨

🔍 문제 분석 과정

1단계: 이상한 현상 발견

// API 1: 전체 처리 - 트랜잭션 롤백 안 됨 ❌
POST /api/payments/batch-process

// API 2: 선택 처리 - 정상 작동 ✅  
POST /api/payments/process-selected

// 둘 다 같은 서비스 메서드 호출하는데 왜 결과가 다를까?

2단계: 서비스 통합 이력 확인

Before: Payment 서비스 (독립 서버) + Order 서비스 (독립 서버)
After:  Order 서비스에 Payment 코드 통합 (단일 서버)

3단계: DB 구조 확인

-- 실제로는 같은 DB의 다른 스키마
SELECT * FROM main_db.payment_schema.payment_history;
SELECT * FROM main_db.order_schema.order_info;

-- 하지만 Spring R2DBC는 각각 다른 Connection으로 접근

4단계: 로그 분석

[API-1 호출 시]
🟡 Starting transaction with: OrderTransactionManager
📝 UPDATE payment_schema.payment_history SET status = 'PAID'
⚠️  No active transaction for this connection!
📝 INSERT INTO order_schema.order_log (...) -- 이건 트랜잭션 내
❌ Error occurred - Rolling back...
✅ order_log rolled back 
❌ payment_history NOT rolled back (이미 커밋됨)

💡 원인 파악

핵심 원인: 스키마별 TransactionManager 설정 누락

// OrderDatabaseConfig.kt (기존 서비스)
@Configuration
class OrderDatabaseConfig {
    
    @Bean
    @Primary  // Spring Boot가 이것만 TransactionManager 생성
    fun orderConnectionFactory(): ConnectionFactory {
        return ConnectionFactories.get(
            builder()
                .option(DRIVER, "postgresql")
                .option(DATABASE, "main_db")
                .option(Option.valueOf("currentSchema"), "order_schema")
                .build()
        )
    }
    // TransactionManager는 Spring Boot가 자동 생성
}

// PaymentDatabaseConfig.kt (통합된 서비스)
@Configuration
class PaymentDatabaseConfig {
    
    @Bean
    fun paymentConnectionFactory(): ConnectionFactory {
        return ConnectionFactories.get(
            builder()
                .option(DRIVER, "postgresql")
                .option(DATABASE, "main_db")
                .option(Option.valueOf("currentSchema"), "payment_schema")
                .build()
        )
    }
    // ❌ TransactionManager Bean이 없음!
}

왜 같은 DB인데 트랜잭션이 분리될까?

R2DBC에서는 Connection = 트랜잭션 경계

OrderConnectionFactory    → Connection A → OrderTransactionManager
PaymentConnectionFactory  → Connection B → ??? (없음)
  • 같은 DB여도 다른 Connection은 독립적인 트랜잭션
  • Payment 작업이 Order TransactionManager를 사용
  • 서로 다른 스키마 작업이 하나의 트랜잭션으로 묶일 수 없음

🛠️ 해결 과정

시도 1: @Transactional에 이름 명시 ❌

@Service
class PaymentService {
    @Transactional("paymentTransactionManager")  
    suspend fun processPayment() {
        // NoSuchBeanDefinitionException: paymentTransactionManager
    }
}

실패: Bean이 없어서 에러 발생

시도 2: 단일 TransactionManager로 통합 ❌

// 모든 작업을 orderTransactionManager로?
@Transactional("orderTransactionManager")
suspend fun processPayment() {
    paymentRepository.updateStatus()  // payment_schema 접근
    // Connection이 order_schema용이라 실패
}

실패: Connection의 currentSchema가 맞지 않음

최종 해결: 각 스키마별 TransactionManager 생성 ✅

// PaymentDatabaseConfig.kt 수정
@Configuration
@EnableR2dbcRepositories(
    basePackages = ["com.example.payment.repository"],
    entityOperationsRef = "paymentR2dbcEntityOperations"
)
class PaymentDatabaseConfig {
    
    @Bean
    fun paymentConnectionFactory(): ConnectionFactory {
        return ConnectionFactories.get(
            builder()
                .option(DRIVER, "postgresql")
                .option(HOST, dbHost)
                .option(PORT, dbPort)
                .option(DATABASE, "main_db")
                .option(USER, dbUser)
                .option(PASSWORD, dbPassword)
                .option(Option.valueOf("currentSchema"), "payment_schema")
                .build()
        )
    }
    
    // ✅ 핵심 해결책: TransactionManager 추가!
    @Bean
    fun paymentTransactionManager(
        @Qualifier("paymentConnectionFactory") connectionFactory: ConnectionFactory
    ): R2dbcTransactionManager {
        return R2dbcTransactionManager(connectionFactory)
    }
    
    @Bean
    fun paymentR2dbcEntityOperations(
        @Qualifier("paymentConnectionFactory") connectionFactory: ConnectionFactory
    ): R2dbcEntityOperations {
        val databaseClient = DatabaseClient.create(connectionFactory)
        return R2dbcEntityTemplate(databaseClient)
    }
}

서비스 수정

@Service
class PaymentProcessingService(
    private val paymentRepository: PaymentRepository,
    private val orderService: OrderService
) {
    
    @Transactional("paymentTransactionManager")
    suspend fun processBatchPayments(requests: List<PaymentRequest>): BatchResult {
        val results = mutableListOf<PaymentResult>()
        
        requests.forEach { request ->
            // payment_schema 작업
            val payment = paymentRepository.findById(request.paymentId)
                ?: throw PaymentNotFoundException(request.paymentId)
            
            payment.status = PaymentStatus.PROCESSING
            paymentRepository.save(payment)
            
            // 외부 PG 연동
            val pgResult = callPGApi(payment)
            
            if (!pgResult.success) {
                throw PGProcessingException("PG 처리 실패: ${pgResult.message}")
                // 이제 모든 payment_schema 변경사항이 롤백됨!
            }
            
            payment.status = PaymentStatus.COMPLETED
            paymentRepository.save(payment)
            
            results.add(PaymentResult.success(payment.id))
        }
        
        // Order 서비스 호출 (별도 트랜잭션)
        orderService.updateOrderStatus(results)
        
        return BatchResult(results)
    }
}

📊 결과

Before

  • 트랜잭션 경계 불일치로 부분 커밋 발생
  • 결제 데이터 정합성 깨짐
  • 수동으로 데이터 보정 필요

After

  • 각 스키마별 독립적인 트랜잭션 관리
  • 실패 시 해당 스키마의 모든 변경사항 롤백
  • 데이터 정합성 보장

🎓 교훈 및 Best Practices

1. 단일 DB라도 Multi-Schema는 Multi-Connection

// ❌ Bad: Auto-configuration에만 의존
@Configuration
class DatabaseConfig {
    @Bean
    fun connectionFactory(): ConnectionFactory { ... }
    // TransactionManager는 Spring이 알아서 하겠지?
}

// ✅ Good: 명시적 TransactionManager 설정
@Configuration
class DatabaseConfig {
    @Bean
    fun connectionFactory(): ConnectionFactory { ... }
    
    @Bean
    fun transactionManager(
        @Qualifier("connectionFactory") cf: ConnectionFactory
    ): R2dbcTransactionManager {
        return R2dbcTransactionManager(cf)
    }
}

2. 스키마 간 트랜잭션이 필요한 경우

// 방법 1: Saga Pattern
class PaymentSaga {
    suspend fun processWithCompensation() {
        try {
            val paymentTx = processPayment()
            val orderTx = updateOrder()
        } catch (e: Exception) {
            compensatePayment()  // 보상 트랜잭션
            compensateOrder()
        }
    }
}

// 방법 2: Event Driven
class PaymentService {
    suspend fun processPayment() {
        // payment 처리
        eventPublisher.publish(PaymentCompletedEvent(...))
        // order 서비스가 이벤트 수신 후 처리
    }
}

3. 개발/운영 환경 차이 주의

# application-local.yml
spring:
  r2dbc:
    url: r2dbc:postgresql://localhost/test_db
    # 로컬에서는 스키마 구분 없이 작업 (문제 숨겨짐)

# application-prod.yml  
spring:
  r2dbc:
    url: r2dbc:postgresql://prod-server/main_db?currentSchema=payment_schema
    # 운영에서만 문제 발생!

🔗 참고 자료

💭 마무리

처음엔 "같은 DB인데 왜 트랜잭션이 안 될까?"라고 생각했지만, R2DBC에서는 Connection이 곧 트랜잭션 경계라는 점을 간과했다.

단일 DB의 멀티 스키마 환경은 논리적으로는 하나지만, Spring 입장에서는 다른 DataSource와 동일하게 취급된다. 특히 금융/결제 시스템에서는 이런 트랜잭션 설정을 명시적으로 관리하는 것이 필수다.

마이크로서비스를 모놀리스로 통합할 때는 각 서비스의 DB 설정이 어떻게 상호작용하는지 꼼꼼히 확인해야 한다는 교훈을 얻었다.


Tags: #Spring #R2DBC #Transaction #MultiSchema #PostgreSQL #트러블슈팅

profile
어제의 나보다 한걸음 더

0개의 댓글