Outbox Pattern: 어려운 부분 (그리고 Namastack Outbox가 어떻게 도움이 되는지)
Source: Dev.to
위의 링크에 포함된 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다.
대부분의 사람들은 트랜잭션 아웃박스 패턴을 높은 수준에서 알고 있습니다.
하지만 실제 부하와 장애 상황에서 아웃박스가 신뢰할 수 있는지를 결정하는 실제 운영 단계의 세부 사항—즉 “어려운 부분”—은 종종 빠져 있습니다:
- 정렬 의미론(보통 전체가 아니라 집계/키 별) 및 순서에 있는 하나의 레코드가 실패했을 때의 처리 방식
- 스케일링: 락 없이 여러 인스턴스에서 동작하도록 하는 파티셔닝 + 리밸런싱
- 장애 발생 시에도 잘 동작하는 재시도 메커니즘
- 영구적으로 실패한 레코드에 대한 명확한 전략
- 모니터링 및 운영(백로그, 실패, 파티션, 클러스터 상태)
이 글에서는 이러한 어려운 부분과 Namastack Outbox 가 이를 어떻게 해결하는지에 초점을 맞춥니다.
문서 빠른 링크
먼저 간단히 살펴보고 싶다면, 이 영상이 Namastack Outbox 를 소개하고 아웃박스 패턴의 기본 개념을 요약합니다.
어려운 부분
Ordering: 실제 프로덕션에서 실제로 필요한 것
사람들이 “정렬이 필요하다”라고 말할 때는 보통 전역 정렬을 의미합니다. 프로덕션에서는 이것이 대부분 잘못된 목표입니다.
보통 필요한 것은 비즈니스 키별 정렬(대개는 애그리게이트별)입니다:
- 특정
order-123에 대해 레코드를 생성 순서대로 엄격히 처리합니다. - 서로 다른 키(
order-456,order-789)에 대해서는 병렬로 처리합니다.
Namastack Outbox가 정렬을 정의하는 방식
정렬은 레코드 키에 의해 정의됩니다:
- 같은 키 → 순차적이며 결정론적인 처리
- 다른 키 → 동시에 처리
@Service
class OrderService(
private val outbox: Outbox,
private val orderRepository: OrderRepository
) {
@Transactional
fun createOrder(command: CreateOrderCommand) {
val order = Order.create(command)
orderRepository.save(order)
// Schedule event – saved atomically with the order
outbox.schedule(
payload = OrderCreatedEvent(order.id, order.customerId),
key = "order-${order.id}" // Groups records for ordered processing
)
}
}
Spring 이벤트와 함께 사용:
@OutboxEvent(key = "#this.orderId")
data class OrderCreatedEvent(val orderId: String)
실패 동작: 이후 레코드가 기다려야 할까?
핵심 프로덕션 질문은 시퀀스 중 하나의 레코드가 실패하면 어떻게 되는가입니다.
- 기본값 (
outbox.processing.stop-on-first-failure=true): 같은 키를 가진 이후 레코드가 대기합니다. 레코드 간에 의존성이 있을 때 엄격한 의미를 유지합니다. - 레코드가 독립적이라면
outbox.processing.stop-on-first-failure=false로 설정하여 같은 키에 대한 이후 레코드가 실패에 의해 차단되지 않도록 합니다.
좋은 키 선택하기
정렬이 중요한 단위에 대한 키를 사용합니다:
order-${orderId}customer-${customerId}
피해야 할 키 유형:
- 너무 거친 (모두 직렬화) 예:
"global" - 너무 미세한 (정렬이 안 됨) 예: 무작위 UUID
스케일 아웃 시에도 정렬이 유지되는 이유
Namastack Outbox는 키 기반 정렬과 해시 기반 파티셔닝을 결합합니다. 따라서 키는 일관되게 같은 파티션으로 라우팅되고, 해당 파티션에서는 한 번에 하나의 인스턴스만 활성화되어 처리합니다.
Source: …
스케일링: 파티셔닝 및 리밸런싱
아웃박스를 1개의 인스턴스에서 N개의 인스턴스로 확장하는 과정에서 많은 구현이 무너지곤 합니다. 다음 두 가지가 필요합니다:
- 작업 분배 – 모든 인스턴스가 도움을 줄 수 있음.
- 중복 처리 방지 + 정렬 보장 – 특히 같은 키에 대해.
일반적인 접근 방식은 “데이터베이스 락을 그냥 사용한다”는 것입니다. 이는 동작할 수 있지만, 트래픽이 증가하면 락 경합, 핫 로우, 예측할 수 없는 지연이 발생하기 쉽습니다.
Namastack Outbox 접근 방식: 해시 기반 파티셔닝
분산 락 대신 Namastack Outbox는 해시 기반 파티셔닝을 사용합니다:
- 256개의 고정 파티션.
- 각 레코드 키는 일관된 해싱을 통해 파티션에 매핑됩니다.
- 각 애플리케이션 인스턴스는 그 파티션들의 일부를 소유합니다.
- 인스턴스는 자신에게 할당된 파티션의 레코드만 폴링/처리합니다.
결과
- 서로 다른 인스턴스가 같은 레코드를 경쟁하지 않음(낮은 락 경합).
- 정렬이 의미 있게 유지됨: 같은 키 → 같은 파티션 → 순차적으로 처리.
리밸런싱이 의미하는 바
실제 운영 환경에서는 활성 인스턴스 수가 변합니다:
- 새로운 버전 배포(롤링 재시작)
- 자동 스케일링으로 파드 추가/제거
- 인스턴스 충돌
Namastack Outbox는 주기적으로 살아 있는 인스턴스를 재평가하고 파티션을 재분배합니다. 이것이 리밸런싱 단계입니다.
중요: 리밸런싱은 자동으로 설계되어 별도의 코디네이터가 필요하지 않아야 합니다.
인스턴스‑코디네이션 설정값
다음 설정은 인스턴스가 어떻게 협업하고 장애를 감지할지를 제어합니다:
outbox:
rebalance-interval: 10000 # ms between rebalance checks
instance:
heartbeat-interval-seconds: 5 # how often to send heartbeats
stale-instance-timeout-seconds: 30 # when to consider an instance dead
graceful-shutdown-timeout-seconds: 15 # time to hand over partitions on shutdown
경험 법칙
- 짧은 하트비트 + 짧은 스테일 타임아웃 → 빠른 장애 복구, DB 트래픽 증가.
- 값을 크게 → 오버헤드 감소, 노드 장애에 대한 반응 속도 감소.
실용적인 가이드
- 키 설계를 의도적으로 유지하십시오 (Ordering 장을 참고). 이는 정렬과 파티셔닝 모두에 영향을 줍니다.
- 하나의 키가 매우 “핫”(예:
tenant-1)하면 단일 파티션에 매핑되어 처리량 병목이 발생합니다. 이 경우 더 세분화된 키(예:tenant-1-order-${orderId})를 사용하거나 파티션 수를 늘리세요(구현을 직접 제어하는 경우). - 파티션 지연, 백로그 크기, 실패 레코드 수를 모니터링하십시오. 급격한 스파이크에 대한 알림을 설정하면 시스템이 정지하기 전에 대응할 수 있습니다.
- 데드레터 전략을 정의하십시오: N 회 재시도 후 레코드를 데드레터 테이블이나 토픽으로 이동시켜 수동으로 조사합니다.
- 스테이징 환경에서 장애 시나리오(DB 중단, 인스턴스 충돌, 네트워크 파티션)를 테스트하여 정렬, 리밸런싱 및 재시도 동작이 기대대로 작동하는지 확인하십시오.
장애: 재시도 및 실패 레코드
장애와 일시적인 실패는 예외 상황이 아니라 정상입니다: 속도 제한, 브로커 다운타임, 불안정한 네트워크, 자격 증명 교체 등.
어려운 부분은 재시도를 예측 가능하게 만드는 것입니다:
- 재시도를 과도하게 하면 → 장애가 확대되고 자체 시스템에 과부하가 걸립니다.
- 재시도를 너무 느리게 하면 → 백로그가 늘어나고 전달 지연이 급증합니다.
Namastack Outbox 재시도 모델 (고수준)
각 레코드는 핸들러에 의해 처리됩니다. 핸들러가 예외를 발생시키면 레코드는 손실되지 않고 — 다시 시도하도록 재스케줄됩니다.
레코드는 간단한 라이프사이클을 거칩니다:
| State | Meaning |
|---|---|
NEW | 대기 중 / 재시도 중 |
COMPLETED | 성공적으로 처리됨 |
FAILED | 재시도 소진 (주의 필요) |
기본 설정 옵션
폴링, 배치, 재시도를 구성으로 조정할 수 있습니다:
outbox:
poll-interval: 2000 # ms
batch-size: 10
retry:
policy: exponential
max-retries: 3
# Optional: only retry specific exceptions
include-exceptions:
- java.net.SocketTimeoutException
# Optional: never retry these exceptions
exclude-exceptions:
- java.lang.IllegalArgumentException
좋은 운영 기본값은 지수 백오프이며, 이는 장애 시 자연스럽게 부하를 줄여줍니다.
재시도가 소진된 후는 어떻게 되나요?
Namastack Outbox는 해당 경우에 폴백 핸들러를 지원합니다:
- 재시도 소진, 또는
- 재시도 불가능 예외
어노테이션 기반 핸들러의 경우, 폴백 메서드는 핸들러와 같은 Spring 빈에 있어야 합니다.
@Component
class OrderHandlers(
private val publisher: OrderPublisher,
private val deadLetter: DeadLetterPublisher,
) {
@OutboxHandler
fun handle(event: OrderCreatedEvent) {
publisher.publish(event)
}
@OutboxFallbackHandler
fun onFailure(event: OrderCreatedEvent, ctx: OutboxFailureContext) {
deadLetter.publish(event, ctx.lastFailure)
}
}
- 폴백이 성공하면 레코드는
COMPLETED로 표시됩니다. - 폴백이 없거나 (또는 폴백이 실패하면) 레코드는
FAILED가 됩니다.
실무 가이드
- 조직에서 “FAILED”가 의미하는 바를 초기에 정의하세요: 알림, 대시보드, 데드레터 큐, 혹은 수동 재생 등.
- 핸들러가 외부 시스템과 통신할 때는 재시도 횟수를 보수적으로 유지하고, 빠른 루프보다 백오프에 의존하세요.
- 중요한 흐름에서는
FAILED레코드가 나타나거나 백로그가 증가할 때 알림을 발생시키는 메트릭을 활용하세요.
Next steps
이 글이 도움이 되셨다면 GitHub에 ⭐을 눌러 주시면 정말 감사하겠습니다 — 그리고 자유롭게 글을 공유하거나 피드백/질문을 댓글로 남겨 주세요.
- Quickstart:
- Features overview (recommended):
- Example projects:
- GitHub repository: