본문으로 건너뛰기

"ssr" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

모든 태그 보기

RSC + Streaming SSR로 대시보드 체감 속도 2배 높이기

· 약 4분
김현재
개발자

들어가며

사내 보안 대시보드를 개발하던 중, 이상한 점을 발견했습니다. 기능 구현은 완료됐는데 Lighthouse를 돌려보니 FCP(First Contentful Paint)가 3초를 훌쩍 넘기고 있었습니다. 사용자는 페이지에 접속하자마자 3초 이상 빈 화면을 마주하고 있었던 것입니다.

원인이 무엇이였을까요? 대시보드 페이지는 여러 API를 동시에 호출해야 했고, SSR 환경에서 서버가 모든 데이터를 다 받아온 뒤에야 HTML을 클라이언트로 전송하고 있었습니다. 모든 응답이 끝날 때까지 기다리는 구조였으니, FCP가 3초를 넘는 건 당연한 결과였습니다.

이번 포스팅에서는 Next.js의 RSC(React Server Components)Streaming SSR 을 조합하여 이 문제를 해결한 경험을 정리했습니다.

본 포스팅은 사내 프로젝트의 경험을 바탕으로 작성되었으나, 보안 정책상 실제 코드와 이미지를 그대로 사용할 수 없습니다. 따라서 프로젝트에서 해결한 핵심 로직과 메커니즘을 동일하게 재현한 별도의 데모 코드와 자료를 활용하여 정리했습니다.

기존 방식의 문제점

App Router에서는 페이지 컴포넌트 자체를 async로 선언하고 직접 데이터를 fetch할 수 있습니다. Promise.all로 병렬 요청을 보내면 총 대기 시간을 줄일 수 있지만, 근본적인 한계가 있습니다.

// page.tsx — async Server Component
export default async function DashboardPage() {
// 병렬 요청이지만, 모두 완료될 때까지 렌더링 불가
const [threats, assets, compliance] = await Promise.all([
fetchThreats(), // 800ms
fetchAssets(), // 700ms
fetchCompliance(), // 1200ms ← 이 API가 전체를 결정
]);

return (
<div>
<ThreatCard data={threats} />
<AssetCard data={assets} />
<ComplianceCard data={compliance} />
</div>
);
}
서버                          브라우저
|── fetchThreats() ──▶ 800ms |
|── fetchAssets() ───▶ 700ms | 사용자는
|── fetchCompliance()▶ 1200ms | 이 동안
| | 아무것도
|◀── 모든 응답 완료 ──────────| 볼 수 없음
|── HTML 전송 ───────────────▶|

Promise.all로 병렬 요청을 보내더라도, 모든 응답이 완료될 때까지 HTML 전송 자체가 불가능합니다. 가장 느린 API(1,200ms)가 전체 페이지 속도를 결정하며, 이는 Web Vital의 핵심 지표인 FCP에 직접적인 영향을 줍니다.

그렇다면 await을 하나씩 순차적으로 사용하면 어떨까요? 오히려 더 느려집니다. 직렬로 요청하면 800 + 700 + 1,200 = 2,700ms를 기다려야 하기 때문입니다. Promise.all이든 순차 await이든, 페이지 컴포넌트의 async 함수가 완전히 끝나야 React가 렌더링을 시작한다는 구조 자체가 문제입니다. 그렇다면 어떻게 이 문제를 해결할 수 있을까요?

해결책: RSC + Streaming SSR

Next.js App Router는 두 가지 핵심 기능을 제공합니다.

  • RSC(React Server Components): 컴포넌트 자체가 async 함수가 될 수 있어, 컴포넌트 단위로 데이터 패칭이 가능합니다.
  • Streaming SSR: HTML을 조각(chunk) 단위로 나눠서 준비되는 대로 클라이언트에 전송합니다.

이 두 가지를 Suspense와 조합하면 섹션별로 독립적인 스트리밍이 가능해집니다.

구현 방법

루트 페이지를 Shell로 만들기

페이지 컴포넌트를 async로 선언하지 않으면, 서버는 데이터를 기다리지 않고 즉시 HTML을 생성해 전송합니다. 이 Shell이 클라이언트에 도착하는 순간 FCP가 기록됩니다.

Shell이란 데이터 없이 즉시 렌더링할 수 있는 페이지의 뼈대를 말합니다. 사용자는 빈 화면 대신 페이지 구조를 먼저 볼 수 있습니다.

// page.tsx — async 없이 즉시 렌더링
export default function DashboardPage() {
return <main>{/* 각 섹션은 Suspense로 감싸 독립적으로 스트리밍 */}</main>;
}

각 섹션을 RSC로 분리하기

// ThreatSection.tsx — async RSC
export default async function ThreatSection() {
const data = await fetchThreatData(); // 이 컴포넌트만을 위한 fetch
return <ThreatCard data={data} />;
}

각 섹션이 독립적인 컴포넌트로 분리되어 있기 때문에, 모든 fetch가 동시에 시작됩니다. 페이지 컴포넌트가 await Promise.all로 묶어서 기다리지 않아도 됩니다.

Suspense로 감싸 스켈레톤 UI 제공하기

Suspense로 감싼 RSC는 데이터가 준비될 때까지 fallback(스켈레톤)을 보여줍니다. 데이터가 도착하면 React가 스켈레톤을 실제 컴포넌트로 교체하고, 그 결과를 스트리밍으로 클라이언트에 전송합니다.

<main>
<Suspense fallback={<ThreatCardSkeleton />}>
<ThreatSection /> {/* 800ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<AssetCardSkeleton />}>
<AssetSection /> {/* 700ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<IssueTableSkeleton />}>
<IssueSection /> {/* 600ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<ComplianceTableSkeleton />}>
<ComplianceSection /> {/* 1200ms 후 스트리밍 */}
</Suspense>
</main>

스트리밍 타임라인

이렇게 구현하면 다음과 같은 순서로 화면이 채워집니다.

0ms     → Shell 즉시 전송 (FCP 기록)
0ms → 4개 섹션이 동시에 fetch 시작 (병렬)
+600ms → IssueSection 완료, 스트리밍
+700ms → AssetSection 완료, 스트리밍
+800ms → ThreatSection 완료, 스트리밍
+1200ms → ComplianceSection 완료, 스트리밍

기존 방식이라면 1,200ms가 지나도록 빈 화면이었겠지만, 이제는 600ms부터 순차적으로 콘텐츠가 채워집니다.

성능 비교

지표기존 방식RSC + Streaming
FCP (First Contentful Paint)~1,200ms 이상~200ms (shell)
LCP (Largest Contentful Paint)~3,000ms~900ms
체감 로딩 시간~3,000ms~1,500ms 이하

수치상으로는 절반이지만, 사용자가 느끼는 체감 속도 차이는 훨씬 큽니다. 기존에는 로딩이 끝날 때까지 아무것도 할 수 없었다면, 이제는 화면이 뜨는 순간부터 순차적으로 정보를 확인할 수 있습니다.

주의할 점

스켈레톤 UI는 필수

Suspensefallback이 없으면 섹션 자리가 비어있다가 갑자기 내용이 나타나 레이아웃 시프트(CLS)가 발생합니다. 실제 컴포넌트와 동일한 크기의 스켈레톤을 반드시 제공해야 합니다.

섹션 경계 설계

Suspense 경계를 너무 잘게 나누면 관리가 복잡해지고, 너무 크게 잡으면 스트리밍 효과가 줄어듭니다. 사용자에게 의미 있는 단위로 섹션을 나누는 것이 중요합니다.

예를 들어 Suspense 경계를 너무 잘게 나누면 네트워크 요청 수가 늘어나 오히려 비효율적인 방법이 될 수 있습니다.

마무리

CSR에서 SSR로 전환하면 성능이 좋아질 거라 막연히 기대했습니다. 하지만 SSR도 만능이 아니었습니다. 서버에서 모든 데이터를 기다렸다가 한 번에 렌더링하는 구조는, 오히려 CSR보다 긴 공백 화면을 만들어낼 수 있습니다.

RSC와 Streaming SSR은 그 한계를 보완하기 위해 Next.js가 내놓은 해답이었습니다. 이번 경험을 통해 기술을 도입할 때 "왜 이 기술이 등장했는가"를 먼저 이해하는 것이 중요하다는 걸 다시 한번 느꼈습니다.