CDC Outbox 패턴의 정석

들어가며: 이벤트 발행, 왜 이렇게 어려운가?

마이크로서비스 아키텍처에서 가장 흔히 마주치는 버그 중 하나는 이렇게 생겼다:

“DB에는 주문이 저장됐는데, Kafka에 이벤트는 안 날아갔어요.”

반대 케이스도 있다. Kafka에는 이벤트가 갔는데 DB 트랜잭션이 롤백된 경우. 어느 쪽이든 데이터 불일치 — 그리고 새벽 2시 장애 대응이다.

이 문제의 근본 원인은 DB 트랜잭션과 메시지 브로커 발행이 서로 다른 시스템이라는 데 있다. 두 시스템을 동시에 원자적으로 커밋할 방법은 2PC(Two-Phase Commit)뿐인데, 2PC는 성능 비용이 크고 Kafka는 XA 트랜잭션을 지원하지 않는다.

Transactional Outbox 패턴은 이 문제를 우아하게 해결한다. 이 글에서는 개념부터 NestJS + TypeORM 실전 구현, Debezium CDC 연동, 그리고 프로덕션 운영 팁까지 한 번에 정리한다.


1. 왜 Outbox 패턴인가? — 원자성 문제의 본질

1.1 일반적인 실수: 두 단계로 나눈 저장 + 발행

// ❌ 위험한 패턴
async createOrder(dto: CreateOrderDto) {
  const order = await this.orderRepo.save(dto); // DB 저장
  await this.kafkaProducer.send({               // 이벤트 발행
    topic: 'order.created',
    messages: [{ value: JSON.stringify(order) }],
  });
  return order;
}

이 코드의 문제점:

  • save() 성공 후 kafkaProducer.send()가 네트워크 오류로 실패 → DB에는 주문, Kafka엔 이벤트 없음
  • Kafka는 성공했지만 DB가 나중에 롤백될 경우(다른 코드 경로) → 유령 이벤트
  • 프로세스가 두 호출 사이에 크래시 → 재시작 후 어떤 상태인지 알 수 없음

1.2 2PC는 왜 안 되나?

Two-Phase Commit은 이론적으로 두 시스템을 원자적으로 커밋할 수 있다. 하지만:

  • Kafka는 XA 지원 없음 — Kafka의 트랜잭션은 Kafka 내부 토픽 간에만 동작한다
  • 성능 비용 — 2PC는 코디네이터 라운드트립이 추가되어 레이턴시가 2~3배 증가
  • 가용성 감소 — 코디네이터가 죽으면 모든 참여자가 블로킹

결론: 2PC 없이 최종적 일관성(eventual consistency)을 보장하는 설계가 필요하다. 그것이 Outbox 패턴이다.

1.3 Outbox 패턴의 핵심 아이디어

핵심은 단순하다: 이벤트를 같은 DB 트랜잭션 안에 outbox 테이블에 저장한다.

BEGIN;
  INSERT INTO orders (...) VALUES (...);       -- 도메인 저장
  INSERT INTO outbox_events (...) VALUES (...); -- 이벤트 기록
COMMIT;

이 단일 트랜잭션이 커밋되면 “주문도 저장됐고, 이벤트도 보내야 한다”는 사실이 원자적으로 보장된다. 그 이후 별도 프로세스가 outbox_events 테이블을 읽어 Kafka로 발행한다. DB가 단일 진실 공급원(source of truth)이 되는 것이다.


2. Outbox 패턴 구조 — Polling vs CDC

Outbox 테이블에 쌓인 이벤트를 Kafka로 전달하는 방법은 크게 두 가지다.

2.1 폴링 방식 (Polling Publisher)


[App] ──COMMIT──▶ [DB: outbox_events]
                        ▲
                   [Poller] ──SELECT unprocessed──▶ [Kafka]
                        │
                   UPDATE processed_at = NOW()
  • 구현 단순: @nestjs/schedule로 수 초마다 미처리 레코드를 조회해 발행
  • 인프라 추가 불필요: 별도 Kafka Connect 클러스터 없어도 됨
  • 단점: DB 폴링 부하, 발행 레이턴시 = 폴링 주기 (최소 수 초)
  • 단점: 폴러가 여러 인스턴스면 중복 발행 방지를 위한 락 필요

2.2 CDC 방식 (Change Data Capture)


[App] ──COMMIT──▶ [DB: outbox_events (WAL)]
                        │
                   [Debezium] ──WAL tail──▶ [Kafka]
  • PostgreSQL WAL(Write-Ahead Log)을 직접 구독 → 레코드 변경 즉시 캡처
  • 발행 레이턴시: 수백 ms 이하 (거의 실시간)
  • DB 폴링 부하 없음
  • 단점: Debezium + Kafka Connect 클러스터 운영 비용
  • 단점: 초기 설정 복잡도 높음

2.3 선택 기준 요약

기준 폴링 CDC (Debezium)
구현 복잡도 낮음 높음
발행 레이턴시 초 단위 밀리초 단위
DB 부하 있음 WAL만 사용 (낮음)
인프라 의존성 없음 Kafka Connect 필요
적합한 규모 소~중규모 중~대규모

실전 조언: 팀 규모가 작고 인프라 운영 여력이 부족하면 폴링으로 시작하라. 이벤트 레이턴시가 SLA에 영향을 주거나 처리량이 커지면 CDC로 마이그레이션하면 된다. Outbox 테이블 스키마는 동일하게 유지된다.


3. NestJS 실전 구현 — 폴링 방식

3.1 OutboxEvent 엔티티

// outbox-event.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  Index,
} from 'typeorm';

export enum OutboxEventStatus {
  PENDING = 'PENDING',
  PROCESSED = 'PROCESSED',
  FAILED = 'FAILED',
}

@Entity('outbox_events')
@Index(['status', 'createdAt']) // 폴링 쿼리 최적화
export class OutboxEvent {
  @PrimaryGeneratedColumn('uuid')
  id: string; // 멱등성 키로 활용

  @Column()
  aggregateType: string; // e.g. 'Order'

  @Column()
  aggregateId: string; // e.g. orderId

  @Column()
  eventType: string; // e.g. 'OrderCreated'

  @Column({ type: 'jsonb' })
  payload: Record<string, unknown>;

  @Column({
    type: 'enum',
    enum: OutboxEventStatus,
    default: OutboxEventStatus.PENDING,
  })
  status: OutboxEventStatus;

  @Column({ nullable: true })
  processedAt: Date;

  @Column({ nullable: true, type: 'text' })
  errorMessage: string;

  @Column({ default: 0 })
  retryCount: number;

  @CreateDateColumn()
  createdAt: Date;
}

스키마 설계 포인트:

  • id (UUID): Kafka 메시지 key 혹은 consumer dedup key로 사용
  • aggregateType + aggregateId: 어떤 도메인 객체에서 발생한 이벤트인지 추적 가능
  • status: 폴링 시 WHERE 절 필터링 + 재시도 로직의 기반
  • (status, createdAt) 복합 인덱스: PENDING 레코드를 시간 순으로 빠르게 조회

인덱스 설계에 대한 더 자세한 내용은 PostgreSQL 인덱스 설계 가이드: B-Tree·GIN·BRIN 타입 선택과 복합 인덱스 실전 패턴을 참고하라. outbox 테이블처럼 range scan이 많은 경우 B-Tree 복합 인덱스 컬럼 순서가 성능에 직결된다.

3.2 마이그레이션 SQL

CREATE TABLE outbox_events (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type VARCHAR(100) NOT NULL,
  aggregate_id   VARCHAR(255) NOT NULL,
  event_type     VARCHAR(100) NOT NULL,
  payload        JSONB        NOT NULL,
  status         VARCHAR(20)  NOT NULL DEFAULT 'PENDING',
  processed_at   TIMESTAMPTZ,
  error_message  TEXT,
  retry_count    INT          NOT NULL DEFAULT 0,
  created_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_outbox_status_created
  ON outbox_events (status, created_at)
  WHERE status = 'PENDING'; -- Partial Index: PENDING 레코드만 인덱싱

Partial Index를 사용하면 PROCESSED 레코드가 쌓여도 인덱스 크기는 작게 유지된다. 이는 폴링 쿼리 성능에 직접적인 영향을 준다.

3.3 OrderService — 트랜잭션 내 Outbox 저장

// order.service.ts
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Order } from './order.entity';
import { OutboxEvent, OutboxEventStatus } from '../outbox/outbox-event.entity';
import { CreateOrderDto } from './dto/create-order.dto';

@Injectable()
export class OrderService {
  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
  ) {}

  async createOrder(dto: CreateOrderDto): Promise<Order> {
    return this.dataSource.transaction(async (manager) => {
      // 1. 도메인 로직: 주문 저장
      const order = manager.create(Order, {
        userId: dto.userId,
        items: dto.items,
        totalAmount: dto.totalAmount,
        status: 'PENDING',
      });
      const savedOrder = await manager.save(order);

      // 2. 같은 트랜잭션 안에서 outbox 레코드 저장
      const outboxEvent = manager.create(OutboxEvent, {
        aggregateType: 'Order',
        aggregateId: savedOrder.id,
        eventType: 'OrderCreated',
        payload: {
          orderId: savedOrder.id,
          userId: savedOrder.userId,
          totalAmount: savedOrder.totalAmount,
          items: savedOrder.items,
          occurredAt: new Date().toISOString(),
        },
        status: OutboxEventStatus.PENDING,
      });
      await manager.save(outboxEvent);

      // 이 COMMIT이 성공하면:
      // - 주문 레코드: 보장됨
      // - outbox 레코드: 보장됨
      // → 이벤트 발행은 나중에 폴러가 처리
      return savedOrder;
    });
  }

  async cancelOrder(orderId: string): Promise<void> {
    await this.dataSource.transaction(async (manager) => {
      const order = await manager.findOneOrFail(Order, { where: { id: orderId } });
      order.status = 'CANCELLED';
      await manager.save(order);

      await manager.save(OutboxEvent, {
        aggregateType: 'Order',
        aggregateId: orderId,
        eventType: 'OrderCancelled',
        payload: { orderId, cancelledAt: new Date().toISOString() },
        status: OutboxEventStatus.PENDING,
      });
    });
  }
}

핵심은 dataSource.transaction() 콜백 안에서 도메인 저장과 outbox 저장이 동일한 EntityManager를 공유한다는 점이다. 둘 중 하나라도 예외를 던지면 전체가 롤백된다.

3.4 OutboxPollerService — 스케줄 기반 발행

// outbox-poller.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, LessThan } from 'typeorm';
import { Kafka } from 'kafkajs';
import { OutboxEvent, OutboxEventStatus } from './outbox-event.entity';

@Injectable()
export class OutboxPollerService {
  private readonly logger = new Logger(OutboxPollerService.name);
  private isRunning = false; // 중복 실행 방지

  private readonly kafka = new Kafka({
    clientId: 'outbox-poller',
    brokers: [process.env.KAFKA_BROKER ?? 'localhost:9092'],
  });
  private readonly producer = this.kafka.producer({
    idempotent: true, // 멱등성 프로듀서 활성화
  });

  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
  ) {}

  async onModuleInit() {
    await this.producer.connect();
  }

  async onModuleDestroy() {
    await this.producer.disconnect();
  }

  @Cron(CronExpression.EVERY_5_SECONDS)
  async pollAndPublish() {
    if (this.isRunning) return; // 이전 실행이 끝나지 않으면 스킵
    this.isRunning = true;

    try {
      await this.dataSource.transaction(async (manager) => {
        // SKIP LOCKED: 다중 인스턴스 환경에서 동일 레코드 중복 처리 방지
        const events = await manager
          .getRepository(OutboxEvent)
          .createQueryBuilder('e')
          .where('e.status = :status', { status: OutboxEventStatus.PENDING })
          .andWhere('e.retryCount < :maxRetry', { maxRetry: 5 })
          .orderBy('e.createdAt', 'ASC')
          .limit(100)
          .setLock('pessimistic_write_or_fail') // FOR UPDATE SKIP LOCKED
          .getMany();

        if (events.length === 0) return;

        const messages = events.map((e) => ({
          topic: this.resolveKafkaTopic(e.eventType),
          messages: [{
            key: e.aggregateId,        // 파티셔닝: 같은 aggregate는 같은 파티션
            value: JSON.stringify(e.payload),
            headers: {
              eventId: e.id,           // consumer dedup 키
              eventType: e.eventType,
              aggregateType: e.aggregateType,
            },
          }],
        }));

        // Kafka 발행 (idempotent producer)
        await this.producer.sendBatch({ topicMessages: messages });

        // 성공: 상태 업데이트
        const ids = events.map((e) => e.id);
        await manager.getRepository(OutboxEvent).update(ids, {
          status: OutboxEventStatus.PROCESSED,
          processedAt: new Date(),
        });

        this.logger.log(`Published ${events.length} outbox events`);
      });
    } catch (err) {
      this.logger.error('Outbox polling failed', err);
      // 실패한 이벤트는 상태 변경 없이 다음 폴링에서 재시도
    } finally {
      this.isRunning = false;
    }
  }

  // 실패 이벤트 재시도 카운트 증가 (별도 cron)
  @Cron(CronExpression.EVERY_MINUTE)
  async handleFailedEvents() {
    // retryCount 임계치 초과 이벤트 FAILED 처리 + 알림
    await this.dataSource
      .getRepository(OutboxEvent)
      .createQueryBuilder()
      .update()
      .set({ status: OutboxEventStatus.FAILED })
      .where('status = :status', { status: OutboxEventStatus.PENDING })
      .andWhere('retryCount >= :maxRetry', { maxRetry: 5 })
      .andWhere('createdAt < :threshold', {
        threshold: new Date(Date.now() - 30 * 60 * 1000), // 30분 이상 된 것
      })
      .execute();
  }

  private resolveKafkaTopic(eventType: string): string {
    const topicMap: Record<string, string> = {
      OrderCreated: 'orders.created',
      OrderCancelled: 'orders.cancelled',
      OrderShipped: 'orders.shipped',
    };
    return topicMap[eventType] ?? 'orders.events';
  }
}

FOR UPDATE SKIP LOCKED는 다중 인스턴스 환경에서 핵심이다. 한 폴러 인스턴스가 잡은 레코드를 다른 인스턴스가 건너뛰어 중복 처리를 방지한다. PostgreSQL 9.5+에서 지원한다.

3.5 모듈 등록

// outbox.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { OutboxEvent } from './outbox-event.entity';
import { OutboxPollerService } from './outbox-poller.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([OutboxEvent]),
    ScheduleModule.forRoot(), // AppModule에서 한 번만 등록
  ],
  providers: [OutboxPollerService],
  exports: [OutboxPollerService],
})
export class OutboxModule {}

4. Debezium CDC 방식 구현

폴링 방식으로 시작했다가 이벤트 레이턴시가 문제가 되거나 처리량이 늘어나면 Debezium으로 전환을 고려한다. Outbox 테이블 스키마는 그대로 유지하면서 폴러만 제거하면 된다.

4.1 PostgreSQL WAL 설정

-- postgresql.conf 또는 ALTER SYSTEM
ALTER SYSTEM SET wal_level = logical;
ALTER SYSTEM SET max_replication_slots = 5;
ALTER SYSTEM SET max_wal_senders = 5;

-- 재시작 후 replication slot 생성
SELECT pg_create_logical_replication_slot('debezium_slot', 'pgoutput');

-- outbox_events 테이블에 REPLICA IDENTITY 설정
ALTER TABLE outbox_events REPLICA IDENTITY FULL;

4.2 Debezium PostgreSQL Connector 설정

{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "plugin.name": "pgoutput",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "debezium_password",
    "database.dbname": "mydb",
    "database.server.name": "mydb",
    "slot.name": "debezium_slot",
    "publication.name": "debezium_pub",

    "table.include.list": "public.outbox_events",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "outbox.${routedByValue}",

    "tombstones.on.delete": "false",
    "heartbeat.interval.ms": "10000",

    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false"
  }
}

Debezium의 EventRouter SMT(Single Message Transform)는 outbox 테이블의 행을 자동으로 Kafka 메시지로 변환한다. aggregate_type 값에 따라 토픽을 동적으로 라우팅하므로 (outbox.Order, outbox.Payment 등) 토픽 관리가 자동화된다.

4.3 Connector 등록

# Kafka Connect REST API로 커넥터 등록
curl -X POST http://kafka-connect:8083/connectors \
  -H 'Content-Type: application/json' \
  -d @outbox-connector.json

# 상태 확인
curl http://kafka-connect:8083/connectors/outbox-connector/status

5. 멱등성 보장 — 중복 이벤트 처리

Outbox 패턴은 at-least-once delivery를 제공한다. 폴러가 Kafka에 발행했지만 status 업데이트 전에 크래시하면 동일 이벤트가 다시 발행된다. Consumer 쪽에서 멱등성을 보장해야 한다.

5.1 Producer 측 멱등성

// KafkaJS idempotent producer
const producer = kafka.producer({
  idempotent: true,           // enable.idempotence=true
  transactionalId: 'outbox-poller-1', // exactly-once semantics (선택)
});

KafkaJS의 idempotent: true는 네트워크 재시도로 인한 브로커 측 중복을 방지한다. transactionalId를 설정하면 Kafka 트랜잭션을 사용해 exactly-once를 달성할 수 있지만, consumer 쪽도 isolation.level=read_committed를 설정해야 한다.

5.2 Consumer 측 dedup

// consumer.service.ts
@Injectable()
export class OrderEventConsumer {
  constructor(
    private readonly processedEventRepo: Repository<ProcessedEvent>,
  ) {}

  async handleOrderCreated(message: KafkaMessage) {
    const eventId = message.headers?.['eventId']?.toString();
    if (!eventId) throw new Error('Missing eventId header');

    // Redis 또는 DB에서 이미 처리한 이벤트인지 확인
    const alreadyProcessed = await this.processedEventRepo.findOne({
      where: { eventId },
    });
    if (alreadyProcessed) {
      this.logger.warn(`Duplicate event skipped: ${eventId}`);
      return; // 멱등성 보장: 재처리 건너뜀
    }

    // 실제 처리 + 처리 기록을 같은 트랜잭션에
    await this.dataSource.transaction(async (manager) => {
      await this.processOrderCreated(manager, message);
      await manager.save(ProcessedEvent, { eventId, processedAt: new Date() });
    });
  }
}

Redis를 dedup 저장소로 쓸 경우: SET eventId NX EX 86400 (1일 TTL)으로 원자적으로 중복 체크 + 마킹이 가능하다. DB보다 빠르지만 Redis 재시작 시 TTL 내 이벤트 재처리 가능성이 있다.

5.3 Consumer의 비즈니스 로직 멱등성

기술적 dedup 외에도 비즈니스 로직 자체가 멱등해야 한다:

  • 재고 차감: UPDATE SET stock = stock - 1 WHERE stock > 0 AND last_order_id != :orderId
  • 알림 발송: 같은 orderId로 이미 발송한 경우 건너뜀
  • 외부 API 호출: 결제 게이트웨이에 idempotency-key: eventId 헤더 전송

6. 운영 팁 — 프로덕션에서 살아남기

6.1 Outbox 테이블 파티셔닝

이벤트가 하루 수십만 건 쌓이면 테이블이 거대해진다. PostgreSQL Range 파티셔닝으로 오래된 데이터를 효율적으로 관리하자:

-- 월별 파티셔닝
CREATE TABLE outbox_events (
  id            UUID NOT NULL,
  -- ... 나머지 컬럼
  created_at    TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

-- 월별 파티션 생성
CREATE TABLE outbox_events_2026_02
  PARTITION OF outbox_events
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE outbox_events_2026_03
  PARTITION OF outbox_events
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- 오래된 파티션 아카이빙 후 DROP (O(1) 삭제)
ALTER TABLE outbox_events DETACH PARTITION outbox_events_2025_12;
-- 아카이브 후: DROP TABLE outbox_events_2025_12;

파티션 테이블의 각 파티션에 로컬 인덱스가 자동으로 생성되므로, PENDING 레코드 조회도 해당 기간 파티션만 스캔하여 빠르다.

6.2 처리된 이벤트 아카이빙 전략

-- 30일 이상 된 PROCESSED 이벤트 아카이브 테이블로 이동
INSERT INTO outbox_events_archive
SELECT * FROM outbox_events
WHERE status = 'PROCESSED'
  AND processed_at < NOW() - INTERVAL '30 days';

DELETE FROM outbox_events
WHERE status = 'PROCESSED'
  AND processed_at < NOW() - INTERVAL '30 days';

이 작업은 야간에 배치로 실행하되, 트랜잭션 크기를 작게 나눠 (LIMIT 1000) 락 경합을 줄인다.

6.3 모니터링 쿼리

-- 처리 대기 중인 이벤트 현황 (대시보드용)
SELECT
  event_type,
  COUNT(*) AS pending_count,
  MIN(created_at) AS oldest_pending,
  EXTRACT(EPOCH FROM (NOW() - MIN(created_at))) AS lag_seconds
FROM outbox_events
WHERE status = 'PENDING'
GROUP BY event_type
ORDER BY lag_seconds DESC;

-- 실패한 이벤트 목록
SELECT id, event_type, aggregate_id, retry_count, error_message, created_at
FROM outbox_events
WHERE status = 'FAILED'
ORDER BY created_at DESC
LIMIT 50;

-- 시간별 처리 처리량 (지난 24시간)
SELECT
  date_trunc('hour', processed_at) AS hour,
  COUNT(*) AS events_processed
FROM outbox_events
WHERE status = 'PROCESSED'
  AND processed_at > NOW() - INTERVAL '24 hours'
GROUP BY 1
ORDER BY 1;

이 쿼리들을 Grafana 대시보드에 연결해 outbox lag(가장 오래된 PENDING 이벤트의 나이)를 실시간 모니터링하라. lag_seconds가 임계치(예: 60초)를 초과하면 알림을 발송한다.

6.4 커넥션 풀 설정

OutboxPollerService는 매 5초마다 DB 트랜잭션을 열고 닫는다. 폴러 인스턴스가 늘어날수록 커넥션 사용량이 증가한다. TypeORM의 커넥션 풀 설정을 인스턴스 수와 폴링 주기를 감안해 적절히 조정해야 한다. 커넥션 풀 산정 원리는 HikariCP 커넥션 풀 심화 가이드에서 자세히 다루고 있다. TypeORM이지만 동일한 리틀의 법칙(Little’s Law)이 적용된다.

// TypeORM DataSource 설정
TypeOrmModule.forRoot({
  type: 'postgres',
  // ...
  extra: {
    max: 20,              // 최대 커넥션 수
    min: 5,               // 최소 유지 커넥션
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 5000,
  },
})

6.5 분산 환경에서의 폴러 인스턴스 관리

Kubernetes에서 여러 Pod가 동일한 OutboxPollerService를 실행할 경우, FOR UPDATE SKIP LOCKED가 중복 처리를 막아주지만 경합이 생길 수 있다. 대안:

  • 리더 선출: Kubernetes Leader Election (coordination.k8s.io/v1 Lease)으로 폴러를 단일 인스턴스로 실행
  • 샤딩: aggregate_id % num_pollers = poller_index로 각 폴러가 담당 파티션만 처리
  • Debezium 사용: 이 문제 자체를 없앤다 (Debezium은 단일 커넥터로 WAL을 읽음)

7. 마무리 — Polling vs CDC, 언제 무엇을 선택할까

지금까지 구현한 내용을 바탕으로 실전 판단 기준을 정리한다.

폴링(Polling)을 선택할 때

  • 팀 규모가 작아 Kafka Connect 클러스터 운영 부담이 큰 경우
  • 이벤트 레이턴시가 수 초 이내면 충분한 경우 (결제 알림보다 주문 통계 집계에 가까운 경우)
  • 초기 MVP, 빠른 구현이 우선인 경우
  • RDS 같은 관리형 DB라 WAL 접근 권한이 없는 경우

CDC(Debezium)를 선택할 때

  • 이벤트 레이턴시가 SLA(예: 1초 이내)에 영향을 주는 경우
  • 처리량이 초당 수천 건 이상으로 폴링 DB 부하가 문제가 되는 경우
  • 이미 Kafka Connect 인프라가 있는 경우
  • Self-hosted PostgreSQL에서 WAL 설정 권한이 있는 경우

핵심 원칙

어떤 방식을 선택하든, Outbox 패턴의 본질은 동일하다: 도메인 저장과 이벤트 기록을 단일 트랜잭션으로 묶는 것. 발행 메커니즘은 언제든 교체 가능하다.

폴링으로 시작하고, 문제가 생기면 CDC로 마이그레이션하라. 이 두 방식은 같은 outbox 테이블 스키마를 공유하므로 전환 비용이 낮다.


직접 구현해보자

이 글의 코드는 NestJS + TypeORM 환경이라면 그대로 붙여넣어 테스트할 수 있다. 먼저 폴링 방식으로 로컬에서 동작을 확인하고, 이벤트 수가 늘어나면 Debezium 전환을 검토하길 권한다.

체크리스트:

  1. outbox_events 테이블 마이그레이션 적용
  2. OrderService에서 dataSource.transaction()으로 도메인 + outbox 함께 저장
  3. OutboxPollerService 등록 및 Kafka 연결 확인
  4. ✅ Consumer 쪽 eventId 헤더 기반 dedup 로직 추가
  5. ✅ 모니터링 쿼리로 outbox lag 대시보드 구성

구현하다 막히는 부분이 있거나, FOR UPDATE SKIP LOCKED 대신 Redis 기반 분산 락을 사용하는 방식이나 Debezium + Schema Registry 연동에 대해 궁금한 점이 있으면 댓글로 남겨주세요. 실제 프로덕션에서 어떤 방식을 선택했고 어떤 트레이드오프를 경험했는지도 함께 이야기 나눠봐요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux