순차 루프를 10배 빠르게: Promise.all 병렬화 + findMany distinct 배치화로 API 응답 시간 잡기
주간 리포트 API가 과제 수에 비례하여 느려졌다. for 루프의 sprint+task 쿼리를 Promise.all로 병렬화해 10배, findFirst N회를 findMany(distinct) + createMany 2쿼리로 바꿔 50배. 두 최적화를 한 커밋에 담은 기록.
1. 문제 상황
1.1 과제 수에 비례하여 느려지는 API
주간 리포트 GET API의 응답 시간이 과제 수가 늘수록 선형으로 나빠졌습니다.
| 과제 수 | 응답 시간 |
|---|---|
| 3개 | ~300ms |
| 7개 | ~700ms |
| 10개 | ~1.1s |
| 20개 | ~2.3s |
"과제 수 × 100ms" 패턴. 20개 과제에서 2초는 "로딩 스피너"가 한참 도는 체감이었습니다.
1.2 프로파일링
Prisma 쿼리 로그를 켜고 한 번의 GET 요청을 관찰했습니다.
DATABASE_URL=... pnpm dev
prisma:query SELECT * FROM "WeeklyReport" WHERE ... [18ms]
prisma:query SELECT * FROM "Organization" WHERE id = $1 [3ms]
prisma:query SELECT * FROM "Sprint" WHERE "projectId" = $1 ORDER BY ... [45ms]
prisma:query SELECT * FROM "Task" WHERE "sprintId" = $1 AND ... [52ms]
prisma:query SELECT * FROM "Task" WHERE "projectId" = $1 AND ... [47ms]
prisma:query SELECT * FROM "Sprint" WHERE "projectId" = $2 ORDER BY ... [44ms]
prisma:query SELECT * FROM "Task" WHERE "sprintId" = $2 AND ... [51ms]
prisma:query SELECT * FROM "Task" WHERE "projectId" = $2 AND ... [46ms]
...
과제(entry)마다 3개의 쿼리가 순차적으로 실행. 10개 과제면 30개 쿼리가 시리얼하게 DB 왕복.
코드는 이랬습니다:
// ❌ Before: for 루프로 순차 실행
const entries: ReportEntryWithDetails[] = [];
for (const entry of report.entries) {
// RBAC check...
const latestSprint = await prisma.sprint.findFirst({...});
const [inProgress, completed] = await Promise.all([
prisma.task.findMany({...}),
prisma.task.findMany({...}),
]);
entries.push({...});
}
재미있는 건 이미 Promise.all이 있었다는 점. 한 과제 안의 inProgress/completed는 병렬이었지만, 과제 간에는 for 루프로 순차.
1.3 병렬화로 얼마나 빨라질까?
각 과제의 처리는 서로 독립적입니다. 이론상 N=10이면 10배 개선. 현실은 DB 커넥션 풀 때문에 3~7배.
2. 해결 1 — Promise.all(entries.map(...))로 병렬화
// ✅ After: 과제별 처리를 병렬로
const entryResults = await Promise.all(
report.entries.map(async (entry): Promise<ReportEntryWithDetails | null> => {
if (isFeatureEnabled("auth")) {
try { await requireProjectPermission(entry.projectId, "read"); }
catch { return null; }
}
const latestSprint = await prisma.sprint.findFirst({
where: { projectId: entry.projectId, createdAt: { lte: endDate } },
orderBy: { sprintNumber: "desc" }, select: { id: true },
});
const [inProgress, completed] = await Promise.all([
latestSprint
? prisma.task.findMany({
where: {
projectId: entry.projectId, sprintId: latestSprint.id,
inProgressAt: { not: null, lte: endDate },
OR: [{ completedAt: null }, { completedAt: { gt: endDate } }],
},
select: TASK_SUMMARY_SELECT, orderBy: { inProgressAt: "asc" },
})
: Promise.resolve([]),
prisma.task.findMany({
where: { projectId: entry.projectId, completedAt: { gte: startDate, lte: endDate } },
select: TASK_SUMMARY_SELECT, orderBy: { completedAt: "asc" },
}),
]);
return { ...entry, stats: { inProgress: inProgress.map(toTaskSummary), completed: completed.map(toTaskSummary), inProgressCount: inProgress.length, completedCount: completed.length } };
}),
);
const entries = entryResults.filter((e): e is ReportEntryWithDetails => e !== null);
구조적 차이
continue→return null—.map()콜백은 값을 반환해야 하므로 null + filter 패턴toTaskSummary헬퍼 모듈 레벨로 추출 — 루프 내 재생성 방지
성능 측정
| 구분 | Before | After | 개선 |
|---|---|---|---|
| 10 entries | 1.1s | ~150ms | ~7배 |
| 20 entries | 2.3s | ~180ms | ~12배 |
| 50 entries | 5.8s | ~280ms | ~20배 |
3. 해결 2 — findFirst N회 → findMany(distinct) + createMany
3.1 백필 스크립트의 N+1 패턴
// ❌ Before: N+1 패턴 (100 프로젝트 → 201 쿼리)
for (const project of projects) {
const existing = await prisma.projectStatusLog.findFirst({ where: { projectId: project.id } });
if (existing) continue;
await prisma.projectStatusLog.create({ data: { ... } });
}
3.2 배치화
// ✅ After: 3 쿼리로 압축
const existingLogs = await prisma.projectStatusLog.findMany({
where: { projectId: { in: projects.map((p) => p.id) } },
distinct: ["projectId"], select: { projectId: true },
});
const existingIds = new Set(existingLogs.map((l) => l.projectId));
const toCreate = projects
.filter((p) => !existingIds.has(p.id))
.map((p) => ({ projectId: p.id, status: p.status, changedAt: p.createdAt, changedBy: null }));
if (toCreate.length > 0) await prisma.projectStatusLog.createMany({ data: toCreate });
| 프로젝트 수 | Before | After | 감소 |
|---|---|---|---|
| 10 | 21 쿼리 | 3 쿼리 | 7배 |
| 100 | 201 쿼리 | 3 쿼리 | 67배 |
| 500 | 1001 쿼리 | 3 쿼리 | 333배 |
4. 두 최적화의 공통점
- 문제 1: N개 과제 × 3 쿼리 = 순차 round-trip → 병렬로 "접기" (시간축 압축)
- 문제 2: N개 프로젝트 × 2 쿼리 = 순차 round-trip → 집합 연산으로 "묶기" (공간축 압축)
핵심: DB 왕복 비용이 지배적. 쿼리가 복잡해져도 왕복 수가 줄면 전체가 빨라짐.
5. 언제 병렬화가 위험한가
- DB 커넥션 풀 한계 —
Promise.all100개 동시 → Prisma 풀(기본 10) 포화. p-limit으로 제어. - 락 경합 — 같은 row를 잠그는 병렬 쿼리는 병렬성 무효화
- 순서 의존성 — 이 예제는 과제 간에는 독립이라 바깥만 병렬화
6. 베스트 프랙티스
- [ ] for 루프 안에
await이 있다면Promise.all(arr.map(...))가능한지 의심 - [ ] 병렬 쿼리 수가 커넥션 풀을 넘지 않는지 확인
- [ ] N+1 패턴은
findMany({ where: { in }})로 집합 조회 + 메모리 join - [ ] bulk insert는
createMany— 각 create를 병렬로 돌리는 것보다 한 쿼리가 빠름 - [ ] 측정 없는 최적화는 없다 — Prisma 쿼리 로그가 가장 효율적인 진단 도구
7. FAQ
Q. 기존 Prisma N+1 쿼리 성능 문제 해결하기 글과 어떻게 다른가요?
A. 이전 글은 "2개 쿼리를 nested select로 1개로 통합". 이번 글은 "N개 쿼리를 병렬로 돌리거나 2개 bulk로 묶기". 기법이 다릅니다.
Q. Promise.allSettled는 언제?
A. 이 경우 try/catch를 콜백 내부에 두어 실패를 null로 처리했으므로 Promise.all로 충분. 바깥에서 하나라도 throw하면 전체 실패가 문제인 경우 allSettled 사용.
Q. createMany의 한계?
A. nested relation 생성 불가. PostgreSQL에서는 단일 INSERT 문으로 변환되어 빠름.
Q. 병렬화 후 CPU가 올라갔는데?
A. Node.js 병렬화는 I/O 대기 겹침이지 CPU 병렬성이 아님. CPU 상승은 결과 파싱/직렬화 때문 → select로 필드 줄이기.
8. 참고 자료
- Prisma - Bulk operations
- MDN - Promise.all
- Prisma - Connection pool
- 관련 글: Promise.all vs Promise.allSettled
9. 다음 단계
병렬화로 응답 시간을 잡았다면, 다음은 같은 엔트리의 진행중+완료 중복이나 잠긴 스프린트의 버려진 태스크 제외 같은 의미론적 정확성입니다.