Promise.all 과 트랜잭션

Dongwon Ahn·2024년 3월 8일
2

JS & TS 학습

목록 보기
7/7
post-thumbnail

최근 팀에서 코드 리뷰 진행하는 중에 Promise.all를 통해 트랜잭션을 하는 등 잘못 사용하는 부분을 봐서 정리하게 된 글입니다.
Promise.all과 트랜잭션 외에도 DB 처리 시 유의할 점에 대해 정리하려고 합니다.

동기 비동기

Promise.all 에 대해 이야기 하기 전에 동기 비동기 개념에 대해 간단하게 이야기 하려고 합니다.

  • 동기는 요청과 그 결과가 동시에 일어나는 것을 뜻합니다. 요청을 진행하면, 시간이 얼마나 걸리던지 요청한 자리에서 결과값이 나옵니다. 순서에 맞춰 진행되지만, 여러 가지 요청을 동시에 처리할 수 없습니다.
    그림 (a) 처럼 커피 주문을 받고 나올 때까지 기다리는 것이 동기 방식의 예시입니다.
  • 비동기는 동시에 일어나지 않는다를 의미합니다. 하나의 요청에 따른 응답을 즉시 처리하지 않아도, 그 대기 시간동안 또 다른 요청에 대해 처리 가능한 방식입니다.
    그림 (b)처럼 점원 한 명이 커피 주문을 받고 다른 점원이 커피를 건내주는 것이 비동기 방식의 예시입니다.

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.allSettled 이란

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의 값에 따라서 다른 처리를 진행할 수 있습니다.

Promise.all과 DB 처리시 유의점

이제 진행 될 예제 코드는 테스트의 편의성 및 사내 공유를 위해 현재 사용하고 있는 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에 모두 반영되거나, 전혀 반영되지 않거나를 뜻합니다. 만약 에러가 난 경우 모든 작업을 롤백을 통해 모두 반영하지 않게 처리를 진행합니다.
  • 독립성은 하나의 트랜잭션은 다른 트랜잭션에 끼어들 수 없고 마찬가지로 독립적임을 의미합니다. 즉, 각각의 트랜잭션은 독립적이라 서로 간섭이 불가능합니다.

DB는 위 특징을 지키기 위해 하나의 커넥션을통해 모든 트랜잭션 관련 작업이 실행됩니다. 이 커넥션은 트랜잭션이 종료(커밋 또는 롤백)될 때까지 유지됩니다.

트랜잭션은 하나의 커넥션을 통해 동작을 하기 때문에, 병렬 시행을 해도 각 요청이 완료될 때 까지 커넥션을 사용하기 위해 대기를 진행합니다. 그렇기 때문에 순차적으로 동작을 하게 됩니다. 그렇기 때문에 트랜잭션이 들어간 Promise.all 같은 경우 개발자가 생각하는 병렬로 동작하지 않습니다.

profile
Typescript를 통해 풀스택 개발을 진행하고 있습니다.

0개의 댓글