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

서비스가 DB에 주문이나 결제 상태를 저장한 뒤 외부 API를 호출하거나 이벤트를 발행한다고 해보겠습니다. 코드만 보면 순서는 단순합니다. 저장하고, 호출하고, 응답합니다.
문제는 프로세스가 그 사이에서 종료될 수 있다는 점입니다. DB 쓰기는 성공했지만 HTTP 호출 직전에 종료될 수 있고, 외부 API는 성공했지만 로컬 상태 갱신 전에 종료될 수도 있습니다. 클라이언트는 타임아웃을 보고 같은 요청을 다시 보냅니다. 명시적인 경계가 없으면 중복 요청, 부분 쓰기, 이벤트 전달 누락을 구분하고 복구하기 어렵습니다.
이 글은 패턴 이름보다 bluetape4k-workshop과 exposed-workshop 예제가 구현한 실패 경계를 다룹니다.
어디까지 하나의 트랜잭션으로 묶고, 어디부터 재시도 가능한 작업으로 분리하는지 살펴봅니다.
먼저 실패 경계를 그린다
섹션 제목: “먼저 실패 경계를 그린다”아웃박스와 멱등성을 같이 쓰는 이유는 간단합니다. 사용자 요청의 의도는 DB에 영속적으로 남기고, 외부 전달은 커밋 이후에 다시 시도할 수 있게 분리합니다.

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 ErrorAdvice | Ktor StatusPages |
Ktor 쪽에서 봐야 할 차이는 블로킹 JDBC 작업을 suspend 함수 안에 그대로 두지 않는 부분입니다.
private suspend fun <T> transactionIO(block: () -> T): T = withContext(Dispatchers.IO) { transaction(database) { block() } }예제의 핵심은 프레임워크 비교가 아닙니다. 같은 실패 계약을 Spring MVC와 Ktor 라우트에서 각각 어떤 코드 형태로 표현하는지 보는 것입니다.
첫 요청, 동일 키 재시도, 릴레이 재시도
섹션 제목: “첫 요청, 동일 키 재시도, 릴레이 재시도”아래 시퀀스 다이어그램은 이 글에서 다루는 세 가지 상황을 한 번에 묶습니다.

테스트도 이 계약을 그대로 검증합니다. 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.idduplicate.duplicate shouldBeEqualTo truegateway.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.SUCCEEDEDretried.attempts shouldBeEqualTo 2커밋 이후에도 실패는 발생한다
섹션 제목: “커밋 이후에도 실패는 발생한다”트랜잭셔널 아웃박스가 모든 실패를 없애지는 않습니다. 대신 실패를 관찰하고 재시도할 수 있는 위치로 옮깁니다.
| 실패 지점 | 기대 동작 | 예제 |
|---|---|---|
| DB 트랜잭션 전 | 영속 레코드가 없으므로 재요청은 새 요청처럼 처리 | 결제 createPending |
| 도메인 행 저장 후 아웃박스 행 저장 전 | 같은 트랜잭션이므로 둘 다 롤백 | OrderService.placeOrder |
| 커밋 후 릴레이 전 | 아웃박스 행이 남아 폴러가 다시 읽을 수 있음 | OutboxPublisher.publishPendingEvents |
| 외부 API의 재시도 가능 실패 | RETRYABLE_FAILED, /retry 대상 | Spring/Ktor PaymentService.retry |
| 외부 API의 영구 실패 | PERMANENT_FAILED, 재시도 금지 | Spring/Ktor 서비스 테스트 |
| Kafka 발행 실패 | 재시도 횟수 증가, 한도 초과 시 DEAD_LETTER | OutboxTransactionTest |
| 같은 멱등성 키로 재요청 | 기존 행 반환, 외부 API 재호출 없음 | Spring/Ktor 서비스 테스트 |
OutboxPublisher는 Kafka 전송이 실패하면 재시도 횟수를 올리고, 재시도 한도를 넘으면 DEAD_LETTER로 보냅니다.
catch (e: Exception) { val newRetry = currentRetry + 1 incrementRetry(eventId, newRetry) false}여기서도 중요한 점은 “커밋 이후에는 실패하지 않는다”가 아닙니다. 커밋 이후에도 실패합니다. 다만 실패를 다시 읽을 수 있는 행으로 남겨 둡니다. 장애가 발생했을 때는 추측하는 대신 아웃박스 테이블과 재시도 상태를 확인할 수 있습니다.
실시간 아웃박스는 같은 원리를 UI 전달에 적용한다
섹션 제목: “실시간 아웃박스는 같은 원리를 UI 전달에 적용한다”07-spring-outbox-realtime과 08-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에서 확인할 수 있습니다. 그 차이가 재시도 가능한 시스템과 추측에 의존해 복구하는 시스템을 가릅니다.
- 트랜잭셔널 아웃박스 워크숍:
messaging/transactional-outbox - 주문과 아웃박스의 원자적 저장:
OrderService.kt - Kafka 릴레이와 재시도 상태:
OutboxPublisher.kt - Spring HTTP 아웃박스·멱등성 예제:
03-spring-http-outbox-idempotency - Ktor HTTP 아웃박스·멱등성 예제:
04-ktor-http-outbox-idempotency - Spring 실시간 아웃박스 예제:
07-spring-outbox-realtime - Ktor 실시간 아웃박스 예제:
08-ktor-outbox-realtime
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.