Outbox Pattern과 분산 시스템

Outbox Pattern 실전 - 분산 시스템에서 "정확히 한 번"이 왜 이렇게 어려운가

이 글은 분산 시스템에서 데이터 정합성이 깨지는 근본적인 이유에서 출발하여, Outbox Pattern이 왜 탄생했는지, 그리고 실제 프로덕션에서 이 패턴을 구현하며 경험한 것들을 다루도록 합니다.

단일 서버에서는 문제가 없었다

모놀리스 시절, 트랜잭션은 단순했습니다.

주문이 생성되면 재고를 차감하고, 결제를 처리하고, 알림을 보낸다.

이 모든 것이 하나의 데이터베이스, 하나의 트랜잭션 안에서 일어났습니다. 하나라도 실패하면 전부 롤백. 이처럼 ACID가 보장하는 세계에서, 데이터 정합성은 DB가 알아서 해결해 주는 꽤나 간단한 문제로 구현 레벨에서는 크게 고민하지 않아도 됐을지도 모릅니다.

BEGIN TRANSACTION
  INSERT INTO orders (...)
  UPDATE inventory SET stock = stock - 1
  INSERT INTO notifications (...)
COMMIT

이 코드가 실패할 수 있는 경우는 명확하고, 실패하면 깔끔하게 롤백됩니다. 개발자가 정합성에 대해 깊이 고민할 필요가 없을지도 모릅니다.

문제는 서비스가 분리되면서 시작됩니다.

두 시스템을 동시에 바꿀 수 없다

요즘 널리 사용되고 있는 시스템 디자인 중, 마이크로서비스 아키텍처에서 "주문 생성 후 알림 전송"이라는 요구사항을 구현한다고 가정해 보겠습니다. 주문 서비스와 알림 서비스는 별도의 프로세스이고, 각자의 데이터베이스를 가지고 있습니다.

가장 직관적인 구현은 아래와 같습니다.

async createOrder(command: CreateOrderCommand): Promise<void> {
  // 1. 주문을 DB에 저장
  await this.orderRepository.save(command);

  // 2. 알림 서비스에 메시지 전송
  await this.messageQueue.send({ type: "ORDER_CREATED", orderId: order.id });
}

이 코드는 두 가지 시스템(DB와 메시지 큐)에 WRITE를 수행합니다. 문제는, 이 두 WRITE를 하나의 원자적인 연산으로 묶을 수 없다는 것입니다. 이유는 다음과 같이 실패 시나리오를 보며 알아보도록 하겠습니다.

시나리오는 다음과 같습니다.

시나리오 1: DB 성공, 메시지 전송 실패

1. 주문 DB 저장 ✅
2. 메시지 큐 전송 ❌ (네트워크 오류)
→ 주문은 생성되었는데, 알림은 영원히 발송되지 않음

시나리오 2: DB 성공, 메시지 전송 성공, 응답 수신 실패

1. 주문 DB 저장 ✅
2. 메시지 큐 전송 ✅
3. 메시지 큐 응답 수신 ❌ (타임아웃)
→ 메시지가 전송되었는지 알 수 없음. 재시도하면 중복 전송

시나리오 3: 순서를 바꿔볼까?

1. 메시지 큐 전송 ✅
2. 주문 DB 저장 ❌
→ 주문은 없는데 알림은 발송됨

순서를 어떻게 바꿔도 문제가 발생합니다. 이것은 코드의 버그가 아닙니다. 분산 시스템의 근본적인 제약일지도 모릅니다. 또한 코드적으로 해결하기 어려울지도 모릅니다.

이 문제를 정의한 이름은 무엇일까요?

두 개의 서로 다른 시스템에 원자적으로 쓰기를 수행할 수 없다는 것은 1970년대부터 알려진 문제이지만, 마이크로서비스 시대에 와서 모든 팀이 일상적으로 마주하는 문제가 되었습니다. 그리고 이 문제를 해결하기 위해선 해당 문제를 겪고 있는 팀원들이 꽤나 괴로울지도 모릅니다.

사람들이 시도한 해결책들

2PC (Two-Phase Commit)

분산 트랜잭션의 고전적 해법은 2PC입니다. 코디네이터가 모든 참여자에게 "준비됐나?"라고 물어보고, 모두가 "예"라고 하면 "커밋하라"고 명령하는 프로토콜 입니다.

Phase 1: Prepare
  Coordinator → DB: "준비됐나?"DB: "예"
  Coordinator → MQ: "준비됐나?"MQ: "예"

Phase 2: Commit
  Coordinator → DB: "커밋하라"DB: ✅
  Coordinator → MQ: "커밋하라"MQ:

이론적으로는 완벽한 듯 합니다. 하지만 실무에서 2PC는 몇 가지 치명적인 문제를 안고 있었습니다.

첫째, 코디네이터가 단일 장애점(SPOF)입니다. Phase 1과 Phase 2 사이에서 코디네이터가 다운되면, 해당 페이즈의 참여자들은 커밋해야 하는지 혹은 롤백해야 하는지 알 수 없는 상태에 놓입니다.

둘째, 모든 참여자가 2PC를 지원해야 합니다. MongoDB는 분산 트랜잭션을 지원하지만, Kafka나 RabbitMQ와 묶어서 2PC를 돌리는 것은 현실적인 경우 대부분 불가능 합니다. 이기종 시스템 간 2PC는 대부분의 경우 불가능합니다.

셋째, 성능. 2PC 동안 모든 참여자가 락을 잡고 대기합니다. 트랜잭션이 길어지면 전체 시스템의 처리량이 급격히 떨어집니다. 이는 곧 서비스 전체에 마비를 줄 수도 있겠습니다.

Google의 Spanner 같은 시스템은 2PC를 대규모로 운영하지만, 이는 TrueTime이라는 원자시계 기반의 전용 인프라 위에서만 가능한 이야기입니다. 일반적인 마이크로서비스 환경에서 2PC는 사실상 가능한 선택지가 아닙니다.

Saga Pattern

2PC의 대안으로 등장한 것이 Saga 패턴입니다. 1987년, Hector Garcia-Molina와 Kenneth Salem이 제안한 이 패턴은 하나의 긴 트랜잭션을 여러 개의 로컬 트랜잭션으로 분리하고, 실패 시 보상 트랜잭션(compensating transaction)을 실행하여 일관성을 맞추는 방식입니다.

주문 생성 → 재고 차감 → 결제 처리 → 알림 발송
                         ❌ 실패!
            ← 재고 복원 ← 주문 취소 (보상 트랜잭션)

Saga는 2PC의 성능 문제를 해결하지만, 새로운 복잡도를 만들어 냅니다. 보상 트랜잭션을 모든 단계에 대해 설계해야 하고, 중간 상태(예: 주문은 생성됐지만 결제는 아직인 상태)가 외부에 노출될 수 있습니다. 또한 상태머신에 대한 훌륭한 설계가 뒷받침 되어야 합니다. 해결되지 못한 상태는 없어야 하며, 복구하지 못할 상태 또한 존재하면 안됩니다.

무엇보다, Saga에서도 각 단계의 로컬 트랜잭션과 메시지 전송 사이의 원자성 문제는 여전히 남아 있습니다. "DB에 쓰고 다음 단계에 메시지를 보낸다"는 행위 자체가 Dual Write이기 때문입니다.

결국 문제는 같은 곳으로 수렴합니다.

  • DB 쓰기와 메시지 발행을 어떻게 원자적으로 만들 것인가?

Outbox Pattern의 탄생

Outbox Pattern은 이 문제에 대해 아주 쉬운 해결책을 제시합니다.

  • 메시지를 외부로 직접 보내지 말고, DB에 저장하라.
BEGIN TRANSACTION
  INSERT INTO orders (...)           -- 비즈니스 데이터
  INSERT INTO outbox (...)           -- 발행할 메시지
COMMIT

하나의 DB, 하나의 트랜잭션. 비즈니스 데이터와 발행할 메시지를 같은 트랜잭션에 묶어 저장합니다. 두 시스템에 쓰는 대신 하나의 시스템에만 쓰기 때문에, Dual Write 문제 자체가 사라집니다.

그러면 Outbox 테이블에 쌓인 메시지는 누가 보내는가? 별도의 프로세스가 outbox 테이블을 주기적으로 폴링(polling)하거나, DB의 변경 로그(CDC)를 감시하여 메시지를 외부 시스템으로 전달합니다.

[서비스]
  └─ @Transactional
       ├─ 도메인 데이터 저장
       └─ outbox 이벤트 저장 (같은 트랜잭션)

[별도 프로세스 (Polling)]
  └─ outbox에서 PENDING 이벤트 조회
       ├─ 외부 시스템에 전달
       └─ 상태를 SENT로 변경

이 패턴의 이름은 이메일의 "보낼 편지함(Outbox)"에서 유래 됐습니다. 편지를 쓰면 바로 우체국에 가는 게 아니라, 보낼 편지함에 넣어 두고 우체부가 수거해 가는 것. Outbox Pattern은 이와 완벽하게 같은 구조를 취하고 있습니다.

Outbox Pattern이 공식적으로 명명된 시점은 명확하지 않지만, Chris Richardson의 "Microservices Patterns"(2018)과 Debezium 프로젝트의 CDC 기반 Outbox 커넥터가 이 패턴을 널리 알리는 데 크게 기여했습니다.

HoBom에서의 Outbox 구현

HoBom 시스템은 세 종류의 이벤트를 Outbox Pattern으로 처리합니다.

전체 흐름을 호봄 시스템에 내재돼 있는 개인정보 보호법 변경 감지 스크래핑 서비스를 예시로 설명하겠습니다.

[Cron Job: 법령 스크래핑]
  └─ @Transactional()
       ├─ 법령 버전 저장
       ├─ 변경 diff 저장
       └─ outbox 이벤트 저장 (LAW_CHANGED)

[hobom-event-processor (Go)]
  └─ gRPC polling: PENDING 이벤트 조회
       └─ hobom-llm-service-backend에 학습자료 생성 요청
            └─ 완료 후 gRPC: outbox 상태를 SENT로 변경

[Scheduler: 매일 새벽 3시]
  └─ 30일 지난 outbox 이벤트 정리

Outbox 스키마

@Schema({ collection: "outbox" })
export class OutboxEntity extends BaseEntity {
  @Prop({ type: String, required: true, unique: true, default: () => randomUUID() })
  eventId: string;

  @Prop({ type: String, enum: EventType, required: true })
  eventType: EventType;

  @Prop({ type: Mixed, required: true })
  payload: Record<string, unknown>;

  @Prop({ type: String, enum: OutboxStatus, default: OutboxStatus.PENDING })
  status: OutboxStatus;

  @Prop({ type: Number, required: true, default: 0 })
  retryCount: number;

  @Prop({ type: Date, default: null })
  sentAt: Date | null;

  @Prop({ type: Date, default: null })
  failedAt: Date | null;

  @Prop({ type: String, default: null })
  lastError: string | null;

  @Prop({ type: Number, required: true, default: 1 })
  version: number;
}

Outbox 엔티티 설계에 대한 이야기를 해보도록 하겠습니다.

eventId는 UUID입니다. MongoDB의 _id와 별개로, 이벤트 자체의 식별자를 UUID로 관리합니다. 외부 시스템이 이벤트를 참조할 때 MongoDB의 ObjectId를 노출하지 않기 위함이고, 멱등성(idempotency) 보장에도 활용됩니다. 소비자가 같은 eventId를 두 번 받으면 중복 처리를 건너뛸 수 있습니다.

version 필드는 낙관적 락(Optimistic Locking)을 위한 필드입니다. 상태를 변경할 때 $inc: { version: 1 }로 버전을 올립니다. 두 프로세스가 동시에 같은 이벤트를 처리하려 할 때, 먼저 처리한 쪽이 버전을 올리면 나중에 온 쪽의 업데이트는 실패합니다.

retryCount와 lastError는 운영을 위한 필드입니다. 재시도 횟수와 마지막 에러 메시지를 기록해 두면, 장애 분석 시 "이 이벤트가 왜 3번이나 실패했는지"를 별도의 로깅 시스템 없이도 추적할 수 있습니다.

핵심: @Transactional과 AsyncLocalStorage

Outbox Pattern의 핵심은 "비즈니스 데이터와 Outbox 이벤트를 같은 트랜잭션에 넣는 것"입니다. HoBom에서는 @Transactional() 데코레이터가 이 역할을 합니다.

@Transactional()
public async invoke(): Promise<void> {
  // 1. 법령 데이터 저장
  await this.lawVersionPersistencePort.save({ ... });

  // 2. 변경 diff 저장
  await this.lawDiffPersistencePort.save({ ... });

  // 3. outbox 이벤트 저장 — 위 1, 2와 같은 트랜잭션
  await this.outboxPersistencePort.save(
    CreateOutboxEntity.of(EventType.LAW_CHANGED, { diffId, changes }, OutboxStatus.PENDING, 0, 1),
  );
}

1, 2, 3 중 하나라도 실패하면 전부 롤백됩니다. 법령 데이터는 저장됐는데 Outbox 이벤트가 누락되는 상황은 발생하지 않습니다.

내부 구현을 보도록 하겠습니다. 이 구현의 핵심은, Node.js의 AsyncLocalStorage 입니다.

// transaction.context.ts
import { AsyncLocalStorage } from "async_hooks";
import { ClientSession } from "mongoose";

const sessionStorage = new AsyncLocalStorage<ClientSession>();

export const MongoSessionContext = {
  runWithSession: <T>(session: ClientSession, fn: () => Promise<T>) =>
    sessionStorage.run(session, fn),
  getSession: (): ClientSession | undefined =>
    sessionStorage.getStore(),
} as const;
// transaction.runner.ts
async run<T>(fn: () => Promise<T>): Promise<T> {
  const session = await this.conn.startSession();
  try {
    return await session.withTransaction(() =>
      MongoSessionContext.runWithSession(session, fn),
    );
  } finally {
    await session.endSession();
  }
}

AsyncLocalStorage는 Java의 ThreadLocal과 유사한 개념이지만, Node.js의 비동기 모델에 맞게 설계된 것입니다. 하나의 비동기 실행 체인 안에서 세션 객체를 공유하되, 다른 요청과는 격리됩니다.

이 덕분에 outboxRepository.save()는 매개변수로 세션을 받지 않아도, 현재 트랜잭션의 세션을 자동으로 가져올 수 있습니다.

// outbox.repository.impl.ts
public async save(entity: CreateOutboxEntity): Promise<void> {
  // 현재 트랜잭션 세션
  const session = MongoSessionContext.getSession();

  await this.outboxModel.create(
    [{ eventType: entity.getEventType, payload: entity.getPayload, ... }],
    { session },
  );
}

Method 시그니처에는 session Parameter가 없습니다. 호출하는 쪽에서 세션을 전달할 필요가 없으니, 비즈니스 로직이 인프라 관심사로부터 깔끔하게 분리될 수 있습니다. 이는 Spring의 @Transactional이 ThreadLocal로 커넥션을 전파하는 것과 본질적으로 같은 메커니즘입니다.

이벤트 타입별 Payload Factory

Outbox의 payload 필드는 Record<string, unknown> 타입으로, 이벤트마다 다른 형태의 데이터를 담을 수 있습니다. 하지만 유연함이 증가할 수록 리스크 또한 비례하여 증가할 수도 있겠습니다. 데이터에 대한 자유도가 높을 수록 잘못된 데이터가 들어왔다 한들 이를 검증할 방법이 없다는 것을 의미하기도 합니다.

HoBom System에서는 이를 방지하기 위해 이벤트 타입별 페이로드 팩토리를 두었습니다.

interface EventInputMap {
  [EventType.MESSAGE]: MessagePayload;
  [EventType.HOBOM_LOG]: HoBomLogPayloadType;
  [EventType.LAW_CHANGED]: LawChangedPayload;
}

export const OutboxPayloadFactoryRegistry: {
  [K in EventType]: (input: EventInputMap[K]) => Record<string, unknown>;
} = {
  [EventType.MESSAGE]: createMessagePayload,
  [EventType.HOBOM_LOG]: createHoBomLogPayload,
  [EventType.LAW_CHANGED]: createLawChangedPayload,
};

EventType.MESSAGE로 이벤트를 만들면서 LawChangedPayload 형태의 데이터를 넣으려 하면, TypeScript 컴파일러가 에러를 발생시킵니다. 런타임이 아니라 빌드 타임에 잘못된 페이로드를 잡아내는 것입니다.

// 컴파일 에러 -> MESSAGE 이벤트에 LAW_CHANGED 페이로드를 넣을 수 없음
const payload = OutboxPayloadFactoryRegistry.MESSAGE({
  diffId: "...",    // ❌ MessagePayload에는 diffId가 없음
  changes: [],
});

// 정상 -> MESSAGE 이벤트에 맞는 페이로드
const payload = OutboxPayloadFactoryRegistry.MESSAGE({
  id: todayMenuId,
  title: "오늘의 추천메뉴가 도착했어요.",
  body: `오늘의 추천 메뉴는 ${pickedName}!`,
  recipient: userInformation.getEmail,
  senderId: userInformation.getId.toString(),
  type: MessageEnum.MAIL_MESSAGE,
});

Polling - Go 서비스가 이벤트를 수거한다

Outbox에 쌓인 이벤트는 hobom-event-processor(Go)가 Polling을 하며 데이터를 수집해 갑니다. 이 서비스는 gRPC로 각 서버에 PENDING 상태의 이벤트를 요청하고, 처리 후 SENT로 상태를 변경합니다.

백엔드 쪽의 gRPC 컨트롤러는 다음과 같습니다.

@Controller()
@UseGuards(GrpcApiKeyGuard)
export class FindLawOutboxController {
  @GrpcMethod("FindHoBomLawOutboxController", "FindOutboxByEventTypeAndStatusUseCase")
  public async findBy(request: {
    eventType: string;
    status: string;
  }): Promise<{ items: FindLawOutboxResultDto[] }> {
    const outbox = await this.useCase.invoke(
      request.eventType as EventType,
      request.status as OutboxStatus,
    );
    return { items: outbox.map(FindLawOutboxResultDto.from) };
  }
}

처리 완료 후 상태 변경도 gRPC로 이루어집니다.

@GrpcMethod("PatchOutboxController", "PatchOutboxMarkAsSentUseCase")
public async markAsSent(request: { eventId: string }): Promise<void> {
  const eventId = EventId.fromString(request.eventId);
  await this.patchOutboxMarkAsSentUseCase.invoke(eventId);
}

이 proto 계약은 hobom-buf-proto 레포지토리에서 별도로 관리됩니다.

service FindHoBomLawOutboxController {
    rpc FindOutboxByEventTypeAndStatusUseCase (Request) returns (Response);
}

message Request {
    string eventType = 1;
    string status = 2;
}

message Response {
    repeated QueryResult items = 1;
}

왜 Kafka 같은 메시지 브로커가 아니라 gRPC Polling을 선택했는지에 대해 설명해 보도록 하겠습니다.

Outbox 이벤트를 외부로 전달하는 방식을 결정할 때, 가장 먼저 떠오르는 선택지는 Kafka입니다. 이벤트 스트리밍의 사실상 표준이고, Debezium과 조합하면 CDC 기반의 실시간 전달까지 가능합니다. 하지만 기술 선택은 "무엇이 가장 좋은가"가 아니라 "무엇이 이 맥락에서 가장 합리적인가"의 문제라고 생각합니다.

사실 HoBom에서 Kafka를 안 쓰는 것은 아닙니다. hobom-event-processor(Go)가 이벤트를 처리한 후 Kafka로 produce하고 있습니다. 문제는 Kafka를 쓰느냐 마느냐가 아니라, Kafka를 어디에 두느냐였습니다.

가장 직관적인 구조는 NestJS가 직접 Kafka에 produce하는 것입니다.

[NestJS] → Kafka Producer → [Kafka Cluster] → Kafka Consumer → [Go]

하지만 이 구조에는 근본적인 문제가 있습니다. 이 글의 앞부분에서 다룬 Dual Write 문제가 그대로 남는다는 것입니다. NestJS가 MongoDB에 비즈니스 데이터를 저장하고, 같은 흐름에서 Kafka에 produce해야 합니다. @Transactional()로 MongoDB와 Kafka를 하나의 원자적 연산으로 묶을 수 없습니다. DB 저장은 성공했는데 Kafka produce가 실패하면? Outbox Pattern이 해결하려던 바로 그 문제가 다시 돌아옵니다.

현재 HoBom 시스템의 구조는 이 문제를 다음과 같이 해결합니다.

[NestJS] → MongoDB (outbox 컬렉션) → gRPC ← [Go] → Kafka (produce)

NestJS는 본인 데이터 소스에 붙어있는 DB에만 씁니다. Kafka 클라이언트도, 토픽 이름도, 직렬화 포맷도 모릅니다. 비즈니스 데이터와 outbox 이벤트가 같은 MongoDB 트랜잭션 안에서 원자적으로 저장되고, Dual Write 문제가 발생할 여지 자체가 없습니다. Kafka에 대한 모든 책임은 Go 서비스에 캡슐화되어 있습니다.

두 서비스가 공유하는 인프라는 이미 존재하는 MongoDB 하나뿐입니다. NestJS가 직접 Kafka에 produce하는 구조였다면, 두 서비스 모두 Kafka라는 공유 인프라에 결합됩니다. 토픽 네이밍, 파티션 전략, 직렬화 포맷, 컨슈머 그룹 설정을 양쪽이 합의해야 합니다. 디커플링을 위해 도입한 Kafka가 오히려 새로운 커플링을 만드는 셈입니다.

hobom-event-processor(Go)가 하는 일을 정리하면 이렇습니다.

  1. PENDING 이벤트를 gRPC로 pull한다
  2. 이벤트를 처리한다
  3. Kafka로 produce한다
  4. 처리 완료 후 SENT로 마킹한다
  5. 실패 시 retryCount를 증가시킨다

이벤트의 수거, 라우팅, 재시도, 상태 관리, 그리고 Kafka produce까지, 이벤트 파이프라인의 모든 책임이 Go 서비스 한 곳에 응집되어 있습니다. NestJS는 이벤트를 만들고, Go는 이벤트를 운반합니다. 역할이 명확하게 나뉘어 있습니다.

운영 비용은 선형이 아니라 계단식이라고 생각합니다. Kafka 클러스터를 운영한다는 것은 단순히 "서버 하나 더 띄운다" 정도가 아니라고 생각합니다.

  • ZooKeeper 또는 KRaft 클러스터 구성 및 관리
  • 브로커 장애 시 파티션 리밸런싱과 ISR(In-Sync Replicas) 모니터링
  • 디스크 관리 → 토픽 retention 정책, 세그먼트 로테이션
  • 컨슈머 랙 모니터링 → 처리 지연이 쌓이고 있는지 추적
  • 스키마 레지스트리 → 이벤트 포맷이 바뀔 때 호환성 관리

소규모 팀이 감당할 수 있는 운영 복잡도에는 한계가 있습니다. 이벤트가 하루 수십~수백 건인 시스템에서 NestJS까지 Kafka에 직접 연결하는 것은, 이미 작동하는 구조 위에 복잡도만 얹는 것입니다.

전환 비용이 낮다는 것 자체가 설계의 장점이라고 생각합니다. 현재 구조에서 NestJS를 Kafka에 직접 연결하는 방식으로 전환하는 것도 어렵지 않습니다. 하지만 그보다 자연스러운 확장 경로가 있습니다. 구조를 바꾼다는 상상을 해보면 흐름은 다음과 같습니다.

Outbox 컬렉션에 이벤트가 쌓이는 구조는 현재 상태 그대로 두고, Go의 gRPC 폴링을 MongoDB Change Streams나 Debezium CDC로 교체하는 것입니다. NestJS 쪽 코드는 한 줄도 바꿀 필요가 없습니다.

이것이 가능한 이유는, Outbox가 이벤트의 단일 진실 공급원 역할을 하고, 폴링은 전달 메커니즘일 뿐이기 때문입니다. 전달 메커니즘은 교체 가능하지만, 단일 진실 공급원은 변하지 않습니다. 이 분리가 유지되는 한, 규모가 커졌을 때의 전환은 그 순간 마이그레이션이 아니라 확장이라는 단어가 어울릴지도 모릅니다.

Cleanup - 30일 보존 후, 배치 삭제

Outbox에 대한 데이터는 지워주지 않으면 지속적으로 쌓이게 됩니다. 이는 곧 필요 없는 데이터가 쌓임을 의미하므로, 사실상 처리 완료된 이벤트에 대한 데이터를 적절히 정리해야할 필요성이 있겠습니다.

@RegisterJob({
  name: "process-expired-outbox-cleanup",
  cron: CronExpression.DAILY_3AM, // 매일 새벽 3시 (KST)
})
export class ProcessExpiredOutboxCleanupScheduler {
  public async process() {
    await this.processExpiredOutboxCleanupUseCase.invoke();
  }
}
private static readonly BATCH_SIZE = 100;
private static readonly RETENTION_DAYS = 30;

public async invoke(): Promise<void> {
  const olderThan = new Date();
  olderThan.setDate(olderThan.getDate() - 30);

  let totalDeleted = 0;
  let deleted = 0;

  do {
    deleted = await this.outboxPersistencePort.deleteExpiredBatch(olderThan, 100);
    totalDeleted += deleted;
  } while (deleted === 100);

  if (totalDeleted > 0) {
    this.logger.log(`만료된 outbox ${totalDeleted}건 삭제 완료`);
  }
}

Outbox 데이터는 한 번에 전부 삭제하지 않고 100건씩 배치로 삭제합니다. MongoDB에서 대량 삭제는 WiredTiger 스토리지 엔진에 부하를 줄 수 있고, 삭제 중에 다른 쿼리의 성능이 저하될 수 있기 때문입니다. 배치 삭제는 이 부하를 분산시킵니다.

보존 기간 30일은 "장애 발생 시 이벤트를 재처리할 수 있는 기간"과 "디스크 사용량" 사이의 트레이드오프에 따른 결정이었습니다. 30일이면 대부분의 운영 이슈를 추적하기에 충분하고, 그 이후의 이벤트는 의미 있는 정보를 제공하지 않는다고 판단했습니다.

Polling vs CDC - 어떤 방식으로 이벤트를 수거해 갈 것인가

Outbox Pattern을 구현하면 다음 질문이 반드시 따라옵니다. outbox 테이블의 이벤트를 어떻게 외부로 전달할 것인가?

두 가지 주요 방식이 있습니다.

Polling (Pull 방식)

HoBom이 사용하는 방식입니다. 별도의 프로세스가 주기적으로 outbox 테이블을 조회하여 PENDING 상태의 이벤트를 가져갑니다.

[Event Processor]
  └─ 매 N초마다
       ├─ SELECT * FROM outbox WHERE status = 'PENDING'
       ├─ 이벤트 처리
       └─ UPDATE outbox SET status = 'SENT'

장점

  • 구현이 단순합니다. DB 조회와 업데이트만 할 수 있으면 됩니다.
  • 별도의 인프라가 필요하지 않습니다.
  • 디버깅이 쉽습니다. DB를 직접 조회하면 현재 상태를 바로 확인할 수 있습니다.

단점

  • 폴링 간격만큼의 지연이 발생합니다.
  • 이벤트가 없어도 DB를 조회하므로 불필요한 쿼리가 발생합니다.
  • 폴링 간격을 줄이면 DB 부하가 증가하고, 늘리면 지연이 커집니다.

CDC (Change Data Capture, Push 방식)

DB의 변경 로그(changelog, oplog)를 감시하여, outbox 테이블에 새로운 행이 추가되면 즉시 이벤트로 발행하는 방식입니다. Debezium이 대표적인 CDC 도구입니다.

[MongoDB oplog][Debezium Connector][Kafka][Consumer]

장점

  • 거의 실시간(near real-time) 전달이 가능합니다.
  • 폴링처럼 불필요한 DB 쿼리가 발생하지 않습니다.
  • 이벤트 순서가 보장됩니다 (oplog의 순서를 따르므로).

단점

  • Debezium + Kafka라는 무거운 인프라가 필요합니다.
  • 운영 복잡도가 크게 증가합니다. Kafka 클러스터 관리, 커넥터 모니터링 등.
  • 장애 지점이 늘어납니다. DB → Debezium → Kafka → Consumer 체인 중 어디서든 문제가 생길 수 있습니다.

"정확히 한 번"은 정말 가능한가?

Outbox Pattern을 이야기할 때 빠질 수 없는 질문입니다. 메시지 전달의 세 가지 보장 수준을 정리하겠습니다. Outbox Pattern은 At-least-once 를 보장합니다. 이벤트는 DB에 저장되어 있으므로 유실되지 않지만, 소비자가 이벤트를 처리한 후 상태를 SENT로 변경하기 전에 죽으면 같은 이벤트를 다시 받게 됩니다.

1. Event Processor: outbox 이벤트 조회 (PENDING)
2. Event Processor: 학습자료 생성 요청 ✅
3. Event Processor: outbox 상태를 SENT로 변경 ❌ (프로세스 크래시)

→ 다음 폴링에서 같은 이벤트를 다시 조회
→ 학습자료 생성을 다시 요청 (중복!)

그렇다면 "정확히 한 번"은 불가능한가? 분산 시스템의 이론적으로는, 네트워크가 존재하는 한 진정한 Exactly-once는 불가능합니다. 하지만 실무적으로는 멱등성(Idempotency) 으로 해결합니다.

소비자가 eventId를 기억하고, 이미 처리한 이벤트는 무시하면 됩니다. At-least-once + 멱등한 소비자 = 실질적 Exactly-once. Kafka가 말하는 "Exactly-once semantics"도 본질적으로는 이 조합입니다.

HoBom에서는 eventId(UUID)가 이 역할을 합니다. 소비자가 중복 이벤트를 받아도, 같은 eventId로 학습자료가 이미 생성되어 있으면 건너뛰는 방식입니다.

결론

Outbox Pattern은 개념적으로는 단순하지만, 운영하면서 몇 가지 고민이 남아 있습니다.

이벤트 순서 보장 → 현재 구현에서는 이벤트의 처리 순서를 엄격하게 보장하지 않습니다. 폴링으로 가져온 이벤트를 병렬 처리하면 순서가 뒤바뀔 수 있습니다. HoBom의 현재 이벤트들(알림 발송, 학습자료 생성)은 순서에 민감하지 않아 문제가 되지 않지만, 순서가 중요한 이벤트가 추가된다면 파티셔닝이나 순차 처리를 고려해야 합니다.

모니터링 → PENDING 상태에서 오래 머무는 이벤트가 있다면, 그것은 소비자의 문제인지 이벤트 자체의 문제인지 빠르게 파악해야 합니다. 현재는 retryCount와 lastError로 기본적인 추적이 가능하지만, 이벤트 처리 지연에 대한 알림 시스템은 아직 구축하지 못했습니다.

스키마 진화 → payload가 Record<string, unknown> 타입이기 때문에, 이벤트의 스키마가 바뀌면 이미 outbox에 쌓여 있는 미처리 이벤트와의 호환성 문제가 생길 수 있습니다. 이벤트 볼륨이 낮은 현재는 문제가 없지만, 이벤트가 쌓이는 속도가 빨라지면 스키마 버저닝을 고려해야 할 것입니다.

그리고 가장 솔직한 고민 → HoBom의 규모에서 Outbox Pattern이 정말 필요했는가? 이벤트가 하루에 수십 ~ 수백건인 시스템에서, "DB에 저장하고 폴링으로 수거한다"는 구조가 "그냥 HTTP로 호출하고 실패하면 재시도한다"보다 나은지 확신이 서지 않을 때가 있습니다.

하지만 이 구현을 통해 확실히 얻은 것이 있습니다. Dual Write 문제가 왜 발생하는지, 트랜잭션 경계가 왜 중요한지, 그리고 "정확히 한 번"이라는 말이 실제로는 얼마나 복잡한 문제를 축약하고 있는지를 직접 경험했다는 것.

분산 시스템의 정합성은 프레임워크가 해결해 주는 문제가 아닙니다. 개발자가 시스템의 경계를 인식하고, 그 경계에서 발생하는 실패를 명시적으로 설계하는 것. Outbox Pattern은 그 설계의 가장 기본적인 출발점이라고 생각합니다.