최근 팀에서 코드 리뷰 진행하는 중에 Promise.all를 통해 트랜잭션을 하는 등 잘못 사용하는 부분을 봐서 정리하게 된 글입니다.
Promise.all과 트랜잭션 외에도 DB 처리 시 유의할 점에 대해 정리하려고 합니다.
Promise.all
에 대해 이야기 하기 전에 동기 비동기 개념에 대해 간단하게 이야기 하려고 합니다.
Promise.all
은 여러 비동기 요청을 동시(병렬)에 실행시키고 모든 비동기 요청이 완료될 때 까지 기다린 후 결과값을 반환받는 메서드 입니다.
const synchronousPromise = async () => {
console.time('프로미스 시간');
await new Promise((resolve) => setTimeout(() => resolve(1), 3000)); // 3초
await new Promise((resolve) => setTimeout(() => resolve(2), 2000)); // 2초
await new Promise((resolve) => setTimeout(() => resolve(3), 1000)); // 1초
console.timeEnd('프로미스 시간');
};
synchronousPromise().then(() => {});
// 프로미스 시간: 6.006s
3초, 2초, 1초 걸리는 요청이 있는 경우 위 예제와 같이 순차적(동기)으로 처리를 하는 경우 각 요청 별 완료된 후 다음 요청을 처리를 하기 때문에 총 6초의 시간이 걸리는 것을 확인할 수 있습니다.
위와 같은 여러 요청을 병렬적으로 진행하려고 하는 경우 Promise.all
를 통해 아래와 같이 사용할 수 있습니다.
const asynchronousPromise = async () => {
console.time('프로미스 시간');
await Promise.all([
new Promise((resolve) => setTimeout(() => resolve(1), 3000)),
new Promise((resolve) => setTimeout(() => resolve(2), 2000)),
new Promise((resolve) => setTimeout(() => resolve(3), 1000)),
]);
console.timeEnd('프로미스 시간');
};
asynchronousPromise().then(() => {});
// 프로미스 시간: 3.001s
병렬적으로 요청을 진행해 가장 오래 걸리는 요청인 3초가 걸리는 것을 알 수 있습니다.
멱등성이란 연산을 여러 번 수행해도 결과가 달라지지 않는 성질을 말합니다. 즉 동일한 요청을 여러 번 보내도 동일한 결과 값이 반환되는 경우 멱등성이 있는 것입니다. Promsie.all
같은 경우 하나의 요청이 실패하면 다른 요청들이 중단될 수 있어, 멱등성이 중요한 상황에서 사용에 주의해야 합니다.
Promise.all
은 요청 중에 실패가 있으면 다른 요청에 대한 파악이 어렵습니다. 요청을 중단을 할 수도 있고, 진행할 수도 있습니다.
예를 여러 요청을 병렬적으로 요청할 경우 멱등성을 보장되지 않는 경우, 실패한 요청으로 인해 다른 요청들이 중단되어 재 요청 시 문제가 발생할 수 있기 때문입니다.
const asynchronousPromise = async () => {
await Promise.all([
new Promise((resolve) =>
setTimeout(() => {
console.log('3초 후에 실행');
}, 3000),
),
new Promise((resolve, reject) =>
setTimeout(() => {
reject(new Error("2초 에러"));
}, 2000),
),
new Promise(() =>
setTimeout(() => {
console.log('1초 후에 실행');
}, 1000),
),
]);
};
asynchronousPromise().then(() => {});
// 1초 후에 실행
// /Users/ryan/WebstormProjects/test/test.js:10
// throw new Error('강제 에러 발생');
위 예제 코드를 실행 할 경우 가장 빠른 1초가 소모되는 요청인 실행은 정상적으로 실행되지만, 2초 실행 시 에러가 발생해, 3초 후에 실행하는 요청이 실행되지 않고 중단되는 것을 확인할 수 있습니다.
만약 1초가 소모되는 요청이 멱등성이 보장되지 않으면, 재 실행이 동일한 결과값이 나오지 않을 수 있습니다.
Promise.all의 경우 하나의 요청이라도 실패가 발생하는 경우 에러로 처리를 진행합니다. 반면, Promise.allSetteled
같은 경우 여러 요청을 병렬적으로 처리하되, 요청에 실패가 있더라도 무조건 모든 요청을 실행합니다.
앞서 실행했던 예제를 Promise.allSettled
로 변경해보겠습니다.
const asynchronousPromiseSettled = async () => {
const results = await Promise.allSettled([
new Promise((resolve) =>
setTimeout(() => {
resolve("3초 완료");
}, 3000)
),
new Promise((resolve, reject) =>
setTimeout(() => {
reject(new Error("2초 에러"));
}, 2000)
),
new Promise((resolve) =>
setTimeout(() => {
resolve("1초 완료");
}, 1000)
),
]);
return results;
};
asynchronousPromiseSettled().then((results) => {
console.log(results);
});
위와 같이 return 받은 결과 값에 status, value 속성을 담은 객체를 반환 받은 것을 확인할 수 있습니다. 응답이 정상적으로 온 경우 status에 fulfilled
값이 있고, 요청이 실패한 경우 reject
값을 넣습니다. 따라서 status의 값에 따라서 다른 처리를 진행할 수 있습니다.
이제 진행 될 예제 코드는 테스트의 편의성 및 사내 공유를 위해 현재 사용하고 있는 typeorm과 mysql을 활용해서 테스트를 진행했습니다.
위에서 이야기한 여러 번 수행해도 결과가 달라지지 않는 성질인 멱등성이 없는 요청의 경우는 Promise.all을 사용하는데 주의가 필요한 경우가 있습니다.
간단한 예제를 통해 발생할 수 있는 이슈와 해결방법에 대해 확인해보겠습니다.
const productRepo = Repository<Product>;
const reorderingProducts = async (
products: Product[] // 상품 Entity
order: number
) => {
const saveProductPromise = [];
for (const [index, product] of products.entries()) {
// promise.all 처리를 하기 위해 order를 변경한 데이터를 saveProductPromise 배열에 push
// 모든 상품의 order를 +1 진행
saveProductPromise.push(Repository.update(product.id, { order: index + 1 }));
}
await Promise.all(saveProductPromise);
};
예시의 상황은 신규 상품 추가로 인해 전체적으로 다른 상품들이 order가 바뀌는 경우라고 가정하겠습니다. 위 상황에서 예를 들어 10개의 상품의 order를 변경해야 하는데 중간에 에러가 나는 경우 일부의 상품들은 order가 변경되었지만 클라이언트는 에러가 발생했다고 인지할 수 있습니다.
그런 경우 Promise.allSettled
를 통해 해당 문제를 해결할 수 있습니다. Promise.allSettled
를 활용한 예제를 통해 하나의 해결방안을 보겠습니다.
이번 예제에서는 에러 발생 시 재실행 하는 것으로 처리를 진행했습니다.
// Promise.allSettled를 실행하는 함수 요청할 프로미스들과 재실행 시도 수를 받음
const executePromises = async (promises: any, retryCnt: number = 0): Promise<void> => {
const retryLimit = 3;
const results = await Promise.allSettled(promises);
const toRetry = [];
for (let i = 0, j = results.length; i < j; i++) {
if (results[i].status === 'rejected') {
// 실행 결과 중 status가 rejected인 결과값을 재실행 목록에 push
toRetry.push(promises[i]);
}
}
if (retryCnt >= retryLimit) {
throw new Error('executePromises : retryLimit 초과');
}
if (toRetry.length > 0 && retryCnt < retryLimit) {
// 재실행 횟수 초과 이전에는 재귀함수를 통해 재 요청
await this.executePromises(toRetry, retryCount + 1);
}
}
const productRepo = Repository<Product>;
const reorderingProducts = async (
products: Product[] // 상품 Entity
order: number
) => {
const saveProductPromise = [];
for (const [index, product] of products.entries()) {
saveProductPromise.push(Repository.update(product.id, { order: index + 1 }));
}
// Promise.allSettled 요청
await executePromises(saveProductPromise);
};
executePromises
함수를 통해 Promise.allSettled
실행 및 에러가 발생한 경우 status를 확인하여 재실행을 통해 멱등성이 없는 경우에 병렬 실행하는 경우입니다.
트랜잭션은 DB의 상태를 변경시키기 위해 수행하는 작업의 단위입니다.
주로 여러 요청들을 실행하면서, 에러가 났을 때 다른 작업들을 롤백할 때 사용합니다.
맨 처음에 봤던 예제에서 트랜잭션을 추가한 예제를 한번 보겠습니다.
typeorm에서는 트랜잭션은
queryRunner
를 통해 진행합니다. typeorm의 트랜잭션에 설명하는 글이 아니기 때문에 예제에서는 트랜잭션에 필요한 일부 문법은 생략을 진행하는 점 참고 부탁드립니다.
const productRepo = Repository<Product>;
// queryRunner를 받아 save(insert or update) 하는 함수
const uptateProduct = async (
queryRunner: QueryRunner,
product: Product
): Promise<Product> => {
return queryRunner.manager.save(product);
}
const reorderingProducts = async (
products: Product[] // 상품 Entity
order: number
) => {
// 트랜잭션을 위한 connection 가져오기
const connection = getConnection();
const queryRunner = connection.createQueryRunner('master');
await queryRunner.connect();
// 트랜잭션 시작
await queryRunner.startTransaction();
const saveProductPromise = [];
for (const [index, product] of products.entries()) {
product.order = index + 1;
saveProductPromise.push(uptateProduct(queryRunner, product);
}
await Promise.all(saveProductPromise);
// 트랜잭션 commit
await queryRunner.commitTransaction();
};
트랜잭션을 통해 Promise.all
을 활용해서 여러 product를 update를 하려고 하는 예제입니다.
만약 product를 update하는데 걸리는 시간이 각각 1초라고 가정했을 때, 10개의 product를 위 예제 코드로 실행하면 총 몇 초의 시간이 걸릴까요?
가장 오래 걸리는 시간인 1초가 걸리는 것이 아닌 10초의 시간이 소요될 것입니다.
Promise.all
인데 병렬 시행시간이 아닌 순차적으로 시행과 동일한 시간이 소요되는 이유는 트랜잭션에 있습니다.
트랜잭션은 원자성, 일관성, 독립성, 지속성 4가지 특징을 가집니다. 여기서는 이해를 위해 2가지 특징만 설명을 하고 넘어가려고 합니다.
DB는 위 특징을 지키기 위해 하나의 커넥션을통해 모든 트랜잭션 관련 작업이 실행됩니다. 이 커넥션은 트랜잭션이 종료(커밋 또는 롤백)될 때까지 유지됩니다.
트랜잭션은 하나의 커넥션을 통해 동작을 하기 때문에, 병렬 시행을 해도 각 요청이 완료될 때 까지 커넥션을 사용하기 위해 대기를 진행합니다. 그렇기 때문에 순차적으로 동작을 하게 됩니다. 그렇기 때문에 트랜잭션이 들어간 Promise.all
같은 경우 개발자가 생각하는 병렬로 동작하지 않습니다.