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

발행: (2026년 1월 20일 오전 04:51 GMT+9)
21 min read
원문: Dev.to

Source: Dev.to


Outbox 패턴: 어려운 부분과 Namastack Outbox가 도와주는 방법

소개

마이크로서비스 아키텍처에서 데이터 일관성을 유지하는 것은 언제나 도전 과제입니다. 특히 서비스 간에 이벤트를 전파해야 할 때, 트랜잭션 경계가 서로 다른 데이터베이스와 메시지 브로커에 걸쳐 있기 때문에 원자성을 보장하기가 어렵습니다.

이 문제를 해결하기 위해 많이 사용되는 패턴이 Outbox 패턴입니다. 이 글에서는 Outbox 패턴을 구현할 때 마주치는 핵심 난관들을 짚어보고, Namastack Outbox가 어떻게 이러한 난관을 완화해 주는지 살펴보겠습니다.


Outbox 패턴이란?

Outbox 패턴은 비동기 이벤트를 동일한 트랜잭션 안에 기록함으로써 데이터베이스와 메시지 브로커 간의 일관성을 확보합니다. 일반적인 흐름은 다음과 같습니다.

  1. 서비스가 비즈니스 로직을 수행하고 DB에 데이터를 저장한다.
  2. 같은 트랜잭션 안에서 outbox 테이블에 이벤트 레코드를 삽입한다.
  3. 별도의 outbox 프로세서가 주기적으로 outbox 테이블을 읽어 메시지 브로커(예: Kafka, RabbitMQ)로 전송한다.
  4. 전송이 성공하면 outbox 레코드를 삭제하거나 processed_at 타임스탬프를 업데이트한다.

이렇게 하면 DB 커밋이 성공했을 때만 이벤트가 브로커에 전달되므로, 두 시스템 간의 데이터 손실이나 중복 전송을 방지할 수 있습니다.


구현 시 마주치는 어려운 부분

#어려운 점설명
1Idempotency (멱등성)소비자가 동일한 이벤트를 여러 번 받았을 때 부작용이 없어야 함.
2Exactly‑once delivery브로커에 한 번만 전송하고, 전송 실패 시 재시도 로직이 복잡함.
3Ordering (순서 보장)특정 도메인에서는 이벤트 순서가 비즈니스 로직에 필수적.
4Scalability (확장성)Outbox 테이블이 급격히 커질 수 있고, 프로세서가 병목이 될 수 있음.
5Error handling & retries전송 실패 시 재시도 전략과 실패 레코드 관리가 필요.
6Schema evolution이벤트 스키마가 바뀔 때 기존 레코드와 호환성을 유지해야 함.

1. Idempotency

  • 중복 전송을 방지하려면 소비자 측에서 deduplication 키(예: event_id)를 저장하고, 이미 처리된 키는 무시하도록 해야 합니다.
  • DB 레벨에서 unique constraint를 걸어두면 중복 삽입 자체를 차단할 수 있습니다.

2. Exactly‑once delivery

  • 대부분의 메시지 브로커는 at‑least‑once 보장을 제공하므로, transactional producer(Kafka)나 outbox‑to‑broker 트랜잭션을 활용해야 합니다.
  • 전송이 성공했는지 여부를 outbox 레코드에 sent_at 컬럼으로 기록해 두면 재시도 로직을 단순화할 수 있습니다.

3. Ordering

  • 파티션 키를 활용해 같은 엔티티에 대한 이벤트가 동일 파티션에 들어가게 하면, 브로커가 순서를 보장합니다.
  • Outbox 프로세서는 시간 순(또는 sequence_number)대로 레코드를 읽어야 합니다.

4. Scalability

  • Batch processing: 한 번에 여러 레코드를 읽어 전송하면 I/O 오버헤드가 감소합니다.
  • Partitioned outbox tables: 서비스별, 테넌트별 파티셔닝을 적용해 테이블 크기를 관리합니다.
  • CDC (Change Data Capture) 기반 구현: Debezium 같은 CDC 툴을 사용하면 별도 폴링 없이 변경 스트림을 바로 브로커에 전달할 수 있습니다.

5. Error handling & retries

  • 전송 실패 시 exponential backoffdead‑letter queue(DLQ)를 활용합니다.
  • 실패 레코드에 retry_countlast_error 컬럼을 두어 재시도 정책을 명시적으로 관리합니다.

6. Schema evolution

  • 이벤트 스키마를 Avro 혹은 Protobuf와 같은 버전 관리가 가능한 포맷으로 정의하고, Schema Registry를 두어 호환성을 검증합니다.
  • 기존 레코드에 대한 backward/forward compatibility를 보장하려면, 필드 추가/삭제 시 기본값을 명시해야 합니다.

Namastack Outbox가 제공하는 솔루션

기능Namastack Outbox 구현 방식장점
자동 Idempotency이벤트 ID를 기본 PK로 설정하고, DB 레벨에서 ON CONFLICT DO NOTHING을 사용중복 삽입 방지, 소비자 로직 단순화
Exactly‑once 전송Kafka transactional producer와 연동, transactional.id를 이용해 커밋/abort 제어브로커에 한 번만 전송 보장
순서 보장sequence_number 컬럼을 자동 증가시키고, 파티션 키와 결합해 순차 전송엔티티 레벨 순서 유지
스케일링Batch sizeparallel workers를 설정 가능, 테이블 파티셔닝 자동 지원높은 처리량, 낮은 레이턴시
에러 처리전송 실패 시 자동으로 DLQ에 라우팅하고, retry_count를 관리장애 복구 용이
스키마 관리내장된 Schema Registry와 Avro 지원, 스키마 호환성 검사 자동 수행버전 관리와 마이그레이션 간소화
관측성Prometheus 메트릭, OpenTelemetry 트레이싱, 대시보드 제공운영 시 문제 탐지와 원인 분석 용이

주요 아키텍처 다이어그램

+-------------------+        +-------------------+        +-------------------+
|   Service DB      |  --->  |   Outbox Table    |  --->  |   Outbox Worker   |
| (Postgres/MySQL)  |        | (event_id, ... )  |        | (batch poller)    |
+-------------------+        +-------------------+        +-------------------+
                                   |                         |
                                   v                         v
                           +-------------------+   +-------------------+
                           |   Kafka Topic    |   |   DLQ (if fail)   |
                           +-------------------+   +-------------------+
  • Service DB: 비즈니스 트랜잭션과 동시에 outbox 레코드가 삽입됩니다.
  • Outbox Worker: SELECT ... FOR UPDATE SKIP LOCKED 로 레코드를 락하고, batch 단위로 Kafka에 전송합니다.
  • Kafka Topic: 소비자는 event_id 기반 deduplication 로직을 구현하지 않아도 됩니다(이미 멱등성이 보장됨).
  • DLQ: 전송이 일정 횟수 초과 실패하면 자동으로 DLQ 로 이동해 별도 분석이 가능합니다.

실제 사용 예시

// Go 예시: 서비스 로직 안에서 Outbox 레코드 삽입
tx, err := db.Begin()
if err != nil { return err }

_, err = tx.ExecContext(ctx,
    `INSERT INTO orders (id, amount) VALUES ($1, $2)`,
    orderID, amount)
if err != nil { tx.Rollback(); return err }

_, err = tx.ExecContext(ctx,
    `INSERT INTO outbox (event_id, aggregate_id, type, payload, created_at)
     VALUES ($1, $2, $3, $4, NOW())`,
    uuid.New(), orderID, 'OrderCreated', payloadJSON)
if err != nil { tx.Rollback(); return err }

return tx.Commit()

위 코드는 트랜잭션 안에서 비즈니스 데이터와 outbox 이벤트를 동시에 커밋합니다. 전송은 별도 워커가 담당하므로 서비스 코드는 비동기 전송에 대한 복잡성을 신경 쓸 필요가 없습니다.


결론

Outbox 패턴은 마이크로서비스 환경에서 데이터 일관성을 보장하는 강력한 도구이지만, 멱등성, 정확히 한 번 전송, 순서 보장, 확장성 등 여러 실무적인 난관이 존재합니다.

Namastack Outbox는 이러한 난관을 자동화된 Idempotency, 트랜잭셔널 프로듀서, 배치 처리, 내장된 스키마 레지스트리 등을 통해 크게 완화합니다. 따라서 개발자는 비즈니스 로직에 집중하고, 복잡한 메시징 인프라 관리에서 벗어날 수 있습니다.

Tip: 아직 직접 구현하고 싶다면, 먼저 작은 규모(단일 서비스, 단일 파티션)에서 Outbox 패턴을 시험해 보고, 점차 Namastack Outbox와 같은 전문 솔루션으로 마이그레이션하는 것이 안전합니다.

대부분의 사람들은 트랜잭션 아웃박스 패턴을 대략적으로 알고 있습니다.

실제 운영 환경에서 놓치기 쉬운 부분은 바로 프로덕션 수준의 세부 사항—실제 부하와 장애 상황에서 아웃박스가 신뢰할 수 있는지를 결정하는 “핵심 부분”입니다:

  • 정렬 의미론 (보통은 전체가 아니라 집계/키 별) 및 순서 내의 하나의 레코드가 실패했을 때의 처리
  • 스케일링: 락 문제 없이 여러 인스턴스에 걸쳐 (파티셔닝 + 리밸런싱) 수행
  • 재시도: 장애 상황에서도 정상적으로 동작
  • 영구적으로 실패한 레코드에 대한 명확한 전략
  • 모니터링 및 운영 (백로그, 실패, 파티션, 클러스터 상태)

이 글에서는 이러한 핵심 부분과 Namastack Outbox 가 이를 어떻게 해결하는지에 초점을 맞춥니다.

문서 빠른 링크

먼저 간단히 살펴보고 싶다면, 이 영상에서 Namastack Outbox 를 소개하고 아웃박스 패턴의 기본 개념을 정리합니다.

The Hard Parts

Ordering: what you actually need in production

사람들이 “정렬이 필요하다”고 말할 때, 보통 전역 정렬을 의미합니다. 실제 운영 환경에서는 이것이 보통 잘못된 목표입니다.

실제로 필요한 것은 비즈니스 키당 정렬(보통 애그리게이트당)입니다:

  • 특정 order-123에 대해서는 레코드를 생성 순서대로 엄격히 처리합니다.
  • 서로 다른 키(order-456, order-789)에 대해서는 병렬로 처리합니다.

How Namastack Outbox defines ordering

정렬은 레코드 키에 의해 정의됩니다:

  • 같은 키 → 순차적이며 결정론적인 처리
  • 다른 키 → 동시에 처리
@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)

Failure behavior: should later records wait?

핵심 운영 질문은 시퀀스 내의 한 레코드가 실패하면 어떻게 할 것인가입니다.

  • 기본값 (outbox.processing.stop-on-first-failure=true): 같은 키를 가진 이후 레코드들은 대기합니다. 이는 레코드 간에 의존성이 있을 때 엄격한 의미를 유지합니다.
  • 레코드들이 독립적이라면 outbox.processing.stop-on-first-failure=false 로 설정하여 같은 키의 이후 레코드가 실패에 의해 차단되지 않도록 합니다.

Choosing good keys

정렬이 중요한 단위에 대한 키를 사용합니다:

  • order-${orderId}
  • customer-${customerId}

다음과 같은 키는 피합니다:

  • 너무 거친 키 (모든 것을 직렬화) 예: "global"
  • 너무 세분화된 키 (정렬이 되지 않음) 예: 무작위 UUID

Why ordering still works when scaling out

Namastack Outbox는 키 기반 정렬과 해시 기반 파티셔닝을 결합합니다. 따라서 키는 일관되게 같은 파티션으로 라우팅되고, 해당 파티션에서는 한 번에 하나의 인스턴스만 활성화되어 처리합니다.

Source:

Scaling: partitioning and rebalancing

아웃박스를 1개의 인스턴스에서 N개의 인스턴스로 확장하는 과정에서 많은 구현이 무너지곤 합니다. 다음 두 가지가 필요합니다:

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

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

Namastack Outbox 접근 방식: 해시 기반 파티셔닝

분산 락 대신, Namastack Outbox는 해시 기반 파티셔닝을 사용합니다:

  • 256개의 고정 파티션.
  • 각 레코드 키는 일관성 해시를 이용해 파티션에 매핑됩니다.
  • 각 애플리케이션 인스턴스는 그 파티션들의 일부를 소유합니다.
  • 인스턴스는 자신에게 할당된 파티션의 레코드만 폴링/처리합니다.

결과

  • 서로 다른 인스턴스가 같은 레코드를 경쟁하지 않으므로 락 경쟁이 낮아집니다.
  • 정렬이 의미 있게 유지됩니다: 같은 키 → 같은 파티션 → 순차적으로 처리.

리밸런싱이 의미하는 바

실 운영 환경에서는 활성 인스턴스 수가 변합니다:

  • 새 버전 배포 (롤링 재시작)
  • 자동 스케일링으로 파드 추가/제거
  • 인스턴스 충돌

Namastack Outbox는 주기적으로 살아있는 인스턴스를 재평가하고 파티션을 재분배합니다. 이것이 리밸런싱 단계입니다.

Important: 리밸런싱은 자동으로 설계되어 있어 별도의 코디네이터가 필요하지 않아야 합니다.

인스턴스‑코디네이션 설정값

다음 설정은 인스턴스가 어떻게 협업하고 장애를 감지할지를 제어합니다:

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

경험 법칙

  • Heartbeat와 stale timeout을 낮게 설정 → 장애 복구가 빠르지만 DB 트래픽이 증가합니다.
  • 값을 높게 설정 → 오버헤드는 줄어들지만 노드 장애에 대한 반응이 늦어집니다.

실용적인 가이드

  • 키 설계를 의도적으로 유지하세요 (Ordering 장을 참조). 이는 순서 지정과 파티셔닝 모두에 영향을 줍니다.
  • 하나의 키가 매우 “핫”(예: tenant-1)하면 단일 파티션에 매핑되어 처리량 병목이 됩니다. 이 경우, 더 세분화된 키(예: tenant-1-order-${orderId})를 사용하거나 파티션 수를 늘리세요(구현을 직접 제어할 수 있는 경우).
  • 파티션 지연, 백로그 크기, 실패 레코드 수를 모니터링하세요. 급격한 급증에 대한 알림은 시스템이 멈추기 전에 대응할 수 있게 해줍니다.
  • 데드레터 전략을 정의하세요: N 회 재시도 후 레코드를 데드레터 테이블이나 토픽으로 이동시켜 수동 조사합니다.
  • 스테이징 환경에서 장애 시나리오(DB 중단, 인스턴스 충돌, 네트워크 파티션)를 테스트하여 순서 지정, 리밸런싱 및 재시도 의미가 예상대로 동작하는지 확인하세요.

Outages: retries and failed records

Outages and transient failures are not edge cases — they’re normal: rate limits, broker downtime, flaky networks, credential rollovers.

The hard part is making retries predictable:

  • Retry too aggressively → you amplify the outage and overload your own system.
  • Retry too slowly → your backlog grows and delivery latency explodes.

Namastack Outbox retry model (high level)

Each record is processed by a handler. If the handler throws, the record is not lost — it is rescheduled for another attempt.

Records move through a simple lifecycle:

StateMeaning
NEW대기 중 / 재시도 중
COMPLETED성공적으로 처리됨
FAILED재시도 소진 (주의 필요)

Default configuration knobs

You can tune polling, batching, and retry via configuration:

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

A good production default is exponential backoff, because it naturally reduces pressure during outages.

What happens after retries are exhausted?

Namastack Outbox supports fallback handlers for that case:

  • retries exhausted, or
  • non‑retryable exception

For annotation‑based handlers, the fallback method must be on the same Spring bean as the handler.

@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)
  }
}
  • If a fallback succeeds, the record is marked COMPLETED.
  • If there’s no fallback (or the fallback fails), the record becomes FAILED.

Practical guidance

  • Decide early what “FAILED” means in your organization: alert, dashboard, dead‑letter queue, or manual replay.
  • Keep retry counts conservative when handlers talk to external systems; rely on backoff rather than fast loops.
  • For critical flows, use metrics to alert when FAILED records appear or when the backlog grows.

다음 단계

이 글이 도움이 되었다면 GitHub에 ⭐를 눌러 주시면 감사하겠습니다 — 그리고 자유롭게 글을 공유하거나 피드백/질문을 댓글로 남겨 주세요.

  • 빠른 시작:
  • 기능 개요 (추천):
  • 예제 프로젝트:
  • GitHub 저장소:
Back to Blog

관련 글

더 보기 »

메시징 & 이벤트 기반 설계

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

도메인 이벤트에서 웹훅으로

도메인 이벤트는 다음 인터페이스를 구현합니다: php interface DomainEvent { public function aggregateRootId: string; public function displayReference: st...