React 서버 컴포넌트와 use client 경계
상품 상세 페이지에 「장바구니 담기」 버튼을 달려고 페이지 파일 맨 위에 한 줄을 더했다고 해 보겠습니다.
"use client";
import { useState } from "react";
import { db } from "@/lib/db";
import { formatPrice } from "@/lib/format";
import { MarkdownBody } from "@/components/MarkdownBody";
export default function ProductPage({ product }) {
const [qty, setQty] = useState(1);
// ...
}버튼은 동작합니다. 그런데 빌드 결과를 보니 이 페이지의 자바스크립트가 크게 늘었고, 데이터베이스 모듈을 브라우저에서 불러오려다 빌드 오류가 나기도 합니다. 버튼 하나를 위해 붙인 한 줄이 파일 전체를, 그리고 그 파일이 가져오는 모든 것을 끌고 갔습니다.
"use client" 는 그 컴포넌트만 브라우저로 보낸다는 생각
서버 컴포넌트를 처음 접하면 흔히 이렇게 정리합니다. 기본은 서버 컴포넌트이고, 상태나 이벤트가 필요한 컴포넌트에만 "use client" 를 붙여 클라이언트 컴포넌트로 만든다. 그리고 그 표시는 그 컴포넌트 하나에 적용된다.
한 가지를 더 오해하기도 합니다. 서버 컴포넌트에는 "use server" 를 붙인다고 생각하는 것입니다.
두 이해 모두 경계를 어디에 긋는지에서 어긋납니다. "use client" 는 컴포넌트가 아니라 모듈 사이에 선을 긋고, 서버 컴포넌트를 표시하는 지시어는 따로 없습니다.
선은 렌더 트리가 아니라 모듈 의존성 트리에 그어진다
'use client' 문서는 이 지시어의 역할을 이렇게 적습니다.
'use client' introduces a server-client boundary in the module dependency tree, effectively creating a subtree of Client modules.
「모듈 의존성 트리」는 import 로 이어진 파일들의 트리입니다. "use client" 가 붙은 파일에서 선이 시작되고, 그 파일이 import 하는 모든 모듈이 선 안쪽, 즉 클라이언트 코드가 됩니다. 같은 문서의 Reference 절도 파일 맨 위에 두면 그 모듈과 **이행적 의존성(transitive dependencies)**이 클라이언트 코드로 표시된다고 적습니다.
처음 예로 돌아가면 db, formatPrice, MarkdownBody 가 모두 클라이언트 쪽으로 넘어갑니다. MarkdownBody 가 무거운 마크다운 파서를 쓰고 있었다면 그 파서도 브라우저로 갑니다. 버튼 하나가 아니라 파일 한 개의 import 트리 전체가 경계 안으로 들어온 것입니다.
그래서 Next.js 문서는 이 지시어를 모든 클라이언트 컴포넌트에 붙일 필요가 없다고 설명합니다. 경계 파일에 한 번 붙이면 그 아래 import 는 자동으로 클라이언트 모듈이 됩니다.
서버 컴포넌트에는 지시어가 없다
Server Components 문서는 두 번째 오해를 직접 짚습니다.
There is no directive for Server Components. A common misunderstanding is that Server Components are denoted by "use server", but there is no directive for Server Components. The "use server" directive is used for Server Functions.
서버 컴포넌트를 쓰는 앱은 기본이 서버입니다. "use client" 경계 안으로 들어가지 않은 모듈은 그대로 서버 컴포넌트입니다. "use server" 는 전혀 다른 기능인 서버 함수를 표시합니다. 이 둘을 짝으로 기억하면 헷갈리기 쉬운데, 서로 대칭인 한 쌍이 아닙니다.
서버 컴포넌트는 브라우저로 코드가 가지 않습니다. 같은 문서에 따르면 그래서 useState 같은 상호작용 API 를 쓸 수 없고, 렌더링이 끝나면 메모리에 남지 않아 자기 state 도 가질 수 없습니다. 대신 데이터베이스를 직접 읽거나 파일을 읽을 수 있고, 무거운 라이브러리를 써도 브라우저로 보내는 자바스크립트가 늘지 않습니다.
클라이언트 컴포넌트도 서버에서 한 번 그려진다
이름 때문에 생기는 오해가 하나 더 있습니다. 클라이언트 컴포넌트는 브라우저에서만 실행된다는 생각입니다.
Next.js 문서의 설명을 보면, 첫 요청에서 서버는 서버 컴포넌트를 RSC Payload 라는 형식으로 그리고, 클라이언트 컴포넌트와 그 Payload 를 함께 써서 HTML 을 미리 만듭니다. 브라우저는 그 HTML 을 먼저 보여 주고, 자바스크립트가 도착하면 클라이언트 컴포넌트에 이벤트를 연결합니다(하이드레이션). 이후 페이지를 이동할 때는 클라이언트 컴포넌트를 브라우저에서만 그립니다.
그래서 클라이언트 컴포넌트는 정확히 말하면 「브라우저에서도 실행되는 컴포넌트」입니다. 렌더링 중에 window 나 localStorage 를 바로 읽으면 서버에서 HTML 을 만들 때 오류가 나는 이유가 이것입니다. 브라우저에만 있는 것은 useEffect 글에서 다룬 효과나 이벤트 핸들러 안에서 읽습니다.
경계를 넘는 props 는 직렬화할 수 있어야 한다
서버 컴포넌트가 클라이언트 컴포넌트에 props 를 넘길 때는 조건이 붙습니다. 두 컴포넌트가 다른 곳에서 실행되기 때문에 그 사이로 전송할 수 있는 값이어야 합니다. 'use client' 문서는 허용되는 값을 나열합니다. 문자열, 숫자, 불리언 같은 원시값, 배열과 Map, Set, Date, 평범한 객체, JSX, Promise, 그리고 서버 함수입니다.
허용되지 않는 목록이 실무에서 더 자주 걸립니다.
Functions that are not exported from client-marked modules or marked with 'use server'
Classes
Objects that are instances of any class (other than the built-ins mentioned) or objects with a null prototype
서버 컴포넌트에서 onClick={() => ...} 같은 함수를 클라이언트 컴포넌트로 넘기면 안 되는 이유입니다. 함수는 코드이고, 서버의 메모리에 있는 클로저를 브라우저로 보낼 방법이 없습니다. 데이터베이스 라이브러리가 돌려준 클래스 인스턴스도 그대로는 넘길 수 없어서, 필요한 필드만 뽑은 평범한 객체로 바꿔 넘깁니다.
버튼만 경계 안으로 보내는 구조
처음 예를 고치는 방향은 경계를 말단으로 내리는 것입니다.
// app/products/[id]/page.jsx: 서버 컴포넌트 (지시어 없음)
import { db } from "@/lib/db";
import { formatPrice } from "@/lib/format";
import { MarkdownBody } from "@/components/MarkdownBody";
import { AddToCart } from "./AddToCart";
export default async function ProductPage({ params }) {
const product = await db.product.find((await params).id);
return (
<article>
<h1>{product.name}</h1>
<p>{formatPrice(product.price)}</p>
<MarkdownBody source={product.description} />
<AddToCart productId={product.id} />
</article>
);
}// AddToCart.jsx
"use client";
import { useState } from "react";
export function AddToCart({ productId }) {
const [qty, setQty] = useState(1);
// ...
}이제 경계 안에는 AddToCart 와 그것이 import 하는 것만 있습니다. 데이터베이스 조회, 가격 포맷, 마크다운 변환은 서버에서 끝나고 결과만 전송됩니다. 경계를 넘는 props 는 productId 문자열 하나라 직렬화 조건도 문제없습니다.
서버 컴포넌트를 클라이언트 컴포넌트 안에 넣는 방법
그렇다면 클라이언트 컴포넌트 안쪽에 서버 컴포넌트를 둘 수는 없을까요. 클라이언트 컴포넌트 파일에서 서버 컴포넌트를 import 하면, 그 모듈이 경계 안으로 끌려 들어가 클라이언트 모듈이 됩니다.
대신 children 이나 props 로 받으면 됩니다.
// Modal.jsx
"use client";
import { useState } from "react";
export function Modal({ children }) {
const [open, setOpen] = useState(false);
return open ? <div className="modal">{children}</div> : <button onClick={() => setOpen(true)}>열기</button>;
}// 서버 컴포넌트
<Modal>
<CartSummary /> {/* 서버 컴포넌트 */}
</Modal>Next.js 문서의 설명대로, children 으로 넘긴 서버 컴포넌트는 클라이언트 컴포넌트의 모듈 그래프에 들어가지 않습니다. 서버에서 먼저 렌더링되고, 그 결과가 클라이언트 컴포넌트에 전달됩니다. JSX 가 직렬화 가능한 값 목록에 들어 있는 이유가 여기 있습니다.
「import 하면 경계 안으로 들어가고, 넘겨받으면 경계 밖에 남는다」. 이 문장이 서버 컴포넌트 구조를 짤 때 가장 자주 쓰는 규칙이라고 봅니다. 리렌더링 글에서 children 으로 받은 JSX 가 감싸는 컴포넌트의 렌더링을 따라가지 않던 것과 같은 모양입니다.
서버 전용 코드를 지키는 방법
모듈이 경계 안팎 어디에 속할지는 import 관계로 정해지므로, 서버에서만 써야 하는 모듈이 실수로 클라이언트 쪽 import 트리에 들어갈 수 있습니다.
Next.js 는 이 경우를 두 겹으로 막습니다. 문서에 따르면 NEXT_PUBLIC_ 으로 시작하지 않는 환경 변수는 클라이언트 번들에서 빈 문자열로 바뀝니다. 비밀 키가 브라우저로 새지는 않지만, 코드는 오류 없이 이상하게 동작합니다. 그래서 문서는 서버 전용 파일 맨 위에 import "server-only" 를 두는 방법을 함께 권합니다. 그 파일이 클라이언트 모듈 트리에 들어오면 빌드가 실패합니다. 조용히 틀리는 것보다 빌드에서 멈추는 편이 낫다고 봅니다.
여기까지가 확실한 부분
"use client" 가 모듈 의존성 트리에 경계를 긋는다는 점, 서버 컴포넌트에 지시어가 없다는 점, 직렬화 가능한 props 목록은 react.dev 문서 기준입니다. 클라이언트 컴포넌트를 서버에서 HTML 로 미리 그리는 방식, children 으로 넘긴 서버 컴포넌트의 처리, 환경 변수와 server-only 는 Next.js 16 문서 기준입니다. 서버 컴포넌트를 지원하는 다른 프레임워크는 HTML 을 미리 그리는 방식과 번들 경계 검사가 다를 수 있습니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React Context와 리렌더링로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.