2026년 4월 17일 금요일
글 목록
Lv.3 중급MongoDB
38분 읽기Lv.3 중급
시리즈MongoDB ACID 완전 정복 · 파트 5/5시리즈 허브 보기

MongoDB의 ACID 완전 정복 Part 5 — 실전 패턴, 최적화, 그리고 피해야 할 것들

MongoDB의 ACID 완전 정복 Part 5 — 실전 패턴, 최적화, 그리고 피해야 할 것들

앞선 네 편에서 ACID의 개념과 내부 동작을 짚었다면, 이번 편은 운영 코드에 옮길 때의 실무 관점에 맞춥니다. `withTransaction`을 중심으로 한 안전한 호출 템플릿, 도메인별로 자주 쓰는 멀티 도큐먼트 패턴, 임베드·원자적 조건 갱신으로 트랜잭션을 줄이는 방법, 그리고 MSA에서 흔한 Transactional Outbox와 소비자 측 멱등성까지 이어서 설명합니다. 성능·한계·모니터링 포인트는 수치를 단정하지 않고 ‘왜 위험해지는지’에 초점을 두었고, 마지막에는 시리즈 전체를 한눈에 보는 요약과 함께 마칩니다. 배포·버전·드라이버는 팀 환경에 맞춰 공식 문서와 대조하시길 권합니다.

시리즈 구성

목차

  1. 들어가며
  2. 프로덕션에서 쓰는 트랜잭션 호출 템플릿
  3. 도메인별 실전 패턴 다섯 가지
  4. 트랜잭션 없이도 맞추는 정합성 — 임베드와 원자적 갱신
  5. Transactional Outbox와 소비자 측 멱등성
  6. 성능과 수명 — 트랜잭션을 짧게 유지하기
  7. 한계·체크리스트와 관측 포인트
  8. 피해야 할 안티패턴 일곱 가지
  9. 시리즈 요약 — 한 장으로 보는 MongoDB ACID
  10. 참고 — 공식 문서
  11. 마치며

1. 들어가며

Part 4까지는 지속성(Durability)과 writeConcern을 중심으로 “응답을 보낸 뒤에도 쓰기가 남는가”를 다뤘습니다. 이제 같은 ACID 틀을 애플리케이션 코드로 옮깁니다.

이번 글의 초점은 세 가지입니다.

  1. 멀티 도큐먼트 트랜잭션을 실수 없이 호출하는 패턴(session 전달, 재시도, 종료).
  2. 도메인별로 반복되는 이체·재고·예약·멱등·배치 예시를 통해, 스키마와 쿼리 형태를 갖추는 방법.
  3. 트랜잭션을 남용하지 않기 — 단일 문서·원자적 연산·Outbox로 경계를 나누는 실무 관점.

아래 코드는 MongoDB Node.js 드라이버 6.x 문법을 기준으로 합니다. 반환 객체 형태·옵션 이름은 메이저 버전마다 달라질 수 있으므로, 프로젝트의 드라이버 버전에 맞춰 공식 문서를 함께 확인해 주시기 바랍니다.


2. 프로덕션에서 쓰는 트랜잭션 호출 템플릿

운영에서 자주 보는 실수는 다음과 같습니다. session을 일부 연산에만 넘기거나, 세션을 닫지 않거나, 일시적 충돌을 재시도하지 않는 경우입니다.

startTransaction/commitTransaction을 직접 호출하는 대신, session.withTransaction 으로 감싸면 커밋·중단·일부 재시도 경로를 드라이버가 정리하기 쉽습니다. 아래는 그 뼈대입니다.

const { MongoClient, MongoError } = require('mongodb');

const client = new MongoClient(process.env.MONGO_URI);

/**
 * withTransaction 콜백 안에서는 반드시 모든 읽기/쓰기에 { session }을 넘깁니다.
 * TransientTransactionError 등은 드라이버·버전에 따라 재시도 정책이 다를 수 있습니다.
 */
async function runTransaction(callback) {
  const session = client.startSession();

  try {
    const result = await session.withTransaction(
      async (s) => callback(s),
      {
        readConcern: { level: 'snapshot' },
        writeConcern: { w: 'majority', wtimeout: 10000 },
        maxCommitTimeMS: 5000,
      }
    );
    return result;
  } catch (error) {
    if (error instanceof MongoError) {
      console.error('[MongoDB Transaction Error]', {
        code: error.code,
        codeName: error.codeName,
        message: error.message,
      });
    }
    throw error;
  } finally {
    await session.endSession();
  }
}

withTransaction에 넘기는 콜백의 시그니처는 사용 중인 드라이버 버전 문서를 따르십시오. 위에서는 callback(session) 형태로 두었습니다.


3. 도메인별 실전 패턴 다섯 가지

3.1 계좌 이체

이체는 출금·입금·원장 기록이 한 단위로 성공하거나 모두 되돌아가야 합니다. findOneAndUpdate로 조건(잔액·상태)과 갱신을 한 번에 묶는 방식이 흔합니다.

중요: findOneAndUpdate반환 형태는 드라이버와 옵션에 따라 다를 수 있습니다. 아래 예시는 ModifyResult.value 형태와 문서 직접 반환 형태를 모두 수용하도록 작성했습니다.

async function transferFunds({ fromId, toId, amount, currency = 'KRW' }) {
  return runTransaction(async (session) => {
    const accounts = client.db('banking').collection('accounts');

    const debitModifyResult = await accounts.findOneAndUpdate(
      {
        _id: fromId,
        balance: { $gte: amount },
        status: 'active',
        currency,
      },
      {
        $inc: { balance: -amount },
        $set: { updatedAt: new Date() },
        $push: {
          history: {
            type: 'debit',
            amount,
            toAccount: toId,
            timestamp: new Date(),
            status: 'completed',
          },
        },
      },
      { session, returnDocument: 'after' }
    );

    const debited = debitModifyResult?.value ?? debitModifyResult;
    if (!debited) {
      throw new Error('INSUFFICIENT_FUNDS_OR_INVALID_ACCOUNT');
    }

    const creditModifyResult = await accounts.findOneAndUpdate(
      { _id: toId, status: 'active', currency },
      {
        $inc: { balance: amount },
        $set: { updatedAt: new Date() },
        $push: {
          history: {
            type: 'credit',
            amount,
            fromAccount: fromId,
            timestamp: new Date(),
            status: 'completed',
          },
        },
      },
      { session, returnDocument: 'after' }
    );

    const credited = creditModifyResult?.value ?? creditModifyResult;
    if (!credited) {
      throw new Error('RECIPIENT_ACCOUNT_NOT_FOUND');
    }

    await client.db('banking').collection('ledger').insertOne(
      {
        type: 'transfer',
        fromAccount: fromId,
        toAccount: toId,
        amount,
        currency,
        executedAt: new Date(),
        balanceAfterDebit: debited.balance,
        balanceAfterCredit: credited.balance,
      },
      { session }
    );
  });
}

3.2 재고 차감과 주문 생성

동일 상품에 대한 동시 구매를 다룰 때는 조건이 포함된 갱신으로 재고를 줄이고, 그 다음 주문 문서를 넣습니다.

async function createOrder({ customerId, items }) {
  return runTransaction(async (session) => {
    const db = client.db('shop');
    let totalAmount = 0;

    for (const { productId, quantity } of items) {
      const product = await db.collection('products').findOneAndUpdate(
        {
          _id: productId,
          stock: { $gte: quantity },
          status: 'available',
        },
        { $inc: { stock: -quantity } },
        { session, returnDocument: 'after' }
      );

      const updated = product?.value ?? product;
      if (!updated) {
        throw new Error(`STOCK_INSUFFICIENT:${productId}`);
      }

      totalAmount += updated.price * quantity;
    }

    const order = await db.collection('orders').insertOne(
      {
        customerId,
        items,
        totalAmount,
        status: 'confirmed',
        createdAt: new Date(),
      },
      { session }
    );

    await db.collection('customers').updateOne(
      { _id: customerId },
      {
        $inc: { totalOrders: 1, totalSpent: totalAmount },
        $set: { lastOrderAt: new Date() },
      },
      { session }
    );

    return order.insertedId;
  });
}

3.3 예약 — 유니크 인덱스와 트랜잭션

슬롯 단위 중복을 막으려면 유니크 인덱스와 함께 삽입을 시도하는 방식이 단순하고 강합니다.

await db.collection('reservations').createIndex(
  { resourceId: 1, timeSlot: 1 },
  { unique: true }
);

async function makeReservation({ resourceId, timeSlot, userId }) {
  return runTransaction(async (session) => {
    const db = client.db('booking');

    try {
      await db.collection('reservations').insertOne(
        {
          resourceId,
          timeSlot,
          userId,
          status: 'confirmed',
          createdAt: new Date(),
        },
        { session }
      );
    } catch (error) {
      if (error.code === 11000) {
        throw new Error('TIME_SLOT_ALREADY_BOOKED');
      }
      throw error;
    }

    await db.collection('outbox').insertOne(
      {
        type: 'RESERVATION_CONFIRMED',
        payload: { resourceId, timeSlot, userId },
        status: 'pending',
        createdAt: new Date(),
      },
      { session }
    );
  });
}

3.4 멱등 키

네트워크 재시도로 같은 요청이 두 번 들어올 수 있습니다. 멱등 키 컬렉션에 결과를 저장해 두면 동일 키는 이전 결과만 돌려줄 수 있습니다.

async function processPaymentIdempotent({ idempotencyKey, paymentData }) {
  return runTransaction(async (session) => {
    const db = client.db('payments');

    const existing = await db
      .collection('idempotency_keys')
      .findOne({ key: idempotencyKey }, { session });

    if (existing) {
      return existing.result;
    }

    const payment = await db.collection('payments').insertOne(
      { ...paymentData, processedAt: new Date() },
      { session }
    );

    await db.collection('idempotency_keys').insertOne(
      {
        key: idempotencyKey,
        result: { paymentId: payment.insertedId },
        createdAt: new Date(),
      },
      { session }
    );

    return { paymentId: payment.insertedId };
  });
}

3.5 배치로 나누는 멀티 도큐먼트 갱신

문서 수가 많은 작업을 한 트랜잭션에 몰아넣으면 잠금·오플로그·수명 한계에 동시에 걸립니다. “1,000개 이하” 같은 숫자는 공식 가이드에서 경험적 상한으로 자주 인용되지만, 실제로 견딜 수 있는 크기는 갱신당 잠금 범위, 문서 크기, 경합, 디스크·CPU 여유에 따라 달라집니다. 숫자만 외우기보다, 왜 길어지면 실패율과 지연이 늘어나는지를 기준으로 배치 크기를 조정하십시오.

async function bulkUpdateWithBatching(updates, batchSize = 500) {
  const results = { success: 0, failed: 0, errors: [] };

  for (let i = 0; i < updates.length; i += batchSize) {
    const batch = updates.slice(i, i + batchSize);

    try {
      await runTransaction(async (session) => {
        const db = client.db('app');

        for (const update of batch) {
          await db.collection('records').updateOne(
            { _id: update.id },
            { $set: update.fields },
            { session }
          );
        }
      });

      results.success += batch.length;
    } catch (error) {
      results.failed += batch.length;
      results.errors.push({ batchStart: i, error: error.message });
    }
  }

  return results;
}

4. 트랜잭션 없이도 맞추는 정합성 — 임베드와 원자적 갱신

4.1 임베디드 문서

서로 강하게 묶인 필드는 한 문서에 넣으면 단일 도큐먼트 원자성만으로도 일관성을 맞출 수 있습니다.

await db.collection('users').updateOne(
  { _id: userId },
  {
    $set: { 'auth.lastLoginAt': new Date(), 'auth.failedAttempts': 0 },
    $inc: { 'stats.loginCount': 1 },
  }
);

4.2 findOneAndUpdate로 읽기·조건·쓰기를 한 연산으로

“읽고 → 판단하고 → 쓰기”를 나누면 경합이 생깁니다. 조건이 포함된 단일 갱신으로 옮기십시오.

const result = await db.collection('tickets').findOneAndUpdate(
  { _id: ticketId, status: 'available' },
  { $set: { status: 'reserved', userId, reservedAt: new Date() } },
  { returnDocument: 'after' }
);

const ticket = result?.value ?? result;
if (!ticket) {
  throw new Error('TICKET_ALREADY_SOLD');
}

5. Transactional Outbox와 소비자 측 멱등성

5.1 왜 이중 쓰기(Dual Write)가 위험한가

DB에 반영한 뒤 메시지 브로커에 발행하는 식의 두 시스템에 순서대로 쓰기는 중간에 실패하면 한쪽만 성공하는 틈이 생깁니다.

5.2 Outbox로 프로듀서 쪽 원자성 확보

같은 트랜잭션 안에 비즈니스 테이블과 outbox 컬렉션을 함께 쓰면, 둘 중 하나만 커밋되는 상황을 줄입니다.

async function createOrderWithOutbox(orderData) {
  return runTransaction(async (session) => {
    const db = client.db('shop');

    const order = await db.collection('orders').insertOne(
      { ...orderData, status: 'pending', createdAt: new Date() },
      { session }
    );

    await db.collection('outbox').insertOne(
      {
        aggregateType: 'Order',
        aggregateId: order.insertedId,
        eventType: 'OrderCreated',
        payload: { orderId: order.insertedId, ...orderData },
        status: 'PENDING',
        createdAt: new Date(),
        retryCount: 0,
      },
      { session }
    );

    return order.insertedId;
  });
}

Outbox 흐름을 한 장으로 정리하면 다음과 같습니다. 애플리케이션이 주문과 이벤트를 같은 트랜잭션에 넣고, 릴레이가 브로커로 넘긴 뒤 상태를 갱신하는 구조입니다.

5.3 릴레이와 “최소 1회” 전달

브로커로 보내는 컴포넌트는 재시도를 전제로 하므로, 대부분의 환경에서 최소 한 번(at-least-once) 전달에 가깝습니다. 즉, 소비자는 동일 이벤트가 두 번 올 수 있다고 가정하고 설계해야 합니다.

  • 소비자 멱등성: 이벤트 ID·집합 키·비즈니스 키를 저장해 “이미 처리함”을 판별합니다.
  • 중복 제거: 짧은 윈도우의 중복만 막는지, 영구 멱등 저장소가 필요한지 도메인에 따라 다릅니다.
  • 정확히 한 번(exactly-once) 는 브로커·소비자·외부 시스템의 조합 없이는 쉽게 약속하기 어렵다는 점을 염두에 두십시오.
async function outboxRelayLoop() {
  const db = client.db('shop');

  const cursor = db.collection('outbox').find({ status: 'PENDING' }).batchSize(50);

  for await (const event of cursor) {
    try {
      await kafka.produce(event.eventType, event.payload);

      await db.collection('outbox').updateOne(
        { _id: event._id },
        { $set: { status: 'PUBLISHED', publishedAt: new Date() } }
      );
    } catch (error) {
      await db.collection('outbox').updateOne(
        { _id: event._id },
        {
          $inc: { retryCount: 1 },
          $set: {
            status: event.retryCount >= 3 ? 'FAILED' : 'PENDING',
            lastError: error.message,
          },
        }
      );
    }
  }
}

6. 성능과 수명 — 트랜잭션을 짧게 유지하기

6.1 트랜잭션 안에서 하지 말 것

외부 HTTP·결제·메시지 큐 호출은 트랜잭션 밖으로 빼십시오. 트랜잭션은 DB 자원을 붙잡고 있으므로, 외부 지연이 곧바로 경합과 타임아웃으로 이어집니다.

6.2 인덱스

트랜잭션 안의 find·update인덱스를 타지 않으면 스캔과 잠금 범위가 커집니다. 운영 전에 실행 계획을 확인하십시오.

6.3 수명 측정

평균·p95·p99 트랜잭션 지연writeConflict 빈도를 함께 보면, “느려졌을 때 무엇을 줄일지” 판단이 쉬워집니다.

async function monitoredTransaction(callback, label = 'unnamed') {
  const startTime = Date.now();
  const result = await runTransaction(callback);
  const duration = Date.now() - startTime;

  if (duration > 1000) {
    console.warn(`[SLOW TRANSACTION] ${label}: ${duration}ms`);
  }
  if (duration > 5000) {
    console.error(`[CRITICAL TRANSACTION] ${label}: ${duration}ms`);
  }

  return result;
}

7. 한계·체크리스트와 관측 포인트

아래 항목은 배포 전 점검표로 쓰기 좋습니다. 수치(예: 최대 수명 초)는 서버 파라미터·버전에 따라 다르므로, 사용 중인 매뉴얼을 기준으로 하십시오.

마케팅·벤치마크 수치에 대해: 특정 버전에서 “혼합 워크로드 대비 이전 버전 대비 개선” 같은 표현은 공식 릴리스 노트·발표 자료의 조건 하에서만 이해하는 것이 안전합니다. 인스턴스 크기, 데이터셋, 토폴로지가 다르면 결과는 달라질 수 있습니다.

증상의심되는 원인먼저 볼 지표·조치
간헐적 WriteConflict짧은 간격의 동일 키 갱신, 큰 잠금 범위트랜잭션 지연 분포, 충돌 키 패턴, 쿼리 인덱스
wtimeout 다발세컨더리 지연·네트워크·과도한 majority 대기복제 지연, RS 상태, wtimeout·업무 SLO
트랜잭션 p99 급증외부 호출 혼입, 배치 과대, 스캔트랜잭션 내부 쿼리 계획, 배치 크기
WiredTiger 캐시 압박장기 트랜잭션·대량 스캔캐시 사용률, evict 대기, 동시 트랜잭션 수

체크리스트(요약):

  • Replica Set 전제(개발 환경도 가능하면 RS로).
  • writeConcernwtimeout 을 함께 고려(무한 대기 방지).
  • 트랜잭션에는 readConcern: snapshot 등, 애플리케이션 요구에 맞는 조합을 명시.
  • config·admin·local 등 시스템 DB에 트랜잭션 쓰기 제한을 기억.

8. 피해야 할 안티패턴 일곱 가지

8.1 일부 연산에만 session 넘기기

한쪽은 트랜잭션 밖에서 커밋되고 다른 한쪽만 롤백되는 분열 커밋이 생깁니다. 모든 읽기·쓰기에 동일 session을 넘겼는지 코드 리뷰로 고정하십시오.

8.2 장기 실행 트랜잭션

대량 커서를 트랜잭션 안에서 한 번에 훑는 패턴은 캐시·수명 한계·경합에 모두 불리합니다. 배치 크기를 줄이고 트랜잭션을 짧게 여러 번 나눕니다.

8.3 단일 문서 갱신에 멀티 도큐먼트 트랜잭션

오버헤드만 늘고 이득이 적은 경우가 많습니다. 정말 필요한지 먼저 판단하십시오.

8.4 majority만 쓰고 wtimeout 없음

세컨더리 응답이 막히면 애플리케이션 스레드가 오래 대기할 수 있습니다.

8.5 재시도 가능한 오류를 그대로 노출

withTransaction이 재시도하는 범위와, 애플리케이션이 덮어써야 할 범위를 나누십시오.

8.6 단독(standalone) mongod에서 멀티 도큐먼트 트랜잭션

개발 환경이라도 RS 구성을 권합니다.

8.7 시스템 DB에 트랜잭션 쓰기

애플리케이션 데이터는 애플리케이션 DB에 두십시오.


9. 시리즈 요약 — 한 장으로 보는 MongoDB ACID

글자무엇을 맞추는가MongoDB에서 자주 쓰는 수단
A여러 쓰기가 한 덩어리로 성공·실패멀티 도큐먼트 트랜잭션, 단일 문서 원자성
C규칙·스키마·제약을 어기지 않음스키마 검증, 유니크 인덱스, 애플리케이션 불변식
I동시에 무엇이 보이는가스냅샷·충돌 정책·Read Concern
D커밋 후 남는가저널·체크포인트·writeConcern

시리즈 전반이 말하는 실무 원칙은 단순합니다. 트랜잭션은 도구이고, 먼저 스키마와 경계를 줄이는 쪽이 운영 비용을 줄입니다.


10. 참고 — 공식 문서

버전·드라이버 조합에 따라 동작 세부는 달라질 수 있으므로, 아래는 개념을 맞출 때 자주 열어보는 MongoDB 측 자료입니다. 읽는 순서는 자유입니다.


11. 마치며

멀티 도큐먼트 트랜잭션은 운영 규칙·버전·토폴로지와 함께 읽을 때 비로소 안전합니다. 벤더 사례나 벤치마크에서 인용되는 처리량·개선율은 측정 조건을 함께 확인하지 않으면 일반화하기 어렵습니다.

이 시리즈가 독자분의 코드 리뷰 체크리스트와 장애 대응 표를 만드는 데 도움이 되기를 바랍니다. 다음 배포에서 바꾸는 설정이 있다면, 스테이징에서의 부하 패턴공식 문서의 변경점을 한 번 더 대조해 주시기 바랍니다.


이전 편: Part 4 — Durability, WiredTiger, Write Concern

이 글 공유하기

시리즈 내비게이션

MongoDB ACID 완전 정복

5 / 5 · 5

같은 주제 더 보기·대표 시리즈로 시작

English

최신 글을 RSS로 받아보세요

뉴스레터 오픈 전에는 RSS로 먼저 업데이트를 받아보실 수 있습니다.

RSS 구독 안내 보기