🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

MongoDB 트랜잭션 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 27. · 21 Views

MongoDB 트랜잭션 완벽 가이드

MongoDB에서 트랜잭션을 사용하여 여러 문서를 안전하게 처리하는 방법을 알아봅니다. 세션 생성부터 격리 수준, 성능 최적화까지 실무에서 필요한 핵심 내용을 다룹니다.


목차

  1. 트랜잭션이_필요한_경우
  2. startSession_사용법
  3. withTransaction_헬퍼
  4. 트랜잭션_격리_수준
  5. Read_Concern과_Write_Concern
  6. 트랜잭션_성능_고려사항

1. 트랜잭션이 필요한 경우

김개발 씨는 쇼핑몰 서비스를 개발하고 있습니다. 어느 날 고객으로부터 황당한 문의가 들어왔습니다.

"결제는 됐는데 주문 내역이 없어요!" 로그를 확인해보니 결제 처리 후 주문 저장 중 서버가 죽었던 것입니다.

트랜잭션은 여러 작업을 하나의 논리적 단위로 묶어서 처리하는 것입니다. 마치 은행에서 돈을 이체할 때 출금과 입금이 반드시 함께 성공하거나 함께 실패해야 하는 것과 같습니다.

MongoDB에서도 여러 문서를 수정할 때 데이터 일관성을 보장해야 하는 경우 트랜잭션이 필수입니다.

다음 코드를 살펴봅시다.

// 트랜잭션 없이 작성한 위험한 코드
async function createOrderWithoutTransaction(orderData) {
  // 1. 결제 처리
  await paymentsCollection.insertOne({
    orderId: orderData.id,
    amount: orderData.total,
    status: 'completed'
  });

  // 2. 여기서 서버가 죽으면? 결제만 되고 주문은 없음!
  await ordersCollection.insertOne(orderData);

  // 3. 재고 차감도 안 됨
  await inventoryCollection.updateOne(
    { productId: orderData.productId },
    { $inc: { quantity: -orderData.quantity } }
  );
}

김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 쇼핑몰 서비스의 주문 시스템을 담당하고 있는데, 어느 날 고객센터에서 긴급 연락이 왔습니다.

"고객님이 결제는 됐는데 주문 내역이 없다고 하세요!" 황급히 서버 로그를 확인해보니 결제 처리 직후 서버가 재시작되었던 흔적이 있었습니다. 결제는 성공했지만 주문 정보 저장 전에 서버가 죽어버린 것입니다.

김개발 씨는 식은땀을 흘렸습니다. 선배 개발자 박시니어 씨가 다가왔습니다.

"아, 트랜잭션을 안 썼구나. 이런 경우가 바로 트랜잭션이 필요한 상황이야." 그렇다면 트랜잭션이란 정확히 무엇일까요?

쉽게 비유하자면, 트랜잭션은 마치 퍼즐 조각을 맞추는 것과 같습니다. 1000조각짜리 퍼즐을 맞추다가 999조각까지 맞췄는데 마지막 1조각이 없다면 어떨까요?

완성이 아닙니다. 트랜잭션도 마찬가지입니다.

모든 작업이 성공해야 진짜 성공이고, 하나라도 실패하면 전부 원래대로 돌아갑니다. 이것이 바로 ACID 원칙 중 **원자성(Atomicity)**입니다.

원자는 더 이상 쪼갤 수 없는 단위라는 뜻처럼, 트랜잭션 안의 작업들은 쪼개질 수 없는 하나의 단위로 취급됩니다. 트랜잭션이 없던 시절에는 어땠을까요?

개발자들은 결제가 실패하면 수동으로 주문을 취소하는 코드를 작성해야 했습니다. 재고 복구도 직접 해야 했습니다.

코드가 복잡해지고, 실수할 여지도 많았습니다. 더 큰 문제는 동시성이었습니다.

두 명의 고객이 동시에 마지막 재고 1개를 주문하면 어떻게 될까요? 둘 다 성공해버리면 재고가 마이너스가 됩니다.

이런 상황을 막기 위해서도 트랜잭션이 필요합니다. 위의 코드를 살펴보면 세 가지 작업이 순차적으로 일어납니다.

결제 처리, 주문 저장, 재고 차감입니다. 이 중 어느 하나라도 중간에 실패하면 데이터 불일치가 발생합니다.

실제로 트랜잭션이 필요한 경우를 정리하면 다음과 같습니다. 첫째, 여러 컬렉션에 걸친 작업입니다.

주문과 결제처럼 서로 다른 컬렉션을 동시에 수정해야 할 때입니다. 둘째, 금융 관련 처리입니다.

돈이 오가는 작업은 절대로 중간 상태가 있으면 안 됩니다. 셋째, 재고 관리입니다.

재고를 차감하고 주문을 생성하는 작업이 원자적으로 처리되어야 합니다. 넷째, 사용자 관련 연쇄 작업입니다.

회원 탈퇴 시 관련 데이터를 모두 삭제하는 것처럼 여러 작업이 연결된 경우입니다. 하지만 주의할 점도 있습니다.

모든 작업에 트랜잭션을 쓰면 오히려 성능이 떨어집니다. 단일 문서만 수정하는 경우에는 트랜잭션이 필요 없습니다.

MongoDB는 단일 문서 수정에 대해서는 기본적으로 원자성을 보장하기 때문입니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 결제와 주문이 따로 놀았군요!" 이제 트랜잭션을 도입해야 할 때입니다.

실전 팁

💡 - 단일 문서 수정은 MongoDB가 자동으로 원자성을 보장하므로 트랜잭션 불필요

  • 여러 컬렉션에 걸친 작업이나 금융 처리에는 반드시 트랜잭션 사용
  • 트랜잭션은 Replica Set 또는 Sharded Cluster 환경에서만 동작

2. startSession 사용법

트랜잭션이 필요하다는 것을 깨달은 김개발 씨는 이제 직접 구현해보려 합니다. 그런데 막상 코드를 작성하려니 어디서부터 시작해야 할지 막막합니다.

박시니어 씨가 웃으며 말합니다. "트랜잭션의 시작은 세션이야."

startSession은 MongoDB에서 트랜잭션을 시작하기 위한 첫 번째 단계입니다. 세션은 마치 은행 창구에서 번호표를 받는 것과 같습니다.

번호표가 있어야 업무를 볼 수 있듯이, 세션이 있어야 트랜잭션을 진행할 수 있습니다. 모든 트랜잭션 작업은 이 세션 안에서 이루어집니다.

다음 코드를 살펴봅시다.

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

async function executeTransaction() {
  const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
  await client.connect();

  // 세션 시작 - 트랜잭션의 시작점
  const session = client.startSession();

  try {
    // 트랜잭션 시작
    session.startTransaction();

    const orders = client.db('shop').collection('orders');
    const inventory = client.db('shop').collection('inventory');

    // 모든 작업에 session을 전달
    await orders.insertOne({ item: 'phone', qty: 1 }, { session });
    await inventory.updateOne(
      { item: 'phone' },
      { $inc: { stock: -1 } },
      { session }
    );

    // 모든 작업 성공 시 커밋
    await session.commitTransaction();
    console.log('트랜잭션 성공!');
  } catch (error) {
    // 오류 발생 시 롤백
    await session.abortTransaction();
    console.log('트랜잭션 실패, 롤백됨');
  } finally {
    // 세션 종료 - 반드시 해야 함
    session.endSession();
    await client.close();
  }
}

김개발 씨는 트랜잭션 코드를 처음 작성해봅니다. 박시니어 씨가 옆에서 차근차근 설명해줍니다.

"트랜잭션을 쓰려면 먼저 세션을 만들어야 해." 세션이란 무엇일까요? 쉽게 비유하자면 은행에서 받는 번호표와 같습니다.

은행에 들어가면 번호표를 뽑고, 그 번호가 불릴 때까지 기다렸다가 창구에서 업무를 봅니다. 번호표가 있어야 내 거래 내역이 추적되고 관리됩니다.

MongoDB의 세션도 마찬가지입니다. 세션을 시작하면 MongoDB가 "아, 이 클라이언트가 트랜잭션을 시작하려 하는구나"라고 인식합니다.

그리고 그 세션 안에서 일어나는 모든 작업을 하나의 단위로 묶어서 관리합니다. 코드를 살펴보겠습니다.

먼저 **client.startSession()**으로 세션을 생성합니다. 이것이 트랜잭션의 시작점입니다.

그 다음 **session.startTransaction()**을 호출하면 본격적으로 트랜잭션이 시작됩니다. 여기서 중요한 점이 있습니다.

모든 데이터베이스 작업에 { session } 옵션을 전달해야 합니다. 이걸 빠뜨리면 해당 작업은 트랜잭션 바깥에서 실행되어 버립니다.

박시니어 씨가 강조합니다. "이거 빠뜨리는 실수가 정말 많아.

꼭 확인해." 모든 작업이 성공하면 **session.commitTransaction()**을 호출합니다. 이 순간 모든 변경사항이 데이터베이스에 실제로 반영됩니다.

커밋 전까지는 변경사항이 임시 상태로 존재합니다. 만약 중간에 오류가 발생하면 어떻게 될까요?

catch 블록에서 **session.abortTransaction()**을 호출합니다. 이렇게 하면 트랜잭션 시작 이후의 모든 변경사항이 취소됩니다.

마치 게임에서 세이브 포인트로 돌아가는 것과 같습니다. 마지막으로 finally 블록에서 반드시 **session.endSession()**을 호출해야 합니다.

세션은 자원을 사용하므로 사용이 끝나면 정리해주어야 합니다. 이것을 빠뜨리면 메모리 누수가 발생할 수 있습니다.

한 가지 더 알아야 할 것이 있습니다. MongoDB 트랜잭션은 Replica Set 환경에서만 동작합니다.

로컬 개발 환경에서 단일 인스턴스로 MongoDB를 실행하면 트랜잭션이 동작하지 않습니다. 개발 환경에서도 Replica Set을 구성하거나 MongoDB Atlas를 사용해야 합니다.

김개발 씨가 고개를 끄덕입니다. "생각보다 복잡하네요.

try-catch-finally 패턴을 잘 지켜야겠군요."

실전 팁

💡 - 모든 DB 작업에 { session } 옵션을 반드시 전달해야 트랜잭션에 포함됨

  • finally 블록에서 endSession()을 호출하여 자원 정리 필수
  • 로컬 개발 시에도 Replica Set 구성 필요 (mongod --replSet rs0)

3. withTransaction 헬퍼

김개발 씨가 startSession 코드를 작성해보니 try-catch-finally가 복잡하게 느껴집니다. "매번 이렇게 길게 써야 하나요?" 박시니어 씨가 웃으며 답합니다.

"더 편한 방법이 있어. withTransaction 헬퍼를 써봐."

withTransaction은 MongoDB에서 제공하는 트랜잭션 헬퍼 메서드입니다. 마치 세탁기의 자동 코스처럼 시작, 커밋, 롤백, 재시도를 자동으로 처리해줍니다.

개발자는 비즈니스 로직에만 집중하면 되므로 코드가 훨씬 간결해지고 실수할 여지도 줄어듭니다.

다음 코드를 살펴봅시다.

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

async function createOrder(orderData) {
  const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
  await client.connect();

  const session = client.startSession();

  try {
    // withTransaction이 모든 것을 자동 처리
    const result = await session.withTransaction(async () => {
      const db = client.db('shop');

      // 재고 확인 및 차감
      const updateResult = await db.collection('inventory').updateOne(
        { productId: orderData.productId, stock: { $gte: orderData.qty } },
        { $inc: { stock: -orderData.qty } },
        { session }
      );

      if (updateResult.modifiedCount === 0) {
        throw new Error('재고가 부족합니다');
      }

      // 주문 생성
      await db.collection('orders').insertOne(orderData, { session });

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

    console.log('주문 완료:', result);
  } finally {
    session.endSession();
    await client.close();
  }
}

김개발 씨는 앞서 작성한 트랜잭션 코드를 보며 고민에 빠졌습니다. try-catch 안에 startTransaction, commitTransaction, abortTransaction을 직접 호출하는 코드가 너무 장황합니다.

실수로 빠뜨릴 것 같은 불안감도 듭니다. 박시니어 씨가 다가옵니다.

"코드가 길어서 걱정되지? withTransaction 헬퍼를 쓰면 훨씬 간단해져." withTransaction은 마치 세탁기의 자동 코스와 같습니다.

세탁기에 빨래를 넣고 자동 코스를 누르면 세탁, 헹굼, 탈수가 알아서 진행됩니다. 중간에 물이 안 빠지면 자동으로 재시도하고, 문제가 생기면 멈춥니다.

사용자는 빨래만 넣으면 됩니다. withTransaction도 마찬가지입니다.

개발자는 콜백 함수 안에 비즈니스 로직만 작성하면 됩니다. 트랜잭션 시작, 커밋, 롤백, 심지어 일시적 오류에 대한 재시도까지 자동으로 처리해줍니다.

코드를 살펴보겠습니다. session.withTransaction() 안에 비동기 콜백 함수를 전달합니다.

이 콜백 안에서 필요한 모든 DB 작업을 수행합니다. 여전히 { session } 옵션은 전달해야 합니다.

콜백 함수가 정상적으로 완료되면 자동으로 커밋됩니다. 만약 콜백 안에서 에러가 발생하면 자동으로 롤백됩니다.

개발자가 직접 commitTransaction이나 abortTransaction을 호출할 필요가 없습니다. 더 좋은 점이 있습니다.

MongoDB는 **일시적 트랜잭션 오류(TransientTransactionError)**라는 개념이 있습니다. 네트워크 일시 장애 같은 이유로 트랜잭션이 실패했을 때, 재시도하면 성공할 수 있는 오류입니다.

withTransaction은 이런 오류가 발생하면 자동으로 재시도합니다. 위의 코드에서 재고 확인 부분을 주목해보세요.

stock: { $gte: orderData.qty } 조건으로 재고가 충분한지 확인하면서 동시에 차감합니다. 만약 재고가 부족하면 modifiedCount가 0이 되고, 직접 에러를 던집니다.

이렇게 하면 withTransaction이 자동으로 롤백합니다. 콜백 함수에서 값을 반환하면 withTransaction의 반환값으로 받을 수 있습니다.

주문 ID 같은 정보를 반환해서 후속 처리에 활용할 수 있습니다. 하지만 주의할 점도 있습니다.

withTransaction 안에서 외부 API 호출이나 부수 효과가 있는 작업을 하면 안 됩니다. 재시도될 때 외부 API가 중복 호출될 수 있기 때문입니다.

예를 들어 결제 API 호출은 트랜잭션 바깥에서 해야 합니다. 김개발 씨가 감탄합니다.

"와, 코드가 훨씬 깔끔해졌네요! 앞으로는 withTransaction을 쓰겠습니다."

실전 팁

💡 - withTransaction 콜백 안에서도 { session } 옵션은 반드시 전달해야 함

  • 콜백 안에서 외부 API 호출이나 부수 효과가 있는 작업은 피할 것
  • 일시적 오류에 대한 재시도가 자동으로 처리되므로 안정성이 높아짐

4. 트랜잭션 격리 수준

김개발 씨의 주문 시스템이 잘 동작하고 있습니다. 그런데 어느 날 이상한 현상이 보고됩니다.

동시에 주문이 들어왔을 때 재고가 이상하게 계산된다는 것입니다. 박시니어 씨가 설명합니다.

"격리 수준에 대해 알아야 할 때가 됐네."

**격리 수준(Isolation Level)**은 동시에 실행되는 트랜잭션들이 서로를 얼마나 볼 수 있는지를 결정합니다. 마치 시험을 볼 때 칸막이가 있으면 옆 사람의 답안지를 볼 수 없는 것처럼, 격리 수준이 높으면 다른 트랜잭션의 변경사항을 볼 수 없습니다.

MongoDB는 기본적으로 Snapshot Isolation을 제공합니다.

다음 코드를 살펴봅시다.

const session = client.startSession();

// 트랜잭션 옵션 설정
const transactionOptions = {
  readConcern: { level: 'snapshot' },
  writeConcern: { w: 'majority' },
  readPreference: 'primary'
};

await session.withTransaction(async () => {
  const inventory = client.db('shop').collection('inventory');

  // 트랜잭션 시작 시점의 스냅샷을 읽음
  const item = await inventory.findOne(
    { productId: 'A001' },
    { session }
  );

  console.log('트랜잭션 시작 시점 재고:', item.stock);

  // 다른 트랜잭션이 재고를 변경해도 이 트랜잭션은 영향 없음
  await inventory.updateOne(
    { productId: 'A001' },
    { $inc: { stock: -1 } },
    { session }
  );
}, transactionOptions);

김개발 씨는 새로운 문제에 부딪혔습니다. 두 명의 고객이 동시에 주문을 넣었는데, 재고가 10개인 상품에서 각각 6개씩 주문이 성공해버린 것입니다.

분명히 트랜잭션을 썼는데 왜 이런 일이 생긴 걸까요? 박시니어 씨가 화이트보드에 그림을 그리며 설명합니다.

"이건 격리 수준 문제야. 트랜잭션끼리 서로를 얼마나 볼 수 있느냐의 문제지." 격리 수준이란 무엇일까요?

쉽게 비유하자면 시험장의 칸막이와 같습니다. 칸막이가 높으면 옆 사람이 뭘 쓰는지 전혀 볼 수 없습니다.

칸막이가 낮으면 슬쩍 보일 수도 있습니다. 데이터베이스에서는 이 칸막이 높이가 격리 수준입니다.

관계형 데이터베이스에서는 보통 네 가지 격리 수준이 있습니다. Read Uncommitted, Read Committed, Repeatable Read, Serializable입니다.

격리 수준이 높을수록 안전하지만 성능은 떨어집니다. MongoDB는 조금 다른 방식을 사용합니다.

Snapshot Isolation이라는 격리 수준을 제공합니다. 이것은 트랜잭션이 시작되는 순간의 데이터 스냅샷을 찍어두고, 그 스냅샷을 기준으로 읽기 작업을 수행합니다.

예를 들어보겠습니다. 트랜잭션 A가 시작됩니다.

그 순간 재고가 10개입니다. 트랜잭션 A가 진행되는 동안 트랜잭션 B가 재고를 5개로 줄여버립니다.

하지만 트랜잭션 A는 여전히 재고를 10개로 봅니다. 자신이 시작한 시점의 스냅샷을 보기 때문입니다.

위의 코드에서 **readConcern: { level: 'snapshot' }**이 바로 이 설정입니다. 이렇게 하면 트랜잭션 안에서 읽는 모든 데이터가 시작 시점 기준으로 일관됩니다.

그런데 아까 김개발 씨의 문제는 왜 발생한 걸까요? 문제는 재고 확인과 차감을 원자적으로 처리하지 않았기 때문입니다.

재고를 먼저 읽고, 조건을 확인하고, 별도로 차감하면 그 사이에 다른 트랜잭션이 끼어들 수 있습니다. 해결책은 조건부 업데이트입니다.

앞서 withTransaction 예제에서 본 것처럼 { stock: { $gte: orderData.qty } } 조건을 updateOne에 넣으면 조건 확인과 업데이트가 원자적으로 이루어집니다. 재고가 부족하면 업데이트가 실패합니다.

Snapshot Isolation의 또 다른 장점은 쓰기 충돌 감지입니다. 두 트랜잭션이 같은 문서를 수정하려 하면 먼저 커밋한 트랜잭션만 성공하고, 나중 트랜잭션은 실패합니다.

이것을 낙관적 동시성 제어라고 합니다. 김개발 씨가 이해했다는 듯 고개를 끄덕입니다.

"그러니까 읽기와 쓰기를 분리하지 말고, 조건부 업데이트로 한 번에 처리해야 하는 거군요!"

실전 팁

💡 - MongoDB 트랜잭션은 기본적으로 Snapshot Isolation 사용

  • 읽고 확인하고 쓰는 패턴 대신 조건부 업데이트 사용 권장
  • 쓰기 충돌 시 나중 트랜잭션이 자동으로 실패하므로 재시도 로직 필요

5. Read Concern과 Write Concern

김개발 씨가 트랜잭션을 사용하면서 코드에 readConcernwriteConcern이라는 옵션을 봤습니다. "이건 뭐예요?" 박시니어 씨가 답합니다.

"분산 시스템에서 얼마나 확실하게 읽고 쓸지를 정하는 거야."

Read Concern은 읽기 작업이 얼마나 최신이고 확정된 데이터를 읽을지를 결정합니다. Write Concern은 쓰기 작업이 얼마나 많은 노드에 복제되어야 성공으로 간주할지를 결정합니다.

마치 중요한 계약서를 작성할 때 공증을 받느냐 마느냐의 차이와 같습니다. 확실할수록 안전하지만 시간이 더 걸립니다.

다음 코드를 살펴봅시다.

const session = client.startSession();

// 다양한 Read/Write Concern 설정 예시
const strictOptions = {
  // majority: 과반수 노드에서 확정된 데이터만 읽음
  readConcern: { level: 'majority' },
  // majority: 과반수 노드에 복제될 때까지 대기
  writeConcern: { w: 'majority', wtimeout: 5000 },
};

const snapshotOptions = {
  // snapshot: 트랜잭션 시작 시점 기준 일관된 스냅샷
  readConcern: { level: 'snapshot' },
  writeConcern: { w: 'majority' },
};

await session.withTransaction(async () => {
  const db = client.db('banking');

  // 잔액 조회 - snapshot으로 일관된 데이터 읽기
  const account = await db.collection('accounts').findOne(
    { accountId: 'ACC-001' },
    { session }
  );

  // 이체 처리 - majority로 확실하게 저장
  await db.collection('accounts').updateOne(
    { accountId: 'ACC-001' },
    { $inc: { balance: -10000 } },
    { session }
  );

  await db.collection('accounts').updateOne(
    { accountId: 'ACC-002' },
    { $inc: { balance: 10000 } },
    { session }
  );
}, snapshotOptions);

김개발 씨는 MongoDB 문서를 읽다가 readConcern과 writeConcern이라는 낯선 용어를 발견했습니다. 단순히 읽고 쓰는 것 아닌가요?

왜 별도의 설정이 필요할까요? 박시니어 씨가 설명합니다.

"MongoDB는 보통 여러 대의 서버로 구성된 Replica Set으로 운영돼. 데이터가 여러 서버에 복제되니까 어느 서버의 데이터를 읽을 것인가, 몇 대의 서버에 써야 안전한가라는 문제가 생기는 거지." 쉽게 비유하자면, 중요한 계약서를 작성한다고 생각해보세요.

그냥 서명만 하면 빠르지만 나중에 분쟁이 생길 수 있습니다. 공증을 받으면 확실하지만 시간이 걸립니다.

readConcern과 writeConcern은 이 확실함의 정도를 설정하는 것입니다. 먼저 Read Concern을 살펴보겠습니다.

세 가지 주요 레벨이 있습니다. local은 가장 빠르지만 가장 덜 확실합니다.

현재 연결된 노드의 데이터를 그냥 읽습니다. 이 데이터가 다른 노드에 복제되었는지, 롤백될 가능성이 있는지 확인하지 않습니다.

majority는 과반수 노드에서 확정된 데이터만 읽습니다. 이 데이터는 롤백되지 않을 것이 보장됩니다.

조금 느리지만 안전합니다. snapshot은 트랜잭션에서 사용되며, 트랜잭션 시작 시점의 스냅샷을 기준으로 읽습니다.

트랜잭션 내 모든 읽기가 일관됩니다. 다음으로 Write Concern을 살펴보겠습니다.

w: 1은 Primary 노드에만 쓰면 성공입니다. 빠르지만 Primary가 죽으면 데이터가 사라질 수 있습니다.

w: majority는 과반수 노드에 복제될 때까지 대기합니다. 느리지만 데이터 손실 가능성이 거의 없습니다.

w: 0은 아예 확인하지 않습니다. Fire and forget 방식으로 로그 같은 덜 중요한 데이터에 사용합니다.

위의 코드에서 wtimeout: 5000은 5초 안에 과반수 노드에 복제되지 않으면 에러를 발생시킵니다. 무한정 기다리면 시스템이 멈출 수 있으니 타임아웃을 설정하는 것이 좋습니다.

트랜잭션에서는 보통 readConcern: snapshotwriteConcern: majority를 함께 사용합니다. 이렇게 하면 읽기는 일관되고, 쓰기는 확실하게 보장됩니다.

은행 이체 같은 금융 작업에서는 반드시 majority를 사용해야 합니다. 돈이 사라지면 안 되니까요.

반면 조회수 카운트 같은 덜 중요한 작업에서는 낮은 레벨을 사용해서 성능을 높일 수 있습니다. 김개발 씨가 정리합니다.

"확실함과 속도의 트레이드오프군요. 상황에 맞게 선택해야겠네요."

실전 팁

💡 - 금융, 결제 등 중요 데이터는 readConcern: majority, writeConcern: majority 사용

  • 트랜잭션에서는 readConcern: snapshot이 기본값으로 권장됨
  • wtimeout을 설정하여 무한 대기 방지

6. 트랜잭션 성능 고려사항

드디어 김개발 씨의 주문 시스템이 안정적으로 동작합니다. 그런데 트래픽이 늘어나자 응답 시간이 눈에 띄게 느려졌습니다.

로그를 보니 트랜잭션 처리 시간이 문제였습니다. 박시니어 씨가 말합니다.

"트랜잭션은 비용이 드는 작업이야. 최적화가 필요해."

MongoDB 트랜잭션은 강력하지만 성능 비용이 따릅니다. 트랜잭션은 락을 획득하고, 로그를 기록하고, 다중 노드 간 조율이 필요합니다.

마치 여러 명이 함께 움직이려면 신호를 맞춰야 하는 것처럼, 조율 비용이 발생합니다. 불필요한 트랜잭션은 피하고, 필요한 경우에도 최대한 짧게 유지해야 합니다.

다음 코드를 살펴봅시다.

// 나쁜 예: 트랜잭션 안에서 오래 걸리는 작업
await session.withTransaction(async () => {
  const order = await orders.findOne({ orderId }, { session });

  // 절대 하면 안 됨! 외부 API 호출
  // const payment = await paymentAPI.charge(order.total);

  // 절대 하면 안 됨! 이미지 처리
  // const thumbnail = await sharp(image).resize(100, 100).toBuffer();

  await orders.updateOne({ orderId }, { $set: { status: 'paid' } }, { session });
}, transactionOptions);

// 좋은 예: 트랜잭션을 최소화
async function processOrderOptimized(orderId) {
  // 1. 트랜잭션 외부에서 외부 API 호출
  const paymentResult = await paymentAPI.charge(orderId);

  if (!paymentResult.success) {
    throw new Error('결제 실패');
  }

  // 2. 트랜잭션은 DB 작업만, 최대한 짧게
  await session.withTransaction(async () => {
    await orders.updateOne(
      { orderId },
      { $set: { status: 'paid', paymentId: paymentResult.id } },
      { session }
    );

    await inventory.updateOne(
      { productId },
      { $inc: { stock: -1 } },
      { session }
    );
  });
}

김개발 씨의 쇼핑몰이 인기를 끌면서 트래픽이 증가했습니다. 그런데 주문 처리 속도가 점점 느려지고 있습니다.

모니터링 도구를 확인해보니 트랜잭션 처리 시간이 평균 2초를 넘기고 있었습니다. 박시니어 씨가 코드를 살펴봅니다.

"아, 트랜잭션 안에서 외부 API를 호출하고 있네. 이러면 안 돼." 트랜잭션은 왜 성능에 영향을 줄까요?

쉽게 비유하자면 단체 사진 찍기와 같습니다. 혼자 셀카를 찍으면 바로 찍으면 됩니다.

하지만 열 명이 단체 사진을 찍으려면? 모두가 카메라를 보고, 눈을 뜨고, 웃어야 합니다.

한 명이라도 늦으면 다시 찍어야 합니다. 트랜잭션도 마찬가지입니다.

여러 작업을 하나로 묶으면 **락(Lock)**을 획득해야 합니다. 다른 트랜잭션이 같은 데이터를 건드리지 못하게 잠가두는 것입니다.

트랜잭션이 길어지면 락을 오래 잡고 있게 되고, 다른 요청들은 대기해야 합니다. MongoDB 트랜잭션의 성능을 최적화하는 핵심 원칙이 있습니다.

첫째, 트랜잭션은 최대한 짧게 유지해야 합니다. 필요한 DB 작업만 포함시키세요.

60초가 기본 타임아웃이지만, 실제로는 몇 초 안에 끝나야 합니다. 둘째, 외부 API 호출은 트랜잭션 바깥에서 해야 합니다.

결제 API가 3초 걸리면 그 동안 락을 잡고 있게 됩니다. 다른 주문이 모두 대기하게 됩니다.

결제는 먼저 처리하고, 성공하면 트랜잭션으로 DB만 업데이트하세요. 셋째, 무거운 연산도 트랜잭션 바깥에서 해야 합니다.

이미지 리사이징, 복잡한 계산, 파일 처리 같은 작업은 트랜잭션 전후에 처리하세요. 넷째, 인덱스를 잘 설계해야 합니다.

트랜잭션 안에서 느린 쿼리가 실행되면 전체가 느려집니다. 자주 사용하는 조건에는 반드시 인덱스를 만들어두세요.

다섯째, 단일 문서 수정은 트랜잭션 없이 처리하세요. MongoDB는 단일 문서 수정에 대해 기본적으로 원자성을 보장합니다.

불필요하게 트랜잭션을 쓰면 오버헤드만 늘어납니다. 위의 코드에서 좋은 예를 살펴보세요.

결제 API는 트랜잭션 바깥에서 먼저 호출합니다. 결제가 성공하면 그 결과를 가지고 트랜잭션 안에서 DB만 업데이트합니다.

이렇게 하면 트랜잭션 시간을 최소화할 수 있습니다. 재시도 로직도 중요합니다.

withTransaction은 일시적 오류에 대해 자동으로 재시도하지만, 재시도 횟수와 간격을 조절할 수도 있습니다. 너무 많은 재시도는 오히려 시스템을 더 힘들게 만들 수 있습니다.

김개발 씨가 코드를 수정하자 응답 시간이 200ms로 줄었습니다. "트랜잭션은 꼭 필요한 곳에만, 최대한 짧게!

기억하겠습니다."

실전 팁

💡 - 트랜잭션 타임아웃은 기본 60초이지만, 실제로는 1-2초 안에 끝나야 함

  • 외부 API, 파일 처리, 무거운 연산은 반드시 트랜잭션 외부에서 처리
  • 단일 문서 수정은 트랜잭션 없이도 원자성 보장됨

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#MongoDB#Transaction#Session#ACID#Database#MongoDB,Database,NoSQL

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.