콘텐츠로 이동

트랜잭셔널 아웃박스와 멱등성: Spring·Ktor 예제로 보는 실패 경계

DB 트랜잭션, 아웃박스 보관함, 릴레이 로봇, API 블록, 멱등성 키와 재시도 카드를 조립하는 bluetape4k 3D 미니어처 작업대
아웃박스는 커밋된 의도와 전달 상태를 남겨 실패 뒤에도 재시도할 수 있게 합니다.

서비스가 DB에 주문이나 결제 상태를 저장한 뒤 외부 API를 호출하거나 이벤트를 발행한다고 해보겠습니다. 코드만 보면 순서는 단순합니다. 저장하고, 호출하고, 응답합니다.

문제는 프로세스가 그 사이에서 종료될 수 있다는 점입니다. DB 쓰기는 성공했지만 HTTP 호출 직전에 종료될 수 있고, 외부 API는 성공했지만 로컬 상태 갱신 전에 종료될 수도 있습니다. 클라이언트는 타임아웃을 보고 같은 요청을 다시 보냅니다. 명시적인 경계가 없으면 중복 요청, 부분 쓰기, 이벤트 전달 누락을 구분하고 복구하기 어렵습니다.

이 글은 패턴 이름보다 bluetape4k-workshopexposed-workshop 예제가 구현한 실패 경계를 다룹니다. 어디까지 하나의 트랜잭션으로 묶고, 어디부터 재시도 가능한 작업으로 분리하는지 살펴봅니다.

아웃박스와 멱등성을 같이 쓰는 이유는 간단합니다. 사용자 요청의 의도는 DB에 영속적으로 남기고, 외부 전달은 커밋 이후에 다시 시도할 수 있게 분리합니다.

클라이언트, Spring 또는 Ktor 서비스, DB 트랜잭션, 아웃박스, 릴레이, 외부 API와 재시도 경계를 보여주는 구조도
도메인 행과 아웃박스 행을 함께 커밋하면 이후 전달 실패를 저장된 상태에서 다시 시도할 수 있습니다.

messaging/transactional-outbox 예제의 OrderService는 주문 행과 아웃박스 행을 같은 Spring 트랜잭션 안에서 저장합니다.

val orderId = OrderTable.insertAndGetId {
it[OrderTable.customerId] = customerId
it[OrderTable.product] = product
it[OrderTable.quantity] = quantity
it[OrderTable.status] = OrderStatus.PENDING
}
OutboxEventTable.insert {
it[OutboxEventTable.aggregateType] = "Order"
it[OutboxEventTable.aggregateId] = orderId.value.toString()
it[OutboxEventTable.eventType] = "OrderPlaced"
it[OutboxEventTable.payload] = payload
it[OutboxEventTable.status] = OutboxStatus.PENDING
}

이렇게 쓰면 “주문은 있는데 발행할 이벤트가 없다”는 상태가 나오지 않습니다. 반대로 트랜잭션이 롤백되면 둘 다 없습니다. 출발점이 분명해집니다.

멱등성 키는 재시도를 정상 요청으로 만든다

섹션 제목: “멱등성 키는 재시도를 정상 요청으로 만든다”

HTTP 클라이언트는 타임아웃 뒤에 요청을 재시도할 수 있습니다. 서버는 같은 요청의 외부 작업을 두 번 수행하지 않아야 합니다. exposed-workshop의 Spring/Ktor HTTP 아웃박스 예제는 idempotencyKey에 고유 인덱스를 두고, 이미 있는 행이면 기존 레코드를 반환합니다.

fun submit(request: CreatePaymentRequest): PaymentResponse {
val command = request.toCommand()
val created = repository.createPending(command)
if (!created.inserted) {
return created.record.toResponse(duplicate = true)
}
return dispatch(created.record.id, command)
}

저장소도 같은 규칙을 DB 경계에서 한 번 더 지킵니다. 다만 선행 조회만으로는 동시 요청의 경합을 막을 수 없습니다. 두 요청이 같은 키를 동시에 조회하면 둘 다 “없음”을 볼 수 있기 때문입니다. 따라서 고유 인덱스가 최종 쓰기 경계를 보호하고, 한 요청이 제약 조건 충돌로 실패하면 커밋된 기존 행을 다시 읽어 중복 응답으로 바꿔야 합니다.

try {
transaction(database) {
findByIdempotencyKey(command.idempotencyKey)?.asDuplicate()
?: insertPending(command)
}
} catch (e: ExposedSQLException) {
rereadDuplicate(command.idempotencyKey) ?: throw e
}

Spring 컨트롤러와 Ktor 라우트는 같은 응답 규칙을 씁니다. 새 요청이면 201 Created, 중복 키면 기존 행을 돌려주면서 200 OK입니다. 여기서 중요한 점은 중복 요청을 “예외 상황”으로 몰지 않는 것입니다. 네트워크가 불안정하면 재시도는 일상적으로 발생합니다. 멱등성 키는 이 상황에서도 같은 요청을 식별하기 위한 값입니다.

Spring과 Ktor는 트랜잭션 경계를 다르게 표현한다

섹션 제목: “Spring과 Ktor는 트랜잭션 경계를 다르게 표현한다”

Spring 예제와 Ktor 예제는 같은 업무 규칙을 보여줍니다.

비교 지점Spring Boot 예제Ktor 예제
HTTP 진입점@RestController + ResponseEntity명시적인 Route.paymentRoutes
저장소 호출블로킹 Exposed JDBC 트랜잭션withContext(Dispatchers.IO) 안의 Exposed JDBC 트랜잭션
중복 키고유 인덱스 + 기존 행 재조회같은 고유 인덱스 + 기존 행 재조회
게이트웨이 실패재시도 가능/영구 예외를 상태로 기록suspend 게이트웨이에 같은 상태 규칙 적용
검증/오류 변환MVC ErrorAdviceKtor StatusPages

Ktor 쪽에서 봐야 할 차이는 블로킹 JDBC 작업을 suspend 함수 안에 그대로 두지 않는 부분입니다.

private suspend fun <T> transactionIO(block: () -> T): T =
withContext(Dispatchers.IO) {
transaction(database) {
block()
}
}

예제의 핵심은 프레임워크 비교가 아닙니다. 같은 실패 계약을 Spring MVC와 Ktor 라우트에서 각각 어떤 코드 형태로 표현하는지 보는 것입니다.

첫 요청, 동일 키 재시도, 릴레이 재시도

섹션 제목: “첫 요청, 동일 키 재시도, 릴레이 재시도”

아래 시퀀스 다이어그램은 이 글에서 다루는 세 가지 상황을 한 번에 묶습니다.

첫 결제 요청, 동일 멱등성 키 재시도, 커밋 이후 릴레이 재시도의 호출 순서를 보여주는 시퀀스 다이어그램
동일 키 요청은 외부 API를 다시 호출하지 않고, 릴레이 실패는 DB에 남은 행을 기준으로 재시도합니다.

테스트도 이 계약을 그대로 검증합니다. Spring/Ktor 서비스 테스트는 같은 키로 두 번 제출했을 때 두 번째 호출이 같은 ID를 반환하고, 게이트웨이 명령이 한 번만 기록되는지 확인합니다.

val first = service.submit(CreatePaymentRequest("order-2", 2_500, "key-2"))
val duplicate = service.submit(CreatePaymentRequest("order-2", 2_500, "key-2"))
duplicate.id shouldBeEqualTo first.id
duplicate.duplicate shouldBeEqualTo true
gateway.commands.size shouldBeEqualTo 1

재시도 가능한 실패도 같은 흐름입니다. 처음에는 RETRYABLE_FAILED로 남고, 재시도 엔드포인트를 호출하면 같은 아웃박스 레코드를 기준으로 다시 전달합니다.

val failed = service.submit(CreatePaymentRequest("order-retry", 1_000, "retry-key"))
failed.status shouldBeEqualTo PaymentStatus.RETRYABLE_FAILED
val retried = service.retry(failed.id)
retried.status shouldBeEqualTo PaymentStatus.SUCCEEDED
retried.attempts shouldBeEqualTo 2

커밋 이후에도 실패는 발생한다

섹션 제목: “커밋 이후에도 실패는 발생한다”

트랜잭셔널 아웃박스가 모든 실패를 없애지는 않습니다. 대신 실패를 관찰하고 재시도할 수 있는 위치로 옮깁니다.

실패 지점기대 동작예제
DB 트랜잭션 전영속 레코드가 없으므로 재요청은 새 요청처럼 처리결제 createPending
도메인 행 저장 후 아웃박스 행 저장 전같은 트랜잭션이므로 둘 다 롤백OrderService.placeOrder
커밋 후 릴레이 전아웃박스 행이 남아 폴러가 다시 읽을 수 있음OutboxPublisher.publishPendingEvents
외부 API의 재시도 가능 실패RETRYABLE_FAILED, /retry 대상Spring/Ktor PaymentService.retry
외부 API의 영구 실패PERMANENT_FAILED, 재시도 금지Spring/Ktor 서비스 테스트
Kafka 발행 실패재시도 횟수 증가, 한도 초과 시 DEAD_LETTEROutboxTransactionTest
같은 멱등성 키로 재요청기존 행 반환, 외부 API 재호출 없음Spring/Ktor 서비스 테스트

OutboxPublisher는 Kafka 전송이 실패하면 재시도 횟수를 올리고, 재시도 한도를 넘으면 DEAD_LETTER로 보냅니다.

catch (e: Exception) {
val newRetry = currentRetry + 1
incrementRetry(eventId, newRetry)
false
}

여기서도 중요한 점은 “커밋 이후에는 실패하지 않는다”가 아닙니다. 커밋 이후에도 실패합니다. 다만 실패를 다시 읽을 수 있는 행으로 남겨 둡니다. 장애가 발생했을 때는 추측하는 대신 아웃박스 테이블과 재시도 상태를 확인할 수 있습니다.

실시간 아웃박스는 같은 원리를 UI 전달에 적용한다

섹션 제목: “실시간 아웃박스는 같은 원리를 UI 전달에 적용한다”

07-spring-outbox-realtime08-ktor-outbox-realtime 예제는 HTTP 결제 대신 알림의 실시간 전달을 보여줍니다. 저장소는 알림 행과 실시간 아웃박스 행을 같은 트랜잭션으로 만들고, 서비스는 대기 중인 행을 읽어 전달을 시도합니다.

val notificationId = Notifications.insertAndGetId {
it[recipientId] = command.recipientId
it[message] = command.message
}.value
val eventId = RealtimeOutbox.insertAndGetId {
it[aggregateId] = notificationId
it[eventType] = "notification.created"
it[status] = OutboxStatus.PENDING.name
it[attempts] = 0
}.value

전달이 성공하면 PUBLISHED, 실패하면 FAILED입니다. replayAfter(eventId)는 이미 발행된 행 중 특정 ID 이후의 이벤트를 다시 읽습니다. WebSocket/SSE 같은 실시간 전달에서도 메모리에서 보낸 데이터에만 의존하지 않고 DB에 남은 이벤트 커서를 기준으로 재생할 수 있습니다. 다만 이는 발행된 행을 다시 읽는 경계이며, 개별 클라이언트가 실제로 수신했다는 보장은 별도의 확인·오프셋 정책이 맡아야 합니다.

이 예제들이 말하는 규칙은 많지 않습니다.

  • 사용자 의도와 아웃박스의 전달 의도는 같은 트랜잭션에 둡니다.
  • 외부 호출은 커밋 이후 실패할 수 있다고 보고 상태를 남깁니다.
  • 재시도 가능한 실패와 영구 실패를 하나의 상태로 합치지 않습니다.
  • 멱등성 키는 컨트롤러 장식이 아니라 DB 고유성 경계입니다.
  • Spring과 Ktor의 코드 형태가 달라도 실패 계약은 같아야 합니다.

아웃박스와 멱등성을 적용하면 코드가 조금 길어집니다. 그러나 정상 경로만 짧게 만든 코드는 장애가 발생했을 때 설명할 수 있는 상태를 남기지 않습니다. 반대로 아웃박스 행과 멱등성 키가 남아 있으면 실패가 발생한 지점을 DB에서 확인할 수 있습니다. 그 차이가 재시도 가능한 시스템과 추측에 의존해 복구하는 시스템을 가릅니다.

댓글

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