← Blog
·
MSANestJSNATSOutboxSagaIdempotency

MSA 데이터 정합성 확보 설계 노트

분산 환경에서 데이터 정합성을 어떻게 보장할 것인가 — 동시성, 멱등성, NATS, Outbox, Saga 패턴을 하나의 흐름으로 정리.

힐링샘 레거시 3종 서비스를 NestJS + gRPC + NATS 기반 MSA로 전환하면서, DB per Service 구조에서 데이터 정합성을 어떻게 보장할지 정리한 설계 문서입니다.

핵심 질문: 분산 환경에서 데이터 정합성을 어떻게 보장하는가.

목차

  1. 전체 흐름 — 왜 이 문제들이 연결되는가
  2. 멀티 인스턴스에서의 동시성 문제
  3. 멱등성 (Idempotency) — 1단계 방어
  4. Redis 분산 락 — 2단계 방어
  5. 아키텍처 레벨 방지 — 3단계 방어
  6. 메시지 브로커 선택 — 왜 NATS인가
  7. NATS 장애 대응 전략
  8. Outbox 패턴 — 이벤트 발행의 신뢰성 보장
  9. Saga 패턴 — 여러 서비스에 걸친 트랜잭션

1. 전체 흐름 — 왜 이 문제들이 연결되는가

MSA(마이크로서비스 아키텍처)를 운영한다
  ↓
서비스가 멀티 인스턴스로 배포된다
  ↓
같은 이벤트가 여러 인스턴스에서 동시에 처리될 수 있다 → [동시성 문제]
  ↓
같은 요청이 2번 와도 1번만 처리되어야 한다 → [멱등성]
  ↓
서비스 간 이벤트를 전달할 메시지 브로커가 필요하다 → [왜 NATS인가]
  ↓
NATS가 죽으면 이벤트가 유실된다 → [NATS 장애 대응]
  ↓
이벤트 발행의 신뢰성을 보장해야 한다 → [Outbox 패턴]
  ↓
여러 서비스에 걸친 트랜잭션을 관리해야 한다 → [Saga 패턴]

각 계층은 앞 계층의 한계를 보완하기 위해 존재합니다. 하나의 패턴이 만능이 아니라, 여러 계층이 겹쳐서 정합성을 만듭니다.


2. 멀티 인스턴스에서의 동시성 문제

왜 이 문제가 발생하는가

MSA에서는 서비스를 여러 인스턴스로 배포합니다 (로드밸런싱, 가용성). 메시지 브로커가 이벤트를 발행하면, 네트워크 지연 · 브로커 재전송 · 컨슈머 ACK 실패 등의 이유로 같은 이벤트가 2개 이상의 인스턴스에 도달할 수 있습니다.

예시: 주문이 생성되면 order.created 이벤트가 발행됩니다. 결제 서비스가 3개 인스턴스로 떠 있는데, 브로커 재전송으로 인해 인스턴스 A와 B가 동시에 같은 이벤트를 받습니다. 멱등성 처리가 없으면 결제가 2번 일어납니다.

해결: 3단계 방어 구조


3. 멱등성 (Idempotency) — 1단계 방어

멱등성이란

같은 요청을 한 번 보내든 열 번 보내든 결과가 동일한 성질.

HTTP 메서드로 보면:

분산 환경에서는 재시도, 브로커 재전송 등으로 같은 요청이 여러 번 올 수 있으므로, 모든 상태 변경 처리는 멱등성을 보장해야 합니다.

구현: DB 유니크 제약을 이용한 멱등키

이벤트마다 고유한 idempotency key(이벤트 ID)를 부여합니다. 처리 전에 DB에 해당 키로 INSERT를 시도하고, 유니크 제약 위반이 발생하면 이미 처리된 이벤트로 판단하고 무시합니다.

핵심: "확인 후 삽입"이 아니라 "삽입 시도 후 충돌 처리"

먼저 자연스럽게 떠오르는 방식부터 봅니다.

// ❌ 위험한 방식
const exists = await db.findOne({ eventId });
if (!exists) {
  await processEvent(event);       // 결제 처리
  await db.insert({ eventId });    // 처리 끝났으니 기록
}

"처리에 성공한 것만 표시하자"는 사고 흐름 때문에 이 순서를 자주 씁니다. 그런데 인스턴스 A, B 가 이벤트를 동시에 받으면 이렇게 됩니다.

시각    인스턴스 A                인스턴스 B
─────────────────────────────────────────────
t1     findOne → 없음
t2                              findOne → 없음
t3     processEvent (결제)
t4                              processEvent (결제)   ← 결제 2번!
t5     insert eventId (성공)
t6                              insert eventId (실패)  ← 이때 막아봐야 늦음

DB 유니크 제약이 두 번째 INSERT는 막아주지만, 그 시점엔 이미 결제가 두 번 일어난 뒤입니다. DB 가 뭘 막아주기 전에 부수효과가 먼저 실행되는 게 문제입니다.

그럼 순서를 뒤집어서 INSERT를 먼저 해도 되지 않냐 할 수 있는데, 그것도 아슬아슬합니다.

// △ 아슬아슬한 방식
const exists = await db.findOne({ eventId });
if (!exists) {
  await db.insert({ eventId });    // ← 여기서 두 번째 놈이 유니크 위반으로 throw
  await processEvent(event);
}

이 코드는 실제로 이중 처리가 안 나긴 합니다. 두 번째 INSERT 가 예외를 던지고, 그 예외 때문에 processEvent 까지 안 갑니다. 그런데 개발자 머릿속 모델과 실제 안전장치가 어긋난 상태입니다. 개발자는 "내가 findOne 으로 체크했으니 안전하다"고 생각하는데, 실제로 안전하게 만들어주는 건 findOne 이 아니라 INSERT 실패 예외입니다. 만약 예외가 위로 안 던져지도록 감싼 코드가 있거나, 나중에 누가 findOne 만 믿고 순서를 바꾸는 순간 바로 위의 "위험한 방식"이 됩니다.

그래서 findOne 은 아예 빼고, INSERT 시도 자체를 체크로 삼는 방식이 안전합니다.

// ✅ 올바른 방식 — INSERT 시도가 곧 체크
try {
  await db.insert({ eventId });    // 유니크 제약이 걸려 있으므로 하나만 성공
  await processEvent(event);       // INSERT 성공한 인스턴스만 여기로
} catch (e) {
  if (e instanceof UniqueConstraintViolation) {
    return;                        // 이미 처리된 이벤트
  }
  throw e;
}

이 방식은 실제 안전장치(유니크 제약)가 코드의 첫 줄에 그대로 드러납니다. 순서를 바꿀 수도 없고(processEvent 가 INSERT 성공 뒤에만 실행), 개발자 머릿속 모델과 실제 방어선이 일치합니다.

처리와 멱등키 저장은 하나의 트랜잭션으로 묶는다

await dataSource.transaction(async (manager) => {
  await manager.insert(ProcessedEvent, { eventId });
  await processEvent(event, manager);
});
// 커밋되면 둘 다 저장, 롤백되면 둘 다 취소

하나의 트랜잭션에 묶으면 이 두 상황이 구조적으로 발생하지 않습니다.


4. Redis 분산 락 — 2단계 방어

왜 필요한가

DB 유니크 제약은 확실하지만, 매번 DB에 INSERT를 시도하므로 이벤트 양이 많으면 DB 부하가 커집니다. 더 빠른 1차 필터가 필요합니다.

구현: Redis SET NX

const acquired = await redis.set(
  `lock:event:${eventId}`,
  '1',
  'NX', // 존재하지 않을 때만 저장
  'EX', // TTL
  60,   // 60초 후 자동 만료
);

if (acquired) {
  try {
    await processEvent(event);
  } catch (e) {
    await redis.del(`lock:event:${eventId}`);
    throw e;
  }
} else {
  // 다른 인스턴스가 이미 처리 중
}

Redis는 싱글 스레드이므로 SET NX 자체가 원자적입니다. 두 인스턴스가 동시에 요청해도 하나만 성공합니다. TTL을 걸어서 락이 영원히 남는 문제도 방지됩니다.

Redis + DB 조합으로 안전성 극대화

이벤트 수신
  ↓
[1차] Redis SET NX → 이미 있으면 무시 (빠른 필터)
  ↓
[2차] DB INSERT + 유니크 제약 → 이미 있으면 무시 (최종 안전망)
  ↓
이벤트 처리

Redis로 대부분의 중복을 걸러내고, Redis가 장애여도 DB 유니크 제약이 잡아줍니다.


5. 아키텍처 레벨 방지 — 3단계 방어

1단계(멱등키)와 2단계(분산 락)는 "중복이 왔을 때 막는 것"입니다. 3단계는 "중복이 오지 않게 구조를 설계하는 것" 입니다.


6. 메시지 브로커 선택 — 왜 NATS인가

기술 선택은 항상 대안 대비 장점으로 설명해야 합니다.

비교 항목NATSKafkaRabbitMQ
복잡도낮음 — 설정 단순높음 — ZooKeeper/KRaft 필요중간
지연 시간매우 낮음낮음 (배치 최적화)낮음
메시지 영속성JetStream 사용 시 지원기본 지원 (로그 기반)기본 지원
NestJS 통합공식 트랜스포터별도 라이브러리공식 트랜스포터
적합한 규모중소규모 MSA대규모 스트리밍중규모, 복잡한 라우팅

선택 근거

NestJS + gRPC 기반 MSA에서 서비스 간 이벤트 전달 용도로 사용합니다. Kafka는 대규모 로그 스트리밍에 강점이 있지만, 힐링샘 서비스 규모에서는 인프라 오버헤드가 과합니다. NATS는:


7. NATS 장애 대응 전략

메시지 브로커는 단일 장애점(SPOF) 이 될 수 있습니다. 3단계로 대응합니다.

1단계: NATS 자체 고가용성 — 클러스터링

단일 노드로 운영하지 않습니다. 최소 3노드 클러스터로 구성하면, 한 노드가 죽어도 나머지 노드가 자동으로 이어받습니다.

nats-server -cluster nats://0.0.0.0:6222 \
  -routes nats://node2:6222,nats://node3:6222

JetStream을 쓰면 메시지가 노드 간 복제되므로 데이터 유실도 방지됩니다.

2단계: NATS 전체 장애 시 — Outbox 폴백

클러스터가 통째로 죽는 극단적 상황에서는, 메시지를 NATS에 직접 보내지 않고 DB에 저장합니다.

try {
  await natsClient.emit('order.created', payload);
} catch (e) {
  await outboxRepository.insert({
    eventType: 'order.created',
    payload: JSON.stringify(payload),
    published: false,
    createdAt: new Date(),
  });
}

NATS가 복구되면 별도 프로세스가 Outbox를 읽어서 재발행합니다. 이벤트 발행이 NATS의 가용성에 의존하지 않게 됩니다.

3단계: 아키텍처 레벨 — 동기/비동기 분리


8. Outbox 패턴 — 이벤트 발행의 신뢰성 보장

Outbox 패턴은 장애 대응만을 위한 것이 아닙니다. 정상 상황에서도 이벤트 발행의 신뢰성을 보장하는 핵심 패턴입니다.

문제: 이중 쓰기 (Dual Write Problem)

MSA에서 가장 흔한 실수는 비즈니스 로직과 이벤트 발행을 따로 처리하는 것입니다.

// ❌ 위험한 코드
await orderRepository.save(order);              // 1. DB 저장 성공
await natsClient.emit('order.created', order);  // 2. 이벤트 발행 실패 시?

DB 트랜잭션과 메시지 발행은 서로 다른 시스템이라 하나의 트랜잭션으로 묶을 수 없습니다. 이것이 이중 쓰기 문제입니다.

해결: 이벤트를 같은 DB 트랜잭션에서 Outbox 테이블에 저장

await dataSource.transaction(async (manager) => {
  // 1. 비즈니스 로직
  const order = manager.create(Order, { userId, items, status: 'CREATED' });
  await manager.save(order);

  // 2. 같은 트랜잭션에서 Outbox에 이벤트 저장
  await manager.insert(OutboxEvent, {
    id: uuid(),
    aggregateType: 'Order',
    aggregateId: order.id,
    eventType: 'ORDER_CREATED',
    payload: JSON.stringify({
      orderId: order.id,
      userId: order.userId,
      items: order.items,
    }),
    published: false,
    createdAt: new Date(),
  });
});
// 트랜잭션 커밋되면 둘 다 저장, 롤백되면 둘 다 취소 → 원자성 보장

Outbox 테이블 구조

CREATE TABLE outbox_event (
  id              UUID PRIMARY KEY,
  aggregate_type  VARCHAR(100) NOT NULL,   -- 'Order', 'Payment'
  aggregate_id    UUID NOT NULL,
  event_type      VARCHAR(100) NOT NULL,   -- 'ORDER_CREATED'
  payload         JSONB NOT NULL,
  published       BOOLEAN DEFAULT FALSE,
  created_at      TIMESTAMP DEFAULT NOW()
);

Outbox → 브로커 전달 방식

방법 1: 폴링 퍼블리셔 (Polling Publisher)

별도 프로세스가 주기적으로 Outbox를 조회하여 미발행 이벤트를 브로커에 발행합니다.

async function publishOutboxEvents() {
  const unpublished = await outboxRepository.find({
    where: { published: false },
    order: { createdAt: 'ASC' },
    take: 100,
  });

  for (const event of unpublished) {
    try {
      await natsClient.emit(event.eventType, JSON.parse(event.payload));
      event.published = true;
      await outboxRepository.save(event);
    } catch (e) {
      break; // 다음 주기에 재시도
    }
  }
}

setInterval(publishOutboxEvents, 5000);

방법 2: CDC (Change Data Capture)

DB의 트랜잭션 로그(WAL/binlog)를 읽어서, Outbox 테이블에 INSERT가 발생하면 자동으로 브로커에 전달합니다. Debezium이 대표적입니다.


9. Saga 패턴 — 여러 서비스에 걸친 트랜잭션

Outbox 패턴으로 하나의 서비스 내에서 이벤트 발행의 신뢰성을 보장했습니다. 하지만 MSA에서는 주문 → 결제 → 재고 → 배송처럼 여러 서비스에 걸친 트랜잭션이 필요합니다. 각 서비스가 자기 DB를 갖고 있으므로 하나의 트랜잭션으로 묶을 수 없습니다.

결제까지 완료된 후에 재고 부족으로 실패하면? 이미 완료된 결제를 취소하고, 주문도 취소해야 합니다. 이것을 보상 트랜잭션(Compensating Transaction) 이라 하고, 이 전체 흐름을 관리하는 것이 Saga 패턴입니다.

핵심 개념: 로컬 트랜잭션 + 보상 트랜잭션

정상 흐름:
주문 생성 → 결제 처리 → 재고 차감 → 배송 요청 → 완료

재고 차감에서 실패 시:
재고 차감 실패 → 결제 취소(환불) → 주문 취소 → 실패 응답
단계로컬 트랜잭션보상 트랜잭션
1주문 생성 (PENDING)주문 취소 (CANCELLED)
2결제 처리결제 취소 (환불)
3재고 차감재고 복구
4배송 요청배송 취소

방식 1: Choreography (이벤트 기반)

중앙 관리자 없이, 각 서비스가 이벤트를 발행하고 다음 서비스가 구독하여 진행합니다.

주문 서비스: 주문 생성 → 'order.created' 발행
    ↓ (구독)
결제 서비스: 결제 처리 → 'payment.completed' 발행
    ↓ (구독)
재고 서비스: 재고 차감 → 'inventory.reserved' 발행
    ↓ (구독)
주문 서비스: 주문 확정 (CONFIRMED)

// 재고 실패 시 → 역방향 보상
재고 서비스: 'inventory.failed' 발행
    ↓
결제 서비스: 환불 → 'payment.refunded' 발행
    ↓
주문 서비스: 주문 취소 (CANCELLED)
@EventPattern('payment.completed')
async handlePaymentCompleted(data: PaymentCompletedEvent) {
  await this.orderRepository.update(data.orderId, {
    status: OrderStatus.PAYMENT_DONE,
  });
  this.client.emit('inventory.reserve', {
    orderId: data.orderId,
    items: data.items,
  });
}

@EventPattern('inventory.failed')
async handleInventoryFailed(data: InventoryFailedEvent) {
  // 보상 트랜잭션
  await this.orderRepository.update(data.orderId, {
    status: OrderStatus.CANCELLED,
    cancelReason: data.reason,
  });
}

방식 2: Orchestration (중앙 관리자)

Saga Orchestrator가 전체 흐름을 중앙에서 관리합니다. 각 단계를 순서대로 호출하고, 실패 시 보상 트랜잭션을 역순으로 실행합니다.

class OrderSagaOrchestrator {
  private steps: SagaStep[] = [
    {
      name: 'createOrder',
      execute: (data) => this.orderService.create(data),
      compensate: (data) => this.orderService.cancel(data.orderId),
    },
    {
      name: 'processPayment',
      execute: (data) => this.paymentService.charge(data),
      compensate: (data) => this.paymentService.refund(data.paymentId),
    },
    {
      name: 'reserveInventory',
      execute: (data) => this.inventoryService.reserve(data),
      compensate: (data) => this.inventoryService.release(data.reservationId),
    },
  ];

  async execute(orderData: CreateOrderDto) {
    const completedSteps: CompletedStep[] = [];

    for (const step of this.steps) {
      try {
        const result = await step.execute(orderData);
        completedSteps.push({ step, result });
        orderData = { ...orderData, ...result };
      } catch (error) {
        await this.compensate(completedSteps);
        throw new SagaFailedException(step.name, error);
      }
    }

    return { success: true, orderId: orderData.orderId };
  }

  private async compensate(completedSteps: CompletedStep[]) {
    for (const { step, result } of completedSteps.reverse()) {
      try {
        await step.compensate(result);
      } catch (e) {
        // 보상 실패 → 로그 + Dead Letter Queue + 수동 개입
        logger.error(`Compensation failed: ${step.name}`, e);
      }
    }
  }
}

선택 기준

기준ChoreographyOrchestration
서비스 수3~4개 이하 단순 플로우5개 이상 또는 복잡한 분기
가시성이벤트 추적 필요한눈에 보임
결합도낮음오케스트레이터에 의존

Outbox + Saga 조합 (실무 적용안)

Saga의 각 단계에서 이벤트를 발행할 때 Outbox 패턴을 함께 사용합니다. Saga 진행 중에 메시지 브로커 장애가 나도 이벤트가 유실되지 않습니다.

주문 서비스 (트랜잭션):
  1. 주문 생성 (DB)
  2. 'order.created' → Outbox에 저장     ← Outbox 패턴
  3. 트랜잭션 커밋

폴링 퍼블리셔:
  4. Outbox → NATS 발행                   ← Saga 진행

결제 서비스:
  5. 결제 처리 + 'payment.completed' → Outbox  ← 다음 Saga 단계
  ...

힐링샘 MSA에서는 주문·결제·재고 흐름에 Orchestration 방식의 Saga를 적용하고, 각 단계의 이벤트 발행은 Outbox 패턴으로 신뢰성을 보장하는 방향으로 설계 중입니다. 보상 트랜잭션 실패에 대비한 Dead Letter Queue와 수동 개입 프로세스까지 함께 검토하고 있습니다.