콘텐츠로 이동

Transactional Outbox Part 2: Kafka 우선 fallback으로 hot transaction 줄이기

작은 로봇 작업자들이 orders database, Kafka conveyor, fallback outbox tray, relay station을 연결하는 3D 작업대 일러스트
성공 경로에서는 event row를 쓰지 않고, 실패 경로에서만 outbox 비용을 지불합니다.

Part 1은 classic transactional outbox에 가까운 이야기였습니다. domain row와 outbox row를 같은 transaction에 넣고, relay가 outbox를 Kafka로 밀어내는 방식입니다. 안정적이고 설명하기 쉽지만, hot write path에서는 매 요청마다 outbox row를 하나 더 씁니다.

이번 Part 2는 일부러 다른 질문에서 시작합니다.

Kafka가 정상일 때도 반드시 outbox table에 먼저 써야 할까?

이번 workshop 예제의 답은 “항상은 아니다”입니다. 주문 저장 transaction은 orders만 책임집니다. transaction이 끝난 뒤 event를 Kafka로 바로 보내고, Kafka publish가 실패하거나 timeout되면 그때 event_publicationsNOT_PUBLISHED row를 남깁니다. Kafka 전면 장애나 broker timeout이 길어지는 구간에서는 다시 outbox relay path로 내려갑니다.

orders only transaction, direct Kafka publish, fallback event_publications row, relay, reconciler를 보여주는 architecture diagram
성공 경로는 orders row만 쓰고 끝납니다. 실패 경로에서만 event_publications row와 relay가 등장합니다.

classic outbox는 보통 이렇게 설명합니다.

단계classic transactional outbox
1같은 DB transaction 안에서 ordersoutbox를 함께 insert
2relay가 outbox row를 claim
3Kafka publish 성공 후 row를 PUBLISHED로 mark

이번 예제는 성공 경로가 다릅니다.

단계Kafka-first fallback
1DB transaction 안에서는 orders만 insert
2transaction 밖에서 Kafka에 직접 publish
3직접 publish 성공 시 event_publications row를 만들지 않음
4직접 publish 실패/timeout 시에만 NOT_PUBLISHED row를 저장
5기존 outbox relay가 fallback row를 Kafka로 전송

핵심은 transaction 부하를 줄이는 대신, event publish와 fallback 저장 사이의 작은 gap을 인정한다는 점입니다. 이 gap은 없다고 말하면 안 됩니다. 예제는 FALLBACK_STORE_FAILED 상태와 reconciler를 두어 그 gap을 관찰하고 복구할 수 있게 만듭니다.

TransactionalOrderWriter는 이름 그대로 transaction boundary입니다. 그런데 이 class는 outbox row를 전혀 쓰지 않습니다.

@Transactional
fun saveOrder(customerId: String, product: String, quantity: Int): OrderRecord {
validate(customerId, product, quantity)
val orderId = OrderTable.insertAndGetId {
it[OrderTable.customerId] = customerId
it[OrderTable.product] = product
it[OrderTable.quantity] = quantity
it[OrderTable.status] = OrderStatus.PENDING
}
return getOrder(orderId.value)
}

테스트도 이 의도를 먼저 고정합니다. transactional writer stores only order row는 주문 저장 뒤 orders = 1, event_publications = 0을 확인합니다. 이 테스트가 없으면 누군가 “안전하게 하자”며 classic outbox insert를 다시 transaction 안에 넣어도 쉽게 눈치채기 어렵습니다.

public use case는 order 저장 뒤 event를 만들어 publisher로 넘깁니다.

fun placeOrder(request: OrderRequest): OrderResponse {
val order = transactionalOrderWriter.saveOrder(
customerId = request.customerId,
product = request.product,
quantity = request.quantity,
)
val event = OrderPlacedEvent.from(order)
val publicationStatus = orderEventPublisher.publishDirectOrFallback(event)
return OrderResponse.from(order, publicationStatus)
}

여기서부터는 이미 order transaction 밖입니다. 그래서 API response에는 PUBLISHED_DIRECT, FALLBACK_STORED, FALLBACK_STORE_FAILED 같은 publication status가 함께 내려갑니다.

Client, Order API, Order Tx, Publisher, Outbox DB, Relay/Reconciler 사이의 Kafka-first fallback sequence diagram
직접 publish 성공 시에는 fallback table이 비어 있고, 실패 시에만 NOT_PUBLISHED row가 생성됩니다.

Kafka 직접 publish는 작게 실패해야 한다

섹션 제목: “Kafka 직접 publish는 작게 실패해야 한다”

Kafka publish는 무한히 기다리면 안 됩니다. 예제는 direct publish에 시도 횟수와 총 timeout을 둡니다.

for (attempt in 1..properties.directPublishAttempts) {
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime())
if (remainingMillis <= 0) break
val timeoutMillis = minOf(properties.directPublishTimeout.toMillis(), remainingMillis)
var future: CompletableFuture<*>? = null
try {
future = kafkaTemplate.send(properties.topic, event.eventId, payload)
future.get(timeoutMillis, TimeUnit.MILLISECONDS)
outboxMetrics.recordDirectPublish("success")
return OrderPublicationStatus.PUBLISHED_DIRECT
} catch (e: TimeoutException) {
future?.cancel(true)
lastFailure = e
outboxMetrics.recordDirectPublish("timeout")
} catch (e: Exception) {
lastFailure = e
outboxMetrics.recordDirectPublish("failure")
}
}

기본 테스트는 3번 retry 후 fallback row를 저장하는 경로를 확인합니다. 이때 lastErrorSummary에는 secret/token/password 같은 문자열을 그대로 남기지 않습니다. 운영에서 outbox table은 사람이 직접 보게 되는 경우가 많아서, 실패 원인을 남기되 credential을 흘리지 않는 것이 중요합니다.

fallback 저장은 별도 transaction입니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
fun upsertNotPublished(
event: OrderPlacedEvent,
payload: String,
directAttemptCount: Int,
errorCode: String,
errorSummary: String,
) {
EventPublicationTable.insert {
it[eventId] = event.eventId
it[aggregateType] = "Order"
it[aggregateId] = event.orderId.toString()
it[eventType] = "OrderPlaced"
it[EventPublicationTable.payload] = payload
it[status] = EventPublicationStatus.NOT_PUBLISHED
it[EventPublicationTable.directAttemptCount] = directAttemptCount
it[relayRetryCount] = 0
it[lastErrorCode] = errorCode
it[lastErrorSummary] = sanitize(errorSummary)
it[nextAttemptAt] = now
}
}

이 row가 만들어지면 이후는 familiar한 outbox relay 문제입니다.

fallback row를 Kafka로 보내는 relay는 NOT_PUBLISHED 또는 FAILED row를 claim합니다. 중요한 부분은 claim이 단순 select가 아니라 conditional update라는 점입니다.

val updated = EventPublicationTable.update({
(EventPublicationTable.id eq rowId) and eligibleForClaim(now)
}) {
it[EventPublicationTable.claimedBy] = claimedBy
it[EventPublicationTable.claimedUntil] = claimUntil
it[updatedAt] = now
}

publish 성공 후에도 claim owner가 맞을 때만 PUBLISHED로 mark합니다. 동시에 두 relay가 같은 row를 잡거나, 오래된 worker의 stale claim이 뒤늦게 상태를 덮어쓰는 상황을 막기 위한 최소 장치입니다. 테스트는 concurrent relay call이 같은 row를 두 번 claim하지 않는지, stale claim이 TTL 뒤 다시 eligible해지는지 확인합니다.

실패가 반복되면 FAILED로 미루다가 retry cap을 넘으면 DEAD_LETTER가 됩니다. 여기까지는 classic outbox와 거의 같습니다. 다른 점은 이 table에 들어온 row가 “모든 event”가 아니라 “직접 publish에 실패했거나 복구된 event”라는 점입니다.

가장 위험한 gap은 fallback 저장 실패다

섹션 제목: “가장 위험한 gap은 fallback 저장 실패다”

이 접근의 약점은 명확합니다.

  1. orders는 commit됐다.
  2. Kafka direct publish는 실패했다.
  3. 그런데 event_publications fallback row 저장도 실패했다.

이때 event가 유실될 수 있습니다. 예제는 이 상황을 숨기지 않고 FALLBACK_STORE_FAILED로 API response와 metric에 드러냅니다. 그리고 reconciler가 오래된 orders row 중 publication row가 없는 것을 찾아 deterministic event id로 fallback row를 재구성합니다.

fun reconcileOnce(): ReconcileResult {
val cutoff = LocalDateTime.now(clock).minus(properties.reconcilerGrace)
val candidates = eventPublicationRepository.findOrdersWithoutPublications()
.filter { event -> !event.createdAt.isAfter(cutoff) }
val reconstructed = candidates.count { event ->
val payload = objectMapper.writeValueAsString(event)
eventPublicationRepository.upsertReconstructed(event, payload)
}
return ReconcileResult(
scanned = candidates.size,
reconstructed = reconstructed,
duplicateRiskDocumented = true,
)
}

duplicateRiskDocumented = true가 중요합니다. reconciler는 event 유실 가능성을 줄이지만, distributed system에서 “정확히 한 번”을 공짜로 만들어 주지는 않습니다. consumer는 eventId 기준 idempotency를 가져야 합니다. Part 1에서 다룬 idempotency 이야기가 여기서 다시 필요해집니다.

이 방식은 “classic outbox보다 항상 낫다”가 아닙니다. 조건이 맞을 때만 의미가 있습니다.

맞는 경우피해야 하는 경우
Kafka가 대부분 정상이고, 성공 경로 write amplification을 줄이고 싶다event 유실 가능성을 절대 허용할 수 없다
consumer idempotency가 이미 event id 기준으로 잡혀 있다transaction과 event persistence를 같은 atomic boundary로 강제해야 한다
fallback/reconciler/metric을 운영할 수 있다reconciler가 복원할 수 없는 event payload를 만든다
실패시 outbox relay로 내려가는 graceful degradation이 필요하다DB outbox row 자체가 감사/증적의 source of truth다

실제 서비스에서는 Redis 같은 buffer를 한 단계 더 둘 수도 있습니다. direct publish 실패를 Redis stream/list에 먼저 넣고, worker가 DB fallback row나 Kafka publish를 이어가는 방식입니다. 다만 그 경우에도 buffer durability, eviction policy, consumer group recovery, duplicate handling을 다시 설계해야 합니다. “DB transaction에서 row 하나를 뺐다”는 장점만 보고 전체 복구 경로를 흐리게 만들면 손해입니다.

예제는 metric을 일부러 작게 잡았습니다.

metric의미
workshop.outbox.direct.publish.attemptsdirect Kafka publish 성공/실패/timeout
workshop.outbox.fallback.storedfallback row 저장 성공/실패
workshop.outbox.relay.eventsrelay publish/failed/dead-lettered/claim-lost
workshop.outbox.reconciler.eventsreconciler가 복원한 gap

제가 운영 dashboard를 만든다면 fallback.stored{result=failure}reconciler.events{result=reconstructed}를 강한 경보로 둡니다. 이 둘은 단순 Kafka 장애가 아니라 event 유실 gap 근처에 있다는 신호입니다.

admin endpoint도 기본은 꺼져 있습니다. relay/reconciler를 수동으로 때리는 endpoint는 장애 대응에는 유용하지만, 공개된 상태로 두면 같은 row를 반복 처리하거나 payload를 노출할 수 있습니다. 테스트는 admin endpoint disabled 상태와 publication 조회 API가 payload/raw exception을 노출하지 않는지도 확인합니다.

Kafka-first fallback은 transactional outbox를 버리는 방식이 아닙니다. 성공 경로에서는 outbox 비용을 내지 않고, 실패 경로에서는 다시 outbox relay로 내려가는 변형입니다. 그 대가로 publish/fallback 사이의 gap, reconciler의 duplicate risk, consumer idempotency 요구가 생깁니다.

그래서 이 패턴의 핵심은 “더 빠르다”가 아니라 “어디서 비용을 낼지 명시한다”입니다. 모든 요청에 outbox row를 쓰는 비용을 hot transaction에서 치를지, Kafka 실패시에만 fallback/reconcile 비용으로 치를지 선택하는 것입니다. 예제는 그 선택을 코드와 테스트로 분리해 보여주기 위한 작은 실험입니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.