이전 글
Outbox 패턴
결제 완료 이후, 서비스 계층에서는 결제 완료 이벤트(order.debited) 를 발행해야 한다.
이 이벤트는 Kafka를 통해 다른 서비스로 전파되고, 각 서비스는 이를 기반으로 비동기 후처리를 수행한다.
여기서 결제는 정상적으로 완료되었지만, Kafka에 장애가 발생한 상황을 가정해보자.
이벤트 발행 로직은 보통 데이터베이스 트랜잭션 바깥에 위치한다. 따라서 재고 차감은 이미 커밋되었고, Kafka 장애로 인해 이벤트 발행만 실패한 상태가 된다.
이 경우에는 재고는 정상적으로 차감되었고, 결제 상태도 내부적으로는 완료되었지만, 이벤트는 발행되지 않았기 때문에 다른 서비스에서는 결제 완료 사실을 영원히 알 수 없다.
즉, 한 번의 장애로 이벤트가 완전히 유실되고, 이로 인해 후속 비동기 로직들이 영원히 실행되지 않는 치명적인 상황이 발생한다.
이벤트를 발행해야 했다는 사실 자체가 이미 사라졌기 때문에 단순한 재시도 로직으로는 해결할 수 없다.
이 문제를 해결하기 위해 도입하는 것이 Outbox 패턴이다.
Outbox 패턴의 아이디어는 이벤트를 바로 Kafka로 보내지 말고, 먼저 같은 트랜잭션 안에서 데이터베이스에 저장하는 것이다.
결제 트랜잭션 흐름은 다음과 같이 바뀐다.
- 재고(또는 잔액) 차감
- Outbox 테이블에 이벤트(
order.debited) 저장 - 위 두 작업을 하나의 트랜잭션으로 묶어 Commit
이 트랜잭션이 성공적으로 Commit 되었다면, 재고 차감과 이벤트 발행 준비는 모두 완료된 상태가 된다.
이후 별도의 Outbox Processor가 주기적으로 Outbox 테이블을 Polling 하면서 아직 처리되지 않은 이벤트를 Kafka로 발행한다.
Outbox 패턴을 도입하면 아래와 같은 불완전한 상태가 발생하지 않도록 보장할 수 있다.
- 재고만 차감되고 이벤트는 없는 상태
- 이벤트는 있는데 재고는 차감되지 않은 상태
Kafka가 일시적으로 장애가 나더라도, 이벤트는 Outbox 테이블에 안전하게 저장되어 있기 때문에 Kafka가 복구되는 시점에 반드시 다시 발행될 수 있다.
프로젝트 코드
아래는 WalletOutbox Entity 정의다.eventType으로 같은 Outbox이더라도 다르게 처리할 수 있고, status로 현재 Outbox의 상태를 저장하였다.
- Outbox의 기본 상태는
PENDING이고, Polling의 대상이다. - 그 후, 성공 / 실패에 따라
PUBLISHED,FAILED로 상태를 업데이트한다.
@Entity
@Table(name = "wallet_outbox")
class WalletOutbox(
@Column(name = "aggregate_id", nullable = false)
val aggregateId: String,
@Column(name = "event_type", nullable = false)
val eventType: String,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload", nullable = false, columnDefinition = "jsonb")
val payload: String,
@Column(nullable = false)
var status: String,
) : BaseEntity() {
fun makePublished() {
this.status = "PUBLISHED"
}
fun makeFailed() {
this.status = "FAILED"
}
companion object {
fun create(
aggregateId: String,
eventType: String,
payload: String,
status: String = "PENDING"
): WalletOutbox {
return WalletOutbox(
aggregateId = aggregateId,
eventType = eventType,
payload = payload,
status = status
)
}
}
}따라서 재고 차감을 처리하는 서비스 계층의 함수에서 WalletOutbox에 결제 성공 이벤트를 저장해둔다.
is Wallet.WalletPaymentResult.Success -> {
walletOutboxService.publishPaymentWalletDebitedEvent(
userId = userId,
orderId = orderId,
)
PaymentResponse(
orderId = order.orderId,
userId = userId,
aggregateId = order.aggregateId,
requestedAmount = order.amount,
beforeBalance = beforeBalance,
afterBalance = afterBalance,
paymentResult = PaymentResult.SUCCEED
)
}이 처리 재고 차감과 같은 트랜잭션에서 처리되기 때문에 재고 차감과 이벤트 발행을 안전하게 묶을 수 있다.
@Service
class WalletOutboxService(
private val walletOutboxRepository: WalletOutboxRepository,
private val objectMapper: ObjectMapper
) {
fun publishPaymentWalletDebitedEvent(userId: String, orderId: String): WalletOutbox {
val payload = objectMapper.writeValueAsString(
WalletDebitedEvent(
userId = userId,
orderId = orderId
)
)
val outbox = WalletOutbox.create(
aggregateId = userId,
eventType = "PAYMENT_WALLET_DEBITED",
payload = payload,
)
walletOutboxRepository.save(outbox)
return outbox
}
}여기까지 구현했으면 재고 차감 성공 후, 결제 완료 이벤트가 Outbox에 들어간 상태이다.
그러면 이제 주기적으로 PENDING 상태인 Outbox를 가져와 처리하는 로직을 추가해야 한다.
Polling Scheduler
우선 PENDING 상태인 Outbox를 조회하는 함수를 만들었다.
나는 QueryDSL을 사용했으나 JPQL을 사용해도 되고 적절한 방식을 선택하면 된다.
fun findAllPendingByLimit(limit: Int): List<WalletOutbox> {
return queryFactory
.selectFrom(walletOutbox)
.where(walletOutbox.status.eq("PENDING"))
.orderBy(walletOutbox.seq.asc())
.limit(limit.toLong())
.fetch()
}다음으로는 Scheduler를 정의한다.@Scheduled 어노테이션으로 주기적으로 실행하는 함수를 정의할 수 있다.
@Component
class OutboxPollingScheduler(
private val outboxRepository: WalletOutboxQueryRepository,
private val eventPublisher: OutboxEventPublisher,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Scheduled(fixedDelay = 1000)
fun publishPendingEvents() {
try {
val events = outboxRepository.findAllPendingByLimit(50)
if (events.isEmpty()) return
for (outbox in events) {
eventPublisher.publishEvent(outbox.id)
}
} catch (e: Exception) {
log.error("Outbox polling failed", e)
}
}
}각 이벤트에 대해 트랜잭션이 적용되어야 하기 때문에 Event를 발행하는 함수는 다른 컴포넌트로 분리했다.
성능 향상을 위해 여러 개의 이벤트를 한 트랜잭션에서 배치 처리를 할 수도 있다.
@Component
class OutboxEventPublisher(
private val outboxRepository: WalletOutboxQueryRepository,
private val kafkaTemplate: KafkaTemplate<String, String>,
) {
@Transactional
fun publishEvent(outboxId: String) {
val outbox = outboxRepository.findPendingByIdWithLock(outboxId)
?: return
try {
val topic = when (outbox.eventType) {
"PAYMENT_WALLET_DEBITED" -> WalletTopics.PAYMENT_WALLET_DEBITED
else -> {
return
}
}
kafkaTemplate.send(
topic,
outbox.aggregateId,
outbox.payload
)
outbox.makePublished()
} catch (e: Exception) {
outbox.makeFailed()
}
}
}Kafka Publish가 @Transactional 안에 있어도 되는 이유
Outbox 패턴을 구현하면서, Kafka에 이벤트를 발행하는 로직이 데이터베이스 트랜잭션 안에 있어도 괜찮은가 의문이 들었다.
Kafka를 사용한 이벤트 처리 구조는 Exactly-Once를 지향 할 뿐, 보장하지 않는다.
네트워크 장애, 브로커 장애, 컨슈머 리밸런싱 등등 다양한 이유로 중복 이벤트 발행이나 이미 처리된 이벤트의 재처리가 언제든지 발생할 수 있다.
Kafka를 사용하는 순간부터 서비스는 반드시 멱등성(Idempotency)을 전제로 설계돼야 한다.
케이스 1: Kafka Publish 실패
Kafka에 이벤트를 발행하려고 했지만, 네트워크 장애나 브로커 문제로 Publish가 실패했다고 가정해보자.
이 경우에는 보통 다음 중 하나가 된다. 예외가 발생하여 Outbox 상태가 FAILED로 기록되거나 아예 상태 변경이 일어나지 않아 PENDING에 머무른다.
어느 경우든 문제가 없어야 한다.
왜냐하면, Outbox 테이블에는 이벤트가 이미 안전하게 저장돼 있고 이후 Scheduler가 다시 동작하면 해당 이벤트는 다시 Kafka Publish 시도가 되기 때문이다
케이스 2: Kafka Publish 성공 + DB 트랜잭션 롤백
이번에는 반대 상황을 가정해보자.
Kafka Publish는 성공했지만, 하지만 그 직후 DB 트랜잭션이 실패하여 롤백된 상황이다.
이 경우에는 Kafka에는 이벤트가 존재하지만 Outbox 테이블은 여전히 PENDING인 상태이다.
하지만 이 상황 역시 문제가 없다.
왜냐하면 앞서 말했듯이, Kafka 기반 이벤트 처리에서는 중복 이벤트가 언제든지 발생할 수 있음을 전제로 Consumer는 반드시 멱등성을 가져야 하기 때문이다.
즉, 같은 orderId, eventId, aggregateId에 대한 이벤트가 여러 번 들어오더라도, 실제 비즈니스 로직은 단 한 번만 수행되도록 설계되어 있어야 한다.
위 두 어떤 경우에도 시스템이 깨지지 않기 때문에 Kafka Publish가 데이터베이스 트랜잭션 안에 있어도 문제가 없다.
그리고 이게 바로 Outbox 패턴과 Kafka를 함께 사용할 때의 핵심 철학이다.
여기까지 구현했으면 1초마다 터미널에 Outbox Select Query가 나가는 것을 볼 수 있다.
2026-01-06T18:09:29.957+09:00 DEBUG 70497 --- [ scheduling-1] org.hibernate.SQL :
select
wo1_0.seq,
wo1_0.aggregate_id,
wo1_0.created_at,
wo1_0.event_type,
wo1_0.id,
wo1_0.payload,
wo1_0.status,
wo1_0.updated_at
from
wallet_outbox wo1_0
where
wo1_0.status=?
order by
wo1_0.seq
fetch
first ? rows only여기까지 구현하면 안전한 이벤트 등록이 완료되었다.
결제 요청 시 재고 감소와 결제 성공 이벤트(Outbox)가 데이터베이스에 저장되고, 주기적으로 데이터를 가져와 이벤트를 발행하게 된다.
이 Outbox 패턴은 Kafka 장애가 있어도 결국에는 이벤트의 정상 처리가 보장되는 안전한 패턴이다.
다음 글에서는 Kafka와 Integration을 완료하여 실제로 주문이 완료되는 최종 상황까지 구현하고, Gateway 까지 도입하여 단일 진입점으로 안전하게 처리하는 구조를 완성해볼 예정이다.


답글 남기기