JavaScript에서 구조적 동시성: Promise.all을 넘어서
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.any와 Promise.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/await와 try…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은 간단하지만 구조화된 동시성을 제공하지 못합니다.AbortController와try…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);
}
올바른 동시성 프리미티브 선택
| Primitive | When 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.