들어가며: Kafka 없이도 완벽한 Outbox 패턴이 가능하다
마이크로서비스 아키텍처에서 데이터 일관성을 보장하는 가장 신뢰할 수 있는 패턴 중 하나가 Transactional Outbox 패턴입니다. 많은 글에서 Outbox 패턴을 설명할 때 Kafka + Debezium(CDC) 조합을 전제로 합니다. 물론 그 방식도 훌륭하지만 (CDC + Debezium Outbox 패턴 완전 가이드 참고), 모든 팀이 Kafka 클러스터를 운영할 여건이 되는 건 아닙니다.
이 글에서는 NestJS + AWS SQS + RDS(PostgreSQL) + Kubernetes 조합으로, Kafka 없이 Outbox 패턴을 FM(Field Manual)대로 완전 구현하는 방법을 단계별로 설명합니다. AWS 환경에서 SQS는 완전 관리형 메시지 큐로 운영 부담이 거의 없고, RDS의 SKIP LOCKED 기능으로 안전한 분산 릴레이가 가능합니다.
1. 왜 이 조합인가? — Kafka vs SQS 비교
Outbox 패턴의 릴레이 계층으로 Kafka와 SQS 중 무엇을 선택할지는 팀의 규모, 운영 역량, 요구사항에 따라 달라집니다.
Kafka vs SQS 비교표
| 항목 | Apache Kafka | AWS SQS |
|---|---|---|
| 운영 복잡도 | 높음 (브로커 클러스터, ZooKeeper/KRaft) | 낮음 (완전 관리형) |
| 처리량 | 초당 수백만 건 이상 | 표준 큐 최대 초당 수천 건 |
| 메시지 순서 보장 | 파티션 내 순서 보장 | FIFO 큐만 순서 보장 |
| 메시지 보존 | 설정된 기간 동안 재소비 가능 | 소비 후 삭제 (최대 14일 보존) |
| 비용 구조 | EC2 인스턴스 비용 | 요청 건수 기반 과금 |
| DLQ 지원 | 별도 구성 필요 | 네이티브 DLQ 지원 |
| CDC(Debezium) 연동 | 공식 지원 | 비공식 (별도 커넥터) |
| 초기 설정 | 복잡 (스키마 레지스트리 등) | 간단 (콘솔/CLI로 즉시) |
SQS FIFO vs Standard 선택 기준
| 상황 | 권장 큐 타입 | 이유 |
|---|---|---|
| 주문 상태 변경 (순서 중요) | FIFO | OrderCreated → OrderPaid → OrderShipped 순서 보장 |
| 이메일 알림, 로그 전송 | Standard | 순서 무관, 처리량 최대화 |
| 결제 이벤트 | FIFO | 중복 방지 (MessageDeduplicationId) |
| 단순 배치 작업 트리거 | Standard | 비용 최적화, 높은 처리량 |
결론: 도메인 이벤트 순서가 중요하다면 FIFO, 알림·로그처럼 순서 무관이면 Standard를 선택하세요. FIFO는 초당 최대 300 TPS(배치 사용 시 3,000 TPS)이므로 대용량 처리가 필요하면 MessageGroupId를 파티셔닝 키로 활용해 처리량을 분산시킵니다.
2. Outbox 테이블 설계 (RDS PostgreSQL)
Outbox 패턴의 핵심은 비즈니스 트랜잭션과 이벤트 저장을 하나의 DB 트랜잭션으로 묶는 것입니다. PostgreSQL의 JSONB 타입과 부분 인덱스(Partial Index)를 활용해 효율적인 테이블을 설계합니다.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(100) NOT NULL, -- 'Order', 'Payment' 등 집계 루트 타입
aggregate_id VARCHAR(100) NOT NULL, -- 집계 루트 ID
event_type VARCHAR(100) NOT NULL, -- 'OrderCreated', 'PaymentCompleted' 등
payload JSONB NOT NULL, -- 이벤트 페이로드 (스키마 자유)
status VARCHAR(20) DEFAULT 'PENDING',
retry_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
CONSTRAINT chk_status CHECK (status IN ('PENDING','PROCESSING','SENT','FAILED'))
);
-- PENDING 상태 이벤트만 인덱싱하는 부분 인덱스 (쿼리 성능 극대화)
CREATE INDEX idx_outbox_pending
ON outbox_events (status, created_at)
WHERE status = 'PENDING';
부분 인덱스(Partial Index)를 사용하면 SENT/FAILED 레코드는 인덱스에서 제외되어 인덱스 크기를 최소화하고 릴레이 쿼리 성능을 극대화합니다. PostgreSQL 인덱스 설계에 대한 자세한 내용은 PostgreSQL 인덱스 완전 정복 가이드를 참고하세요.
상태 전이 흐름
PENDING ──→ PROCESSING ──→ SENT
│
└──→ FAILED (retry_count 초과 시)
- PENDING: 트랜잭션과 함께 저장된 초기 상태
- PROCESSING: 릴레이가 SKIP LOCKED로 선점한 상태
- SENT: SQS 전송 성공 후 최종 상태
- FAILED: 재시도 횟수 초과 시 DLQ로 이동
3. OrderService — Atomic 저장 (NestJS + TypeORM)
주문 생성과 Outbox 이벤트 저장을 하나의 트랜잭션으로 묶어야 “주문은 생성됐는데 이벤트 누락” 현상을 방지할 수 있습니다.
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Order } from './order.entity';
import { OutboxEvent } from '../outbox/outbox-event.entity';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async placeOrder(dto: CreateOrderDto): Promise<Order> {
// dataSource.transaction()으로 원자적 저장 보장
return this.dataSource.transaction(async (em) => {
// 1. 주문 저장
const order = em.create(Order, dto);
await em.save(order);
// 2. 동일 트랜잭션 내에서 Outbox 이벤트 저장
await em.save(OutboxEvent, {
aggregateType: 'Order',
aggregateId: order.id,
eventType: 'OrderCreated',
payload: {
orderId: order.id,
userId: dto.userId,
amount: dto.amount,
},
status: 'PENDING',
});
return order;
});
}
}
핵심 포인트:
dataSource.transaction()콜백 내부에서 Order와 OutboxEvent를 모두 저장합니다. DB 트랜잭션이 롤백되면 OutboxEvent도 함께 롤백됩니다. 이것이 Outbox 패턴의 원자성 보장의 핵심입니다.
OutboxEvent Entity
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('outbox_events')
export class OutboxEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'aggregate_type' })
aggregateType: string;
@Column({ name: 'aggregate_id' })
aggregateId: string;
@Column({ name: 'event_type' })
eventType: string;
@Column({ type: 'jsonb' })
payload: Record<string, any>;
@Column({ default: 'PENDING' })
status: string;
@Column({ name: 'retry_count', default: 0 })
retryCount: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@Column({ name: 'processed_at', nullable: true })
processedAt: Date;
}
4. OutboxRelayService — SKIP LOCKED + SQS SendMessageBatch
릴레이 서비스는 2초마다 실행되며, SKIP LOCKED를 통해 다른 릴레이 인스턴스와 충돌 없이 PENDING 이벤트를 선점하고 SQS로 배치 전송합니다.
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { DataSource, In } from 'typeorm';
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
import { OutboxEvent } from './outbox-event.entity';
@Injectable()
export class OutboxRelayService {
private readonly sqsClient = new SQSClient({ region: process.env.AWS_REGION });
constructor(private readonly dataSource: DataSource) {}
@Cron('*/2 * * * * *') // 2초마다 실행
async relay() {
await this.dataSource.transaction(async (em) => {
// SKIP LOCKED: 다른 릴레이가 이미 선점한 행은 건너뜀
const events = await em
.createQueryBuilder(OutboxEvent, 'e')
.where('e.status = :s', { s: 'PENDING' })
.orderBy('e.createdAt', 'ASC')
.limit(20) // SQS BatchSize 최대 10이므로 2배 여유
.setLock('pessimistic_write_or_fail') // FOR UPDATE SKIP LOCKED
.getMany();
if (!events.length) return;
// 처리 중 상태로 즉시 업데이트 (다른 릴레이 인스턴스 방지)
await em.update(
OutboxEvent,
events.map((e) => e.id),
{ status: 'PROCESSING' }
);
// SQS 배치 전송 (최대 10건씩)
const result = await this.sqsClient.send(
new SendMessageBatchCommand({
QueueUrl: process.env.SQS_QUEUE_URL,
Entries: events.map((e) => ({
Id: e.id,
MessageBody: JSON.stringify(e.payload),
MessageDeduplicationId: e.id, // FIFO 큐용 중복 제거
MessageGroupId: e.aggregateType, // FIFO 큐용 순서 보장
MessageAttributes: {
EventType: { DataType: 'String', StringValue: e.eventType },
EventId: { DataType: 'String', StringValue: e.id },
},
})),
})
);
const sentIds = result.Successful?.map((s) => s.Id) ?? [];
const failedIds = result.Failed?.map((f) => f.Id) ?? [];
// 전송 성공 → SENT 상태로 업데이트
if (sentIds.length) {
await em.update(OutboxEvent, sentIds, {
status: 'SENT',
processedAt: new Date(),
});
}
// 전송 실패 → retry_count 증가
if (failedIds.length) {
await em.increment(
OutboxEvent,
{ id: In(failedIds) },
'retryCount',
1
);
// retry_count >= 5이면 FAILED로 마킹
await em
.createQueryBuilder()
.update(OutboxEvent)
.set({ status: 'FAILED' })
.where('id IN (:...ids) AND retry_count >= 5', { ids: failedIds })
.execute();
}
});
}
}
SKIP LOCKED 동작 원리
Kubernetes에서 outbox-relay Pod가 여러 개 실행되더라도, FOR UPDATE SKIP LOCKED 덕분에 같은 이벤트를 두 Pod가 동시에 처리하는 이중 발행 현상을 방지합니다:
- Pod A가 이벤트 행을 SELECT FOR UPDATE로 잠금 획득
- Pod B는 잠긴 행을 SKIP(건너뜀)하고 다음 PENDING 행 처리
- 데드락 없이 안전한 분산 처리 실현
5. K8s 배포 구조
마이크로서비스를 Kubernetes에 배포할 때 핵심은 order-api는 수평 확장 가능하게, outbox-relay는 단일 인스턴스로 운영하는 것입니다 (SKIP LOCKED로 다중 인스턴스도 안전하지만, 불필요한 경쟁을 줄이기 위해).
order-api Deployment (replicas: 3)
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
containers:
- name: order-api
image: myregistry/order-api:latest
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
- name: AWS_REGION
value: ap-northeast-2
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
outbox-relay Deployment (replicas: 1 + PodDisruptionBudget)
apiVersion: apps/v1
kind: Deployment
metadata:
name: outbox-relay
namespace: production
spec:
replicas: 1 # SKIP LOCKED로 다중 인스턴스 안전하지만, 단일 권장
selector:
matchLabels:
app: outbox-relay
template:
metadata:
labels:
app: outbox-relay
spec:
containers:
- name: outbox-relay
image: myregistry/outbox-relay:latest
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
- name: SQS_QUEUE_URL
valueFrom:
configMapKeyRef:
name: sqs-config
key: queue-url
---
# 롤링 업데이트 중 최소 1개 Pod 유지 보장
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: outbox-relay-pdb
namespace: production
spec:
minAvailable: 1
selector:
matchLabels:
app: outbox-relay
outbox-reaper CronJob (FAILED 이벤트 정리)
apiVersion: batch/v1
kind: CronJob
metadata:
name: outbox-reaper
namespace: production
spec:
schedule: "0 2 * * *" # 매일 새벽 2시 실행
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: reaper
image: myregistry/db-utils:latest
command:
- psql
- $(DATABASE_URL)
- -c
- |
DELETE FROM outbox_events
WHERE status = 'SENT'
AND processed_at < NOW() - INTERVAL '7 days';
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
6. SQS 설정 FM 체크리스트
| 설정 항목 | FIFO 큐 | Standard 큐 | 권장값 / 설명 |
|---|---|---|---|
| MessageDeduplicationId | ✅ 필수 | ❌ 불필요 | outbox_event.id 사용 (UUID로 중복 방지) |
| MessageGroupId | ✅ 필수 | ❌ 불필요 | aggregate_type 사용 (Order, Payment 등) |
| Visibility Timeout | ✅ 설정 필수 | ✅ 설정 필수 | Consumer 처리시간의 6배 이상 (최소 30초) |
| Message Retention | 4일~14일 | 기본 4일, DLQ는 14일 권장 | |
| DLQ 연결 | ✅ 필수 | ✅ 필수 | maxReceiveCount: 3~5회 후 DLQ 이동 |
| Long Polling | ReceiveMessageWaitTimeSeconds: 20 | 빈 응답 줄이고 비용 절감 | |
| 배치 크기 | 최대 10건 | SendMessageBatch / ReceiveMessage 모두 최대 10 | |
⚠️ Visibility Timeout 설정 실수 주의: Consumer 처리 시간보다 짧으면 메시지가 다시 보임(재처리). 처리에 10초 걸린다면 Visibility Timeout은 최소 60초 이상으로 설정하세요.
7. Consumer 멱등성 — ProcessedEvent 테이블
SQS는 at-least-once delivery를 보장합니다. 즉, 같은 메시지가 두 번 이상 올 수 있습니다. Consumer 측에서 ProcessedEvent 테이블 + ON CONFLICT DO NOTHING으로 멱등성을 구현합니다.
-- Consumer 서비스 DB에 생성
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- Consumer 처리 로직 (SQL)
-- 이미 처리한 event_id면 INSERT 무시 → 멱등성 보장
INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
NestJS Consumer에서의 활용 예:
@SqsMessageHandler(process.env.SQS_QUEUE_NAME)
async handleMessage(message: Message) {
const eventId = message.MessageAttributes?.EventId?.StringValue;
const payload = JSON.parse(message.Body);
await this.dataSource.transaction(async (em) => {
// 멱등성 체크: 이미 처리된 이벤트면 RETURNING이 비어있음
const result = await em.query(
`INSERT INTO processed_events (event_id)
VALUES ($1) ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[eventId]
);
if (!result.length) {
// 중복 메시지: 이미 처리됨 → 조용히 ACK
return;
}
// 실제 비즈니스 로직 처리
await this.orderReadModel.apply(payload);
});
}
8. 운영 팁
8-1. Outbox 테이블 파티셔닝
트래픽이 증가하면 outbox_events 테이블이 빠르게 커집니다. PostgreSQL의 범위 파티셔닝(Range Partitioning)으로 관리합니다:
-- 파티셔닝 테이블로 재설계
CREATE TABLE outbox_events (
id UUID NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING',
retry_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
) 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 TABLE로 즉시 삭제 (VACUUM 불필요)
DROP TABLE outbox_events_2026_01;
8-2. CloudWatch DLQ 알람 설정
DLQ에 메시지가 쌓이면 즉시 알림을 받아야 합니다. AWS CDK / CloudFormation으로 알람을 구성하는 예:
// AWS CDK 예시
const dlqAlarm = new cloudwatch.Alarm(this, 'DlqAlarm', {
metric: dlq.metricApproximateNumberOfMessagesVisible({
period: Duration.minutes(5),
statistic: 'Maximum',
}),
threshold: 1, // DLQ에 1건이라도 쌓이면 알람
evaluationPeriods: 1,
alarmDescription: 'Outbox DLQ has messages - immediate investigation required',
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
dlqAlarm.addAlarmAction(new SnsAction(alertTopic));
8-3. 운영 모니터링 체크리스트
- ✅ PENDING 이벤트 수: 특정 임계값(예: 100건) 초과 시 알람
- ✅ FAILED 이벤트 수: 0이 정상, 발생 즉시 알람
- ✅ Relay 처리 지연: created_at과 processed_at 차이 모니터링
- ✅ SQS DLQ 메시지 수: 1건 이상 시 즉시 알람
- ✅ DB 커넥션 풀: SKIP LOCKED 사용 시 트랜잭션 커넥션 점유 시간 모니터링
9. 마무리: 핵심 6가지 요약
NestJS + SQS + RDS + K8s 조합으로 Outbox 패턴을 구현할 때 반드시 기억해야 할 6가지입니다:
-
원자적 트랜잭션
비즈니스 엔티티 저장과 OutboxEvent 저장을 반드시 하나의 DB 트랜잭션으로 묶을 것. 이것이 Outbox 패턴의 존재 이유입니다. -
SKIP LOCKED로 분산 안전성 확보
다중 Pod 환경에서도FOR UPDATE SKIP LOCKED로 이중 발행 없이 안전하게 릴레이합니다. -
SQS FIFO vs Standard 명확히 구분
이벤트 순서가 중요한 도메인은 FIFO, 그렇지 않으면 Standard. MessageDeduplicationId는 outbox event.id를 사용합니다. -
Consumer 멱등성은 필수
SQS는 at-least-once delivery입니다. ProcessedEvent 테이블 + ON CONFLICT DO NOTHING으로 중복 처리를 방지합니다. -
K8s PDB로 릴레이 안정성 확보
PodDisruptionBudget(minAvailable: 1)으로 롤링 업데이트 중에도 릴레이가 중단되지 않게 합니다. -
DLQ 알람은 즉시 대응
DLQ 메시지는 비즈니스 이벤트 유실 가능성을 의미합니다. 1건이라도 쌓이면 즉시 알람을 받고 조사하세요.
더 읽어보기
Outbox 패턴을 더 깊이 이해하고 싶다면 아래 글들을 함께 읽어보세요:
- 📖 CDC + Debezium으로 구현하는 Outbox 패턴 — Kafka를 사용하는 경우, Debezium WAL 기반 CDC 방식의 완전 구현 가이드
- 📖 PostgreSQL 인덱스 완전 정복 — 부분 인덱스, BRIN, GIN 등 Outbox 테이블 성능 최적화에 활용되는 인덱스 전략
이 가이드가 여러분의 NestJS 마이크로서비스 아키텍처에 실질적인 도움이 되길 바랍니다. Kafka 없이도, SQS + SKIP LOCKED + 멱등성이라는 세 가지 원칙만 지키면 프로덕션 수준의 Outbox 패턴을 완벽하게 구현할 수 있습니다. 질문이나 피드백은 댓글로 남겨주세요! 🚀