여러 개의 비동기 작업을 동시에 처리해야 할 때, Promise 조합 메서드를 쓰지 않고 반복문으로 하나씩 기다리는 개발자들이 많다. 다만 대부분은 Promise.all()만 알고 있거나, 하나의 Promise가 실패하면 전체가 실패하는 문제를 겪고도 그 원인을 정확히 모른 채 코드를 작성한다. 이번에는 세 가지 메서드의 동작 방식, 언제 써야 하는지, 그리고 실전에서 자주 마주치는 함정까지 완벽하게 정리해서 소개하겠다.
Promise.all(), Promise.allSettled(), Promise.race()는 모두 여러 Promise를 동시에 처리하지만, 성공/실패 판정 기준이 다르다. 간단히 말하면 Promise.all()은 "모두 성공해야 성공", allSettled()는 "결과가 뭐든 모두 완료되길 기다려", race()는 "가장 먼저 끝난 것의 결과를 반환"한다.
반복문으로 하나씩 await를 쓰면 첫 번째 Promise가 끝날 때까지 기다렸다가, 두 번째가 끝나길 기다리고, 이렇게 순차적으로 진행된다. 예를 들어 각각 1초 걸리는 작업 3개면 총 3초가 걸린다. 하지만 이 세 메서드를 쓰면 병렬로 동시 시작해서 가장 느린 것 기준으로(race 제외) 약 1초 만에 끝난다. 속도 차이는 엄청나다.
✓ 올바른 사용 사례: 모든 작업이 성공해야만 다음 단계로 진행할 때. 예를 들어 회원가입 후 이메일 인증, 휴대폰 인증, 약관 동의 기록을 동시에 처리할 때는 하나라도 실패하면 회원가입 자체가 무효다.
// ✗ 잘못된 코드: 순차 처리로 느린 속도
const result1 = await fetchUser();
const result2 = await fetchPosts();
const result3 = await fetchComments();
// 총 3초 이상 소요
// ✓ 올바른 코드: Promise.all()로 병렬 처리
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
// 가장 느린 것 기준 약 1초 소요
하지만 하나의 Promise가 reject되면 전체 Promise.all()이 즉시 reject 상태로 변한다. 이게 문제다.
// ✗ 위험한 코드: 하나 실패하면 나머지는 무시됨
Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]).then(([user, posts, comments]) => {
console.log('모두 성공');
}).catch(err => {
console.log('하나라도 실패하면 여기로');
// posts API는 성공했는데 comments API만 실패해도
// 어떤 것이 실패했는지 알 수 없음
});
✓ 올바른 사용 사례: 각각의 성공/실패 여부를 개별적으로 처리해야 할 때. 예를 들어 여러 외부 API에서 데이터를 가져올 때 하나가 실패해도 나머지는 정상적으로 표시하고 싶다면 allSettled()를 써야 한다.
// ✓ 올바른 코드: Promise.allSettled()로 각각 결과 확인
const results = await Promise.allSettled([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`API ${index} 성공:`, result.value);
} else {
console.log(`API ${index} 실패:`, result.reason);
}
});
// 출력 예시:
// API 0 성공: {...user data}
// API 1 성공: [{...post data}]
// API 2 실패: Error: 404 Not Found
이렇게 하면 posts API가 실패해도 user, comments는 정상적으로 표시할 수 있다. 각 결과는 {status: 'fulfilled', value: ...} 또는 {status: 'rejected', reason: ...} 형태다.
// 실무 예제: 여러 이미지 업로드 후 성공한 것만 저장
const imageUploads = await Promise.allSettled(
selectedFiles.map(file => uploadToS3(file))
);
const uploadedUrls = imageUploads
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
const failedFiles = imageUploads
.filter(r => r.status === 'rejected')
.map((r, idx) => ({ file: selectedFiles[idx], error: r.reason }));
if (failedFiles.length > 0) {
alert(`${failedFiles.length}개 파일 업로드 실패`);
}
// 성공한 URL만 데이터베이스에 저장
await saveImageUrls(uploadedUrls);
✓ 올바른 사용 사례: 여러 소스 중 가장 빠른 응답이 필요할 때. 예를 들어 캐시에서 먼저 조회하고, 동시에 서버에서도 조회해서 가장 빨리 도착하는 데이터를 쓰고 싶을 때다.
// ✓ 올바른 코드: Promise.race()로 타임아웃 구현
function fetchWithTimeout(url, timeoutMs) {
return Promise.race([
fetch(url).then(r => r.json()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('타임아웃')), timeoutMs)
)
]);
}
const user = await fetchWithTimeout('/api/user', 5000);
// 5초 안에 응답이 오면 그걸 쓰고,
// 5초를 넘으면 타임아웃 에러 발생
race()는 하나의 Promise가 settle(fulfilled 또는 rejected)되는 순간 즉시 반환된다. 따라서 가장 빠른 응답 또는 가장 먼저 발생한 에러를 받는다.
// 실무 예제: 캐시와 네트워크 중 가장 빠른 것 쓰기
const cachedData = localStorage.getItem('user_cache');
const data = await Promise.race([
// 캐시가 있으면 즉시 반환
cachedData ? Promise.resolve(JSON.parse(cachedData)) : new Promise(() => {}),
// 네트워크 요청
fetch('/api/user').then(r => r.json())
]);
console.log(data); // 캐시 또는 네트워크 중 더 빠른 것
| 메서드 | 성공 조건 | 실패 조건 | 반환 값 | 사용 사례 |
|---|---|---|---|---|
| Promise.all() | 모든 Promise가 fulfilled | 하나라도 rejected | 성공한 값들의 배열 | 모든 작업이 필수인 경우(회원가입, 주문 결제) |
| Promise.allSettled() | 모든 Promise가 완료(fulfilled or rejected) | 없음. 항상 성공 상태로 완료 | {status, value/reason} 배열 | 각각 성공/실패를 따로 처리(데이터 수집) |
| Promise.race() | 첫 번째 Promise가 fulfilled | 첫 번째 Promise가 rejected | 가장 먼저 완료된 Promise의 값 | 가장 빠른 응답만 필요(타임아웃, 캐시 레이스) |
✗ 잘못된 코드: 어떤 Promise가 실패했는지 알 수 없음
try {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
} catch (err) {
console.log('뭔가 실패했음');
// 세 개 중 뭐가 실패했는지 모름
}
✓ 올바른 코드: allSettled()로 각각 확인하거나 레이블 붙이기
const results = await Promise.allSettled([
fetchUser().then(user => ({ type: 'user', data: user })),
fetchPosts().then(posts => ({ type: 'posts', data: posts })),
fetchComments().then(comments => ({ type: 'comments', data: comments }))
]);
const failedRequests = results
.map((r, idx) => r.status === 'rejected' ? results[idx] : null)
.filter(Boolean);
if (failedRequests.length > 0) {
failedRequests.forEach(r => {
console.log(`${r.data.type} 조회 실패:`, r.reason);
});
}
✗ 위험한 코드: 타임아웃이 발생해도 백그라운드 요청은 계속 실행됨
try {
const user = await Promise.race([
fetch('/api/user').then(r => r.json()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('타임아웃')), 5000)
)
]);
} catch (err) {
console.log('요청 타임아웃');
// 하지만 fetch는 여전히 백그라운드에서 실행 중
// 5초 후 응답이 오면 메모리 낭비
}
✓ 올바른 코드: AbortController로 요청 명시적 취소
function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { signal: controller.signal })
.then(r => r.json())
.finally(() => clearTimeout(timeoutId));
}
try {
const user = await fetchWithTimeout('/api/user', 5000);
} catch (err) {
if (err.name === 'AbortError') {
console.log('요청 타임아웃');
}
// fetch 요청이 명시적으로 취소됨
}
✗ 잘못된 코드: status를 확인하지 않고 value에 접근
const results = await Promise.allSettled([...]);
results.forEach(r => {
console.log(r.value); // rejected이면 undefined
});
✓ 올바른 코드: status 먼저 확인
const results = await Promise.allSettled([...]);
const successData = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
// 사용자 정보는 필수, 추천 상품은 선택사항
const [user, recommendedProducts] = await Promise.all([
// 필수: 실패하면 전체 실패
fetchUser(),
// 선택: 실패해도 무시
Promise.allSettled([
fetchRecommendedProducts('category1'),
fetchRecommendedProducts('category2'),
fetchRecommendedProducts('category3')
]).then(results =>
results
.filter(r => r.status === 'fulfilled')
.flatMap(r => r.value)
)
]);
console.log(user); // 반드시 존재
console.log(recommendedProducts); // 없을 수도 있음
async function getDataWithCache(key, fetchFn) {
const cached = localStorage.getItem(key);
// 캐시가 있으면 즉시 반환하되, 백그라운드에서 갱신
if (cached) {
// 캐시된 데이터를 즉시 반환
const result = Promise.resolve(JSON.parse(cached));
// 백그라운드에서 새 데이터 가져오기 (기다리지 않음)
fetchFn().then(fresh => {
localStorage.setItem(key, JSON.stringify(fresh));
}).catch(err => console.log('백그라운드 갱신 실패'));
return result;
}
// 캐시가 없으면 네트워크에서 가져오기
const fresh = await fetchFn();
localStorage.setItem(key, JSON.stringify(fresh));
return fresh;
}
const data = await getDataWithCache('user', () => fetchUser());
Promise 조합 메서드는 비동기 작업의 속도를 극적으로 개선한다. Promise.all()은 모두 성공해야 할 때, allSettled()는 각각 결과를 봐야 할 때, race()는 가장 빠른 것만 필요할 때 쓴다. 반복문으로 순차 처리하는 습관을 버리고 이 세 메서드 중 하나를 골라 쓰는 것만으로도 네트워크 대기 시간을 1/n으로 줄일 수 있다. 이 글의 "흔한 실수" 섹션을 참고해 AbortController와 allSettled()의 status 확인 패턴을 습관화하면, 안정적이면서도 빠른 비동기 코드를 작성할 수 있을 것이다.