Promise.all vs Promise.allSettled: 부분 실패를 허용하는 벌크 처리
100명에 대해 데이터를 생성하는데 1명이 실패하면 전체 99명도 실패로 처리되었습니다. Promise.allSettled로 부분 실패를 허용하는 벌크 처리를 구현한 방법을 공유합니다.
1. 문제 상황
증상
100명의 사용자에 대해 데이터 생성 API를 호출했는데, 1명의 데이터가 잘못되어 전체 99명의 처리도 실패하는 현상이 발생했습니다.
// API 응답
{
"error": "처리 중 오류가 발생했습니다.",
"status": 500
}
사용자 시나리오
- 관리자가 "전체 생성" 버튼 클릭
- 100명에 대해 데이터 생성 시작
- 99번째 사용자의 데이터에 문제 발생
- 전체 100명 처리 실패
- 관리자: "98명은 왜 안 되죠?"
기대 동작 vs 실제 동작
| 상황 | 기대 동작 | 실제 동작 |
|---|---|---|
| 100명 중 1명 실패 | 99명 성공, 1명 실패 | 100명 전체 실패 |
| 에러 메시지 | "99명 성공, 1명 실패" | "처리 중 오류 발생" |
| 데이터 상태 | 99명 데이터 저장됨 | 아무것도 저장 안 됨 |
2. 원인 분석
Promise.all의 동작 방식
// ❌ Promise.all: 하나라도 실패하면 전체 실패
const results = await Promise.all([
processUser(user1), // 성공
processUser(user2), // 성공
processUser(user3), // 실패! → 전체 reject
processUser(user4), // 실행되지만 결과 무시
]);
// → Uncaught Error (user3의 에러)
핵심 동작:
- 모든 Promise를 병렬로 시작
- 하나라도 reject되면 즉시 전체 reject
- 다른 Promise들은 계속 실행되지만 결과는 무시
Promise.all의 문제점 시각화
Promise.all([p1, p2, p3, p4])
p1: ──────────────────► resolve(1)
p2: ────────────► resolve(2)
p3: ────► reject(error) ──────┐
p4: ──────────────────────► resolve(4) (결과 무시)
│
▼
전체 결과: reject(error)
기존 코드의 문제점
// ❌ Before: 하나라도 실패하면 전체 실패
export async function POST(request: NextRequest) {
const { userIds } = await request.json();
const results = await Promise.all(
userIds.map(async (userId) => {
// 각 사용자별 처리
const data = await calculateData(userId);
return prisma.record.create({ data });
})
);
return NextResponse.json({
success: true,
count: results.length,
});
}
이 코드의 문제:
- 1명이라도 실패하면 전체 실패
- 어떤 사용자가 실패했는지 알 수 없음
- 성공한 사용자들의 데이터도 반환되지 않음
3. 해결 방법
Promise.allSettled 소개
ES2020에서 도입된 Promise.allSettled는 모든 Promise가 settled(이행 또는 거부)될 때까지 기다립니다.
const results = await Promise.allSettled([
Promise.resolve(1),
Promise.reject(new Error('실패')),
Promise.resolve(3),
]);
// results:
// [
// { status: 'fulfilled', value: 1 },
// { status: 'rejected', reason: Error('실패') },
// { status: 'fulfilled', value: 3 },
// ]
Step 1: Promise.allSettled 적용
// ✅ After: 부분 실패 허용
export async function POST(request: NextRequest) {
const { userIds } = await request.json();
// Promise.allSettled로 변경
const results = await Promise.allSettled(
userIds.map(async (userId) => {
const data = await calculateData(userId);
const record = await prisma.record.create({ data });
return { userId, recordId: record.id };
})
);
// 결과 분류는 Step 2에서...
}
Step 2: 성공/실패 결과 분류
// 타입 가드를 사용한 결과 분류
const succeeded = results.filter(
(r): r is PromiseFulfilledResult<{ userId: string; recordId: string }> =>
r.status === 'fulfilled'
);
const failed = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected'
);
Step 3: 상황별 응답 처리
// 모두 실패한 경우
if (succeeded.length === 0) {
const errorId = crypto.randomUUID().slice(0, 8);
console.error('All operations failed:', {
errorId,
failures: failed.map((f) => f.reason?.message || String(f.reason)),
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{ error: '처리에 실패했습니다.', errorId },
{ status: 500 }
);
}
// 일부 실패한 경우: 경고와 함께 성공 반환
if (failed.length > 0) {
const errorId = crypto.randomUUID().slice(0, 8);
console.warn('Partial failure:', {
errorId,
successCount: succeeded.length,
failureCount: failed.length,
failures: failed.map((f) => f.reason?.message || String(f.reason)),
timestamp: new Date().toISOString(),
});
return NextResponse.json({
success: true,
partial: true,
count: succeeded.length,
failedCount: failed.length,
errorId,
message: `${succeeded.length}명 성공, ${failed.length}명 실패`,
});
}
// 모두 성공한 경우
return NextResponse.json({
success: true,
count: succeeded.length,
message: `${succeeded.length}명이 처리되었습니다.`,
});
전체 코드
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 });
}
const { userIds } = await request.json();
if (!userIds || userIds.length === 0) {
return NextResponse.json(
{ error: '대상이 없습니다.' },
{ status: 400 }
);
}
// Promise.allSettled로 부분 실패 허용
const results = await Promise.allSettled(
userIds.map(async (userId: string) => {
const data = await calculateData(userId);
const record = await prisma.record.upsert({
where: { userId },
create: { userId, ...data },
update: { ...data },
});
return { userId, recordId: record.id };
})
);
// 성공/실패 분류
const succeeded = results.filter(
(r): r is PromiseFulfilledResult<{ userId: string; recordId: string }> =>
r.status === 'fulfilled'
);
const failed = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected'
);
// 모두 실패
if (succeeded.length === 0) {
const errorId = crypto.randomUUID().slice(0, 8);
console.error('Bulk operation all failed:', {
errorId,
failures: failed.map((f) => f.reason?.message || String(f.reason)),
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{ error: '처리에 실패했습니다.', errorId },
{ status: 500 }
);
}
// 일부 실패
if (failed.length > 0) {
const errorId = crypto.randomUUID().slice(0, 8);
console.warn('Bulk operation partial failure:', {
errorId,
successCount: succeeded.length,
failureCount: failed.length,
failures: failed.map((f) => f.reason?.message || String(f.reason)),
timestamp: new Date().toISOString(),
});
return NextResponse.json({
success: true,
partial: true,
count: succeeded.length,
failedCount: failed.length,
errorId,
message: `${succeeded.length}명 성공, ${failed.length}명 실패`,
});
}
// 모두 성공
return NextResponse.json({
success: true,
count: succeeded.length,
message: `${succeeded.length}명이 처리되었습니다.`,
});
} catch (error) {
const errorId = crypto.randomUUID().slice(0, 8);
console.error('Bulk operation error:', {
errorId,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{ error: '서버 오류가 발생했습니다.', errorId },
{ status: 500 }
);
}
}
4. 핵심 개념 정리
Promise.all vs Promise.allSettled 비교
| 특성 | Promise.all | Promise.allSettled |
|---|---|---|
| 실패 시 동작 | 즉시 reject | 모든 Promise 완료 대기 |
| 반환값 | T[] |
PromiseSettledResult<T>[] |
| 부분 실패 | 전체 실패로 처리 | 개별 결과 확인 가능 |
| 에러 정보 | 첫 번째 에러만 | 모든 에러 수집 가능 |
| 사용 사례 | 모두 성공해야 하는 경우 | 부분 성공 허용 |
PromiseSettledResult 타입
type PromiseSettledResult<T> =
| PromiseFulfilledResult<T>
| PromiseRejectedResult;
interface PromiseFulfilledResult<T> {
status: 'fulfilled';
value: T;
}
interface PromiseRejectedResult {
status: 'rejected';
reason: any;
}
타입 가드 패턴
// 타입 가드 함수
function isFulfilled<T>(
result: PromiseSettledResult<T>
): result is PromiseFulfilledResult<T> {
return result.status === 'fulfilled';
}
function isRejected(
result: PromiseSettledResult<unknown>
): result is PromiseRejectedResult {
return result.status === 'rejected';
}
// 사용
const succeeded = results.filter(isFulfilled);
const failed = results.filter(isRejected);
API 응답 설계
// 성공 응답 타입
interface BulkOperationResponse {
success: boolean;
partial?: boolean; // 부분 성공 여부
count: number; // 성공 건수
failedCount?: number; // 실패 건수
errorId?: string; // 로그 추적용
message: string; // 사용자 메시지
}
5. 베스트 프랙티스
체크리스트
- [ ] 벌크 처리에서
Promise.allSettled사용 검토 - [ ] 성공/실패 결과를 타입 가드로 분류
- [ ] 부분 실패 시
partial: true플래그 반환 - [ ] 에러 로깅에
errorId포함 (추적용) - [ ] 프론트엔드에서 부분 실패 UI 처리
언제 Promise.all을 사용해야 하나?
// ✅ Promise.all이 적합한 경우
// 1. 모두 성공해야만 의미가 있는 경우
const [user, profile, settings] = await Promise.all([
fetchUser(id),
fetchProfile(id),
fetchSettings(id),
]);
// 2. 트랜잭션처럼 원자성이 필요한 경우
// (하나라도 실패하면 전체 롤백)
await prisma.$transaction([
prisma.order.create(...),
prisma.inventory.update(...),
]);
// 3. 의존 관계가 있는 병렬 처리
const [a, b] = await Promise.all([fetchA(), fetchB()]);
const c = processWithAB(a, b); // a, b 둘 다 필요
언제 Promise.allSettled를 사용해야 하나?
// ✅ Promise.allSettled가 적합한 경우
// 1. 벌크 처리 (대량 데이터)
const results = await Promise.allSettled(
users.map((user) => sendEmail(user))
);
// 2. 독립적인 작업들
const results = await Promise.allSettled([
fetchFromServiceA(),
fetchFromServiceB(),
fetchFromServiceC(),
]);
// 일부 서비스가 다운되어도 나머지는 사용 가능
// 3. 최선의 노력(Best-effort) 처리
const results = await Promise.allSettled(
notifications.map((n) => sendNotification(n))
);
// 일부 알림 실패해도 나머지는 전송
프론트엔드에서 부분 실패 처리
// API 호출
const response = await fetch('/api/bulk-create', {
method: 'POST',
body: JSON.stringify({ userIds }),
});
const data = await response.json();
// 부분 실패 처리
if (data.partial) {
toast.warning(
`${data.count}명 처리 완료, ${data.failedCount}명 실패\n` +
`문의 시 오류 ID를 알려주세요: ${data.errorId}`
);
} else if (data.success) {
toast.success(data.message);
} else {
toast.error(data.error);
}
로깅 패턴
// 구조화된 로깅
console.warn('Bulk operation partial failure:', {
errorId, // 추적용 ID
operation: 'createRecords', // 작업 유형
successCount: succeeded.length,
failureCount: failed.length,
failures: failed.map((f) => ({
reason: f.reason?.message || String(f.reason),
// 필요 시 추가 정보
})),
userId: session.user.id, // 요청자
timestamp: new Date().toISOString(),
});
6. FAQ
Q: Promise.allSettled는 ES2020인데, 이전 버전에서 사용할 수 있나요?
A: Node.js 12.9.0 이상, 대부분의 모던 브라우저에서 지원합니다. 이전 버전이 필요하다면 폴리필을 사용하세요:
// 폴리필 (필요한 경우)
if (!Promise.allSettled) {
Promise.allSettled = function <T>(
promises: Iterable<Promise<T>>
): Promise<PromiseSettledResult<T>[]> {
return Promise.all(
Array.from(promises).map((p) =>
Promise.resolve(p).then(
(value) => ({ status: 'fulfilled' as const, value }),
(reason) => ({ status: 'rejected' as const, reason })
)
)
);
};
}
Q: Promise.allSettled에서 에러 reason의 타입이 any인 이유는?
A: JavaScript에서는 어떤 값이든 throw할 수 있기 때문입니다:
throw new Error('에러'); // Error 객체
throw '문자열 에러'; // 문자열
throw 123; // 숫자
throw { code: 'ERR' }; // 객체
안전하게 처리하려면:
const errorMessage = failed.map((f) => {
if (f.reason instanceof Error) {
return f.reason.message;
}
return String(f.reason);
});
Q: Promise.all과 Promise.allSettled의 성능 차이가 있나요?
A: 거의 없습니다. 둘 다 모든 Promise를 병렬로 시작합니다.
차이점:
Promise.all: 첫 번째 실패 시 바로 reject (결과를 기다리지 않음)Promise.allSettled: 모든 Promise가 완료될 때까지 대기
실제로 모든 Promise가 성공하는 경우 동일한 시간이 걸립니다.
Q: 부분 실패 시 재시도 로직은 어떻게 구현하나요?
A: 실패한 항목만 재시도:
async function bulkProcessWithRetry<T>(
items: T[],
processor: (item: T) => Promise<void>,
maxRetries = 3
): Promise<{ succeeded: T[]; failed: T[] }> {
let pending = items;
const succeeded: T[] = [];
const failed: T[] = [];
for (let attempt = 0; attempt < maxRetries && pending.length > 0; attempt++) {
const results = await Promise.allSettled(pending.map(processor));
const currentFailed: T[] = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
succeeded.push(pending[index]);
} else {
currentFailed.push(pending[index]);
}
});
pending = currentFailed;
if (attempt < maxRetries - 1 && pending.length > 0) {
// 재시도 전 대기 (exponential backoff)
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
failed.push(...pending);
return { succeeded, failed };
}
Q: 동시성 제한(Concurrency Limit)과 함께 사용하려면?
A: p-limit 같은 라이브러리를 사용하거나 직접 구현:
import pLimit from 'p-limit';
const limit = pLimit(10); // 동시 10개까지
const results = await Promise.allSettled(
userIds.map((userId) =>
limit(() => processUser(userId)) // 동시성 제한 적용
)
);
7. 참고 자료
- MDN Promise.allSettled
- TC39 Promise.allSettled Proposal
- Node.js Promise API
- p-limit - Concurrency Limiter
8. 다음 단계
벌크 처리에서 부분 실패를 허용하게 되었다면, 에러 추적 시스템과 함께 사용해보세요.
시리즈 목차:
- Promise.all vs Promise.allSettled: 부분 실패를 허용하는 벌크 처리 ← 현재 글
- API 에러 추적 개선하기: errorId 패턴으로 디버깅 시간 단축