React로 만든 SPA(Single Page Application)는 기본적으로 CSR 방식으로 동작합니다. 브라우저가 서버에서 빈 HTML을 받고, JavaScript 번들을 다운로드한 뒤, 클라이언트에서 React가 UI를 렌더링하는 방식입니다.
Vite로 만든 React 앱의 index.html을 살펴봅시다:
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-project</title>
</head>
<body>
<div id="root"></div> <!-- 빈 div만 존재. 실제 콘텐츠는 JS가 렌더링 -->
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
브라우저가 이 페이지를 로드하면 다음 과정을 거칩니다:
1. 서버 → 빈 HTML 응답 (div#root만 존재)
2. 브라우저 → bundle.js 다운로드 (수백 KB ~ 수 MB)
3. 브라우저 → JavaScript 파싱 및 실행
4. React → Virtual DOM 생성, 실제 DOM에 렌더링
5. 사용자 → 이제서야 콘텐츠를 볼 수 있음
즉, JavaScript가 완전히 로드되고 실행될 때까지 사용자는 빈 화면을 보게 됩니다.
검색 엔진 크롤러가 CSR 앱을 방문하면 어떤 일이 벌어질까요? 브라우저에서 우클릭 → 페이지 소스 보기를 하면 서버가 보내주는 원본 HTML을 확인할 수 있습니다. 검색 엔진 크롤러도 처음에는 이 HTML을 기준으로 페이지를 분석합니다.
CSR 앱의 소스 보기:
<!doctype html>
<html lang="ko">
<head>
...
</head>
<body>
<div id="root"></div> <!-- 빈 페이지. 콘텐츠가 없음 -->
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Next.js(SSR) 앱의 소스 보기:
<!doctype html>
<html lang="ko">
<head>
...
</head>
<body>
<div> <!-- 실제 콘텐츠가 HTML에 담겨 있음 -->
<h1>Learn, Write, Share.</h1>
<p>
혼자 고민하던 호기심부터 오늘 배운 작은 깨달음까지 기록하고,
공유해보세요.
</p>
</div>
...
</body>
</html>
CSR은 기본적으로 빈 HTML이 내려오므로 검색 인덱싱에 불리할 수 있습니다. 일부 크롤러는 JavaScript를 실행해 콘텐츠를 인식하기도 합니다. 하지만, 일반적으로 서버에서 HTML을 완성해 보내주는 SSR 방식이 더 안정적인 것이 사실입니다.
CSR에서는 화면을 그리기 전에 JavaScript 번들을 먼저 다운로드하고 실행해야 합니다. 앱이 커질수록 초기 번들의 크기가 커질 수 있고, 그만큼 사용자가 첫 콘텐츠를 보기까지(FCP - First Contentful Paint)의 시간이 지연될 수 있습니다. 이를 완화하기 위해 React가 제공하는 코드 분할 기능을 사용할 수 있습니다:
React.lazy - 컴포넌트를 동적으로 import하여, 해당 컴포넌트가 실제로 렌더링될 때만 번들을 다운로드합니다.Suspense - lazy 컴포넌트가 로드되는 동안 보여줄 대체 UI(fallback)를 지정합니다.하지만 코드 분할을 사용해도 CSR의 구조 자체는 변하지 않습니다. 여전히 초기 화면을 렌더링하려면 JavaScript 번들을 먼저 다운로드하고 실행해야 합니다.
import { lazy, Suspense } from 'react';
// 코드 분할: 해당 컴포넌트가 필요할 때만 로드
const Admin = lazy(() => import('./pages/Admin'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
function App() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<Routes>
<Route path="/admin" element={<Admin />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}
export default App;
이 방법은 라우트 단위로 번들을 나눌 수 있지만 한계가 분명합니다:
"로딩 중…")이 노출됩니다. 네트워크가 느리면 이 상태가 길어져 전환이 끊기거나 깜빡이는 것처럼 느껴질 수 있고, 결국 "JS를 받아 실행해야 화면이 완성되는 CSR 구조" 자체는 그대로입니다.CSR 앱은 순수 클라이언트에서 동작하므로 여러 구조적 한계가 있습니다.
클라이언트 코드에서 외부 API를 호출하면 API 키가 브라우저에 노출됩니다:
// 위험! API 키가 브라우저 개발자 도구에서 보임
const res = await fetch('https://api.openai.com/v1/chat', {
headers: {
'Authorization': `Bearer ${import.meta.env.VITE_OPENAI_KEY}`,
},
});
빌드 시점에 환경 변수가 번들에 포함되기 때문에 누구나 확인할 수 있습니다. 따라서, 보안상 중요한 API 키는 클라이언트에서 절대 사용해서는 안 됩니다. 서버에서만 사용해야 합니다.
CSR에서는 보통 컴포넌트가 마운트(화면에 렌더링)된 이후에 데이터를 요청하게 되며, 이 구조 때문에 요청이 순차적으로 이어지는 워터폴이 발생할 수 있습니다:
function Post({ postId }) {
const [post, setPost] = useState(null);
useEffect(() => {
// 1단계: 포스트 데이터 요청
fetch(`/api/posts/${postId}`)
.then(res => res.json())
.then(setPost);
}, [postId]);
if (!post) return <div>로딩 중...</div>;
return (
<div>
<h1>{post.title}</h1>
{/* 포스트가 로드된 후에야 댓글 컴포넌트가 마운트됨 */}
<Comments postId={postId} />
</div>
);
}
function Comments({ postId }) {
const [comments, setComments] = useState([]);
useEffect(() => {
// 2단계: 포스트 로드 후에야 댓글 요청 시작 (워터폴!)
fetch(`/api/posts/${postId}/comments`)
.then(res => res.json())
.then(setComments);
}, [postId]);
return comments.map(c => <p key={c.id}>{c.text}</p>);
}
타임라인:
──────────────────────────────────────────────────────
JS 다운로드 ████████
포스트 요청 ████████
포스트 렌더링 ██
댓글 요청 (대기!) ████████
댓글 렌더링 ██
──────────────────────────────────────────────────────
→ 각 요청이 순차적으로 실행 = 느린 사용자 경험
서버에서 데이터를 가져오면 렌더링 전에 데이터를 준비할 수 있기 때문에, CSR에서 흔히 발생하는 데이터 워터폴을 줄이거나 피하기가 훨씬 쉬워집니다.
CSR의 근본적인 한계를 정리하면:
| 문제 | 원인 |
|---|---|
| SEO에 불리 | 빈 HTML → 크롤러가 콘텐츠를 인식 못함 |
| 느린 초기 로딩 | 대용량 JS 번들을 다운로드 후 실행해야 함 |
| 보안 취약 | API 키 등 민감 정보가 클라이언트에 노출 |
| 데이터 워터폴 | 부모 컴포넌트의 데이터가 준비된 후에야 자식이 요청을 시작하는 구조가 되기 쉬움 |
이러한 한계를 보완하기 위해, 서버에서 HTML을 미리 렌더링하고 서버에서 데이터를 가져와 클라이언트에 전달하는 방식이 등장했습니다. 다음 세션에서는 이 역할을 하는 Next.js를 알아봅니다.