main_db
├── order_schema (주문 서비스)
├── payment_schema (결제 서비스)
├── auth_schema (인증)
└── common_schema (공통)서비스 통합 후 결제 처리 API에서 트랜잭션 롤백이 작동하지 않음
// API 1: 전체 처리 - 트랜잭션 롤백 안 됨 ❌
POST /api/payments/batch-process
// API 2: 선택 처리 - 정상 작동 ✅
POST /api/payments/process-selected
// 둘 다 같은 서비스 메서드 호출하는데 왜 결과가 다를까?
Before: Payment 서비스 (독립 서버) + Order 서비스 (독립 서버)
After: Order 서비스에 Payment 코드 통합 (단일 서버)
-- 실제로는 같은 DB의 다른 스키마
SELECT * FROM main_db.payment_schema.payment_history;
SELECT * FROM main_db.order_schema.order_info;
-- 하지만 Spring R2DBC는 각각 다른 Connection으로 접근
[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 (이미 커밋됨)
// 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이 없음!
}
R2DBC에서는 Connection = 트랜잭션 경계
OrderConnectionFactory → Connection A → OrderTransactionManager
PaymentConnectionFactory → Connection B → ??? (없음)
@Service
class PaymentService {
@Transactional("paymentTransactionManager")
suspend fun processPayment() {
// NoSuchBeanDefinitionException: paymentTransactionManager
}
}
실패: Bean이 없어서 에러 발생
// 모든 작업을 orderTransactionManager로?
@Transactional("orderTransactionManager")
suspend fun processPayment() {
paymentRepository.updateStatus() // payment_schema 접근
// Connection이 order_schema용이라 실패
}
실패: Connection의 currentSchema가 맞지 않음
// 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)
}
}
// ❌ 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)
}
}
// 방법 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 서비스가 이벤트 수신 후 처리
}
}
# 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 #트러블슈팅