Promise 동시성 설계: 순차 실행, 조합자, Critical Path

2026. 6. 24. 18:10Frontend/JavaScript

Promise와 async/await 깊이 이해하기 3/4

들어가며

async/await 코드는 읽기 쉽다.

그러나 너무 자연스럽게 읽히기 때문에 오히려 불필요한 순차 실행을 만들기 쉽다.

 
const user = await fetchUser();
const settings = await fetchSettings();
 

두 작업이 서로 독립적이라면 이 코드는 존재하지 않는 의존 관계를 만들어낸다.

fetchUser 완료
        ↓
fetchSettings 시작
 

Promise 동시성 설계의 핵심은 Promise.all()을 외우는 것이 아니다.

다음 질문에 답하는 것이다.

어떤 작업이 다른 작업의 결과를 필요로 하는가?
어떤 작업은 동시에 시작할 수 있는가?
실패했을 때 나머지 작업은 어떻게 해야 하는가?
동시에 몇 개까지 실행해도 되는가?
 

1. 동시성과 병렬성

동시성과 병렬성은 같은 개념이 아니다.

동시성
= 여러 작업의 진행 구간이 겹칠 수 있도록 구조화하는 것

병렬성
= 여러 실행 주체가 같은 시간에 실제로 작업하는 것
 

브라우저의 메인 JavaScript 실행은 일반적으로 한 번에 하나의 JavaScript 작업을 수행한다.

그러나 네트워크 요청처럼 외부 시스템에 위임된 작업은 여러 개가 동시에 진행될 수 있다.

 
const a = fetch("/api/a");
const b = fetch("/api/b");
const c = fetch("/api/c");
 

세 Promise를 만들었다고 JavaScript 코드 세 개가 메인 스레드에서 병렬 실행되는 것은 아니다.

하지만 세 네트워크 요청의 대기 시간은 겹칠 수 있다.

JavaScript 병렬 실행
≠

외부 I/O 작업의 동시 진행
 

2. 비동기 프로그램은 의존성 그래프다

비동기 작업을 방향성 그래프로 표현해보자.

G = (V, E)
 

V는 작업이고 E는 의존 관계다.

A → B
 

는 B를 시작하거나 완료하기 위해 A의 결과가 필요하다는 뜻이다.

예를 들어 다음 코드는 실제 의존성이 있다.

 
const user = await fetchUser();

const orders = await fetchOrders(user.id);
 

fetchOrders()에는 user.id가 필요하다.

fetchUser → fetchOrders
 

이 순차 실행은 올바르다.

반면 다음 작업들이 서로 독립적이라고 가정해보자.

 
const locale = await fetchLocale();
const featureFlags = await fetchFeatureFlags();
 

코드는 다음 의존성을 만든다.

fetchLocale → fetchFeatureFlags
 

하지만 실제 요구사항에는 이 간선이 없다.

fetchLocale

fetchFeatureFlags
 

성능 최적화는 단순히 await을 줄이는 작업이 아니다.

실제로 존재하지 않는 의존 간선을 제거하는 작업이다.


3. Critical Path

전체 작업 완료 시간은 가장 긴 의존 경로에 의해 제한된다.

단순화하면 다음과 같다.

전체 완료 시간
≥ 가장 긴 의존 경로의 작업 시간 합
 

두 작업을 순차 실행하면 다음과 같다.

 
const a = await taskA();
const b = await taskB();
 

예상 시간은 다음에 가깝다.

T ≈ Ta + Tb
 

두 작업을 함께 시작하면 다음과 같다.

 
const aPromise = taskA();
const bPromise = taskB();

const [a, b] = await Promise.all([
  aPromise,
  bPromise,
]);
 

예상 시간은 다음에 가깝다.

T ≈ max(Ta, Tb) + 조정 비용
 

예를 들어 두 요청에 각각 1초가 걸린다고 하자.

순차 실행
≈ 2초

동시 시작
≈ 1초
 

실제 시간은 네트워크, 서버, 브라우저 요청 제한 등 여러 환경 요인의 영향을 받지만, 의존성 그래프 관점에서는 이 차이가 핵심이다.


4. 작업 시작과 결과 대기를 분리하라

다음 코드는 순차 실행이다.

 
const user = await fetchUser();
const settings = await fetchSettings();
 

fetchSettings() 호출 자체가 첫 번째 요청 완료 이후에 일어난다.

동시 실행을 원한다면 먼저 작업을 시작해야 한다.

 
const userPromise = fetchUser();
const settingsPromise = fetchSettings();

const user = await userPromise;
const settings = await settingsPromise;
 

또는 다음처럼 결합할 수 있다.

 
const [user, settings] = await Promise.all([
  fetchUser(),
  fetchSettings(),
]);
 

중요한 사실은 Promise.all()이 작업을 시작하는 것이 아니라는 점이다.

 
fetchUser();
fetchSettings();
 

함수를 호출하는 시점에 작업이 시작된다.

Promise.all()은 이미 시작된 작업들의 완료 조건을 결합한다.

함수 호출
= 작업 시작

Promise.all
= 여러 결과의 완료 규칙 정의

await
= 결합된 결과를 현재 async 흐름에서 기다림
 

5. 의존성과 독립성이 섞인 경우

실무에서는 모든 작업이 완전히 독립적이거나 완전히 순차적이지 않다.

다음 요구사항을 생각해보자.

사용자 정보가 있어야 주문 정보를 요청할 수 있다.
설정 정보는 사용자 정보와 독립적이다.
 

좋지 않은 구조는 다음과 같다.

 
const user = await fetchUser();
const orders = await fetchOrders(user.id);
const settings = await fetchSettings();
 

전체 그래프가 불필요하게 직렬화된다.

user → orders → settings
 

더 나은 구조는 독립 작업을 먼저 시작하는 것이다.

 
const userPromise = fetchUser();
const settingsPromise = fetchSettings();

const user = await userPromise;

const ordersPromise = fetchOrders(user.id);

const [orders, settings] = await Promise.all([
  ordersPromise,
  settingsPromise,
]);
 

그래프는 다음과 같다.

fetchUser → fetchOrders
       \
        독립적으로 fetchSettings 진행
 

이 구조에서 critical path는 다음 둘 중 긴 경로다.

fetchUser + fetchOrders

fetchSettings
 

6. Promise 조합자는 실패 정책을 표현한다

Promise 조합자는 단순한 편의 함수가 아니다.

각 조합자는 서로 다른 완료 조건과 실패 정책을 나타낸다.

조합자성공 조건실패 조건의미
Promise.all 모든 입력 fulfill 하나라도 reject 모두 필요
Promise.allSettled 모든 입력 settle 입력 rejection으로는 집계 Promise가 reject되지 않음 모든 결과 관찰
Promise.any 하나라도 fulfill 모두 reject 하나의 성공이면 충분
Promise.race 하나가 먼저 settle 첫 settlement가 rejection이면 reject 가장 빠른 결과 채택

ECMAScript 명세도 각 조합자의 완료 규칙을 별도로 정의한다. Promise.all()은 하나라도 reject되면 거부되고, allSettled()는 모든 상태를 모으며, any()는 첫 성공 또는 전체 실패를, race()는 첫 settlement를 채택한다.


7. Promise.all: 모두 필요하다

 
const [user, orders, settings] = await Promise.all([
  fetchUser(),
  fetchOrders(),
  fetchSettings(),
]);
 

세 결과가 모두 있어야 다음 단계로 갈 수 있을 때 사용한다.

결과 배열은 작업 완료 순서가 아니라 입력 순서를 보존한다.

 
const slow = new Promise((resolve) => {
  setTimeout(() => resolve("slow"), 100);
});

const fast = Promise.resolve("fast");

const result = await Promise.all([
  slow,
  fast,
]);

console.log(result);
 

출력은 다음과 같다.

 
["slow", "fast"]
 

fast가 먼저 완료되어도 결과 인덱스는 입력 순서를 따른다.


8. Promise.allSettled: 부분 실패를 데이터로 다룬다

모든 요청 결과를 확인해야 하고 일부 실패를 허용해야 한다면 Promise.allSettled()가 적합하다.

 
const results = await Promise.allSettled([
  fetchMainContent(),
  fetchRecommendations(),
  fetchAdvertisement(),
]);
 

결과는 다음 형태를 가진다.

 
[
  {
    status: "fulfilled",
    value: mainContent,
  },
  {
    status: "rejected",
    reason: error,
  },
  {
    status: "fulfilled",
    value: advertisement,
  },
]
 

처리 예시는 다음과 같다.

 
for (const result of results) {
  if (result.status === "fulfilled") {
    render(result.value);
    continue;
  }

  reportError(result.reason);
}
 

allSettled()는 실패를 없애는 것이 아니다.

실패를 예외 제어 흐름에서 명시적인 결과 데이터로 바꿔준다.


9. Promise.any: 하나의 성공이면 충분하다

여러 공급자 중 하나만 성공해도 되는 경우가 있다.

 
const data = await Promise.any([
  fetchFromPrimaryCDN(),
  fetchFromSecondaryCDN(),
  fetchFromBackupServer(),
]);
 

먼저 reject된 작업이 있어도 다른 작업의 성공 가능성을 계속 기다린다.

모든 작업이 실패하면 AggregateError로 reject된다.

 
try {
  await Promise.any([
    Promise.reject(new Error("A failed")),
    Promise.reject(new Error("B failed")),
  ]);
} catch (error) {
  console.log(error instanceof AggregateError);
  console.log(error.errors);
}
 

Promise.any()는 “가장 빨리 끝난 작업”이 아니라 “가장 먼저 성공한 작업”을 선택한다.


10. Promise.race: 첫 settlement를 채택한다

 
const result = await Promise.race([
  operationA(),
  operationB(),
]);
 

race()는 첫 성공을 고르는 것이 아니다.

가장 먼저 settle된 Promise의 상태를 채택한다.

첫 번째 Promise가 fulfill
→ 결과 fulfill

첫 번째 Promise가 reject
→ 결과 reject
 

따라서 timeout 구현에 자주 사용된다.

 
function timeout(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error("Timeout"));
    }, ms);
  });
}

const result = await Promise.race([
  fetchData(),
  timeout(3000),
]);
 

그러나 이 코드는 fetchData()를 취소하지 않는다.

호출자가 timeout 결과를 먼저 관찰할 뿐, 원래 작업은 계속 실행될 수 있다.


11. fail-fast는 cancellation이 아니다

Promise.all()은 하나의 입력이 reject되면 집계 Promise를 빠르게 reject한다.

 
await Promise.all([
  saveUser(),
  saveOrder(),
  sendNotification(),
]);
 

saveOrder()가 먼저 실패해도 나머지 작업이 자동으로 중단되는 것은 아니다.

Promise.all 결과
→ 빠르게 reject

saveUser 작업
→ 이미 실행 중일 수 있음

sendNotification 작업
→ 계속 진행될 수 있음
 

Promise.all()이 제공하는 것은 다음뿐이다.

결과 관찰의 fail-fast
 

다음은 제공하지 않는다.

작업 취소
부수 효과 롤백
트랜잭션
원자성
 

예를 들어 다음 구조에서 주문 저장이 실패해도 사용자 저장이나 알림 전송이 이미 완료될 수 있다.

 
await Promise.all([
  updateUser(),
  createOrder(),
  sendEmail(),
]);
 

all-or-nothing이 필요하다면 Promise 조합자가 아니라 별도의 도메인 설계가 필요하다.

데이터베이스 트랜잭션
보상 작업
멱등성 키
사가 패턴
명시적 취소 신호
 

12. 조합자는 iterable을 동기적으로 소비한다

다음 호출은 비동기적으로만 동작하는 것처럼 보인다.

 
Promise.all(iterable);
 

그러나 Promise.all()은 전달받은 iterable을 현재 호출 흐름에서 순회한다.

 
const iterable = {
  *[Symbol.iterator]() {
    console.log("yield 1");
    yield Promise.resolve(1);

    console.log("yield 2");
    yield Promise.resolve(2);
  },
};

const resultPromise = Promise.all(iterable);

console.log("after");
 

출력은 다음과 같다.

yield 1
yield 2
after
 

iterator에서 값을 꺼내고 각 값을 Promise로 정규화하는 과정은 호출 중에 진행된다.

ECMAScript의 Promise 조합자 알고리즘도 전달받은 iterator를 순회하면서 각 원소를 Promise로 변환하고 반응을 연결한다.

따라서 무한 iterable을 넘기면 호출 자체가 끝나지 않을 수 있다.

 
function* infinite() {
  while (true) {
    yield Promise.resolve(1);
  }
}

Promise.all(infinite());
 

13. 무제한 동시성 문제

다음 코드는 간결하다.

 
const profiles = await Promise.all(
  users.map((user) => {
    return fetchProfile(user.id);
  }),
);
 

사용자가 10명이라면 문제가 없을 수 있다.

10만 명이라면 호출 즉시 매우 많은 작업을 시작하려 할 수 있다.

병목은 Promise 객체 자체가 아니라 작업이 사용하는 자원이다.

네트워크 연결
브라우저 요청 큐
서버 rate limit
메모리
데이터베이스 연결
외부 API 사용량
파일 디스크립터
 

독립 작업이라고 해서 모두 동시에 시작하는 것이 최적은 아니다.


14. 제한된 동시성 구현

다음 함수는 동시에 실행할 worker 수를 제한한다.

 
async function mapConcurrent(
  items,
  limit,
  mapper,
) {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError(
      "limit은 1 이상의 정수여야 합니다.",
    );
  }

  const results = new Array(items.length);

  let nextIndex = 0;

  async function worker() {
    while (true) {
      const index = nextIndex;
      nextIndex += 1;

      if (index >= items.length) {
        return;
      }

      results[index] = await mapper(
        items[index],
        index,
      );
    }
  }

  const workerCount = Math.min(
    limit,
    items.length,
  );

  await Promise.all(
    Array.from(
      { length: workerCount },
      () => worker(),
    ),
  );

  return results;
}
 

사용 방법은 다음과 같다.

 
const profiles = await mapConcurrent(
  users,
  5,
  async (user) => {
    const response = await fetch(
      `/api/users/${user.id}`,
    );

    if (!response.ok) {
      throw new Error(
        `HTTP ${response.status}`,
      );
    }

    return response.json();
  },
);
 

동시에 최대 다섯 개의 작업만 진행된다.

worker 1: item 0 → item 5 → ...
worker 2: item 1 → item 6 → ...
worker 3: item 2 → item 7 → ...
worker 4: item 3 → item 8 → ...
worker 5: item 4 → item 9 → ...
 

결과는 완료 순서가 아니라 원래 입력 순서에 저장된다.

이 구현에도 한계는 있다.

mapper 하나가 실패하면 반환된 Promise.all()은 reject되지만, 이미 실행 중인 다른 mapper 작업은 자동으로 중단되지 않는다.

동시성 제한
= 새 작업 시작 수 제한

실행 중 작업 취소
= 별도의 AbortSignal 필요
 

취소와 작업 수명은 4부에서 다룬다.


15. Promise는 CPU 병렬화 도구가 아니다

다음 코드는 세 계산을 Promise로 감쌌지만 병렬 계산을 만들지 않는다.

 
const results = await Promise.all([
  Promise.resolve().then(() => heavyA()),
  Promise.resolve().then(() => heavyB()),
  Promise.resolve().then(() => heavyC()),
]);
 

각 handler는 여전히 같은 JavaScript 실행 주체에서 순서대로 실행된다.

Promise 동시성
≠ CPU 병렬 실행
 

CPU 집약 작업을 실제로 분산하려면 별도의 실행 주체가 필요하다.

브라우저에서는 대표적으로 Web Worker를 사용할 수 있다.

네트워크 대기 시간 겹치기
→ Promise 동시성

CPU 계산을 다른 코어에서 실행
→ Worker 등 별도 실행 환경
 

16. 동시성 설계 원칙

비동기 코드를 작성할 때 다음 순서로 생각하면 좋다.

첫째, 작업 사이의 실제 의존성을 찾는다

B가 A의 결과를 필요로 하는가?
 

필요하다면 순차 실행이 맞다.

둘째, 독립 작업은 먼저 시작한다

 
const aPromise = taskA();
const bPromise = taskB();
 

셋째, 요구사항에 맞는 완료 정책을 고른다

모두 필요
→ Promise.all

모든 성공과 실패를 확인
→ Promise.allSettled

하나의 성공이면 충분
→ Promise.any

첫 결과만 필요
→ Promise.race
 

넷째, 동시 실행 수를 제한해야 하는지 판단한다

데이터 수가 작고 통제됨
→ Promise.all 가능

데이터 수가 크거나 외부 제한 존재
→ worker pool 또는 queue
 

다섯째, 실패 시 실행 중인 작업을 어떻게 할지 결정한다

계속 진행
중단
부분 결과 사용
재시도
보상 작업 실행
 

마무리

Promise 동시성 설계의 핵심은 문법이 아니라 그래프다.

작업
의존 관계
실패 전파
자원 제한
완료 조건
 

await은 동시성을 만들지 않는다.

Promise.all()도 작업을 시작하지 않는다.

작업을 시작하는 것
= 비동기 함수를 호출하는 것

결과를 결합하는 것
= Promise 조합자를 사용하는 것

현재 흐름을 중단하는 것
= await하는 것
 

좋은 비동기 코드는 단순히 Promise.all()을 많이 사용하는 코드가 아니다.

실제 의존 관계만 남기고, 시스템이 감당할 수 있는 범위 안에서 작업을 진행하며, 실패 정책을 명시적으로 표현한 코드다.

참고 자료

  • ECMA-262, Promise Concurrency Methods.
  • ECMA-262, Promise Objects 및 Promise Reaction 처리.
반응형