Transaction과 Promise.all with MongoDB

백엔드·2023년 7월 5일
0

MongoDB

목록 보기
8/9

들어가며

Transaction하에서 Promise.all을 사용했을 때, 데이터 정합성을 잘 지킬 수 있을지에 대한 의문이 생겨 해당글을 작성하였습니다.
잘못된 부분이 있다면 피드백 부탁드립니다.

Promise.all 위험성

평소 순서가 보장되지 않아도 되는 상황에서는 Promise.all을 사용해 비동기 작업을 병렬로 진행하고자 하였습니다.
하지만, transaction하에서 Promise.all이 위험 할 수 있다는 을 보았습니다.

try {
    await connection.transaction();
    const promises = [];
    for (const elem of data) {
        promises.push(connection.query('updateElem', elem));
    }
    await Promise.all(promises) 
    await connection.commit();
} catch(error) {
    await connection.rollback();
}

위와 같은 코드를 보고 우리는 다음과 같이 예측할 수 있습니다.

하나의 쿼리가 실패하면 트랜잭션이 롤백되고 데이터의 일관성이 유지된다.

하지만 우리의 예측을 벗어나 다음과 같은 시나리오가 펼쳐질 수 있다고 합니다.
1. Transaction started;
2. Queries queued to execution;
3. One query failed;
4. Transaction rolled back;
5. Some slow queries executed after transaction rollback.

바로 테스트해봅시다.

Promise.all With No Transaction

const main = async () => {
  const sleep = async (ms) => {
    return await new Promise((resolve) =>
      setTimeout(() => resolve("success"), ms)
    );
  };

  const sleep2 = async (ms) => {
    return await new Promise((resolve, reject) =>
      setTimeout(() => reject(new Error("error")), ms)
    );
  };

  const success = async (ms) => {
    const test = await sleep(ms);
    console.log(test);
  };

  const fail = async (ms) => {
    const test = await sleep2(ms);
  };

  const test1 = [success(1000), fail(2000), success(3000)];
  try {
    await Promise.all(test1);
  } catch (e) {
    console.log(e);
    console.log("rollback");
  }
  console.log("test 1 end");

🧐 결과

결과는 흥미로웠습니다.
결과를 보면 fail(2000) 작업 이후, catch문에 걸리게되고 rollback 작업이 진행됩니다.
그러나 success(3000) 작업은 rollback 작업 이후에 실행되는 것을 확인할 수 있습니다.

해당 결과를 보고 transaction 환경 하에서는 어떻게 작동할 지 궁금해졌습니다.

Promise.all With Transaction


async function sleepSuccess(
  model: any,
  ms: number,
  session: mongoose.mongo.ClientSession
) {
  return new Promise((resolve) =>
    setTimeout(() => {
      model
        .create([{ _id: new Types.ObjectId(), title: "test" }], { session })
        .then(() => {
          console.log("success");
          return resolve("success");
        });
    }, ms)
  );
}

async function sleepReject(
  model: any,
  ms: number,
  session: mongoose.mongo.ClientSession
) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      model
        .create([{ _id: new Types.ObjectId(), title: "test" }], { session })
        .then(() => reject(new Error("error")));
    }, ms);
  });
}



async function run() {
   ...MongoDB 연결 및 세팅
  const session = await mongoose.startSession();
  
  try {
    session.startTransaction();
    await Promise.all([
      sleepSuccess(testModel, 1000, session),
      sleepReject22(testModel, 2000, session),
      sleepSuccess(testModel, 3000, session),
    ]);
    await session.commitTransaction();
  } catch (e) {
    console.log(e);
    console.log("rollback");
    await session.abortTransaction();
  } finally {
    await session.endSession();
  }
}

run()
  .then((e) => {
    console.log("done!");
  })
  .catch((err) => console.error(err));

🧐 결과

결과를 보면 sleepReject22(testModel, 2000, session) 작업 이후, catch문에 걸리게되고 session.abortTransaction()이 실행됩니다. 그 후, finally문의 session.endSession()이 실행됩니다.

session.endSession()이 실행됐기 때문에 sleepSuccess(testModel, 3000, session) 작업은 롤백 이후 작업이 실행됐더라도 Cannot set a document's session to a session that has ended. Make sure you haven't called endSession() on the session you are passing to $session(). 해당 에러를 내뿜으며 쿼리가 수행되지 않습니다.

결론

Transaction하에서 Promise.all을 통한 비동기 작업들의 병렬수행은 데이터 정합성을 유지하는데 위험할 수 있습니다. rollback 작업 이후에 쿼리가 실행 될 여지가 있기 때문입니다. 이를 핸들링 하기위해 Promise.allSettled를 사용할 수 있습니다.

그렇지만 롤백 이후 해당 세션을 종료한다면, 언급된 Promise.all의 위험성을 방지할 수 있습니다.

번외로 Transaction하에서는 Promise.all을 통해 작업을 진행했을 때, 병렬 수행이 되지 않는 것 같습니다. DB단에서 순차적으로 실행이 되는 것 같습니다.


ref
ref

profile
백엔드 개발자

0개의 댓글