NestJS + SQS + RDS + K8s로 구현하는

들어가며: 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가지입니다:

  1. 원자적 트랜잭션
    비즈니스 엔티티 저장과 OutboxEvent 저장을 반드시 하나의 DB 트랜잭션으로 묶을 것. 이것이 Outbox 패턴의 존재 이유입니다.
  2. SKIP LOCKED로 분산 안전성 확보
    다중 Pod 환경에서도 FOR UPDATE SKIP LOCKED로 이중 발행 없이 안전하게 릴레이합니다.
  3. SQS FIFO vs Standard 명확히 구분
    이벤트 순서가 중요한 도메인은 FIFO, 그렇지 않으면 Standard. MessageDeduplicationId는 outbox event.id를 사용합니다.
  4. Consumer 멱등성은 필수
    SQS는 at-least-once delivery입니다. ProcessedEvent 테이블 + ON CONFLICT DO NOTHING으로 중복 처리를 방지합니다.
  5. K8s PDB로 릴레이 안정성 확보
    PodDisruptionBudget(minAvailable: 1)으로 롤링 업데이트 중에도 릴레이가 중단되지 않게 합니다.
  6. DLQ 알람은 즉시 대응
    DLQ 메시지는 비즈니스 이벤트 유실 가능성을 의미합니다. 1건이라도 쌓이면 즉시 알람을 받고 조사하세요.

더 읽어보기

Outbox 패턴을 더 깊이 이해하고 싶다면 아래 글들을 함께 읽어보세요:

이 가이드가 여러분의 NestJS 마이크로서비스 아키텍처에 실질적인 도움이 되길 바랍니다. Kafka 없이도, SQS + SKIP LOCKED + 멱등성이라는 세 가지 원칙만 지키면 프로덕션 수준의 Outbox 패턴을 완벽하게 구현할 수 있습니다. 질문이나 피드백은 댓글로 남겨주세요! 🚀

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