Next.js 서버 컴포넌트에서 Promise.all()로 대시보드 응답 시간 2배 단축하기

Published2026.04.04
Read Time12 min read
Related Project: CodeMate

Next.js 서버 컴포넌트에서 Promise.all()로 대시보드 응답 시간 2배 단축하기

서론 - CodeMate 프로젝트 소개

AI 기반 코드 리뷰 & 협업 플랫폼을 만들던 도중, 대시보드 페이지에서 서버 사이드 데이터 페칭을 최적화한 과정을 정리해보겠다.


대시보드에는 어떤 데이터가 필요한가

로그인 후 가장 먼저 보이는 대시보드 페이지에는 다음 4가지 데이터 영역이 있다.

영역데이터DB 쿼리
통계 카드평균 코드 품질, 오픈 PR 수, 주간 리뷰 수6개 (aggregate, count)
품질 추이 차트최근 30일 일별 평균 점수1개 (findMany)
이슈 분포 차트심각도별 이슈 비율2개 (groupBy + findMany)
최근 PR 테이블최신 PR 5개1개 (findMany)

합치면 총 10개의 Prisma 쿼리가 대시보드 한 페이지를 구성한다. 이걸 어떻게 실행하느냐에 따라 응답 시간이 크게 달라진다.


문제: 순차 실행의 병목

처음 단순하게 구현했을 때는 이런 코드가 되어있었다.

// 순차 실행 - 각 함수가 끝나야 다음이 시작
const stats = await fetchDashboardStats(userId)
const qualityTrend = await fetchDashboardQualityTrend(userId)
const issueSeverity = await fetchDashboardIssueSeverity(userId)
const recentPRs = await fetchDashboardRecentPRs(userId)

4개 함수가 직렬로 실행되므로 총 응답 시간 = 각 함수 시간의 합이다.

각 함수 간에 데이터 의존성이 전혀 없다. 4번째 함수가 1번째 함수의 결과를 필요로 하지 않는다. 그렇다면 굳이 기다릴 이유가 없다.


해결: Promise.all()로 2단계 병렬화

1단계 - page.tsx에서 4개 fetch 함수 병렬 실행

Next.js App Router의 서버 컴포넌트에서는 async/await를 바로 사용할 수 있다. 독립적인 4개의 fetch 함수를 Promise.all()로 감싸면 동시에 실행된다.

// app/(protected)/dashboard/page.tsx - 서버 컴포넌트
export default async function Page() {
  const session = await auth()
  if (!session?.user?.id) return null

  const [stats, qualityTrend, issueSeverity, recentPRs] = await Promise.all([
    fetchDashboardStats(session.user.id),       // ① 통계 카드
    fetchDashboardQualityTrend(session.user.id), // ② 품질 추이 차트
    fetchDashboardIssueSeverity(session.user.id), // ③ 이슈 분포 차트
    fetchDashboardRecentPRs(session.user.id),    // ④ 최근 PR 테이블
  ])

  return (
    <div className="max-w-350 mx-auto space-y-4 sm:space-y-6">
      <StatCards>
        <CodeQualityCard score={stats.avgQualityScore} trend={stats.qualityScoreTrend} />
        <OpenPRCard openPRs={stats.openPRs} pendingReviewPRs={stats.pendingReviewPRs} />
        <WeeklyReviewCard weeklyReviews={stats.weeklyReviews} diff={stats.weeklyReviewsDiff} />
      </StatCards>
      <ChartsSection qualityTrend={qualityTrend} issueSeverity={issueSeverity} />
      <RecentPRSection prs={recentPRs} />
    </div>
  )
}

이 시점에서 타임라인은 이렇게 바뀐다.

2단계 - fetchDashboardStats 내부에서도 병렬화

가장 무거운 fetchDashboardStats() 함수 내부를 보면, 통계 카드 하나를 만들기 위해 6개의 독립적인 Prisma 쿼리가 필요하다.

// lib/dashboard.ts
export async function fetchDashboardStats(userId: string): Promise<DashboardStats> {
  const [
    currentQuality,   // 최근 30일 평균 품질 점수
    prevQuality,      // 이전 30일 평균 품질 점수 (트렌드 비교용)
    openPRs,          // 열린 PR 수
    pendingReviewPRs, // 리뷰 대기 PR 수
    weeklyReviews,    // 이번 주 리뷰 수
    prevWeekReviews,  // 지난 주 리뷰 수 (증감 비교용)
  ] = await Promise.all([
    prisma.review.aggregate({
      where: { pullRequest: { repo: { userId } }, status: "COMPLETED", reviewedAt: { gte: thirtyDaysAgo } },
      _avg: { qualityScore: true },
    }),
    prisma.review.aggregate({
      where: { pullRequest: { repo: { userId } }, status: "COMPLETED", reviewedAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo } },
      _avg: { qualityScore: true },
    }),
    prisma.pullRequest.count({
      where: { repo: { userId }, status: "OPEN" },
    }),
    prisma.pullRequest.count({
      where: { repo: { userId }, status: "OPEN", reviews: { none: { status: "COMPLETED" } } },
    }),
    prisma.review.count({
      where: { pullRequest: { repo: { userId } }, status: "COMPLETED", reviewedAt: { gte: thisWeekStart } },
    }),
    prisma.review.count({
      where: { pullRequest: { repo: { userId } }, status: "COMPLETED", reviewedAt: { gte: lastWeekStart, lt: thisWeekStart } },
    }),
  ])

  // 결과 가공...
}

이렇게 외부 4개 × 내부 최대 6개 = 총 10개의 DB 쿼리가 2단계로 병렬 실행된다.

10개의 쿼리가 모두 동시에 DB 커넥션 풀에서 실행된다. 총 응답 시간은 가장 느린 단일 쿼리 하나의 시간에 수렴한다.


서버 컴포넌트 아키텍처의 이점

대시보드의 컴포넌트 트리를 보면 다음과 같다.

"use client" 선언이 있는 컴포넌트는 Recharts를 렌더링하는 2개뿐이다. 나머지는 전부 서버 컴포넌트다.

이 구조의 핵심 이점은 다음과 같다.

1. 클라이언트에 API 호출 코드가 없다

데이터 페칭은 전부 서버에서 끝난다. 클라이언트에는 완성된 데이터가 props로 내려간다. useEffect로 API를 호출하고, 로딩 상태를 관리하고, 에러를 처리하는 코드가 필요 없다.

2. JS 번들 최소화

통계 카드, PR 테이블 같은 정적 UI는 서버에서 HTML로 렌더링되어 전송된다. 클라이언트로 보내는 JavaScript는 Recharts 차트 컴포넌트 2개뿐이다.

3. 데이터 페칭 함수에서 Prisma를 직접 호출

서버 컴포넌트이기 때문에 별도의 API 라우트를 만들지 않고 Prisma로 DB에 직접 쿼리할 수 있다. 네트워크 홉이 하나 줄어든다.


데이터 흐름 정리

서버에서 페칭한 데이터가 클라이언트까지 전달되는 전체 흐름이다.

각 컴포넌트는 자신이 필요한 데이터만 props로 받는다. 전체 DashboardStats 객체를 통째로 내리지 않고, page.tsx에서 구조 분해하여 각 컴포넌트가 필요한 최소한의 props만 전달한다.


성능 측정

측정 방법

순차 실행과 병렬 실행의 차이를 확인하기 위해 console.time()으로 page.tsx의 데이터 페칭 구간을 측정했다.

// 순차 실행 (비교 대상)
console.time("dashboard-fetch")
const stats = await fetchDashboardStats(userId)
const qualityTrend = await fetchDashboardQualityTrend(userId)
const issueSeverity = await fetchDashboardIssueSeverity(userId)
const recentPRs = await fetchDashboardRecentPRs(userId)
console.timeEnd("dashboard-fetch")

// 병렬 실행 (적용 버전)
console.time("dashboard-fetch")
const [stats, qualityTrend, issueSeverity, recentPRs] = await Promise.all([...])
console.timeEnd("dashboard-fetch")

결과 (cold start 기준)

실행 방식응답 시간비고
순차 실행~871ms4개 함수 직렬 실행
Promise.all 병렬 실행~429ms가장 느린 함수 시간에 수렴

약 2배(51%) 단축. 특히 fetchDashboardStats 내부의 6개 쿼리도 병렬화되어 있으므로, 이 함수 단독으로도 내부 순차 실행 대비 상당한 개선이 있다.

정리

서버 컴포넌트에서 독립적인 데이터 소스를 다룰 때 핵심은 간단하다.

의존 관계가 없는 쿼리는 Promise.all()로 병렬 실행하라.

이 프로젝트에서 적용한 최적화를 요약하면 다음과 같다.

최적화 기법적용 위치효과
Promise.all() 2단계 병렬화page.tsx + lib/dashboard.tscold start 871ms → 429ms (약 2배 단축)
서버 컴포넌트 전용 렌더링대시보드 컴포넌트 트리 전체클라이언트 JS 번들 최소화, API 라우트 불필요
Prisma 직접 호출lib/dashboard.tsAPI 라우트 경유 대비 네트워크 홉 1단계 제거
선별적 selectfetchDashboardRecentPRs필요한 필드만 조회하여 전송량 최소화
TanStack Query staleTime클라이언트 사이드 기능 (PR, 댓글, 알림 등)60초 이내 재조회 시 API 호출 생략

서버 컴포넌트의 장점을 제대로 살리려면, 데이터 페칭은 서버에서 끝내고, 클라이언트에는 완성된 결과만 전달하는 것이 핵심이다. 그리고 서버에서 여러 데이터를 가져와야 할 때, 순차적으로 await 하지 말고 Promise.all()로 묶어주는 것만으로도 체감 성능이 크게 달라진다.