두 가지 혁신
React 19는 데이터 패칭의 패러다임을 두 가지 방향에서 바꿨습니다.
- Server Components , 서버에서만 실행되는 컴포넌트가 데이터를 직접 가져옵니다
use()Hook , 클라이언트에서 Promise를 직접 읽을 수 있게 해줍니다
이 둘은 서로 다른 문제를 해결하지만, 함께 쓰면 데이터 흐름이 자연스러워집니다.
클라이언트 데이터 패칭의 한계
React 18까지의 데이터 패칭은 거의 모두 클라이언트에서 일어났습니다.
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>;
}이 패턴에는 여러 문제가 있습니다.
- 워터폴 , 부모가 데이터를 받고 렌더링한 후에야 자식이 자기 데이터를 요청합니다
- 번들 크기 , 데이터를 가공하는 모든 로직이 클라이언트 번들에 포함됩니다
- 보안 , API 키나 민감한 로직을 클라이언트에서 다룰 수 없습니다
- SEO와 초기 로딩 , 데이터가 도착하기 전에는 빈 화면입니다
이런 문제를 해결하기 위해 SWR, TanStack Query 같은 라이브러리가 등장했고, Next.js의 getServerSideProps 같은 서버 사이드 데이터 패칭이 사용되었습니다. 하지만 이들은 모두 React 외부의 해결책이었습니다.
Server Components, 컴포넌트가 서버에 산다
Server Components 는 서버에서만 실행되는 React 컴포넌트입니다. 데이터베이스에 직접 접근하고, 환경 변수를 읽고, 서버 전용 라이브러리를 사용할 수 있습니다. 그 결과는 직렬화된 형태로 클라이언트로 전송되어 화면을 구성합니다.
// 이것은 Server Component입니다
async function UserProfile({ userId }) {
const user = await db.users.findById(userId); // DB 직접 접근
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}몇 가지 핵심 특징이 있습니다.
async함수 , Server Component는 비동기 함수가 될 수 있습니다.await로 데이터를 직접 기다립니다- 클라이언트 번들에 포함되지 않음 , 이 컴포넌트의 코드는 클라이언트로 전송되지 않습니다. 결과 (HTML/직렬화 트리) 만 전송됩니다
- 상태와 이벤트 핸들러 없음 ,
useState,onClick같은 것을 사용할 수 없습니다. 이런 것이 필요하면 Client Component를 만들어 사용합니다
Server Component vs Client Component
React 19에서는 두 종류의 컴포넌트가 공존합니다.
| Server Component | Client Component | |
|---|---|---|
| 실행 위치 | 서버 | 브라우저 |
| 데이터 접근 | DB, 파일, 환경 변수 직접 | API 호출만 |
| 상태 | 사용 불가 | useState 등 사용 가능 |
| 이벤트 핸들러 | 사용 불가 | 사용 가능 |
| 클라이언트 번들 | 포함 안 됨 | 포함됨 |
| 표시 방법 | 기본값 | 파일 상단에 'use client' |
Client Component를 만들려면 파일 상단에 'use client' 지시문을 추가합니다.
// Counter.jsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}컴포넌트 트리 구성
Server Component와 Client Component는 같은 트리 안에서 함께 사용할 수 있습니다. 핵심 규칙은 다음과 같습니다.
- Server Component는 Client Component를 import해서 사용할 수 있습니다
- Client Component는 Server Component를 import할 수 없습니다. 대신 Server Component를 children으로 받을 수 있습니다
// page.jsx, Server Component
import { Counter } from './Counter'; // Client Component
async function Page() {
const user = await db.users.findCurrent();
return (
<>
<h1>안녕하세요, {user.name}님</h1>
<Counter /> {/* Client Component를 사용 */}
</>
);
}// ClientLayout.jsx, Client Component
'use client';
import { useState } from 'react';
export function ClientLayout({ children }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>토글</button>
{open && children} {/* children은 Server Component일 수 있음 */}
</div>
);
}워터폴 문제의 해결
Server Components는 클라이언트 데이터 패칭의 워터폴 비용을 크게 줄여줍니다.
// 부모-자식 waterfall이지만 서버 안에서 일어남
async function Page() {
const user = await db.users.findCurrent();
return (
<>
<UserHeader user={user} />
<UserPosts userId={user.id} /> {/* 이 컴포넌트도 await 가능 */}
</>
);
}
async function UserPosts({ userId }) {
const posts = await db.posts.findByUser(userId);
return posts.map((p) => <Post key={p.id} post={p} />);
}여기서도 findCurrent() 이후에야 findByUser(userId) 가 실행되는 parent-child waterfall은 여전히 존재합니다. 하지만 핵심은 각 단계의 비용이 훨씬 낮다 는 것입니다. 클라이언트에서는 브라우저 → API 서버 → DB 라는 왕복을 단계마다 반복해야 했지만, Server Component는 서버 안에서 DB에 바로 접근합니다. 같은 waterfall 모양이라도 지연의 절대량이 크게 줄어듭니다.
또한 sibling 컴포넌트들의 데이터 요청은 자연스럽게 병렬로 일어납니다. parent-child 의존성이 없는 형제 컴포넌트는 같은 라운드에서 동시에 fetch합니다. 진정한 parent-child 병렬화가 필요하다면, 뒤에서 다루는 "Promise를 내려보내는" 패턴을 쓰면 됩니다.
use() Hook, Promise를 직접 읽다
Server Components는 async/await로 데이터를 다룹니다. 하지만 Client Components는 그럴 수 없습니다, 클라이언트 컴포넌트는 비동기 함수가 될 수 없기 때문입니다.
이 갭을 메우는 것이 use() Hook 입니다. use()는 Promise나 Context를 직접 읽을 수 있게 해주는 새로운 API입니다.
'use client';
import { use } from 'react';
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // Promise를 직접 읽음
return comments.map(c => <p key={c.id}>{c.text}</p>);
}use(commentsPromise)는 Promise가 resolve될 때까지 컴포넌트를 일시 중단합니다. 가장 가까운 <Suspense> 경계의 fallback이 표시되고, Promise가 끝나면 컴포넌트가 다시 렌더링됩니다.
Server Component와 결합하기
use()의 진가는 Server Component에서 만든 Promise를 Client Component로 전달할 때 드러납니다.
// page.jsx, Server Component
import { Comments } from './Comments';
function Page() {
const commentsPromise = fetchComments(); // Promise를 만들기만 함 (await 없음)
return (
<Suspense fallback={<Loading />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}// Comments.jsx, Client Component
'use client';
import { use } from 'react';
export function Comments({ commentsPromise }) {
const comments = use(commentsPromise);
return comments.map(c => <p key={c.id}>{c.text}</p>);
}Server Component는 Promise를 만들기만 하고 await하지 않습니다. 이 Promise는 Client Component로 전달되고, Client Component가 use()로 읽습니다. 데이터 패칭이 시작되는 시점은 서버지만, 결과를 사용하는 시점은 클라이언트입니다.
이 패턴의 장점은 데이터 패칭이 가능한 한 빨리 시작된다 는 것입니다. 페이지 렌더링이 시작되자마자 fetch가 시작되고, 그 결과는 Streaming SSR을 통해 준비되는 대로 클라이언트로 전송됩니다.
use()는 Hook 규칙의 예외
use()는 기존 Hook 규칙에서 예외적입니다. 조건문, 반복문, 중첩된 함수 안에서도 사용할 수 있습니다.
function MaybeComments({ show, commentsPromise }) {
if (!show) return null;
// 조건문 안에서도 use() 사용 가능
const comments = use(commentsPromise);
return comments.map(c => <p key={c.id}>{c.text}</p>);
}useState나 useEffect는 이렇게 쓸 수 없습니다. React가 호출 순서로 Hook을 식별하기 때문입니다. use()는 다른 메커니즘을 사용합니다, Promise를 throw하여 Suspense에 전달하는 방식입니다. 호출 순서에 의존하지 않으므로 어디서든 호출할 수 있습니다.
use(Context)
use()는 Context도 읽을 수 있습니다.
function ThemedButton({ enabled }) {
if (!enabled) return null;
// 조건문 안에서도 Context 읽기 가능
const theme = use(ThemeContext);
return <button className={theme}>버튼</button>;
}useContext와 동일한 역할이지만, 조건문 안에서 사용할 수 있다는 차이가 있습니다.
베스트 프랙티스 정리
1. 기본은 Server Component, 필요한 경우만 Client Component. Client Component는 인터랙션, 상태, 브라우저 API가 필요한 경우에만 만듭니다. 나머지는 Server Component로 두면 번들 크기가 작아지고 성능이 좋아집니다.
2. Client Component는 트리의 가장 깊은 곳에 둔다. 큰 영역을 Client Component로 만들면 그 안의 모든 자식이 클라이언트 번들에 포함됩니다. 가능한 한 작게 분리합니다.
3. Server Component에서 만든 Promise를 Client Component로 전달한다. Server Component가 await으로 직접 기다리면 그 위의 모든 것이 막힙니다. Promise를 전달하고 Client Component에서 use()로 받으면 Streaming의 이점을 살릴 수 있습니다.
4. use()는 조건부 데이터 패칭에 적합하다. 사용자가 어떤 탭을 열었을 때만 그 데이터를 보여준다면, 조건문 안의 use()가 자연스럽습니다.
5. Server Component에서 환경 변수와 비밀 키를 활용한다. Client Component에서는 절대 할 수 없는 일입니다. API 키, DB 연결 문자열, 인증 토큰 같은 것을 안전하게 다룰 수 있습니다.
6. Server Action과 결합한다. 4편에서 다룬 Actions를 Server Function ('use server') 으로 작성하면, 클라이언트 폼이 서버 함수를 직접 호출할 수 있습니다. API 라우트를 별도로 만들지 않아도 됩니다.
// actions.js
'use server';
export async function updateUser(formData) {
const name = formData.get('name');
await db.users.update({ name });
}// Form.jsx
import { updateUser } from './actions';
export function Form() {
return (
<form action={updateUser}>
<input name="name" />
<button>저장</button>
</form>
);
}다음 단계
다음 글에서는 React 19가 정리한 작지만 의미 있는 변화들을 다룹니다. forwardRef의 종말, <Context> as Provider, 자동 metadata 처리, 매일 쓰는 코드의 보일러플레이트를 줄여주는 변화들입니다.