Transaction하에서 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.
바로 테스트해봅시다.
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 환경 하에서는 어떻게 작동할 지 궁금해졌습니다.
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단에서 순차적으로 실행이 되는 것 같습니다.