Outbox Pattern: 어려운 부분 (그리고 Namastack Outbox가 어떻게 도움이 되는지)

발행: (2026년 1월 20일 오전 04:51 GMT+9)
13 분 소요
원문: Dev.to

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개의 인스턴스로 확장하는 과정에서 많은 구현이 무너지곤 합니다. 다음 두 가지가 필요합니다:

  1. 작업 분배 – 모든 인스턴스가 도움을 줄 수 있음.
  2. 중복 처리 방지 + 정렬 보장 – 특히 같은 키에 대해.

일반적인 접근 방식은 “데이터베이스 락을 그냥 사용한다”는 것입니다. 이는 동작할 수 있지만, 트래픽이 증가하면 락 경합, 핫 로우, 예측할 수 없는 지연이 발생하기 쉽습니다.

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 재시도 모델 (고수준)

각 레코드는 핸들러에 의해 처리됩니다. 핸들러가 예외를 발생시키면 레코드는 손실되지 않고 — 다시 시도하도록 재스케줄됩니다.

레코드는 간단한 라이프사이클을 거칩니다:

StateMeaning
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:
Back to Blog

관련 글

더 보기 »

메시징 & 이벤트 기반 설계

Event‑Driven Architecture(EDA) Event‑driven architecture는 작은, 분리된 서비스들로 구성된 현대적인 패턴으로, 이 서비스들은 이벤트를 publish, consume 또는 route합니다. - Even...