들어가며: 이벤트 발행, 왜 이렇게 어려운가?
마이크로서비스 아키텍처에서 가장 흔히 마주치는 버그 중 하나는 이렇게 생겼다:
“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 전환을 검토하길 권한다.
체크리스트:
- ✅
outbox_events테이블 마이그레이션 적용 - ✅
OrderService에서dataSource.transaction()으로 도메인 + outbox 함께 저장 - ✅
OutboxPollerService등록 및 Kafka 연결 확인 - ✅ Consumer 쪽
eventId헤더 기반 dedup 로직 추가 - ✅ 모니터링 쿼리로 outbox lag 대시보드 구성
구현하다 막히는 부분이 있거나, FOR UPDATE SKIP LOCKED 대신 Redis 기반 분산 락을 사용하는 방식이나 Debezium + Schema Registry 연동에 대해 궁금한 점이 있으면 댓글로 남겨주세요. 실제 프로덕션에서 어떤 방식을 선택했고 어떤 트레이드오프를 경험했는지도 함께 이야기 나눠봐요.