로고 기록을 모아 모아!
Outbox

서버간 이벤트 연동을 신뢰성 있게 만들어보기

2026.03.09 · 32min

목차

1. 들어가며

저희가 운영하는 플랫폼은 서버가 크게 세 개로 나뉘어 있어요. 사용자를 모집하는 모집 서버, 강의와 학습 콘텐츠를 다루는 LMS, 그리고 경험치나 일일 퀘스트처럼 학습 동기를 만드는 학습활동 서버입니다. 각자 자기 DB를 들고 있는 독립된 서비스라서 서로의 상태가 필요할 때는 API로 직접 호출하거나 Kafka 이벤트로 연동하고 있어요.

이 글에서 다룰 기능은 그중에서도 꽤 평범한 축입니다. 사용자가 강의를 끝까지 다 들으면 학습활동 서버의 “강의 수강” 퀘스트가 완료되고 보상이 지급되는 흐름이에요. 말로 풀면 한 줄이고 처음엔 별로 어려울 게 없어 보이는 기능이었어요.

그런데 어느 날 이런 CS가 들어왔습니다. “강의는 분명 끝까지 다 들었는데 퀘스트가 완료가 안 됐어요. 보상도 안 들어왔고요.” 단순한 버그겠거니 했는데 막상 이 기능을 제대로 신뢰성 있게 만들려고 들여다보니 따져야 할 게 생각보다 많더라고요. 두 서비스를 왜 이벤트로 묶어야 하는지, 그 이벤트가 중간에 사라지지 않으려면 뭐가 필요한지, 같은 이벤트가 두 번 와도 한 번만 처리되게 하려면 어떻게 해야 하는지. 그동안 공부해온 분산 시스템 주제가 이 평범한 기능 하나에 거의 다 얽혀 있었어요.

이 글은 그 고민을 정리한 기록입니다. 좀 길긴 한데, MSA에서 서비스끼리 상태를 맞추는 일이 왜 이렇게까지 손이 많이 가는지를 한 번에 짚어보려고 해요.

2. 왜 이벤트로 연동했고 왜 신뢰성이 문제가 되나

그냥 직접 호출하면 안 되나

가장 먼저 든 생각은 이거였어요. 강의 수강이 끝나는 그 순간 LMS가 학습활동 서버 API를 직접 호출해서 퀘스트를 완료시키면 되지 않나 하는 거죠. 제일 단순하잖아요.

그런데 두 서비스가 서로 다른 서버에 서로 다른 DB를 쓴다는 게 발목을 잡았어요. 강의 수강 완료는 LMS의 DB 트랜잭션이고 퀘스트 완료는 학습활동 서버의 DB 트랜잭션이에요. 이 둘은 하나의 트랜잭션으로 묶을 수가 없습니다. 아래처럼 짜면 위험하더라고요.

await session.withTransaction(async () => {
  await lessonRepo.complete(userId, lessonId, { session }); // 내 DB write
  await http.post("/quest/complete", { userId, lessonId }); // 외부 서버 호출
});

분산 트랜잭션을 공부하면서 확실히 박아둔 부분인데요, 트랜잭션 안에서 외부 API를 호출하면 안 되는 이유가 세 가지였어요. 첫째, 외부 호출이 나간 뒤에 내 DB를 롤백해도 상대 서버의 상태는 안 돌아옵니다. 보상 처리가 없으면 영구적인 불일치예요. 둘째, 외부 API 응답을 기다리는 수 초 동안 DB 락과 커넥션을 그대로 쥐고 있어요. 트랜잭션이 열려 있는 시간이 곧 비용인데, 거기에 네트워크 대기를 끼워 넣은 셈이죠. 셋째, 상대 서버가 느려지면 내 트랜잭션도 같이 느려지고 락이 쌓이면서 장애가 내 서비스로 번집니다.

그래서 원칙은 분명해졌어요. DB 트랜잭션과 외부 호출은 분리한다. 상태를 먼저 내 DB에 기록해두고 외부와는 나중에 맞춰가자는 거죠.

flowchart TD
    Start{외부 호출을<br/>어디서 하나?} -->|트랜잭션 안에서| Bad[DB 락을 쥔 채<br/>외부 API 수 초 대기]
    Bad --> Bad1([내 DB를 롤백해도<br/>상대 상태는 그대로 = 영구 불일치])
    Bad --> Bad2([락·커넥션 점유 → 장애가<br/>내 서비스로 전파])
    Start -->|커밋 후, 트랜잭션 밖에서| Good([내 DB에 먼저 기록하고<br/>외부와는 나중에 맞춤])
    style Bad fill:#fff5f5,stroke:#e03131
    style Bad1 fill:#fff5f5,stroke:#e03131
    style Bad2 fill:#fff5f5,stroke:#e03131
    style Good fill:#ebfbee,stroke:#2f9e44

그럼 커밋하고 나서 쏘면 되잖아

그다음으로 떠올린 게, 트랜잭션을 커밋한 다음에 이벤트를 발행하는 방식이었어요.

await session.withTransaction(async () => {
  await lessonRepo.complete(userId, lessonId, { session });
});
await kafka.send("lesson.completed", { userId, lessonId }); // 커밋 끝난 뒤 발행

방향은 맞았어요. 적어도 일어나지도 않은 일의 이벤트가 먼저 나가는 상황은 막아주거든요. 만약 커밋 전에 발행했다가 커밋이 실패하면 “강의 완료도 안 했는데 완료 이벤트가 나간” 최악의 상태가 되니까요. 그러니 직접 발행을 한다면 반드시 커밋 이후에 발행한다, 이건 무조건 지켜야 할 규칙이라고 봤어요.

그런데 여기에도 못 메운 틈이 있더라고요.

await session.withTransaction(...); // 커밋은 성공
// 바로 이 순간에 서버가 죽으면?
await kafka.send(...);              // 영영 실행되지 않는다

커밋은 됐는데 발행 직전에 프로세스가 죽으면 “강의는 완료됐는데 이벤트는 안 나간” 상태가 남아요. 그리고 이게 스스로 복구가 안 됩니다. 사용자는 강의를 이미 다 들었으니 다시 누를 일이 없고 재시도를 걸어줄 트리거도 어디에도 없으니까요. 그렇게 퀘스트는 영원히 미완료로 남습니다. 그 CS의 유력한 용의자도 이 틈이라고 봤어요. 다만 이 단정은 뒤에서 한 번 뒤집힙니다.

영향 0과 발행 보장은 동시에 가질 수 없다

여기서 제가 한참 헷갈렸던 지점을 짚고 가야겠어요. 저는 두 가지를 동시에 원했거든요. 하나는 메인 로직에 영향이 0이어야 한다는 것. 카프카가 죽든 말든 강의 수강 완료 자체는 멀쩡해야 하니까요. 다른 하나는 발행이 100% 보장돼야 한다는 것. 강의가 완료됐으면 이벤트는 반드시 나가야 하고요.

그런데 이 둘이, 순진하게 접근하면 동시에 만족이 안 되더라고요. 발행을 트랜잭션 밖으로 빼서 영향을 0으로 만드는 순간 “커밋은 됐는데 발행은 안 됨” 틈이 생겨서 보장이 깨지고 반대로 발행을 트랜잭션 안에 넣어 보장을 만들려고 하면 외부 의존성이 트랜잭션에 들어와 영향이 생겨요. 한참 끙끙댔는데 이 모순을 비껴가는 게 트랜잭셔널 아웃박스 패턴이었습니다.

3. 발행측: 트랜잭셔널 아웃박스

트랜잭션에 넣는 건 발행이 아닌 INSERT 한 건

아웃박스의 발상은 단순한데 좀 영리해요. 트랜잭션 안에 위험한 발행을 넣는 대신, “이 이벤트를 발행해야 한다”는 사실을 DB row 하나로 같이 기록해두는 거예요.

// 검증이랑 외부 조회는 트랜잭션 들어가기 전에 끝내둔다
const profile = await http.get(`/users/${userId}`);
if (!validate(profile)) throw new Error("invalid");

// 트랜잭션은 비즈니스 write랑 outbox INSERT만 짧게 감싼다
await session.withTransaction(async () => {
  await lessonRepo.complete(userId, lessonId, { session });
  await outboxRepo.insert(
    {
      domain: "lesson",
      objectId: lessonId,
      eventType: "LessonCompleted",
      payload: { userId, lessonId },
      status: "PENDING",
      attempts: 0,
      nextAttemptAt: new Date(),
    },
    { session },
  );
});
// 실제 Kafka 발행은 여기 없다. 릴레이 워커가 트랜잭션 밖에서 따로 한다.

이렇게 하니까 트레이드오프가 깔끔하게 갈리더라고요. 위험한 작업, 그러니까 네트워크랑 브로커에 의존하는 실제 Kafka 발행은 트랜잭션 밖에서 릴레이 워커가 합니다. 브로커가 죽어도 수강 완료 API는 멀쩡하니 영향이 0이에요. 트랜잭션 안에는 외부 의존성이 전혀 없는 로컬 INSERT 한 건만 남고요. DB가 INSERT를 받을 만큼 살아 있으면 비즈니스 write도 outbox row도 같이 들어가고 DB가 죽었으면 둘 다 안 들어가요. 그러니 outbox만 단독으로 실패할 일이 현실적으로 거의 없습니다.

outbox INSERT만 단독으로 실패하는 경우는 unique 위반이나 not null 누락, 컬럼 길이 초과 같은 코드 버그뿐이에요. 일시적 장애가 아닌 버그라서 오히려 같이 롤백되는 게 정합성에 맞다고 봤어요. 가장 피해야 할 건 “수강 완료는 됐는데 이벤트는 안 나간” 불일치인데 그게 버그 때문이라면 차라리 수강 완료까지 같이 막고 에러를 내는 편이 낫거든요.

처음엔 “영향 0과 발행 보장은 동시에 불가능”이라는 명제 앞에서 막혔는데 결국 아웃박스가 한 일은 위험한 발행만 트랜잭션 밖으로 빼서 영향을 0으로 만들고 트랜잭션 안에는 위험 없는 INSERT 한 건만 남겨 비즈니스 write와 운명을 같이 묶은 거였어요. 그 모순을 정면 돌파한 게 아니었어요. 비껴간 거죠. 저한텐 이게 현실적인 최적해로 느껴졌습니다.

아웃박스가 항상 필요한 건 아니다

근데 아웃박스도 공짜는 아니에요. 테이블도 늘고 워커도 돌려야 하고 폴링이랑 정리 배치도 신경 써야 하죠. 그래서 이걸 도입할 가치가 있는지 판단할 기준이 필요했어요. 저는 핵심 질문이 “서버가 몇 개냐”가 아니라 “하나의 트랜잭션으로 묶을 수 있느냐”라고 정리했습니다.

이걸 두 갈래로 나눠봤어요. 먼저 하나의 트랜잭션으로 묶을 수 있는가, 묶을 수 없다면 그 이벤트가 유실됐을 때 정합성이 깨지는가.

flowchart TD
    Q1{하나의 트랜잭션으로<br/>묶을 수 있나?} -->|"묶을 수 있음<br/>(단일 서버·단일 DB)"| DBTx([DB 트랜잭션으로 충분<br/>아웃박스 불필요])
    Q1 -->|"못 묶음<br/>(외부 API·여러 DB·MSA)"| Q2{유실되면<br/>정합성이 깨지나?}
    Q2 -->|"안 깨짐<br/>(알림·통계·로그)"| Direct([Kafka 직접 발행 + 모니터링])
    Q2 -->|"깨짐<br/>(결제·포인트·타 서비스 상태)"| Outbox([아웃박스 도입])
    style DBTx fill:#ebfbee,stroke:#2f9e44
    style Direct fill:#e7f5ff,stroke:#1971c2
    style Outbox fill:#fff9db,stroke:#e8590c

저희 케이스를 여기 대입해보니 답이 분명했어요. 강의 수강 완료가 학습활동 서버의 퀘스트를 완료시키고 그 퀘스트 보상이 재화와 연결되거든요. 트리를 따라가면 “못 묶음 → 깨짐”으로 내려와 아웃박스에 정확히 도착합니다. 상태 변경 트리거라서 유실되면 “강의 다 들었는데 퀘스트 미완료”가 영구히 남고 스스로 복구되지도 않아요. 보통 유저 CS로야 발견되는, 조용히 깨지는 종류이고 보상이 재화랑 엮여 있어 신뢰 문제로 번지죠. 그래서 아웃박스를 도입할 가치가 충분하다고 판단했어요. 연동이 실패하면 차라리 기존 수강 완료까지 같이 롤백돼도 되는, 그만큼 강하게 묶인 케이스였으니까요.

EventEmitter나 Kafka만으로는 왜 안 되나

이쯤에서 흔한 반론 두 가지를 짚고 가야겠어요. 저도 처음엔 똑같이 생각했거든요.

먼저 NestJS의 EventEmitter로 커밋 후에 발행하면 되지 않냐는 거예요. EventEmitter는 프로세스 안에서 관심사를 분리하는 데는 좋은데, 리스너에서 실패하면 비즈니스 데이터는 이미 커밋돼 있고 서버가 죽으면 메모리에 있던 이벤트는 그냥 사라져요. 실패 복구나 전달 보장, 원자성을 위한 도구가 아니더라고요.

다음은 우리는 Kafka를 쓰는데 Kafka가 유실을 막아주지 않냐는 거죠. Kafka는 이벤트가 일단 들어오고 나면 유실 안 되게 하는 데는 강해요. 그런데 아웃박스가 푸는 문제는 그 이전, DB 트랜잭션과 Kafka 발행을 원자적으로 묶는 일이에요.

await this.orderRepository.create(order); // DB는 성공
await this.kafka.send("order.created", order); // 여기서 Kafka가 실패하면?
// DB는 커밋됐는데 이벤트는 안 나간다. Kafka가 아무리 튼튼해도 이 틈은 못 막는다.

그러니까 Kafka는 유실 방지, 아웃박스는 원자성. 목적이 다른 거예요. 둘은 경쟁 관계가 아니에요. 아웃박스에서 릴레이를 거쳐 Kafka로 가듯 같이 씁니다. 이걸 헷갈리면 “Kafka 쓰는데 왜 아웃박스가 또 필요해?”에서 막히게 됩니다.

트랜잭션 묶기, 락은 생각보다 걱정 안 해도 된다

비즈니스 로직이 100줄인데 거기에 outbox INSERT까지 트랜잭션으로 묶으면 락을 다 걸어야 하나 걱정했는데 막상 보니 그렇지 않더라고요. 락은 테이블 전체가 아니라 write하는 row에만 걸려요. outbox INSERT는 아무도 안 건드리는 새 row를 추가하는 거라 경합이 거의 없습니다. 진짜 비용은 INSERT 그 자체가 아닙니다. 트랜잭션이 열려 있는 시간이죠. 100줄 자체는 문제가 아니고 그 안에 외부 호출이 끼는 게 문제죠. 앞에서 본 그 문제고요.

정석은 검증이랑 계산, 외부 조회는 트랜잭션 열기 전에 다 끝내두고 트랜잭션은 write들이랑 outbox INSERT, 커밋만 짧게 감싸는 거예요. 위 코드가 딱 그 모양입니다. 그러면 200줄이든 400줄이든 락을 쥐는 구간은 수 ms로 끝나요.

참고로 저희는 MongoDB를 쓰는데 MongoDB의 멀티 도큐먼트 트랜잭션은 RDB보다 비싸고 기본 60초 제한이 있어서 짧게 가져가는 게 더 중요했어요. 레플리카셋도 필수고요. standalone에서는 트랜잭션을 시작하려고 하면 바로 에러가 납니다. 만약 수강 완료가 단일 도큐먼트 갱신이라면, 트랜잭션 없이 같은 도큐먼트 안의 배열 필드에 이벤트를 push해서 단일 도큐먼트 원자성으로 보장하고 Change Stream으로 읽어 발행하는 방법도 있어요.

같은 서버 안에서 먼저 연습해본 아웃박스

사실 저는 MSA보다 먼저 같은 서버 안에서 아웃박스를 한 번 구현해본 적이 있어요. 이메일 인증 기능이었는데 동기로 메일을 보내면 사용자가 SES 응답을 기다려야 해서 비동기로 빼고 싶었거든요. CDC 파이프라인은 아직 없는 상태였고요.

이때는 풀 아웃박스 테이블에 릴레이까지 두는 대신, AsyncLocalStorage로 트랜잭션 컨텍스트를 직접 관리하는 방식이랑 BullMQ로 가볍게 풀었어요. 핵심은 커밋이 끝난 뒤에만 큐에 Job을 넣는다는 거였습니다.

// AsyncLocalStorage로 트랜잭션 컨텍스트를 들고 다니다가, 커밋 후에 콜백을 실행한다
publishAfterCommit<T extends IEvent>(event: T): void {
  if (isTransactionActive()) {
    registerAfterCommit(() => this.eventBus.publish(event)); // 커밋 후 발행
  } else {
    this.eventBus.publish(event); // 트랜잭션 밖이면 그냥 즉시 발행
  }
}
@EventsHandler(EmailVerificationCreatedEvent)
export class EmailVerificationCreatedHandler {
  async handle(event: EmailVerificationCreatedEvent) {
    // 이 시점엔 트랜잭션이 이미 커밋된 상태다
    await this.emailQueue.add(
      "send-verification",
      { ...event },
      {
        attempts: 3,
        backoff: { type: "exponential", delay: 5000 }, // 5초, 10초, 20초
        removeOnComplete: true,
        removeOnFail: false, // 최종 실패는 남겨둔다. DLQ 역할
      },
    );
  }
}

여기에도 똑같은 틈이 있었어요. 커밋은 성공했는데 큐에 넣기 직전에 서버가 죽는 경우요. DB에 init 상태로 남은 row를 5분마다 줍는 폴백 배치를 하나 뒀습니다.

@Cron('0 */5 * * * *')
async recoverStuckJobs() {
  const stuck = await this.repo.find({
    status: 'init',
    createdAt: { $lt: new Date(Date.now() - 5 * 60 * 1000) },
  });
  for (const doc of stuck) await this.emailQueue.add('send-verification', { ... });
}

BullMQ를 쓴 이유는 단순했어요. EventEmitter랑 Cron으로 직접 짜면 재시도, 실패 관리, 동시성 제어, 모니터링을 전부 손으로 구현해야 하는데 BullMQ는 그걸 다 내장하고 있거든요.

항목EventEmitter + CronBullMQ
재시도직접 구현내장 (attempts, backoff)
실패 관리직접 구현failed jobs 자동 관리
동시성 제어낙관적 락 필요concurrency 옵션
모니터링직접 구현Bull Board UI

이 경험이 MSA 아웃박스의 예행연습이 됐어요. 커밋과 발행 사이의 틈이라는 본질은 단일 서버든 MSA든 똑같았거든요.

폴링이냐 CDC냐, 그리고 CDC도 실패한다

아웃박스 row를 어떻게 읽어서 발행할지도 정해야 했어요. 크게 두 가지인데요. 하나는 폴링입니다. 워커가 주기적으로 PENDING인 row를 긁어서 발행하는 방식이고 단순하지만 즉시성이 떨어지고 폴링 비용이 있어요. 다른 하나는 CDC예요. Debezium 같은 도구가 DB의 변경 로그를 읽어 Kafka로 흘려보내는 방식이라 즉시성이 좋습니다.

여기서 오해를 하나 풀어야 했어요. CDC를 쓴다고 마법처럼 안전해지는 게 아니더라고요. Debezium도 실패해요.

실패 지점상황
Debezium에서 Kafka로 전송 실패브로커 다운, 네트워크 단절, Kafka 디스크 풀
Debezium이 DB 변경을 못 읽음WAL/Binlog가 보존 기간 넘겨 정리됨, Replication Slot 유실
스키마 변경 (DDL)호환 안 되는 변경으로 파싱 실패

Debezium의 기본 보호 장치는 Kafka로 전송에 성공해야만 offset을 커밋한다는 거예요. 그래서 전송이 실패하면 offset이 안 넘어가고 재시작하면 같은 지점부터 다시 시도합니다. 그런데 Debezium이 장시간 다운되면 그사이 DB가 WAL을 정리해버려서 따라잡지 못하고 유실되거나 재스냅샷이 필요해질 수 있어요. 그리고 CDC는 변경을 감지하는 방식이라 실패 후에 같은 상태(PENDING)가 계속 유지되면 다시 트리거되지 않을 수도 있고요. 그래서 실무에서는 하이브리드를 쓰더라고요.

방식즉시성재시도/복구
폴링만약함좋음
CDC만좋음약함
CDC + 폴링좋음좋음
CDC + Kafka좋음좋음

규모별로 정리해보면, 스타트업이나 소규모는 CDC 없이 아웃박스에 폴링만 붙여도 충분한 경우가 많고 중규모는 Debezium에 Kafka, 기본 모니터링 정도, 대규모나 금융권은 Debezium HA에 Kafka HA, 아웃박스 병행, 수동 복구 절차까지 갑니다. 저희 규모는 어드민에 몇천 명 수준이라 폴링 기반 아웃박스가 정답이라고 봤어요. 여기서도 오버엔지니어링과 언더엔지니어링을 구분하는 게 핵심이었던 것 같아요.

참고로 아웃박스랑 이벤트 소싱은 헷갈리기 쉬운데 목적이 달라요. 아웃박스는 발행을 위한 임시 테이블이라 쓰고 지우거나 완료 마킹을 합니다. 이벤트 소싱은 이벤트 테이블 자체가 원본 데이터라 영구 보관하고 리플레이로 상태를 계산하죠. 저희가 한 건 전자예요.

4. 릴레이: dual-write는 사라진 게 아니라 옮겨졌다

이 장이 이 글에서 제가 가장 크게 깨달은 부분인 것 같아요.

천장은 at-least-once다

발행측 아웃박스는 비즈니스 write랑 Kafka publish라는 dual-write, 그러니까 두 시스템에 동시에 써야 하는 문제를 풀려고 도입한 거였어요. 그런데 막상 릴레이 워커를 짜고 나니 릴레이 안에서 똑같은 dual-write가 다시 생기더라고요. 릴레이가 하는 일은 Kafka로 publish하고 Mongo에 markSent하는 건데, 이 둘은 서로 다른 두 시스템이라 한 트랜잭션으로 못 묶습니다. dual-write가 사라진 게 아니었어요. 비즈니스 로직에서 릴레이로 자리만 옮긴 거였죠. 이걸 깨닫고 좀 허탈했어요.

그래서 “어떻게 발행과 마킹을 원자적으로 하느냐”는 질문의 답은, 결국 안 만든다는 거였어요. 모든 전이를 멱등하고 반복해도 안전하게 만들어두고 천장이 at-least-once라는 걸 받아들인 다음, 중복은 소비측이 흡수하게 하는 거죠.

모든 예외 경로를 전수조사한 크래시 매트릭스

릴레이가 한 행을 처리하는 흐름은 claim, publish, mark 순서예요. 각 지점에서 크래시나 실패가 나면 어떻게 되는지를 그냥 전부 표로 깔아봤어요. 머릿속으로만 생각하면 자꾸 빠뜨리게 되더라고요.

지점유실중복동작
claim 직후, publish 전 크래시없음후속lease 만료 후 재선점, 재발행 (self-heal)
publish 실패 (브로커 다운)없음없음catch 후 reschedule, MAX 시 markFailed
publish 성공 + markSent 실패없음있음이미 발행됨, 재발행·거짓 markFailed 금지 (중복은 멱등이 흡수)
publish 성공 후 markSent 전 크래시없음있음lease 만료 후 재선점, 재발행 (불가피)
lease 만료 중 처리, 타 워커 재선점없음있음fencing으로 상태 오염은 없음
markFailed 실패없음후속PROCESSING 잔존, 무한 재선점 위험

표를 깔고 나니 한눈에 보이는 게 있었어요. 유실이 어느 경로에도 없다는 거예요. 이게 아웃박스의 핵심 보장이더라고요. 문제는 중복이랑 stuck 두 가지뿐이고 중복은 앞에서 말했듯 소비측 멱등으로 흡수할 거고요.

알림과 보장이 필요한 발행은 다르게 다룬다

발행이 실패했을 때 그걸 호출자에게 알릴지도 정해야 했어요. 발행에 두 종류가 있더라고요. 알림이나 축하 메시지처럼 실패해도 큰 문제 없는 best-effort 발행이 있고 강의 완료처럼 타 서비스 상태를 바꾸니 반드시 보장돼야 하는 발행이 있어요. 그래서 publish에 throwOnError 옵션을 뒀습니다. 알림 같은 best-effort는 기본값 그대로 둬서 실패해도 조용히 넘어가게 하고 릴레이처럼 보장이 필요한 쪽만 옵션을 켜서 실패하면 예외를 던지게 했어요. 같은 publish라도 실패를 전파하는 계약이 다르다는 걸 옵션 하나로 가른 거죠.

워커를 어디서 돌릴까, 파드 50개 문제

API 서버는 오토스케일로 파드가 50개까지 떠요. 이 50개가 전부 outbox를 폴링하면 조회 레이스에 중복 발행까지 터지겠죠. 그래서 릴레이를 어디서 돌릴지 골라야 했어요.

방안장점단점
Redis 분산락가장 간단TTL 딜레마, 락 미해제 위험, Redis 의존성 증가
CronJob / Jenkins락 고민 일부 해소이미지 빌드 느림, 메타데이터 동기화 실패 시 중복 실행
워커 전용 Deployment 1개락 불필요, 기존 이미지 재사용, 단일 인스턴스 보장SRE 협의 필요

저는 워커 전용 Deployment를 골랐어요. 50개 중 하나를 워커 설정으로 띄워서 얘만 릴레이를 돌리는 거예요. API 파드나 소비 파드랑 격리되니 발행 부하나 장애가 트래픽 경로랑 분리되더라고요.

여기서 Redis 락에 대한 흔한 착각을 하나 교정해야 했어요. Redis 락의 TTL은 작업이 그 안에 끝난다는 보장이 아니에요. 작업이 TTL보다 길면 락이 먼저 풀려서 다른 파드가 같은 작업을 동시에 집어가요. 그러니 락 기반은 결국 “중복을 못 막는다”로 귀결됩니다. at-least-once를 못 벗어나니 락으로 중복을 0으로 만들려는 시도 자체가 비용 대비 효과가 낮은 거죠. 정확한 트레이드오프는 이래요. 락이 귀찮아서 단일 워커로 간 게 아니라, 락으로는 어차피 at-least-once를 못 벗어나니 동시성을 1로 낮춰 운영 복잡도를 줄이고 멱등에 베팅한 거죠. SRE를 설득할 때도 이 논리로 갔습니다.

단일 워커가 아니어도 중복은 안 난다, CAS 실측

여기서 단일 워커가 필수는 아니라는 점도 짚고 싶어요. 단순함을 위한 선택이지, 설령 워커를 여러 개로 늘려도 중복 선점은 안 나거든요. 릴레이가 행을 선점할 때 findOneAndUpdate를 쓰기 때문이에요.

// status:PENDING이 compare, $set이 set. 분산락 없는 CAS다
const doc = await coll.findOneAndUpdate(
  { status: "PENDING" },
  { $set: { status: "PROCESSING", lockedBy: workerId, leaseExpiresAt } },
  { returnDocument: "after" },
);

근데 “그렇다더라”로 넘어가기 싫어서 직접 측정해봤어요. 실제 MongoDB 7.0.24를 띄우고 문서 300개에 동시 워커 10개로 부하를 걸어서 atomic 방식이랑 naive 방식을 비교했습니다.

방식문서총 claim고유중복writeConflicts
findOneAndUpdate (atomic)30030030004
find 후 update (naive)30021823001882-

atomic은 각 행을 정확히 한 번씩만 선점해서 중복이 0이고 naive는 같은 행을 1882번이나 중복 선점했어요. atomic이 0인 이유는 세 가지가 겹쳐서더라고요. 첫째, 단일 문서 원자성이에요. findOneAndUpdate는 조건 매칭, 수정, 반환을 한 문서에 대해 쪼개지지 않는 하나의 연산으로 수행해요. MongoDB의 근본 보장이죠. 둘째, WiredTiger의 낙관적 동시성이에요. 같은 문서를 동시에 수정하려 하면 충돌을 감지해서 하나만 커밋하고 진 쪽은 서버가 투명하게 재시도해요. 위 표의 writeConflicts 4가 충돌이 실제로 일어나고 재시도됐다는 증거고요. 셋째, 조건부 필터가 곧 CAS라는 점이에요. 재시도할 때 문서를 다시 읽고 status

필터를 재평가하는데 이미 PROCESSING이면 불일치라 대상에서 빠집니다. 두 번째 워커가 같은 행을 또 flip하는 게 구조적으로 불가능한 거죠.

naive가 깨지는 이유는 읽기랑 쓰기 사이에 판단을 끼워 넣었기 때문이에요. 판단을 find 시점에 하고 쓰기는 _id로 무조건 하니까, 두 워커가 같은 문서를 오래된 상태로 보고 둘 다 “내가 잡았다”가 됩니다. CAS는 그 판단을 쓰기 연산 안의 필터로 밀어 넣어서 원자화한 거고요. 결론적으로 claimNext의 CAS 덕분에 replicas가 2든 N이든 두 워커가 같은 행을 동시에 가져가지 못합니다. 분산락 없이도요. 다만 발행 단의 dual-write 때문에 천장은 여전히 at-least-once라서 소비측 멱등은 그대로 필요해요.

5. 소비측: 멱등으로 effectively-once 만들기

발행측이 애써 유실을 0으로 만들었으니 이제 소비측은 중복이 와도 효과는 한 번만이 되게 책임질 차례예요. 그런데 그 전에 왜 중복을 피할 수 없는지부터 짚어야 합니다.

Exactly-Once Delivery는 원리적으로 불가능하다

저는 한동안 “카프카가 exactly-once를 지원한다며” 하고 생각했는데 Tyler Treat의 글을 읽고 생각이 바뀌었어요. 분산 시스템에서 exactly-once delivery는 불가능하더라고요. “어렵다”가 아니라 수학적으로 증명된 불가능성 결과예요. 메시지 전달은 세 가지로 나뉘는데요. 최대 한 번(at-most-once)은 유실은 가능하지만 중복은 없고 최소 한 번(at-least-once)은 중복은 가능하지만 유실은 없고 정확히 한 번(exactly-once)은 유실도 중복도 없는 건데 이게 원리적으로 불가능합니다.

왜 불가능한지는 Two Generals Problem 비유가 명쾌하더라고요. 친구에게 편지를 보내면서 “받으면 전화해”라고 했는데 전화가 안 와요. 편지가 안 갔는지, 친구가 무시했는지 저는 알 방법이 없습니다. 편지를 10통 보내도 마찬가지예요. ACK를 기다려도 그 ACK가 유실됐을 수 있고 그럼 ACK의 ACK가 또 필요해지고 무한히 반복돼요. 여기에 FLP 정리, 그러니까 프로세스가 하나라도 장애 가능성이 있으면 합의 자체가 불가능하다는 결과까지 더하면 결론은 분명해집니다.

ACK를 언제 보내느냐에 따라 트레이드오프가 갈려요. 처리 전에 ACK를 보내면 at-most-once인데 ACK 직후 처리 중에 크래시 나면 데이터가 영영 사라져요. 처리 후에 ACK를 보내면 at-least-once인데 처리는 했지만 ACK 보내기 전에 크래시 나면 송신자가 재전송해서 중복 처리가 됩니다. 둘 다 문제지만 보통은 유실보다 중복이 나아요. 그래서 주요 메시지 큐는 다 at-least-once를 보장하더라고요. 카프카의 exactly-once라는 것도 사실 Idempotent Producer랑 트랜잭션으로 카프카 내부에서만 성립하는 거고 컨슈머가 외부 시스템에 쓸 때는 여전히 at-least-once예요. RabbitMQ 공식 문서도 솔직하게 “컨슈머가 중복 제거를 하거나 멱등하게 처리해야 한다”고 적어뒀고요.

그럼 실무에서는 어떻게 하느냐. exactly-once를 흉내 냅니다. 핵심 전략은 멱등성이에요. 같은 메시지를 여러 번 적용해도 결과가 같도록 설계하는 거죠. 이 글에서 가장 인상 깊었던 비유가 있어요. 친구를 집으로 부를 때 “좌회전, 직진, 우회전” 식으로 안내하면 메시지 하나가 중복되는 순간 친구는 엉뚱한 곳에 도착해요. 연산을 전달했기 때문이에요. 그런데 “내 위치는 강남역이야”라고 보내면 같은 메시지가 두 번 와도 문제가 없어요. 상태를 전달했으니까요. “연산이 아니라 상태를 전달하라”, 이게 멱등 설계의 정수인 것 같아요.

한 번의 강의 완료가 두 갈래로 처리된다

이제 실제 소비 흐름이에요. LMS의 lesson 이벤트가 도착하면 학습활동 서버가 받아서 두 갈래로 나뉩니다.

flowchart TD
    Lesson([LMS LessonCompleted 이벤트<br/>외부 토픽]) --> Branch{소비 시 두 갈래}
    Branch -->|A| Mission["미션·캠페인 집계<br/>TaskAggregate에 멤버 누적"]
    Mission --> OrgTask["그룹 전원 완료 시<br/>OrganizationTask COMPLETE"]
    Branch -->|B| Record["recordEventHistory<br/>내부 UPDATE_EXP로 재발행"]
    Record --> Consume["consumeUpdateExp<br/>경험치 기록 + 데일리 퀘스트 + 멱등"]
    style Lesson fill:#e7f5ff,stroke:#1971c2
    style Mission fill:#fff9db,stroke:#e8590c
    style OrgTask fill:#fff9db,stroke:#e8590c
    style Record fill:#ebfbee,stroke:#2f9e44
    style Consume fill:#ebfbee,stroke:#2f9e44

A 갈래는 미션이랑 캠페인 집계예요. 미션 태스크가 강의에 연결돼 있으면 멤버를 누적하고 그룹 전원이 완료하면 그룹 태스크를 완료 처리합니다. 개인 점수랑은 별개 트랙이에요. B 갈래는 recordEventHistory인데 외부 이벤트를 내부 표준 이벤트인 UPDATE_EXP로 변환해서 다시 발행해요. 경험치 트리거가 강의, 출석, 배움일기처럼 여러 종류라서 전부 UPDATE_EXP 하나로 모아 단일 컨슈머가 일괄 처리하는 구조거든요. 그래서 외부 lesson 이벤트가 recordEventHistory를 거쳐 내부 UPDATE_EXP가 되고 그걸 consumeUpdateExp가 받는 식으로 한 번 더 발행을 거칩니다.

이벤트는 점수를 직접 주지 않는다: 기록과 보상의 분리

여기서 처음에 저를 헷갈리게 한 게 하나 있어요. 강의 완료 이벤트의 페이로드를 보면 value0입니다. 그리고 점수를 더하는 코드는 대략 이렇게 생겼어요.

addedPoint = event.value * event.point; // value=0 이면 무조건 0

즉 보상을 100으로 잡아도 강의 완료 이벤트만으로는 점수가 한 톨도 안 올라요. 처음엔 버그인 줄 알았는데 알고 보니 설계였어요. integration 이벤트는 점수를 직접 주지 않아요. “이 사람이 강의를 들었다”는 사실을 expHistory에 기록하고 퀘스트를 트리거하는 용도였더라고요. expHistory에 addedPoint:0으로 남아도, 거기 찍힌 eventId가 퀘스트 진행의 원천이 됩니다.

실제 점수는 별개 배선에서 와요. “강의 수강” 같은 일일 퀘스트가 따로 정의돼 있고 그날 expHistory 중 eventId가 일치하는 게 정의된 개수 이상이면 퀘스트가 달성되며 그 퀘스트의 보상이 점수로 들어가요. 완료 트리거도 Kafka 소비 경로가 아닌 클라이언트 쪽 호출이고요.

이 분리가 처음엔 빙 둘러 가는 것처럼 보였는데 뜯어보니 이유가 있더라고요. 보상 정책(어떤 행동에 얼마)을 이벤트 발행에서 떼어낸 거예요. 만약 이벤트가 점수를 직접 실어 나르면 “강의 수강 보상을 50에서 80으로 올리자”가 발행측 코드 변경이 돼버려요. 기록이랑 보상을 분리해두면 이벤트는 “무슨 일이 일어났다”는 사실만 변함없이 나르고 “그 사실에 얼마를 줄지”는 퀘스트 정의에서 운영자가 조정합니다. 그래서 발행·소비 파이프라인이 아무리 완벽해도 점수는 그 파이프라인이 아닌 퀘스트라는 별도 배선에서 와요.

이걸 알고 나니 이 글 첫머리의 “강의 다 들었는데 퀘스트가 안 떴어요”라는 CS도 좀 다르게 보였어요. 그건 한 군데 버그가 아니었어요. 발행 → 소비 → expHistory 기록 → 퀘스트 정의·진행이라는 여러 층위 중 어디서든 끊길 수 있는 문제였던 거죠. 그래서 “이벤트 유실”이라고 원인을 한 층위로 단정하기 전에, 어느 층위에서 멈췄는지를 데이터로 좁혀 들어가는 게 먼저였어요. 이 글이 다룬 발행 보장이랑 소비측 멱등은 그중 가운데 두 층위를 단단하게 만드는 작업이었고요.

도장과 효과를 한 트랜잭션으로 묶는 멱등

같은 정체성, 그러니까 user랑 lesson 조합을 양쪽에서 각각 한 번씩 막습니다.

위치보장
발행측dedupKey (outbox unique)LMSoutbox에 한 번만 적재
소비측eventKind, userId, objectId (processedEvent unique)학습활동 서버효과를 한 번만 적용

소비측 멱등의 핵심 규칙은 도장이랑 효과를 한 트랜잭션으로 묶는다는 거예요.

async function consumeUpdateExp(evt) {
  await session.withTransaction(async () => {
    // 멱등 도장. processedEvent에 INSERT를 시도하는 게 곧 "처음인가" 판정이다
    await processedEvent.claim(buildDedupKey(evt), { session }); // unique index
    // 효과. 경험치 증가랑 퀘스트 진행
    await player.updateExp(evt, { session });
    await expHistory.record(evt, { session });
  });
  // claim에서 중복(E11000)이 나면 DuplicateEventError로 facade가 삼킨다. 정상 흐름
}

claim, 그러니까 도장이랑 효과가 같은 session 트랜잭션이라 커밋되면 둘 다 들어가고 크래시면 둘 다 롤백돼요. 둘을 따로 하면 “효과는 됐는데 도장은 안 찍힘”이라는 중복이나, “도장만 찍힘”이라는 유실 틈이 생깁니다. 빠른 경로로 Redis를 1차 필터로 둘 수도 있지만 진실의 원천은 unique index를 가진 멱등키 테이블이어야 한다고 봤어요. Redis가 비어도 DB unique가 중복을 최종 차단하니까요. Redis는 부하를 줄이는 보조일 뿐 단일 실패점이 되면 안 되고요. 멱등키를 무엇으로 잡느냐도 중요했는데 eventKind랑 userId, objectId처럼 안정적인 비즈니스 정체성으로 잡아야 해요. eventId(uuid)는 publish마다 새로 생성돼서 재발행 시 불안정하고 partition key는 eventKind가 없어 다른 종류와 충돌하더라고요.

재시도와 DLT, 영구 실패와 일시 실패를 나눠라

소비가 실패하면 무조건 재시도하면 될까요. 아니더라고요. 재시도해도 안 풀리는 거랑 곧 회복될 걸 구분해야 합니다.

flowchart TD
    Fail[consumeUpdateExp 실패] --> Type{에러 성격은?}
    Type -->|중복 DuplicateEventError| Skip([삼킴, 이미 한 번 처리됨, 정상])
    Type -->|영구 실패, 계약 위반, objectId 부재| DLT1([DLT 곧장, 재시도 안 함])
    Type -->|내부 토픽 일시 실패| Internal[같은 토픽에 retryCount+1로 재발행]
    Type -->|외부 토픽 일시 실패| External[retry 토픽, 지수 백오프]
    External --> Limit{한도 초과?}
    Limit -->|Yes| DLT2([DLT])
    Limit -->|No| External
    style Skip fill:#ebfbee,stroke:#2f9e44
    style DLT1 fill:#fff5f5,stroke:#e03131
    style DLT2 fill:#fff5f5,stroke:#e03131
    style Internal fill:#e7f5ff,stroke:#1971c2
    style External fill:#fff9db,stroke:#e8590c

여기서 제가 영리하다고 느낀 건 objectId 같은 계약을 발행측이 근원에서 강제한다는 점이에요. 없으면 throw해버리거든요. 그래서 소비측에 objectId가 없는 이벤트가 온다면 그건 “절대 안 와야 할 것”이라서 DLT 직행이 정당하고 DLT가 노이즈로 폭주하지도 않아요. 계약을 근원에서 막으면 누구 잘못인지가 그 서비스에서 드러나니까요. 그리고 MAX_ATTEMPTS를 초과해서 markFailed된 row는 조용히 죽으면 안 돼요. 결제랑 퀘스트가 엮였으니 FAILED로 쌓일 때 알람을 쏘고 사람이 봐야 합니다. 아웃박스의 “유실 0”이라는 약속은 “끝까지 재시도하거나, 못 하면 눈에 띄게 남긴다”까지 포함해야 진짜 보장인 것 같아요.

6. 마치며

처음엔 “강의 다 들었는데 퀘스트가 안 떠요”라는 CS 한 줄이었는데 끝까지 따라가다 보니 글이 이렇게 길어졌어요. 정리하면서 제 안에 남은 것들이 몇 가지 있습니다.

먼저 “보장은 코드가 아니라 계약으로 지켜진다”는 거예요. 아웃박스 테이블도 릴레이도 다 갖췄어도, 발행이 실패했을 때 그걸 호출자에게 알린다는 계약이 없으면 보장은 조용히 사라지더라고요. 알림처럼 실패해도 되는 발행이랑 반드시 보장돼야 하는 발행은 같은 함수라도 실패를 전파하는 계약이 다르고 그걸 명시적으로 드러내야 했어요. 의존성을 쓸 때 “이게 실패하면 나한테 어떻게 알려주나”를 먼저 확인하는 습관이 생겼습니다.

그리고 “중복은 버그가 아니라 계약”이라는 거예요. at-least-once를 택하는 순간 시스템은 중복을 전제합니다. exactly-once는 원리적으로 불가능하니까요. 그러니 “중복이 가끔 나네”를 버그로 쫓을 일이 아니었어요. 소비측 멱등을 처음부터 일급 시민으로 설계해야 했죠. 발행측이 유실 0을 만들고 소비측이 중복 0 효과를 만들면, 둘이 만나서 사실상 한 번처럼 동작하게 되는 거죠.

마지막으로 오버엔지니어링과 언더엔지니어링을 구분하는 일이요. 저희 규모에서는 Debezium HA도, Redis 분산락도, 트리 기반 분산 트랜잭션도 다 과했어요. 폴링 아웃박스에 단일 워커, 소비측 멱등이라는 가장 단순한 조합이 정답이었습니다. 제품의 규모를 정확히 알고 그에 맞는 설계를 고르는 것, 이게 결국 제일 중요한 판단이었던 것 같아요.

분산 시스템은 정답이 하나로 안 떨어지더라고요. 유실을 허용할지, 즉시성이 필요한지, 트래픽이 얼마인지에 따라 매번 다른 트레이드오프를 골라야 해요. 이번 작업에서 그 트레이드오프를 데이터랑 실측으로 직접 확인해본 게 가장 큰 수확이었던 것 같습니다.

다음에는 소비측 멱등을 운영에서 더 잘 들여다볼 수 있게 가시화하는 쪽을 다듬어보려고 하고 이 글은 여기서 마치려고 합니다.