JavaScript에서 구조적 동시성: Promise.all을 넘어서

발행: (2026년 4월 7일 PM 09:31 GMT+9)
12 분 소요
원문: Dev.to

Source: Dev.to

JavaScript에서 구조화된 동시성: Promise.all을 넘어서

동시성을 다룰 때 대부분의 JavaScript 개발자는 Promise.all을 떠올립니다.
하지만 Promise.all구조화된 동시성(structured concurrency)의 원칙을 완전히 만족시키지 못합니다. 이 글에서는 구조화된 동시성이 무엇인지, 왜 중요한지, 그리고 Promise.all을 넘어서는 패턴을 어떻게 구현할 수 있는지 살펴보겠습니다.


1. 구조화된 동시성이란?

구조화된 동시성은 작업 트리를 명확히 정의하고, 부모 작업이 끝날 때 모든 자식 작업도 반드시 정리되는 개념입니다.
주요 목표는 다음과 같습니다.

  • 예측 가능한 리소스 해제: 작업이 중단되면 연관된 모든 비동기 작업도 중단됩니다.
  • 에러 전파: 자식 작업 중 하나가 실패하면 전체 트리가 일관된 방식으로 실패를 전파합니다.
  • 가시성: 현재 실행 중인 작업이 어디에 있는지, 어떤 작업이 남아 있는지 쉽게 파악할 수 있습니다.

2. Promise.all의 한계

await Promise.all([
  fetch(url1),
  fetch(url2),
  fetch(url3)
]);
  • 취소 불가: 하나의 fetch가 실패해도 이미 시작된 다른 fetch는 자동으로 중단되지 않습니다.
  • 에러 전파가 제한적: 첫 번째 거부된 프로미스만 전달되며, 다른 에러는 손실됩니다.
  • 리소스 누수 위험: 네트워크 요청이 계속 진행돼 메모리·네트워크 자원을 잡아두게 됩니다.

3. AbortController와 함께 구조화된 동시성 구현하기

AbortController취소 신호를 전달할 수 있는 표준 API이며, 이를 활용하면 작업 트리를 명시적으로 관리할 수 있습니다.

async function fetchAll(urls) {
  const controller = new AbortController();
  const signal = controller.signal;

  const promises = urls.map(url => fetch(url, { signal }));

  try {
    const results = await Promise.all(promises);
    return results;
  } catch (err) {
    // 하나라도 실패하면 전체를 중단
    controller.abort();
    throw err;
  }
}

핵심 포인트

  • 취소 전파: controller.abort()를 호출하면 아직 완료되지 않은 모든 fetch가 즉시 중단됩니다.
  • 에러 전파: catch 블록에서 원본 에러를 다시 throw함으로써 호출자는 전체 작업이 실패했음을 알 수 있습니다.

4. Promise.anyPromise.race를 활용한 “첫 번째 성공” 패턴

때때로 여러 후보 중 가장 먼저 성공한 결과만 필요할 때가 있습니다. 이때 Promise.any(ES2021) 혹은 Promise.race를 사용할 수 있지만, 역시 취소가 필요합니다.

async function firstSuccessful(urls) {
  const controller = new AbortController();
  const signal = controller.signal;

  const promises = urls.map(url => fetch(url, { signal }));

  try {
    const result = await Promise.any(promises);
    // 성공한 뒤 남은 작업을 모두 중단
    controller.abort();
    return result;
  } catch (aggregateError) {
    // 모든 요청이 실패한 경우
    throw aggregateError;
  }
}
  • Promise.any모든 프로미스가 거부될 때만 AggregateError를 발생시킵니다.
  • 성공한 뒤 controller.abort()를 호출해 남은 요청들을 정리합니다.

5. async/awaittry…finally를 이용한 안전한 정리

구조화된 동시성을 구현할 때 가장 흔히 놓치는 부분은 예외가 발생했을 때 정리 코드를 놓치는 경우입니다. try…finally 블록을 사용하면 언제든지 정리 로직을 보장할 수 있습니다.

async function withCancellation(task) {
  const controller = new AbortController();
  const signal = controller.signal;

  try {
    return await task(signal);
  } finally {
    // 작업이 끝났든, 에러가 발생했든 무조건 취소
    controller.abort();
  }
}

// 사용 예시
await withCancellation(async (signal) => {
  const p1 = fetch(url1, { signal });
  const p2 = fetch(url2, { signal });
  return await Promise.all([p1, p2]);
});
  • task 함수는 취소 가능한 signal을 받아야 합니다.
  • finally 블록에서 controller.abort()를 호출해 모든 자식 작업을 확실히 정리합니다.

6. 라이브러리 활용: p-cancelable & @sindresorhus/abort-controller

이미 검증된 라이브러리를 사용하면 구조화된 동시성을 더 간결하게 구현할 수 있습니다.

p-cancelable

import PCancelable from 'p-cancelable';

const cancellableFetch = (url, signal) => new PCancelable((resolve, reject, onCancel) => {
  const controller = new AbortController();
  const fetchSignal = controller.signal;

  onCancel(() => controller.abort());

  fetch(url, { signal: fetchSignal })
    .then(resolve)
    .catch(reject);
});

@sindresorhus/abort-controller

import AbortController from '@sindresorhus/abort-controller';

const controller = new AbortController();
const { signal } = controller;

// 여러 작업에 동일한 signal을 전달
await Promise.all([
  fetch(url1, { signal }),
  fetch(url2, { signal })
]);

// 필요 시 언제든 중단
controller.abort();

7. 결론

  • Promise.all은 간단하지만 구조화된 동시성을 제공하지 못합니다.
  • AbortControllertry…finally를 결합하면 작업 트리를 명시적으로 관리하고, 에러와 취소를 일관되게 처리할 수 있습니다.
  • Promise.any·Promise.race와 같은 “첫 번째 성공” 패턴에서도 동일한 정리 로직을 적용해야 합니다.
  • 검증된 라이브러리를 활용하면 보일러플레이트를 크게 줄일 수 있습니다.

구조화된 동시성을 도입하면 코드 가독성, 에러 안정성, 리소스 관리가 모두 개선됩니다. 이제 Promise.all에만 의존하지 말고, 위에서 소개한 패턴을 프로젝트에 적용해 보세요!

구조화되지 않은 Async 코드의 문제점

JavaScript async 코드는 스코프 문제를 가지고 있습니다. 여러분은 프라미스를 발사하고 그것이 깔끔하게 완료되거나—실패하길—바랍니다. 실행 중에 무언가 잘못되면 정리(cleanup)는 여러분의 책임입니다.

부분 실패 예시

async function loadDashboard(userId: string) {
  const [user, orders, analytics] = await Promise.all([
    getUser(userId),
    getOrders(userId),     // 2초 후에 오류 발생
    getAnalytics(userId), // 아직 실행 중!
  ]);
  // getAnalytics는 취소되지 않음
}

getOrders가 거부(reject)되면 Promise.all도 거부되지만—getAnalytics는 백그라운드에서 계속 실행되어 리소스를 소모하고 잠재적으로 오래된 데이터를 기록할 수 있습니다.

각 결과를 개별적으로 처리하기

async function loadDashboard(userId: string) {
  const results = await Promise.allSettled([
    getUser(userId),
    getOrders(userId),
    getAnalytics(userId),
  ]);

  const [userResult, ordersResult, analyticsResult] = results;

  const user = userResult.status === 'fulfilled' ? userResult.value : null;
  const orders = ordersResult.status === 'fulfilled' ? ordersResult.value : [];

  if (analyticsResult.status === 'rejected') {
    console.error('Analytics failed:', analyticsResult.reason);
  }

  return {
    user,
    orders,
    analytics:
      analyticsResult.status === 'fulfilled' ? analyticsResult.value : null,
  };
}

타임아웃 및 취소

타임아웃이 있는 중단 가능한 fetch

async function fetchWithTimeout(url: string, timeoutMs: number): Promise {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

React 정리 예시

function useUserData(userId: string) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
        // AbortError is expected on cleanup, ignore it
      });

    return () => controller.abort(); // cleanup
  }, [userId]);

  return data;
}

일반적인 타임아웃 래퍼

function withTimeout(promise: Promise, ms: number): Promise {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

동시성 패턴

가장 빠른 캐시 또는 네트워크

async function getWithFallback(key: string) {
  return Promise.race([
    redis.get(key).then(v => JSON.parse(v!)), // cache
    db.slowQuery(key),                         // database
  ]);
}

첫 번째 성공적인 CDN 미러

async function fetchFromCDN(path: string) {
  return Promise.any([
    fetch(`https://cdn1.example.com${path}`),
    fetch(`https://cdn2.example.com${path}`),
    fetch(`https://cdn3.example.com${path}`),
  ]);
  // Rejects only if ALL fail (AggregateError)
}

배치 처리와 제어된 병렬성

1 000개의 작업을 동시에 실행하면 데이터베이스와 API에 과부하가 걸릴 수 있습니다. 배치로 처리하세요:

async function processInBatches(
  items: T[],
  processor: (item: T) => Promise,
  concurrency: number
): Promise {
  const results: R[] = [];

  for (let i = 0; i < items.length; i += concurrency) {
    const batch = items.slice(i, i + concurrency);
    const batchResults = await Promise.all(batch.map(processor));
    results.push(...batchResults);
  }

  return results;
}

// 사용 예시
await processInBatches(
  users,
  user => limit(() => processUser(user))
);

// 모든 1 000개의 작업이 대기열에 들어가지만, 한 번에 10개만 실행됩니다

Async Generator를 이용한 스트리밍

메모리에 모든 데이터를 로드하지 않고 대용량 데이터 세트를 처리해야 할 때:

async function* generateUsers(): AsyncGenerator {
  let page = 1;
  while (true) {
    const users = await db.users.findMany({ skip: (page - 1) * 100, take: 100 });
    if (users.length === 0) return;
    yield* users;
    page++;
  }
}

// 모든 데이터를 메모리에 로드하지 않고 처리
for await (const user of generateUsers()) {
  await sendEmail(user.email);
}

올바른 동시성 프리미티브 선택

PrimitiveWhen to Use
Promise.all모든 작업이 성공해야 하며, 첫 번째 오류 발생 시 즉시 실패
Promise.allSettled결과에 관계없이 모든 작업의 결과가 필요함
Promise.race첫 번째로 완료되는 작업(성공 또는 실패)이 결과를 결정함
Promise.any첫 번째 성공한 작업; 모든 작업이 실패할 경우에만 거부됨
AbortController진행 중인 요청 취소 (예: fetch)
p-limit / semaphore과부하를 방지하기 위한 제어된 병렬성
Async generators전체를 메모리에 올리지 않고 큰 시퀀스를 스트리밍

These utilities, combined with proper error handling, give you structured concurrency in JavaScript—moving beyond the pitfalls of raw Promise.all.

0 조회
Back to Blog

관련 글

더 보기 »

JavaScript Promises 초보자를 위한 설명

JavaScript는 단일 스레드이며, 한 번에 하나의 작업만 수행할 수 있다는 의미입니다. 하지만 API에서 데이터를 가져오거나 파일을 읽거나 타이머를 기다려야 할 경우는 어떻게 할까요...