클라이언트에서 데이터를 가져오는 것의 문제
React가 SPA를 대표하는 라이브러리로 자리잡으면서, 우리는 오랫동안 이 패턴에 익숙해졌습니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Loading />;
return <div>{user.name}</div>;
}이 코드는 익숙하지만, 몇 가지 근본적인 문제를 가지고 있습니다.
워터폴이 발생합니다. 부모 컴포넌트가 마운트되고 렌더링된 후에야 useEffect가 실행되고, 그제서야 자식 컴포넌트에서 또 다른 fetch가 시작됩니다. 데이터가 중첩될수록 네트워크 요청이 순차적으로 이어지면서 전체 로딩 시간이 길어집니다.
번들에 불필요한 코드가 들어갑니다. fetch 로직, 데이터 파싱, 로딩/에러 상태 관리, 데이터 가공 유틸리티, 이 모든 것이 클라이언트 번들에 포함됩니다. 사용자는 자기가 쓸 컴포넌트만 받는 게 아니라, 그 컴포넌트가 데이터를 가져오는 데 필요한 모든 로직을 함께 다운로드합니다.
보안과 환경 변수를 다루기 어렵습니다. API 키, 데이터베이스 연결 정보 같은 것은 클라이언트에 노출되면 안 됩니다. 그래서 별도의 API 라우트를 만들어 중간 계층으로 두어야 했고, 이는 추가 복잡도를 만들었습니다.
SEO와 첫 paint가 느렸습니다. 콘텐츠가 클라이언트 JavaScript 실행 후에야 나타났고, 검색 엔진과 사용자 모두 빈 화면을 기다려야 했습니다.
이 문제들을 해결하기 위해 SSR, SSG, ISR 같은 전략이 등장했습니다. 하지만 이들은 모두 페이지 단위 로 동작하는 해결책이었습니다. 한 페이지 안에서도 "이 부분은 서버에서, 저 부분은 클라이언트에서" 처럼 세밀하게 나누고 싶은 요구는 여전히 남아 있었습니다.
Server Components라는 새로운 개념
2020년 말, React 팀은 React Server Components (RSC)라는 새로운 개념을 제안했습니다. 이것은 SSR이나 SSG와는 전혀 다른 방향의 해결책이었습니다.
RSC의 핵심 아이디어는 이렇습니다, 컴포넌트 자체가 서버에서만 실행되게 하자.
// 이것은 Server Component입니다
async function UserProfile({ userId }: { userId: string }) {
const user = await db.users.findById(userId); // DB 직접 접근
return <div>{user.name}</div>;
}몇 가지 특징을 봅시다.
async함수입니다. 컴포넌트가 비동기 함수가 될 수 있고, 그 안에서await으로 데이터를 직접 기다립니다- DB에 직접 접근합니다. API 라우트를 통하지 않고, Node.js 환경에서 쓸 수 있는 모든 것을 자유롭게 사용합니다
useState,useEffect가 없습니다. 상태도, 이벤트 핸들러도 없습니다. 이것은 "렌더링만 하는" 컴포넌트입니다- 클라이언트 번들에 포함되지 않습니다. 이 컴포넌트의 코드는 브라우저로 전송되지 않습니다. 결과 HTML만 전송됩니다
이 모델이 해결하는 것들:
- 워터폴의 비용이 크게 줄어듭니다. 클라이언트 워터폴은 브라우저 ↔ API 서버 ↔ DB 라는 왕복을 단계마다 반복하지만, Server Component는 서버 안에서 DB에 바로 접근하므로 각 단계의 지연이 훨씬 짧습니다. Parent-child로 직접
await하는 구조라면 여전히 순차 실행이지만, sibling 컴포넌트들의 데이터 요청은 자연스럽게 병렬로 일어납니다. 진정한 병렬화가 필요하다면 뒤에서 다룰 "Promise를 내려보내는" 패턴을 쓰면 됩니다 - 클라이언트 번들이 줄어듭니다. 데이터 패칭 로직과 서버 전용 라이브러리는 클라이언트에 전혀 포함되지 않습니다
- 보안이 자연스럽게 해결됩니다. 환경 변수와 DB 자격증명을 컴포넌트 안에서 직접 사용할 수 있고, 클라이언트로 노출될 위험이 없습니다
서버와 클라이언트의 공존, 'use client'
하지만 모든 컴포넌트가 서버 컴포넌트가 될 수는 없습니다. 사용자 입력, 상태, 애니메이션, 브라우저 API 같은 것들은 여전히 클라이언트에서 실행되어야 합니다.
React는 이 경계를 'use client' 라는 지시문으로 표현합니다.
// app/components/Counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}파일 상단에 'use client'가 있으면, 이 파일과 그 아래 모든 import는 클라이언트 번들에 포함됩니다. 이 경계를 "use client 경계"라고 부릅니다.
App Router에서 기본은 Server Component 입니다. 'use client'가 없으면 서버에서 실행되고, 있으면 클라이언트에서 실행됩니다. 개발자는 "인터랙션이 필요한가?" 를 기준으로 경계를 명시하면 됩니다.
두 컴포넌트의 공존 규칙
Server Component와 Client Component는 같은 트리 안에 공존할 수 있지만, 몇 가지 규칙이 있습니다.
Server Component는 Client Component를 import해서 사용할 수 있습니다.
// app/page.tsx (Server Component)
import { Counter } from "./Counter"; // 'use client' 파일
export default async function Page() {
const user = await db.users.findCurrent();
return (
<main>
<h1>안녕하세요, {user.name}님</h1>
<Counter /> {/* Client Component 사용 */}
</main>
);
}Client Component는 Server Component를 직접 import할 수 없습니다. 대신 children prop으로 받을 수 있습니다.
// app/ClientLayout.tsx (Client Component)
"use client";
import { useState } from "react";
export function ClientLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>토글</button>
{open && children} {/* 여기 들어오는 children은 Server Component일 수 있습니다 */}
</div>
);
}이 패턴이 중요합니다. Client Component는 자기 자식이 무엇인지 "모른 채" 구조만 제공하고, 부모 (Server Component) 가 실제 Server Component를 children으로 주입합니다. 덕분에 Client Component 안에서도 Server Component를 렌더링할 수 있습니다.
직렬화, Server에서 Client로 전달될 때
Server Component에서 Client Component로 props를 전달할 때, 그 값들은 직렬화 가능해야 합니다.
// Server Component
export default async function Page() {
const data = await db.query();
return (
<ClientChart
// 가능
title="매출"
values={[1, 2, 3]}
createdAt={new Date()}
// 불가능, 함수는 직렬화 안 됨
onClick={() => console.log("clicked")}
// 불가능, 클래스 인스턴스는 직렬화 안 됨
dbConnection={data.connection}
/>
);
}Server Component에서 Client Component로 값이 전달될 때, React는 그 값을 직렬화해서 클라이언트로 보냅니다. 문자열, 숫자, 불린, Date, 배열, 일반 객체, Promise 같은 것은 보낼 수 있습니다. 일반 함수나 클래스 인스턴스는 보낼 수 없습니다.
다만 한 가지 예외가 있습니다, Server Action 으로 선언된 함수는 전달 가능합니다. "use server" 지시문이 붙은 함수는 React가 실제 함수 대신 RPC 프록시 참조로 직렬화하므로, Client Component가 그것을 "호출"하면 서버로 요청이 전송됩니다. 6편에서 자세히 다룰 내용입니다.
이 제약은 자연스럽게 데이터와 동작을 분리 하는 경계가 됩니다. 서버는 데이터와 서버 함수 참조를 제공하고, 클라이언트는 그 데이터를 가지고 동작을 수행하거나 서버 함수를 트리거합니다.
Promise를 props로 전달하기
React 19부터는 Promise도 props로 전달할 수 있습니다. 이것이 매우 흥미로운 패턴을 가능하게 합니다.
// Server Component
export default function Page() {
const commentsPromise = fetchComments(); // await하지 않음
return (
<Suspense fallback={<Loading />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}// Client Component
"use client";
import { use } from "react";
export function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise); // Client에서 Promise를 읽음
return comments.map((c) => <p key={c.id}>{c.text}</p>);
}Server Component는 fetch를 시작만 하고 기다리지 않습니다. 이 Promise는 Client Component로 전달되고, 클라이언트가 use() Hook으로 값을 읽습니다. 데이터 패칭은 서버에서 즉시 시작되고, 결과는 Streaming으로 클라이언트에 도착합니다.
이 패턴의 장점은 워터폴을 피할 수 있다 는 것입니다. Server Component가 await로 직접 기다리면, 그 데이터가 도착할 때까지 페이지의 나머지 부분도 함께 멈춥니다. Promise를 전달하면 서버는 바로 다음 단계로 진행하고, 느린 데이터는 Suspense가 기다립니다.
언제 Server, 언제 Client?
실무에서 이 경계를 잘 그리는 것이 중요합니다. 몇 가지 원칙을 정리합니다.
Server Component가 적합한 경우
- 데이터 패칭 (DB, API, 파일 시스템)
- 환경 변수나 비밀 키 접근
- 무거운 라이브러리 사용 (예: Markdown 파서, 이미지 처리)
- SEO가 중요한 정적 콘텐츠
- 자주 변경되지 않는 UI 셸
Client Component가 필요한 경우
- 사용자 인터랙션 (
onClick,onChange등) - 상태 관리 (
useState,useReducer) - 생명주기 (
useEffect,useLayoutEffect) - 브라우저 API (
localStorage,window,document) - 이벤트 리스너나 애니메이션
- React Context 제공 (일부 사용)
경계를 내리는 것이 핵심
가장 흔한 실수는 "인터랙션이 필요한 페이지 전체를 'use client'로 만드는 것" 입니다. 이렇게 하면 페이지 전체가 클라이언트 번들에 들어가고, Server Components의 이점을 잃게 됩니다.
올바른 방법은 Client Component를 트리의 가장 깊은 곳 에 두는 것입니다.
// app/page.tsx (Server Component)
import { LikeButton } from "./LikeButton"; // Client Component
export default async function Page() {
const post = await db.posts.findOne();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}페이지 전체는 Server Component이고, 좋아요 버튼만 Client Component입니다. 버튼의 인터랙션만 클라이언트 번들에 포함되고, 나머지 콘텐츠는 서버에서 생성된 HTML로 전송됩니다.
Server Components가 바꾼 것
Server Components 이전의 React는 "클라이언트 중심 라이브러리"였습니다. 모든 것이 클라이언트에서 일어나고, 서버는 그저 정적 HTML을 제공하는 역할이었습니다.
Server Components 이후의 React는 서버와 클라이언트가 공존하는 라이브러리 입니다. 어떤 컴포넌트는 서버에서만 실행되고, 어떤 컴포넌트는 클라이언트에서만 실행되고, 한 트리 안에 두 종류가 자연스럽게 섞입니다. 개발자는 각 컴포넌트의 "실행 위치" 를 결정할 수 있게 되었습니다.
이것은 단순한 최적화가 아니라, 컴포넌트라는 단위의 의미 자체가 확장된 것 입니다. 이전에는 컴포넌트 = 클라이언트 UI였지만, 이제는 컴포넌트 = 서버에서든 클라이언트에서든 실행될 수 있는 렌더링 단위입니다.
다음 단계
Server Components의 등장은 데이터 패칭 방식도 근본적으로 바꿨습니다. getServerSideProps나 getStaticProps 같은 특수 함수 없이, 컴포넌트 안에서 직접 fetch나 DB 쿼리를 할 수 있게 되었기 때문입니다.
다음 글에서는 이 새로운 데이터 패칭 모델과 캐싱 시스템을 살펴봅니다. Next.js의 fetch 확장, Request Memoization, Data Cache, 그리고 Next.js 16에서 도입된 Cache Components 까지, 데이터 처리의 진화를 따라갑니다.