웹 개발의 역사는 사용자 경험을 향상시키기 위한 끊임없는 노력의 연속이었습니다. 초기 정적 HTML에서 시작해 동적 콘텐츠를 위한 서버 사이드 렌더링(SSR), 더 빠른 상호작용을 위한 클라이언트 사이드 렌더링(CSR), 그리고 이제는 스트리밍 SSR이라는 새로운 패러다임이 등장했습니다.
이 글에서는 React Server Components와 스트리밍 SSR의 기술적 배경부터 실제 구현까지, 현대 웹 개발의 핵심 기술들을 심도 있게 다뤄보겠습니다.
특징:
데이터 플로우:
1. 브라우저가 HTML 요청
2. 서버가 빈 HTML + JavaScript 번들 응답
3. 브라우저가 JavaScript 실행
4. API 호출로 데이터 요청
5. 데이터 수신 후 DOM 렌더링
장점:
단점:
특징:
데이터 플로우:
1. 브라우저가 페이지 요청
2. 서버가 데이터 페칭
3. 서버가 HTML 생성
4. 완성된 HTML을 클라이언트로 전송
5. 브라우저가 HTML 렌더링 + 하이드레이션
장점:
단점:
특징:
데이터 플로우:
1. 빌드 시 데이터 페칭
2. 정적 HTML 생성
3. CDN에 배포
4. 사용자 요청 시 즉시 HTML 전송
장점:
단점:

특징:
데이터 플로우:
1. 브라우저가 페이지 요청
2. 서버가 HTML 헤더/레이아웃 먼저 전송
3. 데이터 로딩 중인 부분은 Suspense fallback 표시
4. 데이터 준비되면 해당 부분만 스트리밍
5. 클라이언트에서 점진적 하이드레이션
장점:
단점:
HTTP/1.0은 기본적으로 "비연결"이었지만, HTTP/1.1부터 Connection: keep-alive 헤더로 지속 연결이 가능해졌습니다.
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: text/html
HTTP/1.1에서 도입된 청크 인코딩이 핵심입니다:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html
5\r\n
Hello\r\n
6\r\n
World\r\n
0\r\n
\r\n
HTTP/2에서는 더욱 발전된 스트리밍이 가능합니다:
전 세계 지원률:
모바일 디바이스 지원:
데스크톱 브라우저 지원:
Suspense로 감싼 부분이 스트리밍의 단위가 됩니다:
// 1. 즉시 전송되는 부분
<main className="p-8">
<h1>React Server Components + Suspense</h1>
{/* 2. Suspense fallback이 먼저 전송됨 */}
<Loading /> {/* ← 이것이 먼저 클라이언트에 도착 */}
</main>
// 3. UserList 데이터 로딩 완료 후 스트리밍
<UserList /> {/* ← 3초 후에 이 부분이 스트리밍됨 */}

0초: <h1>제목</h1> + <Loading /> + <footer>푸터</footer>
3초: <UserList /> (첫 번째 Suspense 완료)
5초: <PostList /> (두 번째 Suspense 완료)
Suspense 경계 = 스트리밍 단위
<Suspense>가 독립적인 스트리밍 청크fallback이 먼저 전송
병렬 스트리밍
<Suspense fallback="전체 로딩">
{" "}
{/* 1단계 */}
<UserList /> (1초 딜레이) {/* 2단계 */}
<UserCard>
<Suspense fallback="아바타 로딩">
{" "}
{/* 3단계 */}
<UserAvatar /> (1초)
</Suspense>
<Suspense fallback="통계 로딩">
{" "}
{/* 3단계 */}
<UserStats /> (2초)
</Suspense>
</UserCard>
</Suspense>
0초: 최상위 fallback 표시
즉시: UserCard 렌더링 + 하위 fallback들 표시
1초: 아바타 완료 → 해당 부분만 교체
2초: 통계 완료 → 해당 부분만 교체
0초: UserList 시작
1초: UserList 완료 → UserCard 렌더링 → UserAvatar 시작
2초: UserAvatar 완료
3초: UserStats 완료
총 3초
0초: UserList, UserAvatar, UserStats 모두 동시 시작
1초: UserList, UserAvatar 완료
2초: UserStats 완료
총 2초
| 방식 | 실행 순서 | 총 시간 | 의존성 |
|---|---|---|---|
| 워터폴 | 순차적 의존성 | 각 단계 시간의 합 | 이전 단계 완료 후 다음 단계 |
| 병렬 | 독립적 실행 | 가장 오래 걸리는 작업의 시간 | 모든 작업 동시 시작 |
// Promise를 직접 사용
const user = use(fetchUser(userId));
// Context 사용
const theme = use(ThemeContext);
use 훅은 Promise가 pending 상태일 때 자동으로 Suspense를 트리거// Promise가 reject되면
const user = use(fetchUserWithError("error")); // 에러 발생
// ↓
// Error Boundary로 에러 전달
// ↓
// fallback UI 렌더링
<ErrorBoundary fallback={({ error }) => <ErrorUI error={error} />}>
<Suspense fallback={<Loading />}>
<UserProfile userId="error" />
</Suspense>
</ErrorBoundary>
| 방식 | 코드 복잡도 | 성능 | Suspense 지원 | 에러 핸들링 |
|---|---|---|---|---|
| useState + useEffect | 🔴 복잡 | 🟡 보통 | ❌ 수동 구현 | 🔴 수동 |
| React Query | 🟡 보통 | 🟢 좋음 | ✅ 지원 | 🟢 자동 |
| use 훅 | 🟢 간단 | 🟢 좋음 | ✅ 자동 | 🟢 자동 |
// app/streaming/page.tsx
import { Suspense } from "react";
import UserList from "@/components/UserList";
import Loading from "@/components/Loading";
export default function Page() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-4">
React Server Components + Suspense
</h1>
{/* streaming ssr */}
<Suspense fallback={<Loading />}>
<UserList />
</Suspense>
</main>
);
}
// components/UserList.tsx
import UserCard from "./UserCard";
export default async function UserList() {
const res = await fetch("https://jsonplaceholder.typicode.com/users", {
cache: "no-store",
});
await new Promise((resolve) => setTimeout(resolve, 3000));
const users = await res.json();
return (
<div className="space-y-2">
{users.map((user: { id: string; name: string; email: string }) => (
<UserCard key={user.id} name={user.name} email={user.email} />
))}
</div>
);
}
// components/RealNestedSuspense.tsx
import { Suspense } from "react";
async function UserAvatar({ userId }: { userId: string }) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // 1초
return (
<div className="w-12 h-12 bg-blue-500 rounded-full">아바타 {userId}</div>
);
}
async function UserStats({ userId }: { userId: string }) {
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2초
return <div className="text-sm text-gray-600">팔로워: 1,234명</div>;
}
function UserCard({ userId }: { userId: string }) {
return (
<div className="border p-4 rounded">
<h3 className="font-bold">사용자 {userId}</h3>
<Suspense
fallback={<div className="p-2 bg-blue-100">🔄 아바타 로딩...</div>}
>
<UserAvatar userId={userId} />
</Suspense>
<Suspense
fallback={<div className="p-2 bg-green-100">🔄 통계 로딩...</div>}
>
<UserStats userId={userId} />
</Suspense>
</div>
);
}
async function UserList() {
await new Promise((resolve) => setTimeout(resolve, 1000)); // 1초 딜레이
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<UserCard userId="1" />
<UserCard userId="2" />
</div>
);
}
export default function RealNestedSuspense() {
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">3단계 중첩된 Suspense</h2>
<Suspense
fallback={
<div className="p-4 bg-red-100">🔄 전체 사용자 리스트 로딩 중...</div>
}
>
<UserList />
</Suspense>
</div>
);
}
// components/UseHookDemo.tsx
import { use, Suspense } from "react";
async function fetchUser(userId: string) {
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2초
return {
id: userId,
name: `사용자 ${userId}`,
email: `user${userId}@example.com`,
};
}
function UserProfile({ userId }: { userId: string }) {
const user = use(fetchUser(userId));
return (
<div className="border p-4 rounded">
<h3 className="font-bold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
</div>
);
}
function ErrorBoundary({
children,
fallback,
}: {
children: React.ReactNode;
fallback: (error: Error) => React.ReactNode;
}) {
try {
return <>{children}</>;
} catch (error) {
if (error instanceof Error) {
return <>{fallback(error)}</>;
}
throw error;
}
}
export default function UseHookDemo() {
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">React use 훅 + Suspense</h2>
<ErrorBoundary
fallback={({ error }) => (
<div className="p-4 bg-red-100">❌ {error.message}</div>
)}
>
<Suspense
fallback={<div className="p-4 bg-blue-100">🔄 사용자 로딩...</div>}
>
<UserProfile userId="1" />
</Suspense>
</ErrorBoundary>
</div>
);
}
| 지표 | CSR | SSR | SSG | 스트리밍 SSR |
|---|---|---|---|---|
| 초기 로딩 시간 | 🔴 느림 (3-5초) | 🟡 보통 (1-2초) | 🟢 빠름 (0.5-1초) | 🟢 빠름 (0.5-1초) |
| Time to First Byte (TTFB) | 🟢 빠름 | 🟡 보통 | 🟢 빠름 | 🟢 빠름 |
| First Contentful Paint (FCP) | 🔴 느림 | 🟡 보통 | 🟢 빠름 | 🟢 빠름 |
| Largest Contentful Paint (LCP) | 🔴 느림 | 🟡 보통 | 🟢 빠름 | 🟢 빠름 |
| 상호작용 시간 | 🟢 빠름 | 🔴 느림 | 🔴 느림 | 🟡 보통 |
| SEO 점수 | 🔴 낮음 | 🟢 높음 | 🟢 높음 | 🟢 높음 |
| 서버 비용 | 🟢 낮음 | 🔴 높음 | 🟢 낮음 | 🟡 보통 |
스트리밍 SSR:
전통적 SSR:
| 렌더링 방식 | 서버 메모리 | 클라이언트 메모리 | 총 메모리 |
|---|---|---|---|
| CSR | 🟢 낮음 | 🔴 높음 | 🟡 보통 |
| SSR | 🔴 높음 | 🟡 보통 | 🔴 높음 |
| SSG | 🟢 낮음 | 🟢 낮음 | 🟢 낮음 |
| 스트리밍 SSR | 🟡 보통 | 🟡 보통 | 🟡 보통 |
스트리밍 SSR은 현대 웹 개발의 새로운 표준이 될 것으로 예상됩니다. 특히: